Skip to content

AI 서비스 초기 구성 #4

Merged
VitoJeong merged 60 commits intoon-seoul-agentfrom
AGENT-1-foundation
Apr 21, 2026
Merged

AI 서비스 초기 구성 #4
VitoJeong merged 60 commits intoon-seoul-agentfrom
AGENT-1-foundation

Conversation

@VitoJeong
Copy link
Copy Markdown
Collaborator

개요

AI 서비스의 기반이 되는 외부 LLM 연동(Gemini/GPT) 환경과 공통 예외 체계를 구축했습니다.

구현 항목

작업내용 상세 문서는 on-seoul-agent/docs/ai-service-implementation.md를 참고해주세요.

프로젝트 초기 구성 및 인프라

  • FastAPI 기반 앱 엔진: 비동기 핸들러 및 SSE 스트리밍 응답 구조 준비
  • 설정 관리: pydantic-settings 기반 환경 변수 관리
  • DB 연동: SQLAlchemy async 엔진 및 세션 팩토리 구성
  • 로깅: 서비스 레이어 및 에이전트 실행 추적을 위한 정형 로깅 설정

데이터 스키마 및 DB 설계 (Phase 2)

  • 메시지 중심 설계: room_id/message_id 기반 요청/응답 모델 정의
  • 상태 관리: LangGraph 노드 간 문맥 공유를 위한 AgentState 정의(LangChain에서는 미사용)
  • 에이전트 트레이스: 실행 메타데이터 저장을 위한 chat_agent_traces JSONB 모델링
  • DDL 작성: on_ai DB 전용 테이블(service_embeddings, chat_agent_traces) 및 pgvector 확장 적용

LLM 클라이언트 및 유틸리티 (Phase 3)

  • LLM 연동 추상화: llm_provider 설정에 따라 Gemini(Google)와 GPT(OpenAI)를 동적으로 전환하는 get_chat_model 구현
  • 임베딩 최적화: Google Generative AI 기반 1536차원 벡터 생성
  • 공통 예외 체계:
    • LLMException: API 호출 및 쿼터 제한 오류 처리
    • DatabaseException: DB 세션 및 쿼리 오류 처리
    • ConfigurationException: 설정 누락 및 공급자 미지원 오류 처리

테스트 및 검증

  • 외부 API 연동 테스트: pytest 를 사용하여 실제 LLM API 호출 검증
  • Mock 테스트: 단위 테스트를 통한 Generator 및 Embedder 로직 검증 완료

changha and others added 30 commits April 12, 2026 21:30
루트에서 Application 제거하고 common/collector/app 3개 서브모듈로 분리한다.
- common: 공유 엔티티, DTO, 예외 정의
- collector: 서울시 Open API 수집 파이프라인
- app: Spring Boot 진입점, 컨트롤러, 시큐리티

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
DDL 스키마 기준으로 4개 엔티티(PublicServiceReservation, DataSourceCatalog,
CollectionHistory, ServiceChangeLog)와 Repository를 common 모듈에 추가한다.
CollectionHistory는 생성 시 FAILED로 초기화하여 파이프라인 중단 시에도 이력이 남도록 한다.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
DDL 스키마 기준으로 4개 엔티티(PublicServiceReservation, DataSourceCatalog,
CollectionHistory, ServiceChangeLog)와 Repository를 common 모듈에 추가한다.
CollectionHistory는 생성 시 FAILED로 초기화하여 파이프라인 중단 시에도 이력이 남도록 한다.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
페이지네이션 전체 수집, 5xx 재시도(지수 백오프), 4xx 즉시 실패,
응답 파싱(JsonNode → DTO) 로직을 구현한다.
재시도 대상은 SeoulApiServerException(5xx)으로 한정하여
4xx 인증 오류가 재시도되는 문제를 방지한다.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
드라이버는 런타임 접속 주체인 app 모듈의 책임이며
common은 JPA 엔티티 정의만 담당하므로 DB 드라이버를 알 필요 없음

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
샘플 응답 기준으로 V_MIN/V_MAX(이용시간)를 확정하고 USETMINFO 제거.
PublicServiceRowMapper에서 날짜(yyyy-MM-dd HH:mm:ss.S), 이용시간(HH:mm),
좌표(nullable String→BigDecimal), 취소기준일(String→Short) 파싱을 처리한다.
파싱 실패 시 예외 없이 null 반환 후 warn 로그를 남긴다.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
common 모듈에 OnSeoulApiException + ErrorCode 추가.
SeoulApiException/SeoulApiServerException이 ErrorCode를 통해
HTTP 상태 코드와 에러 식별자를 보유하도록 리팩터링.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
webmvc → web, webclient → webflux,
security-oauth2-* → oauth2-* 로 올바른 아티팩트명으로 변경

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
수집 파이프라인 흐름(Mermaid), 주요 컴포넌트, 예외 계층, 설정 항목 문서화.
Spring Boot 버전 4.0.x → 3.5.x 오기 수정.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
서울시 API는 HTTP 200이면서 RESULT.CODE가 INFO-200인 경우
"해당하는 데이터가 없음"을 의미한다. 기존 코드는 이를 예외로 던졌으나
빈 목록 반환으로 수정. 테스트 케이스 및 README 추가.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
SVCID/SVCNM 누락 시 Optional.empty() 반환으로 Upsert 키 오류 및
NOT NULL 위반 방지. Phase 6 예정 클래스(CollectionService, ChangeLogService)
포함한 모듈 책임 범위를 README·다이어그램에 반영.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
common 모듈이 공유 엔티티까지 담는 God 모듈이 되는 문제를 해결하기 위해
모듈 구조를 재편했다.

- domain 모듈 신설: 공용 JPA 엔티티(PublicServiceReservation)와 Repository를 담당
- collector 모듈: 수집 파이프라인 전용 엔티티(CollectionHistory, ServiceChangeLog,
  DataSourceCatalog)와 관련 Repository/Enums를 내부로 이동
- common 모듈: 전역 예외(ErrorCode, OnSeoulApiException)만 남기고 JPA 의존 제거
- 의존 방향: app → domain, collector, common / collector → domain, common / domain → common
- 문서(collector/README.md, on-seoul-api/README.md, docs/architecture.md) 구조도 동기화
네트워크 지연 시 스레드가 무한정 블로킹되는 문제를 방지한다.
- SeoulApiProperties에 connectTimeoutMs(10s), responseTimeoutSeconds(30s) 추가
- CollectorConfig의 HttpClient에 connect timeout, response timeout 적용
- getBlockTimeout()으로 전체 재시도 허용 시간을 외부에서 계산 가능하게 노출
block() 무한 대기 및 빈 body로 인한 NPE 가능성을 제거한다.
- switchIfEmpty로 빈 body 즉시 PARSE_ERROR 예외 처리
- .timeout()으로 시도당 응답 타임아웃 적용, TimeoutException → COLLECT_API_TIMEOUT 변환
- .block(getBlockTimeout())으로 전체 재시도 포함 block 타임아웃 안전망 추가
- 빈 응답 케이스 테스트 추가
complete/fail/partial 결과 기록 후 재호출 시 IllegalStateException을 발생시켜
의도치 않은 상태 덮어쓰기를 방지한다.
- validateNotFinished()로 durationMs 기준 완료 여부 판별
- CollectionStatus 각 값에 Javadoc 추가
- 중복 상태 전이 케이스 4종 테스트 추가
changha added 16 commits April 18, 2026 10:53
engine, AsyncSessionLocal, Base, get_db를 정의하여
FastAPI 의존성 주입 기반의 비동기 DB 세션을 구성한다.
…L 추가

LangGraph 노드 간 공유 상태(AgentState), 채팅 요청/응답 모델(ChatRequest, ChatResponse),
SSE 스트리밍 이벤트 타입(EventType, SSEEvent)을 정의하고,
대화 이력 및 시설 임베딩 벡터 테이블 DDL을 추가한다.
텍스트 임베딩을 위한 Embedder 클래스와 LLM 프롬프트 응답 생성을 위한
Generator 클래스를 구현하고 mock 기반 단위 테스트 4건을 추가했습니다.
pytest asyncio_mode=auto 설정 및 테스트용 conftest 환경변수 설정 포함.
- schemas/state.py에 IntentType(str, Enum) 추가 (SQL_SEARCH, VECTOR_SEARCH, MAP, FALLBACK)
- AgentState.intent 및 ChatResponse.intent 타입을 str에서 IntentType | None으로 변경
- core/logging.py에서 os.getenv 대신 settings.log_level을 사용하도록 변경
- langchain-anthropic 제거, langchain-google-genai 추가
- llm_provider 기본값 openai → gemini
- get_chat_model() 에 provider 파라미터 추가 (gemini | openai)
- 임베딩은 DDL vector(1536) 기준으로 OpenAI 고정
- ChatRequest: user_id/session_id → room_id/message_id
- ChatResponse: session_id → message_id
- SSEEvent: session_id → message_id
- AgentState: room_id/message_id 기반, title_needed 플래그·trace 필드 추가
- schemas/trace.py 신규: AgentTrace (chat_agent_traces JSONB 페이로드 모델)
- ddl.sql 제거 (구 설계: conversations + facility_embeddings)
- ddl_chat_entities.sql: on_ai DB 전용 (service_embeddings + chat_agent_traces)
- HNSW 인덱스 주석 처리로 적재 후 추가 가능하도록 안내
- get_embeddings(): OpenAIEmbeddings → GoogleGenerativeAIEmbeddings
- embedding_model 기본값: text-embedding-3-small → models/gemini-embedding-2-preview
- output_dimensionality=1536으로 DDL vector(1536) 유지
- _GeminiEmbeddings 래퍼: aembed_documents가 배치를 단일 호출로 합치는
  버그를 asyncio.gather + aembed_query 병렬 호출로 우회
- pytest marker: integration → external_api
- addopts: 기본 실행 시 external_api 자동 제외
- test_llm_integration.py → test_llm_external_api.py 재작성
- conftest.py: load_dotenv() 추가, 더미 키 정리
- core/exceptions.py: OnSeoulAgentException 기반의 계층형 예외 정의
  (LLMException, DatabaseException, WorkflowException, ConfigurationException)
- llm/client.py: 설정 오류 시 ConfigurationException 발생
- llm/generator.py & embedder.py: LLM 호출 실패 시 LLMException으로 래핑
- core/database.py: DB 세션 및 연결 오류 시 DatabaseException 발생
@VitoJeong VitoJeong requested a review from f-lab-ted April 18, 2026 13:07
Copy link
Copy Markdown

@f-lab-ted f-lab-ted left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

docs, on-seoul-agent (python) 파트에 대한 리뷰입니다.

Comment thread on-seoul-agent/core/database.py Outdated
Comment thread on-seoul-agent/llm/generator.py Outdated
Comment thread docs/architecture.md
│ └── scheduler/
│ └── CollectionScheduler.java # 일 1회 수집 트리거 → CollectionService → ChangeLogService → NotificationService
│ └── security/
│ ├── SecurityConfig.java # Spring Security 설정 (세션 기반)
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Major] architecture.md에서 SecurityConfig.java를 "Spring Security 설정 (세션 기반)"으로, SessionAuthFilter.java를 "요청마다 세션 검증"으로 기술하고 있으나, docs/schemas/auth-design.md에서는 JWT 기반 인증(Access Token은 클라이언트 저장, 서버 상태 없음)으로 설계되어 있습니다.

두 문서의 인증 전략이 상충합니다:

  • architecture.md → 세션 기반
  • auth-design.md → JWT 기반 (Stateless Access Token + Redis Refresh Token)

JWT 설계를 채택한 것이라면 architecture.md의 SessionAuthFilterJwtAuthFilter, "세션 기반" → "JWT 기반"으로 업데이트가 필요합니다. 설계 문서 간 불일치는 이후 구현 시 혼란을 야기할 수 있습니다.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

해당 피드백은 인증 관련 브랜치에서 처리해두었습니다.
커밋 링크

Comment thread on-seoul-agent/llm/client.py
Comment thread on-seoul-agent/schemas/trace.py Outdated
class AgentTrace(BaseModel):
message_id: int
trace: dict[str, Any]
created_at: datetime = Field(default_factory=datetime.now)
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Major] datetime.now()는 timezone-naive한 datetime을 생성하지만, DDL(ddl_chat_entities.sql)에서 created_atTIMESTAMPTZ(timezone-aware)로 정의하고 있습니다. timezone 불일치는 시간 기반 조회/정렬에서 예측 불가능한 결과를 초래할 수 있습니다.

datetime.now(tz=timezone.utc) 또는 datetime.now(tz=UTC)를 사용하세요:

from datetime import datetime, timezone

created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

해당 피드백 반영해두었습니다.
별개로 API 서비스에서는 TIMESTAMP 타입을 사용하여, 타입이 혼재된 상태로 있어 통일하는 작업해두겠습니다!

Comment thread .gitignore Outdated
Comment thread on-seoul-agent/schemas/chat.py
Comment thread on-seoul-agent/core/logging.py Outdated

def setup_logging() -> None:
log_level = getattr(logging, settings.log_level.upper(), logging.INFO)
logging.basicConfig(
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Minor] logging.basicConfig는 root logger를 직접 설정하므로, uvicorn·SQLAlchemy·httpx 등 서드파티 라이브러리의 로그 레벨까지 함께 변경됩니다. PR 설명에서 언급한 "정형 로깅"과도 거리가 있습니다.

서비스 전용 logger를 설정하는 것이 더 적합합니다:

def setup_logging() -> None:
    logger = logging.getLogger("on_seoul_agent")
    logger.setLevel(getattr(logging, settings.log_level.upper(), logging.INFO))
    handler = logging.StreamHandler()
    handler.setFormatter(
        logging.Formatter("%(asctime)s %(levelname)s %(name)s %(message)s")
    )
    logger.addHandler(handler)

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

해당 피드백 반영하였습니다.

Comment thread on-seoul-agent/core/exceptions.py Outdated
@@ -0,0 +1,34 @@
from typing import Any, Optional
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Minor] from typing import Any, OptionalOptional[Any]를 사용하고 있으나, 같은 프로젝트의 다른 파일들(config.py, state.py, chat.py 등)은 모두 str | None 형태의 modern union syntax를 사용합니다. 코드베이스 일관성을 위해 Optional[Any]Any | None으로 통일하세요.

def __init__(self, message: str, detail: Any | None = None):

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

해당 피드백 반영하였습니다!


google_api_key: str | None = None
gemini_model: str = "gemini-2.0-flash"

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] google_api_keyopenai_api_keyNone이어도 앱이 정상 기동되지만, 실제 LLM 호출 시(get_chat_model)에 None이 그대로 API 클라이언트에 전달되어 모호한 에러가 발생합니다.

get_chat_model() 내부에서 선택된 provider의 키가 없으면 ConfigurationException을 명시적으로 발생시키는 가드를 추가하면 디버깅이 수월해집니다:

if selected_provider in ("gemini", "google"):
    if not settings.google_api_key:
        raise ConfigurationException("GOOGLE_API_KEY is required for Gemini provider")
    ...

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

해당 피드백 반영하였습니다. 그 외 다른 설정값 누락에도 적용하여 테스트코드 추가하였습니다.

Copy link
Copy Markdown

@f-lab-ted f-lab-ted left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

on-seoul-api 리뷰 입니다.


@CreationTimestamp
@Column(name = "created_at", nullable = false, updatable = false)
private LocalDateTime createdAt;
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Bug] @Builder 생성자에 apiServicePath 파라미터가 누락되어 있습니다.

apiServicePath@Column(nullable = false)로 선언되어 있으므로, Builder를 통해 엔티티를 생성하면 해당 필드가 null인 채로 persist되어 DB 제약조건 위반(NOT NULL constraint)이 발생합니다.

@Builder
public ApiSourceCatalog(String datasetId, String datasetName, String datasetUrl,
                        String apiServicePath, boolean active, String tags) {
    this.datasetId = datasetId;
    this.datasetName = datasetName;
    this.datasetUrl = datasetUrl;
    this.apiServicePath = apiServicePath;
    this.active = active;
    this.tags = tags;
}

private String key;
private String baseUrl = "http://openapi.seoul.go.kr:8088";
private int pageSize = 200;
private int maxRetries = 3;
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Operational] key 필드에 유효성 검증이 없습니다.

API 키가 설정되지 않으면(null) WebClient가 경로에 null을 포함한 요청을 보내게 되어, 의미 없는 4xx 응답과 함께 디버깅이 어려운 오류가 발생합니다.

@jakarta.validation.constraints.NotBlank 어노테이션을 추가하고, @Validated와 함께 사용하면 애플리케이션 기동 시점에 설정 누락을 조기 감지할 수 있습니다.

@NotBlank(message = "seoul.api.key 설정이 필요합니다")
private String key;

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

아래 커밋에 피드백 반영했습니다.
커밋 링크

if (totalCount == 0 || firstPage.getRows().isEmpty()) {
log.info("서울시 Open API 수집 결과 없으므로 수집 생략. serviceName={}", serviceName);
return result;
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] fetchAll() 내부에서 result가 불필요하게 두 번 생성됩니다.

List<PublicServiceRow> result = new ArrayList<>();  // 첫 번째 생성

if (totalCount == 0 || firstPage.getRows().isEmpty()) {
    return result;
}

result = new ArrayList<>(totalCount);  // 두 번째 생성 (첫 번째는 즉시 GC 대상)

result 선언을 early return 이후로 옮기면 불필요한 객체 생성을 피할 수 있습니다.

if (totalCount == 0 || firstPage.getRows().isEmpty()) {
    return List.of();
}

List<PublicServiceRow> result = new ArrayList<>(totalCount);
result.addAll(firstPage.getRows());

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

아래 커밋에 피드백 반영했습니다.
커밋 링크

@VitoJeong VitoJeong merged commit 489061b into on-seoul-agent Apr 21, 2026
1 check passed
@VitoJeong VitoJeong deleted the AGENT-1-foundation branch April 21, 2026 13:29
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants