From 76a9cceb7d0ae887c3253d577c44eb2efbc459dd Mon Sep 17 00:00:00 2001 From: jmj Date: Wed, 20 May 2026 18:18:12 +0900 Subject: [PATCH 01/12] =?UTF-8?q?feat(ai):=20=EB=B6=84=EC=84=9D=20?= =?UTF-8?q?=ED=8C=8C=EC=9D=B4=ED=94=84=EB=9D=BC=EC=9D=B8=20=ED=86=B5?= =?UTF-8?q?=ED=95=A9=20(resume/repository/web=20+=20chunking=C2=B7embeddin?= =?UTF-8?q?g)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit us-09 시리즈 작업물(feature/us-09-resume-analyzer → feature/us-09-repo-web-analyzer → feature/us-09-chunking-embedding)을 스프린트 1 통합 브랜치로 가져옴. - analyzer/{resume,repository,web_resume}_analyzer.py: 3종 분석 파이프라인 - analyzer/sources/{pdf,github_repo,web}.py: 소스 추출기 + 추상화 - chain/document_analysis_chain.py + prompts: LangChain 분석 체인 - rag/chunker.py + embedder.py: 청킹 + 임베딩 (Gemini/Mock) - core/client.py: Core 내부 API 클라이언트 (github-token, embeddings upsert) - messaging/consumers/{resume,repository,web}_consumer.py - messaging/runner.py: 3개 consumer wiring - storage/{s3,local_fs}: 객체 스토리지 추상화 - infra/rabbitmq/definitions.json: ai.analyze.web 큐 추가 - 123 tests passing --- ai/.env.example | 26 ++ ai/CLAUDE.md | 25 +- ai/pyproject.toml | 3 + ai/src/ai_server/analyzer/__init__.py | 0 ai/src/ai_server/analyzer/_embedding_step.py | 86 ++++ .../ai_server/analyzer/repository_analyzer.py | 150 +++++++ ai/src/ai_server/analyzer/resume_analyzer.py | 118 ++++++ ai/src/ai_server/analyzer/sources/__init__.py | 22 + ai/src/ai_server/analyzer/sources/base.py | 22 + .../ai_server/analyzer/sources/github_repo.py | 303 ++++++++++++++ ai/src/ai_server/analyzer/sources/pdf.py | 33 ++ ai/src/ai_server/analyzer/sources/web.py | 128 ++++++ .../ai_server/analyzer/web_resume_analyzer.py | 111 ++++++ ai/src/ai_server/chain/__init__.py | 13 + .../chain/document_analysis_chain.py | 62 +++ ai/src/ai_server/chain/prompts/__init__.py | 0 .../chain/prompts/document_analysis.py | 23 ++ ai/src/ai_server/config/settings.py | 58 ++- ai/src/ai_server/core/__init__.py | 15 + ai/src/ai_server/core/client.py | 221 +++++++++++ .../consumers/repository_consumer.py | 142 +++++++ .../messaging/consumers/resume_consumer.py | 86 +++- .../messaging/consumers/web_consumer.py | 134 +++++++ ai/src/ai_server/messaging/runner.py | 140 ++++++- ai/src/ai_server/model/messages/analyze.py | 24 +- ai/src/ai_server/rag/__init__.py | 18 + ai/src/ai_server/rag/chunker.py | 33 ++ ai/src/ai_server/rag/embedder.py | 117 ++++++ ai/src/ai_server/storage/__init__.py | 12 + ai/src/ai_server/storage/base.py | 35 ++ ai/src/ai_server/storage/factory.py | 22 + ai/src/ai_server/storage/local_fs.py | 63 +++ ai/src/ai_server/storage/s3.py | 102 +++++ ai/tests/test_chunker.py | 48 +++ ai/tests/test_core_client.py | 243 ++++++++++++ ai/tests/test_document_chain.py | 35 ++ ai/tests/test_embedder.py | 156 ++++++++ ai/tests/test_github_repo_extractor.py | 191 +++++++++ ai/tests/test_messages_analyze.py | 103 ++++- ai/tests/test_pdf_extractor.py | 66 +++ ai/tests/test_repository_analyzer.py | 151 +++++++ ai/tests/test_repository_consumer.py | 152 +++++++ ai/tests/test_resume_analyzer.py | 108 +++++ ai/tests/test_resume_consumer.py | 185 +++++++-- ai/tests/test_settings.py | 2 +- ai/tests/test_storage_factory.py | 46 +++ ai/tests/test_storage_local_fs.py | 60 +++ ai/tests/test_storage_s3.py | 115 ++++++ ai/tests/test_web_consumer.py | 108 +++++ ai/tests/test_web_extractor.py | 117 ++++++ ai/tests/test_web_resume_analyzer.py | 84 ++++ ai/uv.lock | 375 ++++++++++++++++++ infra/rabbitmq/definitions.json | 14 + 53 files changed, 4608 insertions(+), 98 deletions(-) create mode 100644 ai/src/ai_server/analyzer/__init__.py create mode 100644 ai/src/ai_server/analyzer/_embedding_step.py create mode 100644 ai/src/ai_server/analyzer/repository_analyzer.py create mode 100644 ai/src/ai_server/analyzer/resume_analyzer.py create mode 100644 ai/src/ai_server/analyzer/sources/__init__.py create mode 100644 ai/src/ai_server/analyzer/sources/base.py create mode 100644 ai/src/ai_server/analyzer/sources/github_repo.py create mode 100644 ai/src/ai_server/analyzer/sources/pdf.py create mode 100644 ai/src/ai_server/analyzer/sources/web.py create mode 100644 ai/src/ai_server/analyzer/web_resume_analyzer.py create mode 100644 ai/src/ai_server/chain/__init__.py create mode 100644 ai/src/ai_server/chain/document_analysis_chain.py create mode 100644 ai/src/ai_server/chain/prompts/__init__.py create mode 100644 ai/src/ai_server/chain/prompts/document_analysis.py create mode 100644 ai/src/ai_server/core/__init__.py create mode 100644 ai/src/ai_server/core/client.py create mode 100644 ai/src/ai_server/messaging/consumers/repository_consumer.py create mode 100644 ai/src/ai_server/messaging/consumers/web_consumer.py create mode 100644 ai/src/ai_server/rag/__init__.py create mode 100644 ai/src/ai_server/rag/chunker.py create mode 100644 ai/src/ai_server/rag/embedder.py create mode 100644 ai/src/ai_server/storage/__init__.py create mode 100644 ai/src/ai_server/storage/base.py create mode 100644 ai/src/ai_server/storage/factory.py create mode 100644 ai/src/ai_server/storage/local_fs.py create mode 100644 ai/src/ai_server/storage/s3.py create mode 100644 ai/tests/test_chunker.py create mode 100644 ai/tests/test_core_client.py create mode 100644 ai/tests/test_document_chain.py create mode 100644 ai/tests/test_embedder.py create mode 100644 ai/tests/test_github_repo_extractor.py create mode 100644 ai/tests/test_pdf_extractor.py create mode 100644 ai/tests/test_repository_analyzer.py create mode 100644 ai/tests/test_repository_consumer.py create mode 100644 ai/tests/test_resume_analyzer.py create mode 100644 ai/tests/test_storage_factory.py create mode 100644 ai/tests/test_storage_local_fs.py create mode 100644 ai/tests/test_storage_s3.py create mode 100644 ai/tests/test_web_consumer.py create mode 100644 ai/tests/test_web_extractor.py create mode 100644 ai/tests/test_web_resume_analyzer.py diff --git a/ai/.env.example b/ai/.env.example index 59d079e4..708db850 100644 --- a/ai/.env.example +++ b/ai/.env.example @@ -18,3 +18,29 @@ LLM_API_KEY= LLM_BASE_URL=https://factchat-cloud.mindlogic.ai/v1/gateway LLM_PRO_MODEL=gemini-3.1-pro-preview LLM_PRO_TEMPERATURE=0.2 + +# Github 관련 +CORE_INTERNAL_BASE_URL=http://localhost:38010 +CORE_INTERNAL_API_KEY= +CORE_INTERNAL_TIMEOUT_SEC=10 + +GITHUB_FALLBACK_TOKEN= +GITHUB_API_BASE_URL=https://api.github.com +REPO_MAX_SOURCE_FILES=8 +REPO_MAX_SOURCE_FILE_BYTES=50000 +REPO_FETCH_TIMEOUT_SEC=30 + +# 웹 이력서 관련 +WEB_FETCH_TIMEOUT_SEC=20 +WEB_MAX_HTML_BYTES=2000000 + +# 개발 및 테스트는 mock +# 운영은 gemini +EMBEDDING_PROVIDER=gemini +EMBEDDING_MODEL=gemini-embedding-001 +EMBEDDING_DIM=1536 +EMBEDDING_CHUNK_SIZE=1000 +EMBEDDING_CHUNK_OVERLAP=200 +EMBEDDING_BATCH_SIZE=32 + +GEMINI_API_KEY= diff --git a/ai/CLAUDE.md b/ai/CLAUDE.md index fb5b3fbc..68c09f19 100644 --- a/ai/CLAUDE.md +++ b/ai/CLAUDE.md @@ -88,11 +88,13 @@ ai/ 본 서버는 RabbitMQ **consumer**로 작동. -| Queue | Bind | -|-------|------| -| `q.ai.resume` | `ai.request.resume.*` | -| `q.ai.repo` | `ai.request.repo.*` | -| `q.ai.session` | `ai.request.session.*` | +| Queue | Routing key | 상태 | +|-------|-------------|------| +| `ai.analyze.resume` | `analyze.resume` | 본 구현 (PDF → MD) | +| `ai.analyze.repository` | `analyze.repository` | 본 구현 (GitHub README + tree + 소스 sampling) | +| `ai.analyze.web` | `analyze.web` | 본 구현 (URL → trafilatura) | +| `ai.generate.questions` | `generate.questions` | 큐만, 코드 미구현 | +| `ai.generate.followup` | `generate.followup` | 큐만, 코드 미구현 | 콜백 발행: `ai.callback.{type}` 익스체인지. 상세 envelope/스키마/재시도: [`/docs/messaging.md`](../docs/messaging.md). @@ -162,11 +164,14 @@ chain = prompt | llm | PydanticOutputParser(pydantic_object=...) ## 7. RAG 파이프라인 -### 7.1 인제스트 -1. 마크다운 입력 -2. 청킹 (LangChain `RecursiveCharacterTextSplitter`, chunk_size=1000, overlap=200) -3. 임베딩 생성 (Gemini `text-embedding-004` 또는 OpenAI `text-embedding-3-small`) -4. Core API 호출 → pgvector INSERT +### 7.1 인제스트 (본 구현) +1. 마크다운 입력 (`analyzer/_embedding_step.chunk_embed_and_upsert`) +2. 청킹 — `rag/chunker.MarkdownChunker` (`RecursiveCharacterTextSplitter`, 기본 1000/200) +3. 임베딩 생성 — `rag/embedder.EmbeddingProvider`. 현재 구현체: + - `MockEmbeddingProvider` (default) — 차원 결정 보류, e2e 흐름 검증용 + - `openai` / `ollama` 구현체는 후속 PR +4. Core API 호출 — `CoreClient.upsert_embeddings(document_id, model, dim, chunks)` → + `PUT /api/internal/documents/{id}/embeddings` (idempotent upsert) ### 7.2 검색 1. 쿼리 텍스트 → 임베딩 diff --git a/ai/pyproject.toml b/ai/pyproject.toml index 380d3396..543cfeba 100644 --- a/ai/pyproject.toml +++ b/ai/pyproject.toml @@ -16,6 +16,9 @@ dependencies = [ "boto3>=1.42.77", "aiofiles>=24.1.0", "pypdf>=5.1.0", + "trafilatura>=2.0.0", + "langchain-text-splitters>=0.3.0", + "google-genai>=1.0.0", "langchain>=1.2.13", "langchain-core>=1.2.22", "langchain-community>=0.4.1", diff --git a/ai/src/ai_server/analyzer/__init__.py b/ai/src/ai_server/analyzer/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/ai/src/ai_server/analyzer/_embedding_step.py b/ai/src/ai_server/analyzer/_embedding_step.py new file mode 100644 index 00000000..757585d9 --- /dev/null +++ b/ai/src/ai_server/analyzer/_embedding_step.py @@ -0,0 +1,86 @@ +# 공통 임베딩 모듈 +from __future__ import annotations + +import structlog + +from ai_server.core.client import ( + CoreClient, + CoreEmbeddingUpsertError, + EmbeddingChunkPayload, +) +from ai_server.rag.chunker import MarkdownChunker +from ai_server.rag.embedder import EmbeddingError, EmbeddingProvider + +log = structlog.get_logger(__name__) + + +class EmbeddingStepError(Exception): + def __init__(self, *, code: str, message: str, retriable: bool) -> None: + super().__init__(message) + self.code = code + self.message = message + self.retriable = retriable + + +async def chunk_embed_and_upsert( + *, + document_id: int, + markdown: str, + chunker: MarkdownChunker, + embedder: EmbeddingProvider, + core_client: CoreClient, + log_prefix: str = "analyze", +) -> int: + chunks = chunker.split(markdown) + log.info( + f"{log_prefix}.chunk.done", + document_id=document_id, + chunk_count=len(chunks), + ) + + if not chunks: + return 0 + + try: + vectors = await embedder.embed([c.text for c in chunks]) + except EmbeddingError as err: + raise EmbeddingStepError( + code=err.code, message=err.message, retriable=err.retriable + ) from err + + if len(vectors) != len(chunks): + raise EmbeddingStepError( + code="EMBED_COUNT_MISMATCH", + message=(f"embedder가 chunk {len(chunks)}개 중 {len(vectors)}개만 반환"), + retriable=True, + ) + + payloads = [ + EmbeddingChunkPayload( + chunk_index=chunks[i].index, + chunk_text=chunks[i].text, + embedding=vectors[i], + ) + for i in range(len(chunks)) + ] + + try: + upserted = await core_client.upsert_embeddings( + document_id=document_id, + model=embedder.model, + dim=embedder.dim, + chunks=payloads, + ) + except CoreEmbeddingUpsertError as err: + raise EmbeddingStepError( + code=err.code, message=err.message, retriable=err.retriable + ) from err + + log.info( + f"{log_prefix}.embed.upserted", + document_id=document_id, + chunk_count=upserted, + model=embedder.model, + dim=embedder.dim, + ) + return upserted diff --git a/ai/src/ai_server/analyzer/repository_analyzer.py b/ai/src/ai_server/analyzer/repository_analyzer.py new file mode 100644 index 00000000..f02b9c27 --- /dev/null +++ b/ai/src/ai_server/analyzer/repository_analyzer.py @@ -0,0 +1,150 @@ +from __future__ import annotations + +from dataclasses import dataclass + +import structlog + +from ai_server.analyzer._embedding_step import ( + EmbeddingStepError, + chunk_embed_and_upsert, +) +from ai_server.analyzer.sources.github_repo import ( + GitHubRepoSourceExtractor, + RepositoryFetchError, +) +from ai_server.chain.document_analysis_chain import DocumentAnalyzer +from ai_server.core.client import CoreClient, CoreTokenError +from ai_server.rag.chunker import MarkdownChunker +from ai_server.rag.embedder import EmbeddingProvider +from ai_server.storage.base import ObjectStorage + +log = structlog.get_logger(__name__) + + +class RepositoryAnalyzeError(Exception): + def __init__(self, *, code: str, message: str, retriable: bool) -> None: + super().__init__(message) + self.code = code + self.message = message + self.retriable = retriable + + +@dataclass(frozen=True) +class RepositoryAnalysisResult: + summary: str + tech_stack: list[str] + document_path: str + embedding_chunk_count: int + + +# Core에서 사용자별 GitHub token 수령 → 레포 fetch → LLM 분석 → 마크다운 저장 → 청킹·임베딩 +class RepositoryAnalyzer: + def __init__( + self, + *, + extractor: GitHubRepoSourceExtractor, + core_client: CoreClient, + chain: DocumentAnalyzer, + storage: ObjectStorage, + chunker: MarkdownChunker, + embedder: EmbeddingProvider, + analyzed_key_template: str, + ) -> None: + self._extractor = extractor + self._core_client = core_client + self._chain = chain + self._storage = storage + self._chunker = chunker + self._embedder = embedder + self._analyzed_key_template = analyzed_key_template + + async def analyze( + self, + *, + repository_id: int, + repo_full_name: str, + default_branch: str = "main", + user_id: int | None, + analyzed_document_id: int, + ) -> RepositoryAnalysisResult: + if user_id is None: + raise RepositoryAnalyzeError( + code="MISSING_USER_ID", + message="envelope.context.userId 없이는 GitHub token을 가져올 수 없음", + retriable=False, + ) + + log.info( + "repository.token.fetch", + user_id=user_id, + repository_id=repository_id, + ) + try: + access_token = await self._core_client.fetch_github_token(user_id) + except CoreTokenError as err: + raise RepositoryAnalyzeError( + code=err.code, message=err.message, retriable=err.retriable + ) from err + + log.info( + "repository.extract.start", + repository_id=repository_id, + repo_full_name=repo_full_name, + default_branch=default_branch, + ) + try: + source = await self._extractor.extract( + repo_full_name, + access_token=access_token, + ) + except RepositoryFetchError as err: + raise RepositoryAnalyzeError( + code=err.code, message=err.message, retriable=err.retriable + ) from err + + if not source.text.strip(): + raise RepositoryAnalyzeError( + code="EMPTY_REPO_CONTENT", + message="레포에서 추출된 텍스트가 비어 있음", + retriable=False, + ) + + log.info( + "repository.llm.start", + repository_id=repository_id, + text_chars=len(source.text), + ) + analysis = await self._chain.analyze( + text=source.text, + source_type=source.source_type, + ) + + out_key = self._analyzed_key_template.format(repository_id=repository_id) + await self._storage.put_text(out_key, analysis.markdown) + log.info( + "repository.markdown.saved", + repository_id=repository_id, + key=out_key, + md_chars=len(analysis.markdown), + ) + + try: + chunk_count = await chunk_embed_and_upsert( + document_id=analyzed_document_id, + markdown=analysis.markdown, + chunker=self._chunker, + embedder=self._embedder, + core_client=self._core_client, + log_prefix="repository", + ) + except EmbeddingStepError as err: + raise RepositoryAnalyzeError( + code=err.code, message=err.message, retriable=err.retriable + ) from err + + return RepositoryAnalysisResult( + summary=analysis.summary, + tech_stack=list(analysis.tech_stack), + document_path=out_key, + embedding_chunk_count=chunk_count, + ) diff --git a/ai/src/ai_server/analyzer/resume_analyzer.py b/ai/src/ai_server/analyzer/resume_analyzer.py new file mode 100644 index 00000000..c82f0309 --- /dev/null +++ b/ai/src/ai_server/analyzer/resume_analyzer.py @@ -0,0 +1,118 @@ +from __future__ import annotations + +from dataclasses import dataclass + +import structlog + +from ai_server.analyzer._embedding_step import ( + EmbeddingStepError, + chunk_embed_and_upsert, +) +from ai_server.analyzer.sources.base import SourceExtractor +from ai_server.chain.document_analysis_chain import DocumentAnalyzer +from ai_server.core.client import CoreClient +from ai_server.rag.chunker import MarkdownChunker +from ai_server.rag.embedder import EmbeddingProvider +from ai_server.storage.base import ObjectStorage + +log = structlog.get_logger(__name__) + + +class ResumeAnalyzeError(Exception): + def __init__(self, *, code: str, message: str, retriable: bool) -> None: + super().__init__(message) + self.code = code + self.message = message + self.retriable = retriable + + +@dataclass(frozen=True) +class ResumeAnalysisResult: + summary: str + tech_stack: list[str] + document_path: str + embedding_chunk_count: int + + +# 스토리지에서 가져오고 LLM을 통해 분석 후 청킹·임베딩까지 처리 +class ResumeAnalyzer: + def __init__( + self, + *, + extractor: SourceExtractor, + chain: DocumentAnalyzer, + storage: ObjectStorage, + chunker: MarkdownChunker, + embedder: EmbeddingProvider, + core_client: CoreClient, + analyzed_key_template: str, + ) -> None: + self._extractor = extractor + self._chain = chain + self._storage = storage + self._chunker = chunker + self._embedder = embedder + self._core_client = core_client + self._analyzed_key_template = analyzed_key_template + + async def analyze( + self, + *, + resume_id: int, + file_path: str, + analyzed_document_id: int, + ) -> ResumeAnalysisResult: + log.info( + "resume.extract.start", + resume_id=resume_id, + file_path=file_path, + ) + source = await self._extractor.extract(file_path) + + if not source.text.strip(): + raise ResumeAnalyzeError( + code="EMPTY_PDF_TEXT", + message="추출된 텍스트가 비어있음", + retriable=False, + ) + + log.info( + "resume.llm.start", + resume_id=resume_id, + text_chars=len(source.text), + source_type=source.source_type, + ) + analysis = await self._chain.analyze( + text=source.text, + source_type=source.source_type, + ) + + out_key = self._analyzed_key_template.format(resume_id=resume_id) + await self._storage.put_text(out_key, analysis.markdown) + log.info( + "resume.markdown.saved", + resume_id=resume_id, + key=out_key, + md_chars=len(analysis.markdown), + ) + + try: + chunk_count = await chunk_embed_and_upsert( + document_id=analyzed_document_id, + markdown=analysis.markdown, + chunker=self._chunker, + embedder=self._embedder, + core_client=self._core_client, + log_prefix="resume", + ) + except EmbeddingStepError as err: + raise ResumeAnalyzeError( + code=err.code, message=err.message, retriable=err.retriable + ) from err + + return ResumeAnalysisResult( + summary=analysis.summary, + tech_stack=list(analysis.tech_stack), + document_path=out_key, + embedding_chunk_count=chunk_count, + ) diff --git a/ai/src/ai_server/analyzer/sources/__init__.py b/ai/src/ai_server/analyzer/sources/__init__.py new file mode 100644 index 00000000..53344b6e --- /dev/null +++ b/ai/src/ai_server/analyzer/sources/__init__.py @@ -0,0 +1,22 @@ +from ai_server.analyzer.sources.base import ( + ExtractedSource, + SourceExtractor, + SourceType, +) +from ai_server.analyzer.sources.github_repo import ( + GitHubRepoSourceExtractor, + RepositoryFetchError, +) +from ai_server.analyzer.sources.pdf import PdfSourceExtractor +from ai_server.analyzer.sources.web import WebFetchError, WebSourceExtractor + +__all__ = [ + "ExtractedSource", + "SourceExtractor", + "SourceType", + "PdfSourceExtractor", + "GitHubRepoSourceExtractor", + "RepositoryFetchError", + "WebSourceExtractor", + "WebFetchError", +] diff --git a/ai/src/ai_server/analyzer/sources/base.py b/ai/src/ai_server/analyzer/sources/base.py new file mode 100644 index 00000000..b8a4e0cb --- /dev/null +++ b/ai/src/ai_server/analyzer/sources/base.py @@ -0,0 +1,22 @@ +from __future__ import annotations + +from abc import ABC, abstractmethod +from typing import Any, Literal + +from pydantic import BaseModel, Field + +SourceType = Literal["PDF", "REPOSITORY", "WEB"] + + +# 모든 Source Extractor가 공통으로 반환하는 결과 모델 +class ExtractedSource(BaseModel): + text: str + source_type: SourceType + metadata: dict[str, Any] = Field(default_factory=dict) + + +# 단일 입력 형태에 대한 Adapter +class SourceExtractor(ABC): + + @abstractmethod + async def extract(self, locator: str) -> ExtractedSource: ... diff --git a/ai/src/ai_server/analyzer/sources/github_repo.py b/ai/src/ai_server/analyzer/sources/github_repo.py new file mode 100644 index 00000000..70ea5e47 --- /dev/null +++ b/ai/src/ai_server/analyzer/sources/github_repo.py @@ -0,0 +1,303 @@ +from __future__ import annotations + +import base64 +from dataclasses import dataclass + +import httpx +import structlog + +from ai_server.analyzer.sources.base import ExtractedSource, SourceExtractor + +log = structlog.get_logger(__name__) + + +# 프로젝트 설정 파일을 먼저 읽는다 +_PRIORITY_FILES: tuple[str, ...] = ( + "package.json", + "pyproject.toml", + "requirements.txt", + "go.mod", + "build.gradle", + "build.gradle.kts", + "pom.xml", + "Cargo.toml", + "composer.json", + "Gemfile", + "Dockerfile", +) + +_TEXT_EXTENSIONS: frozenset[str] = frozenset( + { + ".py", + ".ts", + ".tsx", + ".js", + ".jsx", + ".go", + ".java", + ".kt", + ".rs", + ".rb", + ".php", + ".cs", + ".swift", + ".md", + ".yaml", + ".yml", + ".toml", + ".json", + ".sh", + } +) + + +class RepositoryFetchError(Exception): + def __init__(self, *, code: str, message: str, retriable: bool) -> None: + super().__init__(message) + self.code = code + self.message = message + self.retriable = retriable + + +@dataclass(frozen=True) +class _RepoConfig: + api_base_url: str + fallback_token: str + max_files: int + max_file_bytes: int + timeout_sec: float + + +# 리드미, 주요 소스를 읽는다 +class GitHubRepoSourceExtractor(SourceExtractor): + def __init__( + self, + *, + api_base_url: str, + fallback_token: str = "", + max_files: int = 8, + max_file_bytes: int = 50_000, + timeout_sec: float = 30.0, + client: httpx.AsyncClient | None = None, + ) -> None: + self._cfg = _RepoConfig( + api_base_url=api_base_url.rstrip("/"), + fallback_token=fallback_token, + max_files=max_files, + max_file_bytes=max_file_bytes, + timeout_sec=timeout_sec, + ) + self._client = client + + async def extract( + self, + locator: str, + *, + access_token: str | None = None, + ) -> ExtractedSource: + """`locator`는 "owner/repo" 형태의 full name. + + `access_token`이 주어지면 그 token으로 호출(권장 — Core에서 받은 값). + None이면 fallback_token(public repo용)으로 호출. + """ + owner_repo = locator.strip().strip("/") + if "/" not in owner_repo: + raise RepositoryFetchError( + code="INVALID_REPO_LOCATOR", + message=f"locator must be 'owner/repo', got: {locator!r}", + retriable=False, + ) + + effective_token = access_token or self._cfg.fallback_token + + if self._client is not None: + return await self._extract_with_client(self._client, owner_repo) + + async with self._build_client(effective_token) as client: + return await self._extract_with_client(client, owner_repo) + + def _build_client(self, token: str) -> httpx.AsyncClient: + headers = { + "Accept": "application/vnd.github+json", + "X-GitHub-Api-Version": "2022-11-28", + } + if token: + headers["Authorization"] = f"Bearer {token}" + return httpx.AsyncClient( + base_url=self._cfg.api_base_url, + headers=headers, + timeout=self._cfg.timeout_sec, + ) + + async def _extract_with_client( + self, + client: httpx.AsyncClient, + owner_repo: str, + ) -> ExtractedSource: + repo_info = await self._fetch_json(client, f"/repos/{owner_repo}") + default_branch = repo_info.get("default_branch") or "main" + + readme_text = await self._fetch_readme(client, owner_repo) + tree_paths, truncated = await self._fetch_tree( + client, owner_repo, default_branch + ) + picked = _select_files(tree_paths, self._cfg.max_files) + snippets = await self._fetch_snippets( + client, owner_repo, default_branch, picked + ) + + parts: list[str] = [] + parts.append(f"# {owner_repo} (branch: {default_branch})") + if desc := repo_info.get("description"): + parts.append(f"\n> {desc}") + + if readme_text: + parts.append("\n## README\n") + parts.append(readme_text) + + parts.append("\n## File tree (truncated)\n") + parts.append("\n".join(f"- {p}" for p in tree_paths[:200])) + if truncated: + parts.append("\n(tree truncated by GitHub API)") + + if snippets: + parts.append("\n## Sampled sources\n") + for path, body in snippets: + parts.append(f"\n### `{path}`\n```\n{body}\n```") + + text = "\n".join(parts).strip() + return ExtractedSource( + text=text, + source_type="REPOSITORY", + metadata={ + "locator": owner_repo, + "default_branch": default_branch, + "tree_size": len(tree_paths), + "tree_truncated": truncated, + "sampled_files": [p for p, _ in snippets], + }, + ) + + async def _fetch_json(self, client: httpx.AsyncClient, path: str) -> dict: + resp = await client.get(path) + if resp.status_code == 404: + raise RepositoryFetchError( + code="REPO_NOT_FOUND", + message=f"GitHub returned 404 for {path}", + retriable=False, + ) + if resp.status_code in (401, 403): + raise RepositoryFetchError( + code="REPO_AUTH_OR_RATE", + message=f"GitHub auth/rate-limit: {resp.status_code} {resp.text[:200]}", + retriable=True, + ) + resp.raise_for_status() + return resp.json() + + async def _fetch_readme( + self, + client: httpx.AsyncClient, + owner_repo: str, + ) -> str: + try: + data = await self._fetch_json(client, f"/repos/{owner_repo}/readme") + except RepositoryFetchError as err: + if err.code == "REPO_NOT_FOUND": + return "" # README 없는 레포는 정상 + raise + if data.get("encoding") != "base64": + return "" + try: + return base64.b64decode(data.get("content", "")).decode( + "utf-8", errors="replace" + ) + except Exception: + return "" + + async def _fetch_tree( + self, + client: httpx.AsyncClient, + owner_repo: str, + branch: str, + ) -> tuple[list[str], bool]: + data = await self._fetch_json( + client, + f"/repos/{owner_repo}/git/trees/{branch}?recursive=1", + ) + tree = data.get("tree", []) + paths = [ + item["path"] + for item in tree + if item.get("type") == "blob" and item.get("path") + ] + return paths, bool(data.get("truncated")) + + async def _fetch_snippets( + self, + client: httpx.AsyncClient, + owner_repo: str, + branch: str, + picked: list[str], + ) -> list[tuple[str, str]]: + out: list[tuple[str, str]] = [] + for path in picked: + try: + data = await self._fetch_json( + client, f"/repos/{owner_repo}/contents/{path}?ref={branch}" + ) + except RepositoryFetchError as err: + log.warning( + "repo.snippet.skip", + path=path, + code=err.code, + ) + continue + if isinstance(data, list) or data.get("encoding") != "base64": + continue + try: + body = base64.b64decode(data.get("content", "")).decode( + "utf-8", errors="replace" + ) + except Exception: # noqa: BLE001 + continue + if len(body.encode("utf-8")) > self._cfg.max_file_bytes: + body = body[: self._cfg.max_file_bytes] + "\n... (truncated)" + out.append((path, body)) + return out + + +def _select_files(paths: list[str], cap: int) -> list[str]: + """우선순위 → 디렉토리당 대표 텍스트 파일 1개씩, 합쳐서 cap까지.""" + chosen: list[str] = [] + seen: set[str] = set() + + for needle in _PRIORITY_FILES: + for p in paths: + if p == needle or p.endswith("/" + needle): + if p not in seen: + chosen.append(p) + seen.add(p) + break + if len(chosen) >= cap: + return chosen + + by_dir: dict[str, str] = {} + for p in paths: + if p in seen: + continue + if "." not in p.rsplit("/", 1)[-1]: + continue + ext = "." + p.rsplit(".", 1)[-1].lower() + if ext not in _TEXT_EXTENSIONS: + continue + d = p.rsplit("/", 1)[0] if "/" in p else "" + if d not in by_dir: + by_dir[d] = p + for p in by_dir.values(): + if p not in seen: + chosen.append(p) + seen.add(p) + if len(chosen) >= cap: + break + return chosen diff --git a/ai/src/ai_server/analyzer/sources/pdf.py b/ai/src/ai_server/analyzer/sources/pdf.py new file mode 100644 index 00000000..8dcf38b3 --- /dev/null +++ b/ai/src/ai_server/analyzer/sources/pdf.py @@ -0,0 +1,33 @@ +from __future__ import annotations + +import asyncio +import io + +from pypdf import PdfReader + +from ai_server.analyzer.sources.base import ExtractedSource, SourceExtractor +from ai_server.storage.base import ObjectStorage + + +def _extract_pdf_text(data: bytes) -> str: + reader = PdfReader(io.BytesIO(data)) + parts: list[str] = [] + for page in reader.pages: + parts.append(page.extract_text() or "") + return "\n\n".join(p for p in parts if p).strip() + + +# PDF 를 읽어 페이지 텍스트를 이어붙인다. +class PdfSourceExtractor(SourceExtractor): + + def __init__(self, storage: ObjectStorage) -> None: + self._storage = storage + + async def extract(self, locator: str) -> ExtractedSource: + data = await self._storage.get_bytes(locator) + text = await asyncio.to_thread(_extract_pdf_text, data) + return ExtractedSource( + text=text, + source_type="PDF", + metadata={"locator": locator, "bytes": len(data)}, + ) diff --git a/ai/src/ai_server/analyzer/sources/web.py b/ai/src/ai_server/analyzer/sources/web.py new file mode 100644 index 00000000..ca44f9c5 --- /dev/null +++ b/ai/src/ai_server/analyzer/sources/web.py @@ -0,0 +1,128 @@ +from __future__ import annotations + +import asyncio + +import httpx +import structlog +import trafilatura + +from ai_server.analyzer.sources.base import ExtractedSource, SourceExtractor + +log = structlog.get_logger(__name__) + + +class WebFetchError(Exception): + def __init__(self, *, code: str, message: str, retriable: bool) -> None: + super().__init__(message) + self.code = code + self.message = message + self.retriable = retriable + + +# 라이브러리로 본문 추출 +class WebSourceExtractor(SourceExtractor): + def __init__( + self, + *, + timeout_sec: float = 20.0, + max_html_bytes: int = 2_000_000, + client: httpx.AsyncClient | None = None, + ) -> None: + self._timeout_sec = timeout_sec + self._max_html_bytes = max_html_bytes + self._client = client + + async def extract(self, locator: str) -> ExtractedSource: + url = locator.strip() + if not (url.startswith("http://") or url.startswith("https://")): + raise WebFetchError( + code="INVALID_WEB_URL", + message=f"locator must be http(s) URL, got: {locator!r}", + retriable=False, + ) + + html, final_url, content_type = await self._fetch_html(url) + text = await asyncio.to_thread(_extract_main_text, html, final_url) + + if not text.strip(): + raise WebFetchError( + code="EMPTY_WEB_BODY", + message="추출된 본문이 비어 있음 — JS 렌더링 페이지이거나 비어있는 문서일 가능성", + retriable=False, + ) + + return ExtractedSource( + text=text, + source_type="WEB", + metadata={ + "locator": url, + "final_url": final_url, + "content_type": content_type, + "html_bytes": len(html.encode("utf-8")), + "text_chars": len(text), + }, + ) + + async def _fetch_html(self, url: str) -> tuple[str, str, str]: + if self._client is not None: + return await self._do_fetch(self._client, url) + async with httpx.AsyncClient( + timeout=self._timeout_sec, + follow_redirects=True, + headers={ + "User-Agent": "StackUp-AI/1.0 (+resume web extractor)", + "Accept": "text/html,application/xhtml+xml", + }, + ) as client: + return await self._do_fetch(client, url) + + async def _do_fetch( + self, + client: httpx.AsyncClient, + url: str, + ) -> tuple[str, str, str]: + try: + resp = await client.get(url) + except httpx.HTTPError as exc: + raise WebFetchError( + code="WEB_FETCH_FAILED", + message=f"HTTP 요청 실패: {exc}", + retriable=True, + ) from exc + + if resp.status_code >= 400: + raise WebFetchError( + code="WEB_HTTP_STATUS", + message=f"HTTP {resp.status_code}", + retriable=resp.status_code >= 500, + ) + + content_type = resp.headers.get("content-type", "") + if "html" not in content_type.lower(): + raise WebFetchError( + code="WEB_NOT_HTML", + message=f"HTML이 아님 (content-type={content_type})", + retriable=False, + ) + + raw = resp.content + if len(raw) > self._max_html_bytes: + raise WebFetchError( + code="WEB_HTML_TOO_LARGE", + message=f"HTML이 한도({self._max_html_bytes}B)를 초과: {len(raw)}B", + retriable=False, + ) + + html = resp.text + return html, str(resp.url), content_type + + +def _extract_main_text(html: str, url: str) -> str: + result = trafilatura.extract( + html, + url=url, + include_comments=False, + include_tables=True, + favor_recall=True, + ) + return (result or "").strip() diff --git a/ai/src/ai_server/analyzer/web_resume_analyzer.py b/ai/src/ai_server/analyzer/web_resume_analyzer.py new file mode 100644 index 00000000..b5da258d --- /dev/null +++ b/ai/src/ai_server/analyzer/web_resume_analyzer.py @@ -0,0 +1,111 @@ +from __future__ import annotations + +from dataclasses import dataclass + +import structlog + +from ai_server.analyzer._embedding_step import ( + EmbeddingStepError, + chunk_embed_and_upsert, +) +from ai_server.analyzer.sources.base import SourceExtractor +from ai_server.analyzer.sources.web import WebFetchError +from ai_server.chain.document_analysis_chain import DocumentAnalyzer +from ai_server.core.client import CoreClient +from ai_server.rag.chunker import MarkdownChunker +from ai_server.rag.embedder import EmbeddingProvider +from ai_server.storage.base import ObjectStorage + +log = structlog.get_logger(__name__) + + +class WebResumeAnalyzeError(Exception): + def __init__(self, *, code: str, message: str, retriable: bool) -> None: + super().__init__(message) + self.code = code + self.message = message + self.retriable = retriable + + +@dataclass(frozen=True) +class WebResumeAnalysisResult: + summary: str + tech_stack: list[str] + document_path: str + embedding_chunk_count: int + + +# 웹포폴 -> 라이브러리로 텍스트만 -> LLM -> 마크다운 -> 청킹·임베딩·pgvector upsert +class WebResumeAnalyzer: + def __init__( + self, + *, + extractor: SourceExtractor, + chain: DocumentAnalyzer, + storage: ObjectStorage, + chunker: MarkdownChunker, + embedder: EmbeddingProvider, + core_client: CoreClient, + analyzed_key_template: str, + ) -> None: + self._extractor = extractor + self._chain = chain + self._storage = storage + self._chunker = chunker + self._embedder = embedder + self._core_client = core_client + self._analyzed_key_template = analyzed_key_template + + async def analyze( + self, + *, + resume_id: int, + url: str, + analyzed_document_id: int, + ) -> WebResumeAnalysisResult: + log.info("web_resume.extract.start", resume_id=resume_id, url=url) + try: + source = await self._extractor.extract(url) + except WebFetchError as err: + raise WebResumeAnalyzeError( + code=err.code, message=err.message, retriable=err.retriable + ) from err + + log.info( + "web_resume.llm.start", + resume_id=resume_id, + text_chars=len(source.text), + ) + analysis = await self._chain.analyze( + text=source.text, source_type=source.source_type + ) + + out_key = self._analyzed_key_template.format(resume_id=resume_id) + await self._storage.put_text(out_key, analysis.markdown) + log.info( + "web_resume.markdown.saved", + resume_id=resume_id, + key=out_key, + md_chars=len(analysis.markdown), + ) + + try: + chunk_count = await chunk_embed_and_upsert( + document_id=analyzed_document_id, + markdown=analysis.markdown, + chunker=self._chunker, + embedder=self._embedder, + core_client=self._core_client, + log_prefix="web_resume", + ) + except EmbeddingStepError as err: + raise WebResumeAnalyzeError( + code=err.code, message=err.message, retriable=err.retriable + ) from err + + return WebResumeAnalysisResult( + summary=analysis.summary, + tech_stack=list(analysis.tech_stack), + document_path=out_key, + embedding_chunk_count=chunk_count, + ) diff --git a/ai/src/ai_server/chain/__init__.py b/ai/src/ai_server/chain/__init__.py new file mode 100644 index 00000000..d0f5e24a --- /dev/null +++ b/ai/src/ai_server/chain/__init__.py @@ -0,0 +1,13 @@ +from ai_server.chain.document_analysis_chain import ( + DocumentAnalysisResult, + DocumentAnalyzer, + LlmDocumentAnalyzer, + build_document_analysis_chain, +) + +__all__ = [ + "DocumentAnalysisResult", + "DocumentAnalyzer", + "LlmDocumentAnalyzer", + "build_document_analysis_chain", +] diff --git a/ai/src/ai_server/chain/document_analysis_chain.py b/ai/src/ai_server/chain/document_analysis_chain.py new file mode 100644 index 00000000..b212e9c3 --- /dev/null +++ b/ai/src/ai_server/chain/document_analysis_chain.py @@ -0,0 +1,62 @@ +from __future__ import annotations + +from typing import Protocol + +from langchain_core.output_parsers import PydanticOutputParser +from langchain_core.prompts import ChatPromptTemplate +from langchain_core.runnables import Runnable +from pydantic import BaseModel, Field + +from ai_server.analyzer.sources.base import SourceType +from ai_server.chain.prompts.document_analysis import HUMAN_PROMPT, SYSTEM_PROMPT +from ai_server.config.settings import Settings + + +class DocumentAnalysisResult(BaseModel): + summary: str = Field(..., description="2~4 sentence Korean summary") + tech_stack: list[str] = Field(default_factory=list) + markdown: str = Field(..., description="Interviewer-facing markdown") + + +class DocumentAnalyzer(Protocol): + async def analyze( + self, *, text: str, source_type: SourceType + ) -> DocumentAnalysisResult: ... + + +# 랭체인 파이프라인 호출을 감싼다 +class LlmDocumentAnalyzer: + + def __init__(self, chain: Runnable) -> None: + self._chain = chain + + async def analyze( + self, *, text: str, source_type: SourceType + ) -> DocumentAnalysisResult: + result = await self._chain.ainvoke({"text": text, "source_type": source_type}) + if not isinstance(result, DocumentAnalysisResult): + raise TypeError( + f"chain returned {type(result).__name__}, expected DocumentAnalysisResult" + ) + return result + + +# 프롬프트 -> LLM -> 파서 하나로 묶어서 처리함 +def build_document_analysis_chain(settings: Settings) -> Runnable: + from langchain_openai import ChatOpenAI + + parser = PydanticOutputParser(pydantic_object=DocumentAnalysisResult) + prompt = ChatPromptTemplate.from_messages( + [ + ("system", SYSTEM_PROMPT), + ("human", HUMAN_PROMPT), + ] + ).partial(format_instructions=parser.get_format_instructions()) + + llm = ChatOpenAI( + model=settings.llm_pro_model, + temperature=settings.llm_pro_temperature, + api_key=settings.llm_api_key or None, + base_url=settings.llm_base_url, + ) + return prompt | llm | parser diff --git a/ai/src/ai_server/chain/prompts/__init__.py b/ai/src/ai_server/chain/prompts/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/ai/src/ai_server/chain/prompts/document_analysis.py b/ai/src/ai_server/chain/prompts/document_analysis.py new file mode 100644 index 00000000..6cc9784c --- /dev/null +++ b/ai/src/ai_server/chain/prompts/document_analysis.py @@ -0,0 +1,23 @@ +# 문서 분석 프롬프트 템플릿 +# source_type 으로 PDF, 웹, 리포지토리 구분함 +SYSTEM_PROMPT = ( + "당신은 IT 직군 채용을 진행하는 시니어 면접관입니다. " + "지원자 자료(이력서·레포 README·기술 블로그 등)를 분석하여 면접 준비에 사용할 " + "구조화된 결과를 산출하세요.\n" + "- 사실에 근거해서 작성하고, 자료에 없는 내용을 추측해 채우지 마세요.\n" + "- 한국어로 작성하되 기술 용어는 영문 원어를 그대로 둡니다.\n" + "- 응답은 반드시 지정된 JSON 스키마를 따릅니다." +) + +HUMAN_PROMPT = ( + "다음은 지원자 자료입니다 (출처 유형: {source_type}).\n" + "---\n" + "{text}\n" + "---\n\n" + "요구 사항:\n" + "1. summary: 2~4문장 한국어 요약.\n" + "2. tech_stack: 자료에 명시된 핵심 기술 5~15개. 영문 표기.\n" + "3. markdown: 면접관이 훑어볼 한국어 마크다운. 섹션 구조는 " + "'## 개요', '## 주요 경험', '## 기술', '## 추가 메모' 사용.\n\n" + "{format_instructions}" +) diff --git a/ai/src/ai_server/config/settings.py b/ai/src/ai_server/config/settings.py index 0a8530af..3d1a90f0 100644 --- a/ai/src/ai_server/config/settings.py +++ b/ai/src/ai_server/config/settings.py @@ -1,3 +1,5 @@ +from typing import Literal + from pydantic_settings import BaseSettings, SettingsConfigDict @@ -14,25 +16,67 @@ class Settings(BaseSettings): debug: bool = False # RabbitMQ - rabbitmq_url: str = "amqp://stackup:stackup@localhost:5672/" + rabbitmq_url: str = "amqp://stackup:stackup@localhost:38050/" - # AI consumer + # AI consumer (큐 이름, prefetch, 콜백 라우팅 등) ai_queue_resume: str = "ai.analyze.resume" + ai_queue_repository: str = "ai.analyze.repository" + ai_queue_web: str = "ai.analyze.web" ai_queue_prefetch: int = 10 ai_callback_exchange: str = "stackup.ai-to-core" ai_callback_routing_analysis: str = "callback.analysis" ai_publisher_name: str = "ai-server" ai_idempotency_lru_size: int = 1024 - # S3 / MinIO - s3_endpoint_url: str = "http://localhost:9000" + storage_backend: Literal["local", "s3"] = "s3" + storage_local_root: str = "./var/storage" + + s3_endpoint_url: str = "http://localhost:38060" s3_access_key: str = "" s3_secret_key: str = "" s3_bucket_name: str = "stackup" + s3_region: str = "us-east-1" + + # 일단 충대 API 키 사용 + llm_api_key: str = "" + llm_base_url: str = "https://factchat-cloud.mindlogic.ai/v1/gateway" + llm_pro_model: str = "gemini-3.1-pro-preview" + llm_pro_temperature: float = 0.2 + + analyzed_resume_md_key_template: str = "analyzed/resume/{resume_id}/summary.md" + analyzed_repository_md_key_template: str = ( + "analyzed/repository/{repository_id}/summary.md" + ) + analyzed_web_resume_md_key_template: str = ( + "analyzed/web-resume/{resume_id}/summary.md" + ) + + # Core 서버 internal API (사용자별 GitHub access_token 조회 등) + core_internal_base_url: str = "http://localhost:38010" + core_internal_api_key: str = "" + core_internal_timeout_sec: float = 10.0 + + # GitHub repo 분석용 + github_api_base_url: str = "https://api.github.com" + # 사용자별 token은 Core에서 받음. 아래 token은 fallback (public repo 한정). + github_fallback_token: str = "" + repo_max_source_files: int = 8 + repo_max_source_file_bytes: int = 50_000 + repo_fetch_timeout_sec: float = 30.0 + + # 웹 이력서 fetch + web_fetch_timeout_sec: float = 20.0 + web_max_html_bytes: int = 2_000_000 # 2MB 상한 + + # 임베딩 관련 + embedding_provider: Literal["mock", "gemini", "openai", "ollama"] = "mock" + embedding_model: str = "gemini-embedding-001" + embedding_dim: int = 1536 + embedding_chunk_size: int = 1000 + embedding_chunk_overlap: int = 200 + embedding_batch_size: int = 32 - # LLM API Keys - openai_api_key: str = "" - google_api_key: str = "" + gemini_api_key: str = "" def get_settings() -> Settings: diff --git a/ai/src/ai_server/core/__init__.py b/ai/src/ai_server/core/__init__.py new file mode 100644 index 00000000..7dbd25bb --- /dev/null +++ b/ai/src/ai_server/core/__init__.py @@ -0,0 +1,15 @@ +from ai_server.core.client import ( + CoreClient, + CoreEmbeddingUpsertError, + CoreTokenError, + EmbeddingChunkPayload, + HttpCoreClient, +) + +__all__ = [ + "CoreClient", + "CoreTokenError", + "CoreEmbeddingUpsertError", + "EmbeddingChunkPayload", + "HttpCoreClient", +] diff --git a/ai/src/ai_server/core/client.py b/ai/src/ai_server/core/client.py new file mode 100644 index 00000000..bfc3028e --- /dev/null +++ b/ai/src/ai_server/core/client.py @@ -0,0 +1,221 @@ +from __future__ import annotations + +from dataclasses import dataclass +from typing import Protocol + +import httpx +import structlog + +log = structlog.get_logger(__name__) + + +class CoreTokenError(Exception): + def __init__(self, *, code: str, message: str, retriable: bool) -> None: + super().__init__(message) + self.code = code + self.message = message + self.retriable = retriable + + +class CoreEmbeddingUpsertError(Exception): + def __init__(self, *, code: str, message: str, retriable: bool) -> None: + super().__init__(message) + self.code = code + self.message = message + self.retriable = retriable + + +@dataclass(frozen=True) +class EmbeddingChunkPayload: + chunk_index: int + chunk_text: str + embedding: list[float] + + +# 코어 서버 API 호출용 +class CoreClient(Protocol): + async def fetch_github_token(self, user_id: int) -> str: ... + + async def upsert_embeddings( + self, + *, + document_id: int, + model: str, + dim: int, + chunks: list[EmbeddingChunkPayload], + ) -> int: ... + + +class HttpCoreClient: + def __init__( + self, + *, + base_url: str, + api_key: str, + timeout_sec: float = 10.0, + client: httpx.AsyncClient | None = None, + ) -> None: + if not base_url: + raise ValueError("Core internal base_url 누락") + self._base_url = base_url.rstrip("/") + self._api_key = api_key + self._timeout_sec = timeout_sec + self._client = client + + async def fetch_github_token(self, user_id: int) -> str: + path = f"/api/internal/users/{user_id}/github-token" + if self._client is not None: + return await self._do_fetch(self._client, path) + async with self._build_client() as client: + return await self._do_fetch(client, path) + + def _build_client(self) -> httpx.AsyncClient: + return httpx.AsyncClient( + base_url=self._base_url, + headers={"X-Internal-API-Key": self._api_key}, + timeout=self._timeout_sec, + ) + + async def _do_fetch(self, client: httpx.AsyncClient, path: str) -> str: + try: + resp = await client.get(path) + except httpx.HTTPError as exc: + raise CoreTokenError( + code="CORE_UNAVAILABLE", + message=f"Core API 호출 실패: {exc}", + retriable=True, + ) from exc + + status = resp.status_code + if status == 404: + raise CoreTokenError( + code="USER_NOT_FOUND", + message=f"Core에 사용자 또는 token 없음: {path}", + retriable=False, + ) + if status in (401, 403): + raise CoreTokenError( + code="CORE_AUTH_FAILED", + message=f"Core internal 인증 실패: {status}", + retriable=False, + ) + if status >= 500: + raise CoreTokenError( + code="CORE_UNAVAILABLE", + message=f"Core 5xx: {status}", + retriable=True, + ) + if status >= 400: + raise CoreTokenError( + code="CORE_BAD_REQUEST", + message=f"Core {status}: {resp.text[:200]}", + retriable=False, + ) + + try: + data = resp.json() + except ValueError as exc: + raise CoreTokenError( + code="CORE_BAD_RESPONSE", + message=f"Core 응답 JSON 파싱 실패: {exc}", + retriable=True, + ) from exc + + token = data.get("accessToken") or data.get("access_token") + if not token or not isinstance(token, str): + raise CoreTokenError( + code="CORE_TOKEN_EMPTY", + message="Core 응답에 accessToken 없음", + retriable=True, + ) + return token + + async def upsert_embeddings( + self, + *, + document_id: int, + model: str, + dim: int, + chunks: list[EmbeddingChunkPayload], + ) -> int: + """`analyzed_documents.id` 한 건에 대한 chunk + embedding을 Core가 + pgvector(document_embeddings)에 idempotent upsert. + + body: + { "model": "...", "dim": 1536, + "chunks": [{ "chunkIndex": 0, "chunkText": "...", "embedding": [...] }, ...] } + + 반환: upsert된 chunk 수. + """ + body = { + "model": model, + "dim": dim, + "chunks": [ + { + "chunkIndex": c.chunk_index, + "chunkText": c.chunk_text, + "embedding": c.embedding, + } + for c in chunks + ], + } + path = f"/api/internal/documents/{document_id}/embeddings" + if self._client is not None: + return await self._do_upsert(self._client, path, body) + async with self._build_client() as client: + return await self._do_upsert(client, path, body) + + async def _do_upsert( + self, + client: httpx.AsyncClient, + path: str, + body: dict, + ) -> int: + try: + resp = await client.put(path, json=body) + except httpx.HTTPError as exc: + raise CoreEmbeddingUpsertError( + code="CORE_UNAVAILABLE", + message=f"Core API 호출 실패: {exc}", + retriable=True, + ) from exc + + status = resp.status_code + if status == 404: + raise CoreEmbeddingUpsertError( + code="DOCUMENT_NOT_FOUND", + message=f"Core에 document 없음: {path}", + retriable=False, + ) + if status in (401, 403): + raise CoreEmbeddingUpsertError( + code="CORE_AUTH_FAILED", + message=f"Core internal 인증 실패: {status}", + retriable=False, + ) + if status >= 500: + raise CoreEmbeddingUpsertError( + code="CORE_UNAVAILABLE", + message=f"Core 5xx: {status}", + retriable=True, + ) + if status >= 400: + raise CoreEmbeddingUpsertError( + code="CORE_BAD_REQUEST", + message=f"Core {status}: {resp.text[:200]}", + retriable=False, + ) + + try: + data = resp.json() + except ValueError as exc: + raise CoreEmbeddingUpsertError( + code="CORE_BAD_RESPONSE", + message=f"Core 응답 JSON 파싱 실패: {exc}", + retriable=True, + ) from exc + + count = data.get("upserted") if isinstance(data, dict) else None + if not isinstance(count, int): + return len(body["chunks"]) + return count diff --git a/ai/src/ai_server/messaging/consumers/repository_consumer.py b/ai/src/ai_server/messaging/consumers/repository_consumer.py new file mode 100644 index 00000000..97f50ce2 --- /dev/null +++ b/ai/src/ai_server/messaging/consumers/repository_consumer.py @@ -0,0 +1,142 @@ +from __future__ import annotations + +import structlog +from aio_pika.abc import AbstractIncomingMessage + +from ai_server.analyzer.repository_analyzer import ( + RepositoryAnalyzeError, + RepositoryAnalyzer, +) +from ai_server.messaging.idempotency import LruIdempotencyStore +from ai_server.messaging.publisher import CallbackPublisher +from ai_server.model.envelope import Envelope +from ai_server.model.messages.analyze import ( + AnalysisCallbackPayload, + RepositoryAnalyzeRequest, +) + +log = structlog.get_logger(__name__) + + +class RepositoryConsumer: + def __init__( + self, + *, + analyzer: RepositoryAnalyzer, + publisher: CallbackPublisher, + idempotency: LruIdempotencyStore, + callback_routing_key: str, + ) -> None: + self._analyzer = analyzer + self._publisher = publisher + self._idempotency = idempotency + self._callback_routing_key = callback_routing_key + + async def handle(self, message: AbstractIncomingMessage) -> None: + async with message.process(requeue=False): + try: + envelope = Envelope[RepositoryAnalyzeRequest].model_validate_json( + message.body + ) + except Exception as exc: + log.error( + "repository.parse.failed", + error=str(exc), + delivery_tag=message.delivery_tag, + ) + raise + + if self._idempotency.is_seen_then_mark(envelope.message_id): + log.info( + "repository.idempotent.skip", + message_id=envelope.message_id, + trace_id=envelope.trace_id, + ) + return + + req = envelope.payload + user_id = envelope.context.user_id + log.info( + "repository.analyze.start", + message_id=envelope.message_id, + repository_id=req.repository_id, + repo_full_name=req.repo_full_name, + user_id=user_id, + trace_id=envelope.trace_id, + ) + + payload = await self._run_and_build_payload( + req, user_id=user_id, trace_id=envelope.trace_id + ) + + await self._publisher.publish( + routing_key=self._callback_routing_key, + message_type="callback.analysis", + payload=payload, + trace_id=envelope.trace_id, + correlation_id=envelope.message_id, + context=envelope.context, + ) + log.info( + "repository.analyze.done", + message_id=envelope.message_id, + repository_id=req.repository_id, + status=payload.status, + trace_id=envelope.trace_id, + ) + + async def _run_and_build_payload( + self, + req: RepositoryAnalyzeRequest, + *, + user_id: int | None, + trace_id: str, + ) -> AnalysisCallbackPayload: + try: + result = await self._analyzer.analyze( + repository_id=req.repository_id, + repo_full_name=req.repo_full_name, + default_branch=req.default_branch, + user_id=user_id, + analyzed_document_id=req.analyzed_document_id, + ) + except RepositoryAnalyzeError as err: + log.warning( + "repository.analyze.domain_failed", + repository_id=req.repository_id, + code=err.code, + retriable=err.retriable, + trace_id=trace_id, + ) + return AnalysisCallbackPayload( + target_type="REPOSITORY", + target_id=req.repository_id, + status="FAILED", + error_code=err.code, + error_message=err.message, + retriable=err.retriable, + ) + except Exception as exc: + log.exception( + "repository.analyze.unexpected_failed", + repository_id=req.repository_id, + trace_id=trace_id, + ) + return AnalysisCallbackPayload( + target_type="REPOSITORY", + target_id=req.repository_id, + status="FAILED", + error_code="UNEXPECTED", + error_message=str(exc), + retriable=True, + ) + + return AnalysisCallbackPayload( + target_type="REPOSITORY", + target_id=req.repository_id, + status="ANALYZED", + summary=result.summary, + tech_stack=result.tech_stack, + document_path=result.document_path, + embedding_chunk_count=result.embedding_chunk_count, + ) diff --git a/ai/src/ai_server/messaging/consumers/resume_consumer.py b/ai/src/ai_server/messaging/consumers/resume_consumer.py index 6142359e..8aa90944 100644 --- a/ai/src/ai_server/messaging/consumers/resume_consumer.py +++ b/ai/src/ai_server/messaging/consumers/resume_consumer.py @@ -3,6 +3,7 @@ import structlog from aio_pika.abc import AbstractIncomingMessage +from ai_server.analyzer.resume_analyzer import ResumeAnalyzeError, ResumeAnalyzer from ai_server.messaging.idempotency import LruIdempotencyStore from ai_server.messaging.publisher import CallbackPublisher from ai_server.model.envelope import Envelope @@ -14,29 +15,16 @@ log = structlog.get_logger(__name__) -def build_echo_callback( - envelope: Envelope[ResumeAnalyzeRequest], -) -> AnalysisCallbackPayload: - rid = envelope.payload.resume_id - return AnalysisCallbackPayload( - target_type="RESUME", - target_id=rid, - status="ANALYZED", - summary="[ECHO] not yet analyzed", - tech_stack=[], - document_s3_key=f"echo/resume/{rid}/echo.md", - embedding_chunk_count=0, - ) - - class ResumeConsumer: def __init__( self, *, + analyzer: ResumeAnalyzer, publisher: CallbackPublisher, idempotency: LruIdempotencyStore, callback_routing_key: str, ) -> None: + self._analyzer = analyzer self._publisher = publisher self._idempotency = idempotency self._callback_routing_key = callback_routing_key @@ -63,24 +51,80 @@ async def handle(self, message: AbstractIncomingMessage) -> None: ) return + req = envelope.payload log.info( - "resume.echo.start", + "resume.analyze.start", message_id=envelope.message_id, - resume_id=envelope.payload.resume_id, + resume_id=req.resume_id, trace_id=envelope.trace_id, ) - callback = build_echo_callback(envelope) + + payload = await self._run_and_build_payload(req, envelope.trace_id) + await self._publisher.publish( routing_key=self._callback_routing_key, message_type="callback.analysis", - payload=callback, + payload=payload, trace_id=envelope.trace_id, correlation_id=envelope.message_id, context=envelope.context, ) log.info( - "resume.echo.done", + "resume.analyze.done", message_id=envelope.message_id, - resume_id=envelope.payload.resume_id, + resume_id=req.resume_id, + status=payload.status, trace_id=envelope.trace_id, ) + + async def _run_and_build_payload( + self, + req: ResumeAnalyzeRequest, + trace_id: str, + ) -> AnalysisCallbackPayload: + try: + result = await self._analyzer.analyze( + resume_id=req.resume_id, + file_path=req.file_path, + analyzed_document_id=req.analyzed_document_id, + ) + except ResumeAnalyzeError as err: + log.warning( + "resume.analyze.domain_failed", + resume_id=req.resume_id, + code=err.code, + retriable=err.retriable, + trace_id=trace_id, + ) + return AnalysisCallbackPayload( + target_type="RESUME", + target_id=req.resume_id, + status="FAILED", + error_code=err.code, + error_message=err.message, + retriable=err.retriable, + ) + except Exception as exc: + log.exception( + "resume.analyze.unexpected_failed", + resume_id=req.resume_id, + trace_id=trace_id, + ) + return AnalysisCallbackPayload( + target_type="RESUME", + target_id=req.resume_id, + status="FAILED", + error_code="UNEXPECTED", + error_message=str(exc), + retriable=True, + ) + + return AnalysisCallbackPayload( + target_type="RESUME", + target_id=req.resume_id, + status="ANALYZED", + summary=result.summary, + tech_stack=result.tech_stack, + document_path=result.document_path, + embedding_chunk_count=result.embedding_chunk_count, + ) diff --git a/ai/src/ai_server/messaging/consumers/web_consumer.py b/ai/src/ai_server/messaging/consumers/web_consumer.py new file mode 100644 index 00000000..830b4a89 --- /dev/null +++ b/ai/src/ai_server/messaging/consumers/web_consumer.py @@ -0,0 +1,134 @@ +from __future__ import annotations + +import structlog +from aio_pika.abc import AbstractIncomingMessage + +from ai_server.analyzer.web_resume_analyzer import ( + WebResumeAnalyzeError, + WebResumeAnalyzer, +) +from ai_server.messaging.idempotency import LruIdempotencyStore +from ai_server.messaging.publisher import CallbackPublisher +from ai_server.model.envelope import Envelope +from ai_server.model.messages.analyze import ( + AnalysisCallbackPayload, + WebResumeAnalyzeRequest, +) + +log = structlog.get_logger(__name__) + + +class WebResumeConsumer: + def __init__( + self, + *, + analyzer: WebResumeAnalyzer, + publisher: CallbackPublisher, + idempotency: LruIdempotencyStore, + callback_routing_key: str, + ) -> None: + self._analyzer = analyzer + self._publisher = publisher + self._idempotency = idempotency + self._callback_routing_key = callback_routing_key + + async def handle(self, message: AbstractIncomingMessage) -> None: + async with message.process(requeue=False): + try: + envelope = Envelope[WebResumeAnalyzeRequest].model_validate_json( + message.body + ) + except Exception as exc: + log.error( + "web_resume.parse.failed", + error=str(exc), + delivery_tag=message.delivery_tag, + ) + raise + + if self._idempotency.is_seen_then_mark(envelope.message_id): + log.info( + "web_resume.idempotent.skip", + message_id=envelope.message_id, + trace_id=envelope.trace_id, + ) + return + + req = envelope.payload + log.info( + "web_resume.analyze.start", + message_id=envelope.message_id, + resume_id=req.resume_id, + url=req.url, + trace_id=envelope.trace_id, + ) + + payload = await self._run_and_build_payload(req, envelope.trace_id) + + await self._publisher.publish( + routing_key=self._callback_routing_key, + message_type="callback.analysis", + payload=payload, + trace_id=envelope.trace_id, + correlation_id=envelope.message_id, + context=envelope.context, + ) + log.info( + "web_resume.analyze.done", + message_id=envelope.message_id, + resume_id=req.resume_id, + status=payload.status, + trace_id=envelope.trace_id, + ) + + async def _run_and_build_payload( + self, + req: WebResumeAnalyzeRequest, + trace_id: str, + ) -> AnalysisCallbackPayload: + try: + result = await self._analyzer.analyze( + resume_id=req.resume_id, + url=req.url, + analyzed_document_id=req.analyzed_document_id, + ) + except WebResumeAnalyzeError as err: + log.warning( + "web_resume.analyze.domain_failed", + resume_id=req.resume_id, + code=err.code, + retriable=err.retriable, + trace_id=trace_id, + ) + return AnalysisCallbackPayload( + target_type="WEB", + target_id=req.resume_id, + status="FAILED", + error_code=err.code, + error_message=err.message, + retriable=err.retriable, + ) + except Exception as exc: + log.exception( + "web_resume.analyze.unexpected_failed", + resume_id=req.resume_id, + trace_id=trace_id, + ) + return AnalysisCallbackPayload( + target_type="WEB", + target_id=req.resume_id, + status="FAILED", + error_code="UNEXPECTED", + error_message=str(exc), + retriable=True, + ) + + return AnalysisCallbackPayload( + target_type="WEB", + target_id=req.resume_id, + status="ANALYZED", + summary=result.summary, + tech_stack=result.tech_stack, + document_path=result.document_path, + embedding_chunk_count=result.embedding_chunk_count, + ) diff --git a/ai/src/ai_server/messaging/runner.py b/ai/src/ai_server/messaging/runner.py index fdded71b..3b437b94 100644 --- a/ai/src/ai_server/messaging/runner.py +++ b/ai/src/ai_server/messaging/runner.py @@ -3,17 +3,33 @@ import structlog from aio_pika.abc import AbstractRobustQueue +from ai_server.analyzer.repository_analyzer import RepositoryAnalyzer +from ai_server.analyzer.resume_analyzer import ResumeAnalyzer +from ai_server.analyzer.sources.github_repo import GitHubRepoSourceExtractor +from ai_server.analyzer.sources.pdf import PdfSourceExtractor +from ai_server.analyzer.sources.web import WebSourceExtractor +from ai_server.analyzer.web_resume_analyzer import WebResumeAnalyzer +from ai_server.chain.document_analysis_chain import ( + LlmDocumentAnalyzer, + build_document_analysis_chain, +) from ai_server.config.settings import Settings +from ai_server.core.client import HttpCoreClient from ai_server.messaging.connection import RabbitConnection +from ai_server.rag.chunker import MarkdownChunker +from ai_server.rag.embedder import build_embedding_provider +from ai_server.messaging.consumers.repository_consumer import RepositoryConsumer from ai_server.messaging.consumers.resume_consumer import ResumeConsumer +from ai_server.messaging.consumers.web_consumer import WebResumeConsumer from ai_server.messaging.idempotency import LruIdempotencyStore from ai_server.messaging.publisher import CallbackPublisher +from ai_server.storage.factory import build_storage log = structlog.get_logger(__name__) +# FastAPI 서버가 켜져있는 동안 메세지를 보관함 class MessagingRuntime: - """Holds long-lived messaging components for FastAPI lifespan.""" def __init__(self, settings: Settings) -> None: self._settings = settings @@ -29,34 +45,128 @@ def __init__(self, settings: Settings) -> None: self._idempotency = LruIdempotencyStore( max_size=settings.ai_idempotency_lru_size, ) + + storage = build_storage(settings) + chain = build_document_analysis_chain(settings) + chain_analyzer = LlmDocumentAnalyzer(chain) + + chunker = MarkdownChunker( + chunk_size=settings.embedding_chunk_size, + chunk_overlap=settings.embedding_chunk_overlap, + ) + embedder = build_embedding_provider( + provider=settings.embedding_provider, + dim=settings.embedding_dim, + model=settings.embedding_model, + gemini_api_key=settings.gemini_api_key, + ) + + core_client = HttpCoreClient( + base_url=settings.core_internal_base_url, + api_key=settings.core_internal_api_key, + timeout_sec=settings.core_internal_timeout_sec, + ) + + # 이력서 PDF + resume_analyzer = ResumeAnalyzer( + extractor=PdfSourceExtractor(storage=storage), + chain=chain_analyzer, + storage=storage, + chunker=chunker, + embedder=embedder, + core_client=core_client, + analyzed_key_template=settings.analyzed_resume_md_key_template, + ) + + # 리포지토리 + repo_analyzer = RepositoryAnalyzer( + extractor=GitHubRepoSourceExtractor( + api_base_url=settings.github_api_base_url, + fallback_token=settings.github_fallback_token, + max_files=settings.repo_max_source_files, + max_file_bytes=settings.repo_max_source_file_bytes, + timeout_sec=settings.repo_fetch_timeout_sec, + ), + core_client=core_client, + chain=chain_analyzer, + storage=storage, + chunker=chunker, + embedder=embedder, + analyzed_key_template=settings.analyzed_repository_md_key_template, + ) + + # 웹 이력서 + web_analyzer = WebResumeAnalyzer( + extractor=WebSourceExtractor( + timeout_sec=settings.web_fetch_timeout_sec, + max_html_bytes=settings.web_max_html_bytes, + ), + chain=chain_analyzer, + storage=storage, + chunker=chunker, + embedder=embedder, + core_client=core_client, + analyzed_key_template=settings.analyzed_web_resume_md_key_template, + ) + self._resume_consumer = ResumeConsumer( + analyzer=resume_analyzer, + publisher=self._publisher, + idempotency=self._idempotency, + callback_routing_key=settings.ai_callback_routing_analysis, + ) + self._repository_consumer = RepositoryConsumer( + analyzer=repo_analyzer, + publisher=self._publisher, + idempotency=self._idempotency, + callback_routing_key=settings.ai_callback_routing_analysis, + ) + self._web_consumer = WebResumeConsumer( + analyzer=web_analyzer, publisher=self._publisher, idempotency=self._idempotency, callback_routing_key=settings.ai_callback_routing_analysis, ) - self._resume_queue: AbstractRobustQueue | None = None - self._consumer_tag: str | None = None + + self._consumers: list[tuple[AbstractRobustQueue, str]] = [] async def start(self) -> None: await self._connection.open() await self._publisher.open() channel = self._connection.channel + + await self._start_consumer( + channel, + queue_name=self._settings.ai_queue_resume, + handler=self._resume_consumer.handle, + ) + await self._start_consumer( + channel, + queue_name=self._settings.ai_queue_repository, + handler=self._repository_consumer.handle, + ) + await self._start_consumer( + channel, + queue_name=self._settings.ai_queue_web, + handler=self._web_consumer.handle, + ) + + async def _start_consumer(self, channel, *, queue_name, handler) -> None: queue = await channel.declare_queue( - self._settings.ai_queue_resume, + queue_name, durable=True, - passive=True, # 정의 파일이 이미 선언함 - ) - self._resume_queue = queue - self._consumer_tag = await queue.consume(self._resume_consumer.handle) - log.info( - "ai.consumer.started", - queue=self._settings.ai_queue_resume, - consumer_tag=self._consumer_tag, + passive=True, ) + tag = await queue.consume(handler) + self._consumers.append((queue, tag)) + log.info("ai.consumer.started", queue=queue_name, consumer_tag=tag) async def stop(self) -> None: - if self._resume_queue is not None and self._consumer_tag is not None: - await self._resume_queue.cancel(self._consumer_tag) - log.info("ai.consumer.stopped", consumer_tag=self._consumer_tag) + for queue, tag in self._consumers: + try: + await queue.cancel(tag) + log.info("ai.consumer.stopped", consumer_tag=tag) + except Exception: + log.exception("ai.consumer.cancel_failed", consumer_tag=tag) await self._connection.close() diff --git a/ai/src/ai_server/model/messages/analyze.py b/ai/src/ai_server/model/messages/analyze.py index a9a5f473..aff39b25 100644 --- a/ai/src/ai_server/model/messages/analyze.py +++ b/ai/src/ai_server/model/messages/analyze.py @@ -4,7 +4,7 @@ from ai_server.model._config import camel_config -TargetType = Literal["RESUME", "REPOSITORY"] +TargetType = Literal["RESUME", "REPOSITORY", "WEB"] AnalysisStatus = Literal["ANALYZED", "FAILED"] @@ -12,7 +12,25 @@ class ResumeAnalyzeRequest(BaseModel): model_config = camel_config() resume_id: int - s3_key: str + file_path: str + analyzed_document_id: int + + +class RepositoryAnalyzeRequest(BaseModel): + model_config = camel_config() + + repository_id: int + repo_full_name: str + default_branch: str = "main" + analyzed_document_id: int + + +class WebResumeAnalyzeRequest(BaseModel): + model_config = camel_config() + + resume_id: int + url: str + analyzed_document_id: int class AnalysisCallbackPayload(BaseModel): @@ -23,7 +41,7 @@ class AnalysisCallbackPayload(BaseModel): status: AnalysisStatus summary: str | None = None tech_stack: list[str] = [] - document_s3_key: str | None = None + document_path: str | None = None embedding_chunk_count: int = 0 error_code: str | None = None error_message: str | None = None diff --git a/ai/src/ai_server/rag/__init__.py b/ai/src/ai_server/rag/__init__.py new file mode 100644 index 00000000..865cb7eb --- /dev/null +++ b/ai/src/ai_server/rag/__init__.py @@ -0,0 +1,18 @@ +from ai_server.rag.chunker import Chunk, MarkdownChunker +from ai_server.rag.embedder import ( + EmbeddingError, + EmbeddingProvider, + GeminiEmbeddingProvider, + MockEmbeddingProvider, + build_embedding_provider, +) + +__all__ = [ + "Chunk", + "MarkdownChunker", + "EmbeddingError", + "EmbeddingProvider", + "MockEmbeddingProvider", + "GeminiEmbeddingProvider", + "build_embedding_provider", +] diff --git a/ai/src/ai_server/rag/chunker.py b/ai/src/ai_server/rag/chunker.py new file mode 100644 index 00000000..8c6a6ed4 --- /dev/null +++ b/ai/src/ai_server/rag/chunker.py @@ -0,0 +1,33 @@ +from __future__ import annotations + +from dataclasses import dataclass + +from langchain_text_splitters import RecursiveCharacterTextSplitter + + +@dataclass(frozen=True) +class Chunk: + index: int + text: str + + +# md를 잘라냄. size와 overlap은 설정에서 가져다 씀 +class MarkdownChunker: + def __init__(self, *, chunk_size: int, chunk_overlap: int) -> None: + if chunk_size <= 0: + raise ValueError(f"chunk_size must be > 0, got {chunk_size}") + if chunk_overlap < 0 or chunk_overlap >= chunk_size: + raise ValueError( + f"chunk_overlap must be in [0, chunk_size), got {chunk_overlap}" + ) + self._splitter = RecursiveCharacterTextSplitter( + chunk_size=chunk_size, + chunk_overlap=chunk_overlap, + length_function=len, + ) + + def split(self, markdown: str) -> list[Chunk]: + if not markdown or not markdown.strip(): + return [] + parts = self._splitter.split_text(markdown) + return [Chunk(index=i, text=text) for i, text in enumerate(parts)] diff --git a/ai/src/ai_server/rag/embedder.py b/ai/src/ai_server/rag/embedder.py new file mode 100644 index 00000000..d6239758 --- /dev/null +++ b/ai/src/ai_server/rag/embedder.py @@ -0,0 +1,117 @@ +from __future__ import annotations + +import hashlib +import struct +from typing import Protocol + + +class EmbeddingError(Exception): + def __init__(self, *, code: str, message: str, retriable: bool) -> None: + super().__init__(message) + self.code = code + self.message = message + self.retriable = retriable + + +# 구현체는 바꿔서 사용할 수 있음 +class EmbeddingProvider(Protocol): + @property + def dim(self) -> int: ... + + @property + def model(self) -> str: ... + + async def embed(self, texts: list[str]) -> list[list[float]]: ... + + +# 우선 mock 구현체 +class MockEmbeddingProvider: + def __init__(self, *, dim: int = 1536, model: str = "mock") -> None: + if dim <= 0: + raise ValueError(f"dim must be > 0, got {dim}") + self._dim = dim + self._model = model + + @property + def dim(self) -> int: + return self._dim + + @property + def model(self) -> str: + return self._model + + async def embed(self, texts: list[str]) -> list[list[float]]: + return [self._embed_one(t) for t in texts] + + def _embed_one(self, text: str) -> list[float]: + # [-1, 1] 범위로 매핑 진행 + digest = hashlib.sha256(text.encode("utf-8")).digest() + repeats = (self._dim * 4 + len(digest) - 1) // len(digest) + blob = (digest * repeats)[: self._dim * 4] + ints = struct.unpack(f">{self._dim}I", blob) + scale = 2.0 / 0xFFFFFFFF + return [v * scale - 1.0 for v in ints] + + +# Gemini Embedding 을 사용합니다. +# 이건 충대키로 안되니 키 발급 필요함 +class GeminiEmbeddingProvider: + def __init__(self, *, api_key: str, model: str, dim: int) -> None: + if not api_key: + raise ValueError("GEMINI_API_KEY 누락 — provider=gemini 사용 불가") + if dim <= 0: + raise ValueError(f"dim must be > 0, got {dim}") + from google import genai + + self._client = genai.Client(api_key=api_key) + self._model = model + self._dim = dim + + @property + def dim(self) -> int: + return self._dim + + @property + def model(self) -> str: + return self._model + + async def embed(self, texts: list[str]) -> list[list[float]]: + if not texts: + return [] + from google.genai import types as genai_types + + try: + resp = await self._client.aio.models.embed_content( + model=self._model, + contents=texts, + config=genai_types.EmbedContentConfig( + task_type="RETRIEVAL_DOCUMENT", + output_dimensionality=self._dim, + ), + ) + except Exception as exc: + raise EmbeddingError( + code="GEMINI_FAILED", + message=f"Gemini embedding 호출 실패: {exc}", + retriable=True, + ) from exc + + return [list(e.values) for e in resp.embeddings] + + +def build_embedding_provider( + *, + provider: str, + dim: int, + model: str, + gemini_api_key: str = "", +) -> EmbeddingProvider: + if provider == "mock": + return MockEmbeddingProvider(dim=dim, model=model) + if provider == "gemini": + return GeminiEmbeddingProvider(api_key=gemini_api_key, model=model, dim=dim) + if provider == "openai": + raise NotImplementedError("openai embedding provider 미구현 — 후속 PR에서 추가") + if provider == "ollama": + raise NotImplementedError("ollama embedding provider 미구현 — 후속 PR에서 추가") + raise ValueError(f"Unsupported EMBEDDING_PROVIDER={provider!r}") diff --git a/ai/src/ai_server/storage/__init__.py b/ai/src/ai_server/storage/__init__.py new file mode 100644 index 00000000..d4b17699 --- /dev/null +++ b/ai/src/ai_server/storage/__init__.py @@ -0,0 +1,12 @@ +from ai_server.storage.base import ObjectStorage, StorageKeyError +from ai_server.storage.factory import build_storage +from ai_server.storage.local_fs import LocalFilesystemStorage +from ai_server.storage.s3 import S3Storage + +__all__ = [ + "ObjectStorage", + "StorageKeyError", + "LocalFilesystemStorage", + "S3Storage", + "build_storage", +] diff --git a/ai/src/ai_server/storage/base.py b/ai/src/ai_server/storage/base.py new file mode 100644 index 00000000..b9b95744 --- /dev/null +++ b/ai/src/ai_server/storage/base.py @@ -0,0 +1,35 @@ +from __future__ import annotations + +from abc import ABC, abstractmethod + + +# 스토리지 키 전용 에러 +class StorageKeyError(ValueError): + pass + + +class ObjectStorage(ABC): + + @abstractmethod + async def get_bytes(self, key: str) -> bytes: ... + + @abstractmethod + async def put_bytes( + self, + key: str, + data: bytes, + *, + content_type: str | None = None, + ) -> None: ... + + @abstractmethod + async def put_text( + self, + key: str, + text: str, + *, + content_type: str = "text/markdown; charset=utf-8", + ) -> None: ... + + @abstractmethod + async def exists(self, key: str) -> bool: ... diff --git a/ai/src/ai_server/storage/factory.py b/ai/src/ai_server/storage/factory.py new file mode 100644 index 00000000..7587d6f2 --- /dev/null +++ b/ai/src/ai_server/storage/factory.py @@ -0,0 +1,22 @@ +from __future__ import annotations + +from ai_server.config.settings import Settings +from ai_server.storage.base import ObjectStorage +from ai_server.storage.local_fs import LocalFilesystemStorage +from ai_server.storage.s3 import S3Storage + + +# 설정에 명시된 스토리지를 생성함 +def build_storage(settings: Settings) -> ObjectStorage: + backend = settings.storage_backend + if backend == "local": + return LocalFilesystemStorage(root_dir=settings.storage_local_root) + if backend == "s3": + return S3Storage( + bucket=settings.s3_bucket_name, + endpoint_url=settings.s3_endpoint_url or None, + access_key=settings.s3_access_key or None, + secret_key=settings.s3_secret_key or None, + region=settings.s3_region, + ) + raise ValueError(f"Unsupported STORAGE_BACKEND={backend!r}") diff --git a/ai/src/ai_server/storage/local_fs.py b/ai/src/ai_server/storage/local_fs.py new file mode 100644 index 00000000..2d2fc8b1 --- /dev/null +++ b/ai/src/ai_server/storage/local_fs.py @@ -0,0 +1,63 @@ +from __future__ import annotations + +import os +from pathlib import Path + +import aiofiles +import aiofiles.os + +from ai_server.storage.base import ObjectStorage, StorageKeyError + + +# local 일 때 사용되는 스토리지 시스템 +class LocalFilesystemStorage(ObjectStorage): + + def __init__(self, root_dir: str) -> None: + self._root = Path(root_dir).resolve() + + @property + def root(self) -> Path: + return self._root + + def _resolve(self, key: str) -> Path: + if not key or key.startswith("/") or "\x00" in key: + raise StorageKeyError(f"invalid storage key: {key!r}") + candidate = (self._root / key).resolve() + root_str = str(self._root) + cand_str = str(candidate) + if cand_str != root_str and not cand_str.startswith(root_str + os.sep): + raise StorageKeyError(f"key escapes storage root: {key!r}") + return candidate + + async def get_bytes(self, key: str) -> bytes: + path = self._resolve(key) + async with aiofiles.open(path, "rb") as f: + return await f.read() + + async def put_bytes( + self, + key: str, + data: bytes, + *, + content_type: str | None = None, + ) -> None: + path = self._resolve(key) + await aiofiles.os.makedirs(path.parent, exist_ok=True) + async with aiofiles.open(path, "wb") as f: + await f.write(data) + + async def put_text( + self, + key: str, + text: str, + *, + content_type: str = "text/markdown; charset=utf-8", + ) -> None: + await self.put_bytes(key, text.encode("utf-8"), content_type=content_type) + + async def exists(self, key: str) -> bool: + try: + path = self._resolve(key) + except StorageKeyError: + return False + return await aiofiles.os.path.exists(path) diff --git a/ai/src/ai_server/storage/s3.py b/ai/src/ai_server/storage/s3.py new file mode 100644 index 00000000..24ebe37b --- /dev/null +++ b/ai/src/ai_server/storage/s3.py @@ -0,0 +1,102 @@ +from __future__ import annotations + +import asyncio +from typing import Any + +import boto3 +from botocore.client import BaseClient +from botocore.exceptions import ClientError + +from ai_server.storage.base import ObjectStorage, StorageKeyError + + +# S3 호환 스토리지 (MinIO 포함) +class S3Storage(ObjectStorage): + + def __init__( + self, + *, + bucket: str, + endpoint_url: str | None = None, + access_key: str | None = None, + secret_key: str | None = None, + region: str = "us-east-1", + client: BaseClient | None = None, + ) -> None: + if not bucket: + raise ValueError("bucket name required") + self._bucket = bucket + if client is not None: + self._client = client + else: + self._client = boto3.client( + "s3", + endpoint_url=endpoint_url or None, + aws_access_key_id=access_key or None, + aws_secret_access_key=secret_key or None, + region_name=region, + ) + + @property + def bucket(self) -> str: + return self._bucket + + @property + def client(self) -> BaseClient: + return self._client + + @staticmethod + def _validate_key(key: str) -> None: + if not key or key.startswith("/") or "\x00" in key: + raise StorageKeyError(f"invalid storage key: {key!r}") + + async def get_bytes(self, key: str) -> bytes: + self._validate_key(key) + resp = await asyncio.to_thread( + self._client.get_object, Bucket=self._bucket, Key=key + ) + body = resp["Body"] + return await asyncio.to_thread(body.read) + + async def put_bytes( + self, + key: str, + data: bytes, + *, + content_type: str | None = None, + ) -> None: + self._validate_key(key) + kwargs: dict[str, Any] = { + "Bucket": self._bucket, + "Key": key, + "Body": data, + } + if content_type: + kwargs["ContentType"] = content_type + await asyncio.to_thread(self._client.put_object, **kwargs) + + async def put_text( + self, + key: str, + text: str, + *, + content_type: str = "text/markdown; charset=utf-8", + ) -> None: + await self.put_bytes(key, text.encode("utf-8"), content_type=content_type) + + async def exists(self, key: str) -> bool: + try: + self._validate_key(key) + except StorageKeyError: + return False + try: + await asyncio.to_thread( + self._client.head_object, Bucket=self._bucket, Key=key + ) + return True + except ClientError as exc: + code = exc.response.get("Error", {}).get("Code", "") + + if code in ("404", "NoSuchKey", "NotFound"): + return False + raise diff --git a/ai/tests/test_chunker.py b/ai/tests/test_chunker.py new file mode 100644 index 00000000..de6328c1 --- /dev/null +++ b/ai/tests/test_chunker.py @@ -0,0 +1,48 @@ +import pytest + +from ai_server.rag.chunker import MarkdownChunker + + +def test_split_short_text_into_single_chunk() -> None: + chunker = MarkdownChunker(chunk_size=200, chunk_overlap=20) + chunks = chunker.split("# 짧은 문서\n한 문장.") + assert len(chunks) == 1 + assert chunks[0].index == 0 + assert "짧은 문서" in chunks[0].text + + +def test_split_long_text_into_multiple_chunks_with_overlap() -> None: + # 충분히 길어서 chunk_size 초과 + body = "## 개요\n" + ("문장. " * 200) + chunker = MarkdownChunker(chunk_size=200, chunk_overlap=50) + chunks = chunker.split(body) + assert len(chunks) >= 2 + assert chunks[0].index == 0 + assert chunks[-1].index == len(chunks) - 1 + for c in chunks: + assert len(c.text) <= 200 + 50 # splitter 특성상 약간의 여유 + + +def test_empty_text_returns_no_chunks() -> None: + chunker = MarkdownChunker(chunk_size=100, chunk_overlap=10) + assert chunker.split("") == [] + assert chunker.split(" \n\t ") == [] + + +def test_invalid_chunk_size_raises() -> None: + with pytest.raises(ValueError): + MarkdownChunker(chunk_size=0, chunk_overlap=0) + + +def test_overlap_must_be_less_than_chunk_size() -> None: + with pytest.raises(ValueError): + MarkdownChunker(chunk_size=100, chunk_overlap=100) + with pytest.raises(ValueError): + MarkdownChunker(chunk_size=100, chunk_overlap=-1) + + +def test_chunk_indices_are_sequential() -> None: + chunker = MarkdownChunker(chunk_size=50, chunk_overlap=10) + chunks = chunker.split("abc " * 200) + indices = [c.index for c in chunks] + assert indices == list(range(len(chunks))) diff --git a/ai/tests/test_core_client.py b/ai/tests/test_core_client.py new file mode 100644 index 00000000..9f71227e --- /dev/null +++ b/ai/tests/test_core_client.py @@ -0,0 +1,243 @@ +from unittest.mock import AsyncMock, MagicMock + +import httpx +import pytest + +from ai_server.core.client import ( + CoreEmbeddingUpsertError, + CoreTokenError, + EmbeddingChunkPayload, + HttpCoreClient, +) + + +def _make_client( + *, + status: int = 200, + json_body: dict | None = None, + text: str = "", + raise_exc: Exception | None = None, +) -> MagicMock: + client = MagicMock() + resp = MagicMock(spec=httpx.Response) + resp.status_code = status + resp.text = text + if json_body is None: + resp.json = MagicMock(side_effect=ValueError("no json")) + else: + resp.json = MagicMock(return_value=json_body) + if raise_exc is not None: + client.get = AsyncMock(side_effect=raise_exc) + else: + client.get = AsyncMock(return_value=resp) + return client + + +def _build(client: MagicMock) -> HttpCoreClient: + return HttpCoreClient(base_url="http://core:38010", api_key="k", client=client) + + +@pytest.mark.asyncio +async def test_returns_access_token_on_200_camel() -> None: + client = _make_client(json_body={"accessToken": "tok-1"}) + core = _build(client) + token = await core.fetch_github_token(user_id=42) + assert token == "tok-1" + client.get.assert_awaited_once_with("/api/internal/users/42/github-token") + + +@pytest.mark.asyncio +async def test_returns_access_token_on_snake_case_response() -> None: + client = _make_client(json_body={"access_token": "tok-s"}) + core = _build(client) + assert await core.fetch_github_token(user_id=1) == "tok-s" + + +@pytest.mark.asyncio +async def test_404_translates_to_user_not_found() -> None: + client = _make_client(status=404) + core = _build(client) + with pytest.raises(CoreTokenError) as exc_info: + await core.fetch_github_token(user_id=42) + assert exc_info.value.code == "USER_NOT_FOUND" + assert exc_info.value.retriable is False + + +@pytest.mark.asyncio +async def test_401_translates_to_core_auth_failed() -> None: + client = _make_client(status=401) + core = _build(client) + with pytest.raises(CoreTokenError) as exc_info: + await core.fetch_github_token(user_id=42) + assert exc_info.value.code == "CORE_AUTH_FAILED" + assert exc_info.value.retriable is False + + +@pytest.mark.asyncio +async def test_5xx_translates_to_retriable_unavailable() -> None: + client = _make_client(status=503) + core = _build(client) + with pytest.raises(CoreTokenError) as exc_info: + await core.fetch_github_token(user_id=42) + assert exc_info.value.code == "CORE_UNAVAILABLE" + assert exc_info.value.retriable is True + + +@pytest.mark.asyncio +async def test_other_4xx_non_retriable() -> None: + client = _make_client(status=400, text="bad") + core = _build(client) + with pytest.raises(CoreTokenError) as exc_info: + await core.fetch_github_token(user_id=42) + assert exc_info.value.code == "CORE_BAD_REQUEST" + assert exc_info.value.retriable is False + + +@pytest.mark.asyncio +async def test_httpx_error_translates_to_retriable() -> None: + client = _make_client(raise_exc=httpx.ConnectError("dns fail")) + core = _build(client) + with pytest.raises(CoreTokenError) as exc_info: + await core.fetch_github_token(user_id=42) + assert exc_info.value.code == "CORE_UNAVAILABLE" + assert exc_info.value.retriable is True + + +@pytest.mark.asyncio +async def test_empty_token_field_treated_as_retriable() -> None: + client = _make_client(json_body={"accessToken": ""}) + core = _build(client) + with pytest.raises(CoreTokenError) as exc_info: + await core.fetch_github_token(user_id=42) + assert exc_info.value.code == "CORE_TOKEN_EMPTY" + assert exc_info.value.retriable is True + + +@pytest.mark.asyncio +async def test_invalid_json_translates_to_bad_response() -> None: + client = _make_client(status=200, json_body=None) + core = _build(client) + with pytest.raises(CoreTokenError) as exc_info: + await core.fetch_github_token(user_id=42) + assert exc_info.value.code == "CORE_BAD_RESPONSE" + + +def test_constructor_requires_base_url() -> None: + with pytest.raises(ValueError): + HttpCoreClient(base_url="", api_key="x") + + +# ----------- upsert_embeddings ----------- + + +def _make_put_client( + *, + status: int = 200, + json_body: dict | None = None, + raise_exc: Exception | None = None, +) -> MagicMock: + client = MagicMock() + resp = MagicMock(spec=httpx.Response) + resp.status_code = status + resp.text = "" + resp.json = ( + MagicMock(return_value=json_body) + if json_body is not None + else MagicMock(side_effect=ValueError("no json")) + ) + if raise_exc is not None: + client.put = AsyncMock(side_effect=raise_exc) + else: + client.put = AsyncMock(return_value=resp) + return client + + +def _payloads(n: int = 2, dim: int = 4) -> list[EmbeddingChunkPayload]: + return [ + EmbeddingChunkPayload( + chunk_index=i, chunk_text=f"chunk {i}", embedding=[0.1] * dim + ) + for i in range(n) + ] + + +@pytest.mark.asyncio +async def test_upsert_embeddings_happy_path_returns_count() -> None: + client = _make_put_client(status=200, json_body={"upserted": 2}) + core = HttpCoreClient(base_url="http://core:38010", api_key="k", client=client) + n = await core.upsert_embeddings( + document_id=88, + model="mock", + dim=4, + chunks=_payloads(2, 4), + ) + assert n == 2 + client.put.assert_awaited_once() + args, kwargs = client.put.call_args + assert args[0] == "/api/internal/documents/88/embeddings" + body = kwargs["json"] + assert body["dim"] == 4 + assert body["model"] == "mock" + assert len(body["chunks"]) == 2 + assert body["chunks"][0] == { + "chunkIndex": 0, + "chunkText": "chunk 0", + "embedding": [0.1, 0.1, 0.1, 0.1], + } + + +@pytest.mark.asyncio +async def test_upsert_embeddings_falls_back_to_len_when_no_upserted_field() -> None: + client = _make_put_client(status=200, json_body={}) + core = HttpCoreClient(base_url="http://core:38010", api_key="k", client=client) + n = await core.upsert_embeddings( + document_id=1, model="m", dim=4, chunks=_payloads(3, 4) + ) + assert n == 3 # 응답에 카운트 없으면 보낸 만큼 적용 가정 + + +@pytest.mark.asyncio +async def test_upsert_embeddings_404_translates_to_document_not_found() -> None: + client = _make_put_client(status=404) + core = HttpCoreClient(base_url="http://core:38010", api_key="k", client=client) + with pytest.raises(CoreEmbeddingUpsertError) as exc_info: + await core.upsert_embeddings( + document_id=99, model="m", dim=4, chunks=_payloads() + ) + assert exc_info.value.code == "DOCUMENT_NOT_FOUND" + assert exc_info.value.retriable is False + + +@pytest.mark.asyncio +async def test_upsert_embeddings_5xx_retriable() -> None: + client = _make_put_client(status=503) + core = HttpCoreClient(base_url="http://core:38010", api_key="k", client=client) + with pytest.raises(CoreEmbeddingUpsertError) as exc_info: + await core.upsert_embeddings( + document_id=1, model="m", dim=4, chunks=_payloads() + ) + assert exc_info.value.code == "CORE_UNAVAILABLE" + assert exc_info.value.retriable is True + + +@pytest.mark.asyncio +async def test_upsert_embeddings_401_non_retriable_auth() -> None: + client = _make_put_client(status=401) + core = HttpCoreClient(base_url="http://core:38010", api_key="k", client=client) + with pytest.raises(CoreEmbeddingUpsertError) as exc_info: + await core.upsert_embeddings( + document_id=1, model="m", dim=4, chunks=_payloads() + ) + assert exc_info.value.code == "CORE_AUTH_FAILED" + + +@pytest.mark.asyncio +async def test_upsert_embeddings_httpx_error_retriable() -> None: + client = _make_put_client(raise_exc=httpx.ConnectError("dns fail")) + core = HttpCoreClient(base_url="http://core:38010", api_key="k", client=client) + with pytest.raises(CoreEmbeddingUpsertError) as exc_info: + await core.upsert_embeddings( + document_id=1, model="m", dim=4, chunks=_payloads() + ) + assert exc_info.value.code == "CORE_UNAVAILABLE" + assert exc_info.value.retriable is True diff --git a/ai/tests/test_document_chain.py b/ai/tests/test_document_chain.py new file mode 100644 index 00000000..32317641 --- /dev/null +++ b/ai/tests/test_document_chain.py @@ -0,0 +1,35 @@ +import pytest +from langchain_core.runnables import RunnableLambda + +from ai_server.chain.document_analysis_chain import ( + DocumentAnalysisResult, + LlmDocumentAnalyzer, +) + + +@pytest.mark.asyncio +async def test_llm_document_analyzer_invokes_chain_with_inputs() -> None: + fake_result = DocumentAnalysisResult( + summary="요약", tech_stack=["Python"], markdown="## 개요\n..." + ) + captured: dict = {} + + async def fake_invoke(inputs: dict) -> DocumentAnalysisResult: + captured.update(inputs) + return fake_result + + analyzer = LlmDocumentAnalyzer(RunnableLambda(fake_invoke)) + out = await analyzer.analyze(text="raw text", source_type="PDF") + + assert captured == {"text": "raw text", "source_type": "PDF"} + assert out is fake_result + + +@pytest.mark.asyncio +async def test_llm_document_analyzer_rejects_wrong_type() -> None: + async def fake_invoke(_: dict) -> dict: + return {"summary": "not a model"} + + analyzer = LlmDocumentAnalyzer(RunnableLambda(fake_invoke)) + with pytest.raises(TypeError): + await analyzer.analyze(text="x", source_type="PDF") diff --git a/ai/tests/test_embedder.py b/ai/tests/test_embedder.py new file mode 100644 index 00000000..bdb67160 --- /dev/null +++ b/ai/tests/test_embedder.py @@ -0,0 +1,156 @@ +import pytest + +from ai_server.rag.embedder import MockEmbeddingProvider, build_embedding_provider + + +@pytest.mark.asyncio +async def test_mock_embed_returns_fixed_dim_vectors() -> None: + emb = MockEmbeddingProvider(dim=8) + vectors = await emb.embed(["a", "b", "ccc"]) + assert len(vectors) == 3 + for v in vectors: + assert len(v) == 8 + for x in v: + assert -1.0 <= x <= 1.0 + + +@pytest.mark.asyncio +async def test_mock_embed_is_deterministic() -> None: + emb = MockEmbeddingProvider(dim=16) + a = await emb.embed(["같은 입력"]) + b = await emb.embed(["같은 입력"]) + assert a == b + + +@pytest.mark.asyncio +async def test_mock_embed_different_input_yields_different_vector() -> None: + emb = MockEmbeddingProvider(dim=16) + a = await emb.embed(["A"]) + b = await emb.embed(["B"]) + assert a[0] != b[0] + + +def test_mock_provider_exposes_dim_and_model() -> None: + emb = MockEmbeddingProvider(dim=1536, model="mock-test") + assert emb.dim == 1536 + assert emb.model == "mock-test" + + +def test_constructor_rejects_non_positive_dim() -> None: + with pytest.raises(ValueError): + MockEmbeddingProvider(dim=0) + with pytest.raises(ValueError): + MockEmbeddingProvider(dim=-1) + + +def test_factory_returns_mock_for_mock_provider() -> None: + emb = build_embedding_provider(provider="mock", dim=64, model="any") + assert isinstance(emb, MockEmbeddingProvider) + assert emb.dim == 64 + + +def test_factory_raises_for_unimplemented_providers() -> None: + with pytest.raises(NotImplementedError): + build_embedding_provider(provider="openai", dim=1536, model="x") + with pytest.raises(NotImplementedError): + build_embedding_provider(provider="ollama", dim=768, model="x") + + +def test_factory_rejects_unknown_provider() -> None: + with pytest.raises(ValueError): + build_embedding_provider(provider="unknown", dim=64, model="x") + + +# --- Gemini provider --- + + +def test_factory_rejects_gemini_without_api_key() -> None: + with pytest.raises(ValueError): + build_embedding_provider( + provider="gemini", + dim=1536, + model="gemini-embedding-001", + gemini_api_key="", + ) + + +def test_factory_builds_gemini_provider_with_api_key() -> None: + from ai_server.rag.embedder import GeminiEmbeddingProvider + + emb = build_embedding_provider( + provider="gemini", + dim=1536, + model="gemini-embedding-001", + gemini_api_key="fake-key", + ) + assert isinstance(emb, GeminiEmbeddingProvider) + assert emb.dim == 1536 + assert emb.model == "gemini-embedding-001" + + +@pytest.mark.asyncio +async def test_gemini_embed_returns_vector_list_from_sdk_response() -> None: + from types import SimpleNamespace + from unittest.mock import AsyncMock, MagicMock, patch + + from ai_server.rag.embedder import GeminiEmbeddingProvider + + # SDK 응답 형태 모사: resp.embeddings[i].values + fake_resp = SimpleNamespace( + embeddings=[ + SimpleNamespace(values=[0.1, 0.2, 0.3]), + SimpleNamespace(values=[0.4, 0.5, 0.6]), + ] + ) + fake_aio = MagicMock() + fake_aio.models.embed_content = AsyncMock(return_value=fake_resp) + fake_client = MagicMock() + fake_client.aio = fake_aio + + with patch("google.genai.Client", return_value=fake_client): + emb = GeminiEmbeddingProvider( + api_key="fake", model="gemini-embedding-001", dim=3 + ) + out = await emb.embed(["chunk A", "chunk B"]) + assert out == [[0.1, 0.2, 0.3], [0.4, 0.5, 0.6]] + fake_aio.models.embed_content.assert_awaited_once() + kwargs = fake_aio.models.embed_content.await_args.kwargs + assert kwargs["model"] == "gemini-embedding-001" + assert kwargs["contents"] == ["chunk A", "chunk B"] + + +@pytest.mark.asyncio +async def test_gemini_embed_empty_input_returns_empty_without_sdk_call() -> None: + from unittest.mock import MagicMock, patch + + from ai_server.rag.embedder import GeminiEmbeddingProvider + + fake_client = MagicMock() + with patch("google.genai.Client", return_value=fake_client): + emb = GeminiEmbeddingProvider( + api_key="fake", model="gemini-embedding-001", dim=8 + ) + assert await emb.embed([]) == [] + fake_client.aio.models.embed_content.assert_not_called() + + +@pytest.mark.asyncio +async def test_gemini_embed_wraps_sdk_exception_as_retriable() -> None: + from unittest.mock import AsyncMock, MagicMock, patch + + from ai_server.rag.embedder import EmbeddingError, GeminiEmbeddingProvider + + fake_aio = MagicMock() + fake_aio.models.embed_content = AsyncMock(side_effect=RuntimeError("rate limit")) + fake_client = MagicMock() + fake_client.aio = fake_aio + + with patch("google.genai.Client", return_value=fake_client): + emb = GeminiEmbeddingProvider( + api_key="fake", model="gemini-embedding-001", dim=4 + ) + + with pytest.raises(EmbeddingError) as exc_info: + await emb.embed(["x"]) + assert exc_info.value.code == "GEMINI_FAILED" + assert exc_info.value.retriable is True diff --git a/ai/tests/test_github_repo_extractor.py b/ai/tests/test_github_repo_extractor.py new file mode 100644 index 00000000..9fa165ab --- /dev/null +++ b/ai/tests/test_github_repo_extractor.py @@ -0,0 +1,191 @@ +import base64 +from unittest.mock import AsyncMock, MagicMock + +import httpx +import pytest + +from ai_server.analyzer.sources.github_repo import ( + GitHubRepoSourceExtractor, + RepositoryFetchError, + _select_files, +) + + +def _make_client(routes: dict[str, dict | int]) -> tuple[MagicMock, AsyncMock]: + """`routes`: path → response dict (JSON) or int (HTTP status).""" + client = MagicMock() + + async def _get(path: str) -> MagicMock: + body = routes.get(path) + resp = MagicMock(spec=httpx.Response) + if isinstance(body, int): + resp.status_code = body + resp.text = "" + resp.raise_for_status = MagicMock() + return resp + resp.status_code = 200 + resp.json = MagicMock(return_value=body) + resp.raise_for_status = MagicMock() + return resp + + client.get = AsyncMock(side_effect=_get) + return client, client.get + + +def _b64(text: str) -> str: + return base64.b64encode(text.encode("utf-8")).decode("ascii") + + +@pytest.mark.asyncio +async def test_extract_assembles_readme_tree_and_snippets() -> None: + routes = { + "/repos/user/repo": { + "default_branch": "dev", + "description": "demo project", + }, + "/repos/user/repo/readme": { + "encoding": "base64", + "content": _b64("# Hello\nReadme body"), + }, + "/repos/user/repo/git/trees/dev?recursive=1": { + "tree": [ + {"path": "pyproject.toml", "type": "blob"}, + {"path": "src/app.py", "type": "blob"}, + {"path": "tests/test_app.py", "type": "blob"}, + {"path": "docs", "type": "tree"}, + ], + "truncated": False, + }, + "/repos/user/repo/contents/pyproject.toml?ref=dev": { + "encoding": "base64", + "content": _b64('[project]\nname = "app"'), + }, + "/repos/user/repo/contents/src/app.py?ref=dev": { + "encoding": "base64", + "content": _b64("def main():\n return 1"), + }, + "/repos/user/repo/contents/tests/test_app.py?ref=dev": { + "encoding": "base64", + "content": _b64("def test(): pass"), + }, + } + client, _ = _make_client(routes) + extractor = GitHubRepoSourceExtractor( + api_base_url="https://api.github.com", client=client + ) + + result = await extractor.extract("user/repo") + + assert result.source_type == "REPOSITORY" + assert "user/repo" in result.text + assert "Readme body" in result.text + assert "pyproject.toml" in result.text + assert "def main()" in result.text + assert result.metadata["default_branch"] == "dev" + assert result.metadata["tree_size"] == 3 + assert "pyproject.toml" in result.metadata["sampled_files"] + + +@pytest.mark.asyncio +async def test_extract_handles_missing_readme_gracefully() -> None: + routes = { + "/repos/user/repo": {"default_branch": "main"}, + "/repos/user/repo/readme": 404, + "/repos/user/repo/git/trees/main?recursive=1": {"tree": [], "truncated": False}, + } + client, _ = _make_client(routes) + extractor = GitHubRepoSourceExtractor( + api_base_url="https://api.github.com", client=client + ) + + result = await extractor.extract("user/repo") + assert "user/repo" in result.text + assert result.metadata["tree_size"] == 0 + + +@pytest.mark.asyncio +async def test_extract_raises_on_repo_not_found() -> None: + routes = {"/repos/user/repo": 404} + client, _ = _make_client(routes) + extractor = GitHubRepoSourceExtractor( + api_base_url="https://api.github.com", client=client + ) + + with pytest.raises(RepositoryFetchError) as exc_info: + await extractor.extract("user/repo") + assert exc_info.value.code == "REPO_NOT_FOUND" + assert exc_info.value.retriable is False + + +@pytest.mark.asyncio +async def test_extract_raises_on_rate_limit_as_retriable() -> None: + routes = {"/repos/user/repo": 403} + client, _ = _make_client(routes) + extractor = GitHubRepoSourceExtractor( + api_base_url="https://api.github.com", client=client + ) + + with pytest.raises(RepositoryFetchError) as exc_info: + await extractor.extract("user/repo") + assert exc_info.value.code == "REPO_AUTH_OR_RATE" + assert exc_info.value.retriable is True + + +@pytest.mark.asyncio +async def test_extract_rejects_malformed_locator() -> None: + client, _ = _make_client({}) + extractor = GitHubRepoSourceExtractor( + api_base_url="https://api.github.com", client=client + ) + with pytest.raises(RepositoryFetchError) as exc_info: + await extractor.extract("not-a-full-name") + assert exc_info.value.code == "INVALID_REPO_LOCATOR" + + +def test_select_files_prioritizes_manifests_then_one_per_dir() -> None: + paths = [ + "package.json", + "src/index.ts", + "src/util/format.ts", + "tests/index.test.ts", + "README.md", + ] + picked = _select_files(paths, cap=4) + # manifest 우선 + assert "package.json" in picked + # 디렉토리당 1개씩 sampling (src/, tests/, root) + assert any(p.startswith("src/") for p in picked) + assert any(p.startswith("tests/") for p in picked) + assert len(picked) <= 4 + + +def test_select_files_respects_cap() -> None: + paths = [f"src/a{i}.py" for i in range(20)] + picked = _select_files(paths, cap=3) + assert len(picked) <= 3 + + +def test_build_client_includes_authorization_with_user_token() -> None: + extractor = GitHubRepoSourceExtractor(api_base_url="https://api.github.com") + client = extractor._build_client("user-token-xyz") + try: + assert client.headers.get("Authorization") == "Bearer user-token-xyz" + finally: + # 비동기 cleanup 없이 닫기 OK — 실제 연결은 안 함 + pass + + +def test_build_client_omits_authorization_when_token_empty() -> None: + extractor = GitHubRepoSourceExtractor(api_base_url="https://api.github.com") + client = extractor._build_client("") + assert "Authorization" not in client.headers + + +def test_fallback_token_used_when_no_per_call_token() -> None: + extractor = GitHubRepoSourceExtractor( + api_base_url="https://api.github.com", + fallback_token="fallback-tok", + ) + # access_token=None → fallback 사용 + client = extractor._build_client(extractor._cfg.fallback_token) + assert client.headers.get("Authorization") == "Bearer fallback-tok" diff --git a/ai/tests/test_messages_analyze.py b/ai/tests/test_messages_analyze.py index 49a6d3ab..1f200fbe 100644 --- a/ai/tests/test_messages_analyze.py +++ b/ai/tests/test_messages_analyze.py @@ -3,16 +3,23 @@ from ai_server.model.messages.analyze import ( AnalysisCallbackPayload, + RepositoryAnalyzeRequest, ResumeAnalyzeRequest, + WebResumeAnalyzeRequest, ) def test_resume_request_parses() -> None: req = ResumeAnalyzeRequest.model_validate( - {"resumeId": 42, "s3Key": "resumes/raw/123/abc.pdf"} + { + "resumeId": 42, + "filePath": "resumes/raw/123/abc.pdf", + "analyzedDocumentId": 77, + } ) assert req.resume_id == 42 - assert req.s3_key == "resumes/raw/123/abc.pdf" + assert req.file_path == "resumes/raw/123/abc.pdf" + assert req.analyzed_document_id == 77 def test_resume_request_requires_fields() -> None: @@ -20,25 +27,51 @@ def test_resume_request_requires_fields() -> None: ResumeAnalyzeRequest.model_validate({"resumeId": 42}) +def test_resume_request_serializes_camel_case() -> None: + req = ResumeAnalyzeRequest(resume_id=7, file_path="r/7.pdf", analyzed_document_id=1) + dumped = req.model_dump(by_alias=True) + assert dumped == { + "resumeId": 7, + "filePath": "r/7.pdf", + "analyzedDocumentId": 1, + } + + def test_callback_success_serializes_camel_case() -> None: cb = AnalysisCallbackPayload( target_type="RESUME", target_id=42, status="ANALYZED", - summary="[ECHO] not yet analyzed", - tech_stack=[], - document_s3_key="echo/resume/42/echo.md", + summary="요약", + tech_stack=["Python", "FastAPI"], + document_path="analyzed/resume/42/summary.md", embedding_chunk_count=0, ) dumped = cb.model_dump(by_alias=True) assert dumped["targetType"] == "RESUME" assert dumped["targetId"] == 42 assert dumped["status"] == "ANALYZED" - assert dumped["techStack"] == [] - assert dumped["documentS3Key"] == "echo/resume/42/echo.md" + assert dumped["techStack"] == ["Python", "FastAPI"] + assert dumped["documentPath"] == "analyzed/resume/42/summary.md" assert dumped["embeddingChunkCount"] == 0 +def test_callback_failure_carries_error_fields() -> None: + cb = AnalysisCallbackPayload( + target_type="RESUME", + target_id=42, + status="FAILED", + error_code="EMPTY_PDF_TEXT", + error_message="no text", + retriable=False, + ) + dumped = cb.model_dump(by_alias=True) + assert dumped["status"] == "FAILED" + assert dumped["errorCode"] == "EMPTY_PDF_TEXT" + assert dumped["errorMessage"] == "no text" + assert dumped["retriable"] is False + + def test_callback_target_type_must_be_known() -> None: with pytest.raises(ValidationError): AnalysisCallbackPayload( @@ -46,3 +79,59 @@ def test_callback_target_type_must_be_known() -> None: target_id=1, status="ANALYZED", ) + + +def test_repository_request_parses_with_default_branch() -> None: + req = RepositoryAnalyzeRequest.model_validate( + { + "repositoryId": 5, + "repoFullName": "user/repo", + "analyzedDocumentId": 88, + } + ) + assert req.repository_id == 5 + assert req.repo_full_name == "user/repo" + assert req.default_branch == "main" + assert req.analyzed_document_id == 88 + + +def test_repository_request_serializes_camel_case() -> None: + req = RepositoryAnalyzeRequest( + repository_id=5, + repo_full_name="user/repo", + default_branch="dev", + analyzed_document_id=1, + ) + dumped = req.model_dump(by_alias=True) + assert dumped == { + "repositoryId": 5, + "repoFullName": "user/repo", + "defaultBranch": "dev", + "analyzedDocumentId": 1, + } + + +def test_web_resume_request_parses() -> None: + req = WebResumeAnalyzeRequest.model_validate( + { + "resumeId": 9, + "url": "https://example.com/me", + "analyzedDocumentId": 12, + } + ) + assert req.resume_id == 9 + assert req.url == "https://example.com/me" + assert req.analyzed_document_id == 12 + + +def test_callback_accepts_web_target_type() -> None: + cb = AnalysisCallbackPayload( + target_type="WEB", + target_id=9, + status="ANALYZED", + summary="ok", + document_path="analyzed/web-resume/9/summary.md", + ) + dumped = cb.model_dump(by_alias=True) + assert dumped["targetType"] == "WEB" + assert dumped["documentPath"] == "analyzed/web-resume/9/summary.md" diff --git a/ai/tests/test_pdf_extractor.py b/ai/tests/test_pdf_extractor.py new file mode 100644 index 00000000..1d4bb988 --- /dev/null +++ b/ai/tests/test_pdf_extractor.py @@ -0,0 +1,66 @@ +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + +from ai_server.analyzer.sources.pdf import PdfSourceExtractor + + +def _fake_pdf_reader(page_texts: list[str]) -> MagicMock: + reader = MagicMock() + pages = [] + for t in page_texts: + page = MagicMock() + page.extract_text.return_value = t + pages.append(page) + reader.pages = pages + return reader + + +@pytest.mark.asyncio +async def test_extract_reads_from_storage_and_joins_pages() -> None: + storage = AsyncMock() + storage.get_bytes.return_value = b"PDF-BYTES" + + fake_reader = _fake_pdf_reader(["page one", "page two"]) + with patch( + "ai_server.analyzer.sources.pdf.PdfReader", return_value=fake_reader + ) as reader_cls: + extractor = PdfSourceExtractor(storage=storage) + result = await extractor.extract("resumes/raw/1/x.pdf") + + storage.get_bytes.assert_awaited_once_with("resumes/raw/1/x.pdf") + reader_cls.assert_called_once() + assert "page one" in result.text + assert "page two" in result.text + assert result.source_type == "PDF" + assert result.metadata["bytes"] == len(b"PDF-BYTES") + assert result.metadata["locator"] == "resumes/raw/1/x.pdf" + + +@pytest.mark.asyncio +async def test_extract_skips_none_page_text() -> None: + storage = AsyncMock() + storage.get_bytes.return_value = b"PDF-BYTES" + + fake_reader = _fake_pdf_reader(["", "real content"]) + fake_reader.pages[0].extract_text.return_value = None # pypdf may return None + + with patch("ai_server.analyzer.sources.pdf.PdfReader", return_value=fake_reader): + extractor = PdfSourceExtractor(storage=storage) + result = await extractor.extract("a.pdf") + + assert result.text == "real content" + + +@pytest.mark.asyncio +async def test_extract_returns_empty_on_empty_pdf() -> None: + storage = AsyncMock() + storage.get_bytes.return_value = b"PDF-BYTES" + + fake_reader = _fake_pdf_reader([]) + with patch("ai_server.analyzer.sources.pdf.PdfReader", return_value=fake_reader): + extractor = PdfSourceExtractor(storage=storage) + result = await extractor.extract("empty.pdf") + + assert result.text == "" + assert result.source_type == "PDF" diff --git a/ai/tests/test_repository_analyzer.py b/ai/tests/test_repository_analyzer.py new file mode 100644 index 00000000..d1a7633e --- /dev/null +++ b/ai/tests/test_repository_analyzer.py @@ -0,0 +1,151 @@ +from unittest.mock import AsyncMock + +import pytest + +from ai_server.analyzer.repository_analyzer import ( + RepositoryAnalyzeError, + RepositoryAnalyzer, +) +from ai_server.analyzer.sources.base import ExtractedSource +from ai_server.analyzer.sources.github_repo import RepositoryFetchError +from ai_server.chain.document_analysis_chain import DocumentAnalysisResult +from ai_server.core.client import CoreTokenError +from ai_server.rag.chunker import MarkdownChunker +from ai_server.rag.embedder import MockEmbeddingProvider + + +def _make_analyzer( + *, + extract_result: ExtractedSource | Exception | None = None, + analysis: DocumentAnalysisResult | None = None, + token_result: str | Exception = "user-token", + upsert_result: int = 2, +) -> tuple[RepositoryAnalyzer, AsyncMock, AsyncMock, AsyncMock, AsyncMock]: + extractor = AsyncMock() + if isinstance(extract_result, Exception): + extractor.extract = AsyncMock(side_effect=extract_result) + else: + extractor.extract = AsyncMock(return_value=extract_result) + + chain = AsyncMock() + if analysis is not None: + chain.analyze = AsyncMock(return_value=analysis) + + storage = AsyncMock() + + core_client = AsyncMock() + if isinstance(token_result, Exception): + core_client.fetch_github_token = AsyncMock(side_effect=token_result) + else: + core_client.fetch_github_token = AsyncMock(return_value=token_result) + core_client.upsert_embeddings = AsyncMock(return_value=upsert_result) + + analyzer = RepositoryAnalyzer( + extractor=extractor, + core_client=core_client, + chain=chain, + storage=storage, + chunker=MarkdownChunker(chunk_size=200, chunk_overlap=50), + embedder=MockEmbeddingProvider(dim=16), + analyzed_key_template="analyzed/repository/{repository_id}/summary.md", + ) + return analyzer, extractor, chain, storage, core_client + + +@pytest.mark.asyncio +async def test_happy_path_fetches_token_extracts_analyzes_and_upserts() -> None: + analyzer, extractor, chain, storage, core_client = _make_analyzer( + extract_result=ExtractedSource( + text="README + tree" * 30, source_type="REPOSITORY" + ), + analysis=DocumentAnalysisResult( + summary="요약", tech_stack=["Go"], markdown="## 개요\n" + "x" * 300 + ), + token_result="user-abc", + upsert_result=2, + ) + result = await analyzer.analyze( + repository_id=7, + repo_full_name="user/repo", + default_branch="main", + user_id=42, + analyzed_document_id=88, + ) + + core_client.fetch_github_token.assert_awaited_once_with(42) + extractor.extract.assert_awaited_once_with("user/repo", access_token="user-abc") + chain.analyze.assert_awaited_once() + storage.put_text.assert_awaited_once() + core_client.upsert_embeddings.assert_awaited_once() + kwargs = core_client.upsert_embeddings.await_args.kwargs + assert kwargs["document_id"] == 88 + assert kwargs["dim"] == 16 + assert result.embedding_chunk_count == 2 + assert result.document_path == "analyzed/repository/7/summary.md" + + +@pytest.mark.asyncio +async def test_missing_user_id_raises_before_token_fetch() -> None: + analyzer, _, _, _, core_client = _make_analyzer() + with pytest.raises(RepositoryAnalyzeError) as exc_info: + await analyzer.analyze( + repository_id=1, + repo_full_name="user/repo", + user_id=None, + analyzed_document_id=1, + ) + assert exc_info.value.code == "MISSING_USER_ID" + core_client.fetch_github_token.assert_not_called() + + +@pytest.mark.asyncio +async def test_token_fetch_error_propagates() -> None: + analyzer, _, _, _, _ = _make_analyzer( + token_result=CoreTokenError( + code="USER_NOT_FOUND", message="404", retriable=False + ), + ) + with pytest.raises(RepositoryAnalyzeError) as exc_info: + await analyzer.analyze( + repository_id=1, + repo_full_name="user/repo", + user_id=42, + analyzed_document_id=1, + ) + assert exc_info.value.code == "USER_NOT_FOUND" + + +@pytest.mark.asyncio +async def test_fetch_error_translates() -> None: + analyzer, _, _, storage, core_client = _make_analyzer( + extract_result=RepositoryFetchError( + code="REPO_NOT_FOUND", message="not found", retriable=False + ), + ) + with pytest.raises(RepositoryAnalyzeError) as exc_info: + await analyzer.analyze( + repository_id=1, + repo_full_name="user/repo", + user_id=42, + analyzed_document_id=1, + ) + assert exc_info.value.code == "REPO_NOT_FOUND" + storage.put_text.assert_not_called() + core_client.upsert_embeddings.assert_not_called() + + +@pytest.mark.asyncio +async def test_empty_text_raises_empty_repo_content() -> None: + analyzer, _, _, storage, core_client = _make_analyzer( + extract_result=ExtractedSource(text=" ", source_type="REPOSITORY"), + ) + with pytest.raises(RepositoryAnalyzeError) as exc_info: + await analyzer.analyze( + repository_id=1, + repo_full_name="user/repo", + user_id=42, + analyzed_document_id=1, + ) + assert exc_info.value.code == "EMPTY_REPO_CONTENT" + storage.put_text.assert_not_called() + core_client.upsert_embeddings.assert_not_called() diff --git a/ai/tests/test_repository_consumer.py b/ai/tests/test_repository_consumer.py new file mode 100644 index 00000000..909085b8 --- /dev/null +++ b/ai/tests/test_repository_consumer.py @@ -0,0 +1,152 @@ +from datetime import datetime, timezone +from unittest.mock import AsyncMock, MagicMock + +import pytest + +from ai_server.analyzer.repository_analyzer import ( + RepositoryAnalysisResult, + RepositoryAnalyzeError, +) +from ai_server.messaging.consumers.repository_consumer import RepositoryConsumer +from ai_server.messaging.idempotency import LruIdempotencyStore +from ai_server.model.envelope import Envelope +from ai_server.model.messages.analyze import ( + AnalysisCallbackPayload, + RepositoryAnalyzeRequest, +) + + +def _request_envelope(message_id: str = "req-1", repository_id: int = 7) -> bytes: + env = Envelope[RepositoryAnalyzeRequest].model_validate( + { + "messageId": message_id, + "messageType": "analyze.repository", + "version": "v1", + "traceId": "trace-r", + "publishedAt": datetime(2026, 5, 14, tzinfo=timezone.utc), + "publisher": "core-server", + "payload": { + "repositoryId": repository_id, + "repoFullName": "user/repo", + "defaultBranch": "main", + "analyzedDocumentId": 88, + }, + "context": {"userId": 1}, + } + ) + return env.model_dump_json(by_alias=True).encode("utf-8") + + +def _incoming_message(body: bytes) -> MagicMock: + msg = MagicMock() + msg.body = body + msg.delivery_tag = 1 + process_ctx = AsyncMock() + process_ctx.__aenter__.return_value = None + process_ctx.__aexit__.return_value = False + msg.process = MagicMock(return_value=process_ctx) + return msg + + +def _make_consumer( + analyzer: AsyncMock, +) -> tuple[RepositoryConsumer, AsyncMock]: + publisher = AsyncMock() + consumer = RepositoryConsumer( + analyzer=analyzer, + publisher=publisher, + idempotency=LruIdempotencyStore(max_size=16), + callback_routing_key="callback.analysis", + ) + return consumer, publisher + + +def _captured_payload(publisher: AsyncMock) -> AnalysisCallbackPayload: + publisher.publish.assert_awaited_once() + kwargs = publisher.publish.await_args.kwargs + assert kwargs["message_type"] == "callback.analysis" + payload = kwargs["payload"] + assert isinstance(payload, AnalysisCallbackPayload) + return payload + + +@pytest.mark.asyncio +async def test_happy_path_publishes_analyzed_callback() -> None: + analyzer = AsyncMock() + analyzer.analyze = AsyncMock( + return_value=RepositoryAnalysisResult( + summary="요약", + tech_stack=["Go"], + document_path="analyzed/repository/7/summary.md", + embedding_chunk_count=3, + ) + ) + consumer, publisher = _make_consumer(analyzer) + await consumer.handle(_incoming_message(_request_envelope())) + + analyzer.analyze.assert_awaited_once_with( + repository_id=7, + repo_full_name="user/repo", + default_branch="main", + user_id=1, + analyzed_document_id=88, + ) + payload = _captured_payload(publisher) + assert payload.status == "ANALYZED" + assert payload.target_type == "REPOSITORY" + assert payload.target_id == 7 + assert payload.summary == "요약" + assert payload.tech_stack == ["Go"] + assert payload.document_path == "analyzed/repository/7/summary.md" + assert payload.embedding_chunk_count == 3 + + +@pytest.mark.asyncio +async def test_domain_error_publishes_failed_callback() -> None: + analyzer = AsyncMock() + analyzer.analyze = AsyncMock( + side_effect=RepositoryAnalyzeError( + code="REPO_NOT_FOUND", message="404", retriable=False + ) + ) + consumer, publisher = _make_consumer(analyzer) + await consumer.handle(_incoming_message(_request_envelope())) + + payload = _captured_payload(publisher) + assert payload.status == "FAILED" + assert payload.error_code == "REPO_NOT_FOUND" + assert payload.retriable is False + assert payload.target_type == "REPOSITORY" + + +@pytest.mark.asyncio +async def test_unexpected_error_publishes_retriable_failure() -> None: + analyzer = AsyncMock() + analyzer.analyze = AsyncMock(side_effect=RuntimeError("boom")) + consumer, publisher = _make_consumer(analyzer) + await consumer.handle(_incoming_message(_request_envelope())) + + payload = _captured_payload(publisher) + assert payload.status == "FAILED" + assert payload.error_code == "UNEXPECTED" + assert payload.retriable is True + + +@pytest.mark.asyncio +async def test_idempotent_duplicate_skipped() -> None: + analyzer = AsyncMock() + analyzer.analyze = AsyncMock( + return_value=RepositoryAnalysisResult( + summary="x", + tech_stack=[], + document_path="analyzed/repository/7/summary.md", + embedding_chunk_count=0, + ) + ) + consumer, publisher = _make_consumer(analyzer) + body = _request_envelope(message_id="dup-r") + await consumer.handle(_incoming_message(body)) + await consumer.handle(_incoming_message(body)) + + assert analyzer.analyze.await_count == 1 + assert publisher.publish.await_count == 1 diff --git a/ai/tests/test_resume_analyzer.py b/ai/tests/test_resume_analyzer.py new file mode 100644 index 00000000..e4ad0e71 --- /dev/null +++ b/ai/tests/test_resume_analyzer.py @@ -0,0 +1,108 @@ +from unittest.mock import AsyncMock + +import pytest + +from ai_server.analyzer.resume_analyzer import ResumeAnalyzeError, ResumeAnalyzer +from ai_server.analyzer.sources.base import ExtractedSource +from ai_server.chain.document_analysis_chain import DocumentAnalysisResult +from ai_server.core.client import CoreEmbeddingUpsertError +from ai_server.rag.chunker import MarkdownChunker +from ai_server.rag.embedder import MockEmbeddingProvider + + +def _make_analyzer( + *, + extracted: ExtractedSource, + analysis: DocumentAnalysisResult | None = None, + upsert_result: int | Exception = 3, +) -> tuple[ResumeAnalyzer, AsyncMock, AsyncMock, AsyncMock, AsyncMock]: + extractor = AsyncMock() + extractor.extract = AsyncMock(return_value=extracted) + chain = AsyncMock() + if analysis is not None: + chain.analyze = AsyncMock(return_value=analysis) + storage = AsyncMock() + + core_client = AsyncMock() + if isinstance(upsert_result, Exception): + core_client.upsert_embeddings = AsyncMock(side_effect=upsert_result) + else: + core_client.upsert_embeddings = AsyncMock(return_value=upsert_result) + + analyzer = ResumeAnalyzer( + extractor=extractor, + chain=chain, + storage=storage, + chunker=MarkdownChunker(chunk_size=200, chunk_overlap=50), + embedder=MockEmbeddingProvider(dim=16), + core_client=core_client, + analyzed_key_template="analyzed/resume/{resume_id}/summary.md", + ) + return analyzer, extractor, chain, storage, core_client + + +@pytest.mark.asyncio +async def test_happy_path_extracts_analyzes_saves_and_upserts_embeddings() -> None: + analyzer, extractor, chain, storage, core_client = _make_analyzer( + extracted=ExtractedSource(text="hello", source_type="PDF"), + analysis=DocumentAnalysisResult( + summary="요약", tech_stack=["Go"], markdown="## 개요\n" + "x" * 500 + ), + upsert_result=3, + ) + result = await analyzer.analyze( + resume_id=42, file_path="r/raw/1/a.pdf", analyzed_document_id=77 + ) + + extractor.extract.assert_awaited_once_with("r/raw/1/a.pdf") + chain.analyze.assert_awaited_once() + storage.put_text.assert_awaited_once() + + core_client.upsert_embeddings.assert_awaited_once() + kwargs = core_client.upsert_embeddings.await_args.kwargs + assert kwargs["document_id"] == 77 + assert kwargs["dim"] == 16 + assert kwargs["model"] == "mock" + assert len(kwargs["chunks"]) > 0 # 마크다운이 chunk_size 넘으니 여러 chunk + + assert result.summary == "요약" + assert result.tech_stack == ["Go"] + assert result.document_path == "analyzed/resume/42/summary.md" + assert result.embedding_chunk_count == 3 + + +@pytest.mark.asyncio +async def test_empty_text_raises_before_llm_or_embedding() -> None: + analyzer, _, chain, storage, core_client = _make_analyzer( + extracted=ExtractedSource(text=" \n\t ", source_type="PDF"), + ) + with pytest.raises(ResumeAnalyzeError) as exc_info: + await analyzer.analyze( + resume_id=1, file_path="empty.pdf", analyzed_document_id=9 + ) + + assert exc_info.value.code == "EMPTY_PDF_TEXT" + assert exc_info.value.retriable is False + chain.analyze.assert_not_called() + storage.put_text.assert_not_called() + core_client.upsert_embeddings.assert_not_called() + + +@pytest.mark.asyncio +async def test_embedding_upsert_failure_translates_to_domain_error() -> None: + analyzer, _, _, storage, core_client = _make_analyzer( + extracted=ExtractedSource(text="hello", source_type="PDF"), + analysis=DocumentAnalysisResult( + summary="x", tech_stack=[], markdown="md content" + ), + upsert_result=CoreEmbeddingUpsertError( + code="DOCUMENT_NOT_FOUND", message="404", retriable=False + ), + ) + with pytest.raises(ResumeAnalyzeError) as exc_info: + await analyzer.analyze(resume_id=1, file_path="x.pdf", analyzed_document_id=9) + assert exc_info.value.code == "DOCUMENT_NOT_FOUND" + assert exc_info.value.retriable is False + # 마크다운 자체는 저장됐을 수 있음 — embedding만 실패 + storage.put_text.assert_awaited_once() + core_client.upsert_embeddings.assert_awaited_once() diff --git a/ai/tests/test_resume_consumer.py b/ai/tests/test_resume_consumer.py index ccf410da..1f00f471 100644 --- a/ai/tests/test_resume_consumer.py +++ b/ai/tests/test_resume_consumer.py @@ -1,56 +1,173 @@ from datetime import datetime, timezone +from unittest.mock import AsyncMock, MagicMock -from ai_server.messaging.consumers.resume_consumer import ( - build_echo_callback, +import pytest + +from ai_server.analyzer.resume_analyzer import ( + ResumeAnalyzeError, + ResumeAnalysisResult, +) +from ai_server.messaging.consumers.resume_consumer import ResumeConsumer +from ai_server.messaging.idempotency import LruIdempotencyStore +from ai_server.model.envelope import Envelope +from ai_server.model.messages.analyze import ( + AnalysisCallbackPayload, + ResumeAnalyzeRequest, ) -from ai_server.model.envelope import Envelope, MessageContext -from ai_server.model.messages.analyze import ResumeAnalyzeRequest -def _request_envelope() -> Envelope[ResumeAnalyzeRequest]: - return Envelope[ResumeAnalyzeRequest].model_validate( +def _request_envelope(message_id: str = "req-1", resume_id: int = 42) -> bytes: + env = Envelope[ResumeAnalyzeRequest].model_validate( { - "messageId": "req-1", + "messageId": message_id, "messageType": "analyze.resume", "version": "v1", "traceId": "trace-x", "publishedAt": datetime(2026, 5, 8, tzinfo=timezone.utc), "publisher": "core-server", - "payload": {"resumeId": 42, "s3Key": "resumes/raw/123/abc.pdf"}, + "payload": { + "resumeId": resume_id, + "filePath": "resumes/raw/123/abc.pdf", + "analyzedDocumentId": 77, + }, "context": {"userId": 123}, } ) + return env.model_dump_json(by_alias=True).encode("utf-8") + + +def _incoming_message(body: bytes) -> MagicMock: + msg = MagicMock() + msg.body = body + msg.delivery_tag = 1 + # `async with message.process(...)` 형태 호출을 위해 async context manager 반환. + process_ctx = AsyncMock() + process_ctx.__aenter__.return_value = None + process_ctx.__aexit__.return_value = False + msg.process = MagicMock(return_value=process_ctx) + return msg + + +def _make_consumer( + analyzer: AsyncMock, + *, + idempotency: LruIdempotencyStore | None = None, +) -> tuple[ResumeConsumer, AsyncMock]: + publisher = AsyncMock() + consumer = ResumeConsumer( + analyzer=analyzer, + publisher=publisher, + idempotency=idempotency or LruIdempotencyStore(max_size=16), + callback_routing_key="callback.analysis", + ) + return consumer, publisher -def test_echo_callback_target_type_resume() -> None: - cb = build_echo_callback(_request_envelope()) - assert cb.target_type == "RESUME" - assert cb.target_id == 42 - assert cb.status == "ANALYZED" +def _captured_payload(publisher: AsyncMock) -> AnalysisCallbackPayload: + publisher.publish.assert_awaited_once() + kwargs = publisher.publish.await_args.kwargs + assert kwargs["message_type"] == "callback.analysis" + assert kwargs["routing_key"] == "callback.analysis" + payload = kwargs["payload"] + assert isinstance(payload, AnalysisCallbackPayload) + return payload + + +@pytest.mark.asyncio +async def test_happy_path_publishes_analyzed_callback() -> None: + analyzer = AsyncMock() + analyzer.analyze = AsyncMock( + return_value=ResumeAnalysisResult( + summary="요약", + tech_stack=["Python", "FastAPI"], + document_path="analyzed/resume/42/summary.md", + embedding_chunk_count=4, + ) + ) + consumer, publisher = _make_consumer(analyzer) + + await consumer.handle(_incoming_message(_request_envelope())) + + analyzer.analyze.assert_awaited_once_with( + resume_id=42, file_path="resumes/raw/123/abc.pdf", analyzed_document_id=77 + ) + payload = _captured_payload(publisher) + assert payload.status == "ANALYZED" + assert payload.target_type == "RESUME" + assert payload.target_id == 42 + assert payload.summary == "요약" + assert payload.tech_stack == ["Python", "FastAPI"] + assert payload.document_path == "analyzed/resume/42/summary.md" + assert payload.embedding_chunk_count == 4 + + +@pytest.mark.asyncio +async def test_domain_error_publishes_failed_callback_with_code() -> None: + analyzer = AsyncMock() + analyzer.analyze = AsyncMock( + side_effect=ResumeAnalyzeError( + code="EMPTY_PDF_TEXT", + message="no text", + retriable=False, + ) + ) + consumer, publisher = _make_consumer(analyzer) + + await consumer.handle(_incoming_message(_request_envelope())) + + payload = _captured_payload(publisher) + assert payload.status == "FAILED" + assert payload.error_code == "EMPTY_PDF_TEXT" + assert payload.error_message == "no text" + assert payload.retriable is False + assert payload.summary is None + assert payload.document_path is None + + +@pytest.mark.asyncio +async def test_unexpected_error_publishes_failed_callback_as_retriable() -> None: + analyzer = AsyncMock() + analyzer.analyze = AsyncMock(side_effect=RuntimeError("boom")) + consumer, publisher = _make_consumer(analyzer) + + await consumer.handle(_incoming_message(_request_envelope())) + + payload = _captured_payload(publisher) + assert payload.status == "FAILED" + assert payload.error_code == "UNEXPECTED" + assert payload.retriable is True + + +@pytest.mark.asyncio +async def test_idempotent_message_skips_analyzer_and_publish() -> None: + analyzer = AsyncMock() + analyzer.analyze = AsyncMock( + return_value=ResumeAnalysisResult( + summary="요약", + tech_stack=[], + document_path="analyzed/resume/42/summary.md", + embedding_chunk_count=0, + ) + ) + idem = LruIdempotencyStore(max_size=16) + consumer, publisher = _make_consumer(analyzer, idempotency=idem) + body = _request_envelope(message_id="dup-1") + await consumer.handle(_incoming_message(body)) + await consumer.handle(_incoming_message(body)) # 중복 메시지 -def test_echo_callback_includes_marker_summary() -> None: - cb = build_echo_callback(_request_envelope()) - assert cb.summary is not None - assert "ECHO" in cb.summary - assert cb.tech_stack == [] - assert cb.document_s3_key == "echo/resume/42/echo.md" - assert cb.embedding_chunk_count == 0 + assert analyzer.analyze.await_count == 1 + assert publisher.publish.await_count == 1 -def test_echo_callback_preserves_resume_id_in_s3_key() -> None: - env = _request_envelope() - env_dict = env.model_dump(by_alias=True) - env_dict["payload"]["resumeId"] = 7 - other = Envelope[ResumeAnalyzeRequest].model_validate(env_dict) - cb = build_echo_callback(other) - assert cb.target_id == 7 - assert cb.document_s3_key == "echo/resume/7/echo.md" +@pytest.mark.asyncio +async def test_parse_failure_raises_for_dlq() -> None: + analyzer = AsyncMock() + consumer, publisher = _make_consumer(analyzer) + bad = _incoming_message(b"not-json") + with pytest.raises(Exception): + await consumer.handle(bad) -def test_extract_context_passes_user_id() -> None: - env = _request_envelope() - cb = build_echo_callback(env) - # context 검증은 consumer entry에서 하지만 핸들러에 의존하지 않음. - # 여기서는 envelope.context가 그대로 보존되는지만 확인. - assert env.context.user_id == 123 + analyzer.analyze.assert_not_called() + publisher.publish.assert_not_called() diff --git a/ai/tests/test_settings.py b/ai/tests/test_settings.py index 98818e43..6baef0c6 100644 --- a/ai/tests/test_settings.py +++ b/ai/tests/test_settings.py @@ -20,7 +20,7 @@ def test_settings_defaults_for_messaging(monkeypatch: pytest.MonkeyPatch) -> Non s = Settings(_env_file=None) # type: ignore[call-arg] - assert s.rabbitmq_url == "amqp://stackup:stackup@localhost:5672/" + assert s.rabbitmq_url == "amqp://stackup:stackup@localhost:38050/" assert s.ai_queue_resume == "ai.analyze.resume" assert s.ai_queue_prefetch == 10 assert s.ai_callback_exchange == "stackup.ai-to-core" diff --git a/ai/tests/test_storage_factory.py b/ai/tests/test_storage_factory.py new file mode 100644 index 00000000..4369bdcc --- /dev/null +++ b/ai/tests/test_storage_factory.py @@ -0,0 +1,46 @@ +from unittest.mock import MagicMock, patch + +from ai_server.config.settings import Settings +from ai_server.storage.factory import build_storage +from ai_server.storage.local_fs import LocalFilesystemStorage +from ai_server.storage.s3 import S3Storage + + +def test_local_backend_returns_local_fs(tmp_path) -> None: + s = Settings( + _env_file=None, # type: ignore[call-arg] + storage_backend="local", + storage_local_root=str(tmp_path), + ) + storage = build_storage(s) + assert isinstance(storage, LocalFilesystemStorage) + assert storage.root == tmp_path.resolve() + + +def test_s3_backend_builds_with_endpoint_and_credentials() -> None: + s = Settings( + _env_file=None, # type: ignore[call-arg] + storage_backend="s3", + s3_bucket_name="stackup", + s3_endpoint_url="http://minio:9000", + s3_access_key="ak", + s3_secret_key="sk", + s3_region="us-east-1", + ) + with ( + patch("ai_server.storage.factory.boto3", create=True), + patch( + "ai_server.storage.s3.boto3.client", return_value=MagicMock() + ) as boto3_client, + ): + storage = build_storage(s) + + assert isinstance(storage, S3Storage) + assert storage.bucket == "stackup" + boto3_client.assert_called_once_with( + "s3", + endpoint_url="http://minio:9000", + aws_access_key_id="ak", + aws_secret_access_key="sk", + region_name="us-east-1", + ) diff --git a/ai/tests/test_storage_local_fs.py b/ai/tests/test_storage_local_fs.py new file mode 100644 index 00000000..9b2557fd --- /dev/null +++ b/ai/tests/test_storage_local_fs.py @@ -0,0 +1,60 @@ +from pathlib import Path + +import pytest + +from ai_server.storage.base import StorageKeyError +from ai_server.storage.local_fs import LocalFilesystemStorage + + +@pytest.mark.asyncio +async def test_put_then_get_roundtrip(tmp_path: Path) -> None: + storage = LocalFilesystemStorage(root_dir=str(tmp_path)) + await storage.put_bytes("nested/dir/file.bin", b"hello world") + assert await storage.get_bytes("nested/dir/file.bin") == b"hello world" + + +@pytest.mark.asyncio +async def test_put_text_writes_utf8(tmp_path: Path) -> None: + storage = LocalFilesystemStorage(root_dir=str(tmp_path)) + await storage.put_text("note.md", "# 한글") + assert await storage.get_bytes("note.md") == "# 한글".encode("utf-8") + + +@pytest.mark.asyncio +async def test_put_creates_parent_dirs(tmp_path: Path) -> None: + storage = LocalFilesystemStorage(root_dir=str(tmp_path)) + await storage.put_text("a/b/c/d.md", "x") + assert (tmp_path / "a" / "b" / "c" / "d.md").exists() + + +@pytest.mark.asyncio +async def test_exists_returns_false_for_missing(tmp_path: Path) -> None: + storage = LocalFilesystemStorage(root_dir=str(tmp_path)) + assert await storage.exists("never/written.md") is False + + +@pytest.mark.asyncio +async def test_rejects_absolute_key(tmp_path: Path) -> None: + storage = LocalFilesystemStorage(root_dir=str(tmp_path)) + with pytest.raises(StorageKeyError): + await storage.put_bytes("/etc/passwd", b"x") + + +@pytest.mark.asyncio +async def test_rejects_parent_traversal(tmp_path: Path) -> None: + storage = LocalFilesystemStorage(root_dir=str(tmp_path)) + with pytest.raises(StorageKeyError): + await storage.get_bytes("../outside.txt") + + +@pytest.mark.asyncio +async def test_rejects_empty_key(tmp_path: Path) -> None: + storage = LocalFilesystemStorage(root_dir=str(tmp_path)) + with pytest.raises(StorageKeyError): + await storage.put_bytes("", b"x") + + +@pytest.mark.asyncio +async def test_exists_returns_false_on_invalid_key(tmp_path: Path) -> None: + storage = LocalFilesystemStorage(root_dir=str(tmp_path)) + assert await storage.exists("../escape.md") is False diff --git a/ai/tests/test_storage_s3.py b/ai/tests/test_storage_s3.py new file mode 100644 index 00000000..11e94522 --- /dev/null +++ b/ai/tests/test_storage_s3.py @@ -0,0 +1,115 @@ +from unittest.mock import MagicMock + +import pytest +from botocore.exceptions import ClientError + +from ai_server.storage.base import StorageKeyError +from ai_server.storage.s3 import S3Storage + + +def _make_storage() -> tuple[S3Storage, MagicMock]: + client = MagicMock() + storage = S3Storage(bucket="test-bucket", client=client) + return storage, client + + +@pytest.mark.asyncio +async def test_get_bytes_calls_s3_get_object() -> None: + storage, client = _make_storage() + body = MagicMock() + body.read.return_value = b"hello" + client.get_object.return_value = {"Body": body} + + out = await storage.get_bytes("a/b/c.pdf") + + client.get_object.assert_called_once_with(Bucket="test-bucket", Key="a/b/c.pdf") + body.read.assert_called_once() + assert out == b"hello" + + +@pytest.mark.asyncio +async def test_put_bytes_passes_content_type() -> None: + storage, client = _make_storage() + await storage.put_bytes("k.bin", b"x", content_type="application/octet-stream") + + client.put_object.assert_called_once() + kwargs = client.put_object.call_args.kwargs + assert kwargs["Bucket"] == "test-bucket" + assert kwargs["Key"] == "k.bin" + assert kwargs["Body"] == b"x" + assert kwargs["ContentType"] == "application/octet-stream" + + +@pytest.mark.asyncio +async def test_put_bytes_omits_content_type_when_none() -> None: + storage, client = _make_storage() + await storage.put_bytes("k.bin", b"x") + + kwargs = client.put_object.call_args.kwargs + assert "ContentType" not in kwargs + + +@pytest.mark.asyncio +async def test_put_text_encodes_utf8_with_markdown_content_type() -> None: + storage, client = _make_storage() + await storage.put_text("doc.md", "# 한글") + + kwargs = client.put_object.call_args.kwargs + assert kwargs["Body"] == "# 한글".encode("utf-8") + assert "markdown" in kwargs["ContentType"] + + +@pytest.mark.asyncio +async def test_exists_returns_true_when_head_succeeds() -> None: + storage, client = _make_storage() + client.head_object.return_value = {} + assert await storage.exists("doc.md") is True + client.head_object.assert_called_once_with(Bucket="test-bucket", Key="doc.md") + + +@pytest.mark.asyncio +async def test_exists_returns_false_on_404() -> None: + storage, client = _make_storage() + client.head_object.side_effect = ClientError( + {"Error": {"Code": "404", "Message": "Not Found"}}, + "HeadObject", + ) + assert await storage.exists("missing.md") is False + + +@pytest.mark.asyncio +async def test_exists_reraises_unexpected_client_error() -> None: + storage, client = _make_storage() + client.head_object.side_effect = ClientError( + {"Error": {"Code": "AccessDenied", "Message": "nope"}}, + "HeadObject", + ) + with pytest.raises(ClientError): + await storage.exists("forbidden.md") + + +@pytest.mark.asyncio +async def test_rejects_absolute_key() -> None: + storage, client = _make_storage() + with pytest.raises(StorageKeyError): + await storage.put_bytes("/etc/passwd", b"x") + client.put_object.assert_not_called() + + +@pytest.mark.asyncio +async def test_rejects_empty_key() -> None: + storage, _ = _make_storage() + with pytest.raises(StorageKeyError): + await storage.get_bytes("") + + +@pytest.mark.asyncio +async def test_exists_false_on_invalid_key() -> None: + storage, client = _make_storage() + assert await storage.exists("/abs") is False + client.head_object.assert_not_called() + + +def test_constructor_requires_bucket() -> None: + with pytest.raises(ValueError): + S3Storage(bucket="", client=MagicMock()) diff --git a/ai/tests/test_web_consumer.py b/ai/tests/test_web_consumer.py new file mode 100644 index 00000000..880f2f27 --- /dev/null +++ b/ai/tests/test_web_consumer.py @@ -0,0 +1,108 @@ +from datetime import datetime, timezone +from unittest.mock import AsyncMock, MagicMock + +import pytest + +from ai_server.analyzer.web_resume_analyzer import ( + WebResumeAnalysisResult, + WebResumeAnalyzeError, +) +from ai_server.messaging.consumers.web_consumer import WebResumeConsumer +from ai_server.messaging.idempotency import LruIdempotencyStore +from ai_server.model.envelope import Envelope +from ai_server.model.messages.analyze import ( + AnalysisCallbackPayload, + WebResumeAnalyzeRequest, +) + + +def _request_envelope(message_id: str = "req-1", resume_id: int = 11) -> bytes: + env = Envelope[WebResumeAnalyzeRequest].model_validate( + { + "messageId": message_id, + "messageType": "analyze.web", + "version": "v1", + "traceId": "trace-w", + "publishedAt": datetime(2026, 5, 14, tzinfo=timezone.utc), + "publisher": "core-server", + "payload": { + "resumeId": resume_id, + "url": "https://example.com/me", + "analyzedDocumentId": 99, + }, + "context": {"userId": 1}, + } + ) + return env.model_dump_json(by_alias=True).encode("utf-8") + + +def _incoming_message(body: bytes) -> MagicMock: + msg = MagicMock() + msg.body = body + msg.delivery_tag = 1 + process_ctx = AsyncMock() + process_ctx.__aenter__.return_value = None + process_ctx.__aexit__.return_value = False + msg.process = MagicMock(return_value=process_ctx) + return msg + + +def _make_consumer(analyzer: AsyncMock) -> tuple[WebResumeConsumer, AsyncMock]: + publisher = AsyncMock() + consumer = WebResumeConsumer( + analyzer=analyzer, + publisher=publisher, + idempotency=LruIdempotencyStore(max_size=16), + callback_routing_key="callback.analysis", + ) + return consumer, publisher + + +def _captured_payload(publisher: AsyncMock) -> AnalysisCallbackPayload: + publisher.publish.assert_awaited_once() + payload = publisher.publish.await_args.kwargs["payload"] + assert isinstance(payload, AnalysisCallbackPayload) + return payload + + +@pytest.mark.asyncio +async def test_happy_path_publishes_analyzed_callback() -> None: + analyzer = AsyncMock() + analyzer.analyze = AsyncMock( + return_value=WebResumeAnalysisResult( + summary="요약", + tech_stack=["React"], + document_path="analyzed/web-resume/11/summary.md", + embedding_chunk_count=2, + ) + ) + consumer, publisher = _make_consumer(analyzer) + await consumer.handle(_incoming_message(_request_envelope())) + + analyzer.analyze.assert_awaited_once_with( + resume_id=11, url="https://example.com/me", analyzed_document_id=99 + ) + payload = _captured_payload(publisher) + assert payload.status == "ANALYZED" + assert payload.target_type == "WEB" + assert payload.target_id == 11 + assert payload.summary == "요약" + assert payload.document_path == "analyzed/web-resume/11/summary.md" + + +@pytest.mark.asyncio +async def test_domain_error_publishes_failed_callback() -> None: + analyzer = AsyncMock() + analyzer.analyze = AsyncMock( + side_effect=WebResumeAnalyzeError( + code="WEB_HTTP_STATUS", message="404", retriable=False + ) + ) + consumer, publisher = _make_consumer(analyzer) + await consumer.handle(_incoming_message(_request_envelope())) + + payload = _captured_payload(publisher) + assert payload.status == "FAILED" + assert payload.error_code == "WEB_HTTP_STATUS" + assert payload.retriable is False + assert payload.target_type == "WEB" diff --git a/ai/tests/test_web_extractor.py b/ai/tests/test_web_extractor.py new file mode 100644 index 00000000..3bd75c66 --- /dev/null +++ b/ai/tests/test_web_extractor.py @@ -0,0 +1,117 @@ +from unittest.mock import AsyncMock, MagicMock + +import httpx +import pytest + +from ai_server.analyzer.sources.web import WebFetchError, WebSourceExtractor + + +def _make_client( + *, + status: int = 200, + content_type: str = "text/html; charset=utf-8", + body: ( + str | bytes + ) = "

주요 내용

본문 텍스트입니다.

", + final_url: str = "https://example.com/r", + raise_exc: Exception | None = None, +) -> MagicMock: + client = MagicMock() + resp = MagicMock(spec=httpx.Response) + resp.status_code = status + resp.headers = {"content-type": content_type} + resp.text = body if isinstance(body, str) else body.decode("utf-8", errors="ignore") + resp.content = body.encode("utf-8") if isinstance(body, str) else body + resp.url = final_url + if raise_exc is not None: + client.get = AsyncMock(side_effect=raise_exc) + else: + client.get = AsyncMock(return_value=resp) + return client + + +@pytest.mark.asyncio +async def test_extract_returns_main_body_text() -> None: + html = ( + "이력서" + "

김OO

" + "

백엔드 개발자. Spring Boot 3년차.

" + "" + ) + client = _make_client(body=html) + extractor = WebSourceExtractor(client=client) + result = await extractor.extract("https://example.com/r") + + assert result.source_type == "WEB" + assert "백엔드 개발자" in result.text + assert "김OO" in result.text + assert result.metadata["final_url"] == "https://example.com/r" + assert result.metadata["text_chars"] > 0 + + +@pytest.mark.asyncio +async def test_rejects_non_http_locator() -> None: + extractor = WebSourceExtractor(client=_make_client()) + with pytest.raises(WebFetchError) as exc_info: + await extractor.extract("ftp://example.com/x") + assert exc_info.value.code == "INVALID_WEB_URL" + assert exc_info.value.retriable is False + + +@pytest.mark.asyncio +async def test_raises_on_http_error_status() -> None: + client = _make_client(status=503) + extractor = WebSourceExtractor(client=client) + with pytest.raises(WebFetchError) as exc_info: + await extractor.extract("https://example.com/r") + assert exc_info.value.code == "WEB_HTTP_STATUS" + assert exc_info.value.retriable is True # 5xx → retriable + + +@pytest.mark.asyncio +async def test_raises_on_4xx_as_non_retriable() -> None: + client = _make_client(status=404) + extractor = WebSourceExtractor(client=client) + with pytest.raises(WebFetchError) as exc_info: + await extractor.extract("https://example.com/r") + assert exc_info.value.code == "WEB_HTTP_STATUS" + assert exc_info.value.retriable is False + + +@pytest.mark.asyncio +async def test_rejects_non_html_content_type() -> None: + client = _make_client(content_type="application/pdf") + extractor = WebSourceExtractor(client=client) + with pytest.raises(WebFetchError) as exc_info: + await extractor.extract("https://example.com/r") + assert exc_info.value.code == "WEB_NOT_HTML" + + +@pytest.mark.asyncio +async def test_rejects_oversized_html() -> None: + big = b"" + b"a" * 1024 + b"" + client = _make_client(body=big) + extractor = WebSourceExtractor(client=client, max_html_bytes=512) + with pytest.raises(WebFetchError) as exc_info: + await extractor.extract("https://example.com/r") + assert exc_info.value.code == "WEB_HTML_TOO_LARGE" + + +@pytest.mark.asyncio +async def test_raises_on_empty_body() -> None: + client = _make_client(body="") + extractor = WebSourceExtractor(client=client) + with pytest.raises(WebFetchError) as exc_info: + await extractor.extract("https://example.com/r") + assert exc_info.value.code == "EMPTY_WEB_BODY" + assert exc_info.value.retriable is False + + +@pytest.mark.asyncio +async def test_raises_on_httpx_error_as_retriable() -> None: + client = _make_client(raise_exc=httpx.ConnectError("dns fail")) + extractor = WebSourceExtractor(client=client) + with pytest.raises(WebFetchError) as exc_info: + await extractor.extract("https://example.com/r") + assert exc_info.value.code == "WEB_FETCH_FAILED" + assert exc_info.value.retriable is True diff --git a/ai/tests/test_web_resume_analyzer.py b/ai/tests/test_web_resume_analyzer.py new file mode 100644 index 00000000..57b13d0a --- /dev/null +++ b/ai/tests/test_web_resume_analyzer.py @@ -0,0 +1,84 @@ +from unittest.mock import AsyncMock + +import pytest + +from ai_server.analyzer.sources.base import ExtractedSource +from ai_server.analyzer.sources.web import WebFetchError +from ai_server.analyzer.web_resume_analyzer import ( + WebResumeAnalyzeError, + WebResumeAnalyzer, +) +from ai_server.chain.document_analysis_chain import DocumentAnalysisResult +from ai_server.rag.chunker import MarkdownChunker +from ai_server.rag.embedder import MockEmbeddingProvider + + +def _make_analyzer( + *, + extract_result: ExtractedSource | Exception | None = None, + analysis: DocumentAnalysisResult | None = None, + upsert_result: int = 2, +) -> tuple[WebResumeAnalyzer, AsyncMock, AsyncMock, AsyncMock, AsyncMock]: + extractor = AsyncMock() + if isinstance(extract_result, Exception): + extractor.extract = AsyncMock(side_effect=extract_result) + else: + extractor.extract = AsyncMock(return_value=extract_result) + chain = AsyncMock() + if analysis is not None: + chain.analyze = AsyncMock(return_value=analysis) + storage = AsyncMock() + + core_client = AsyncMock() + core_client.upsert_embeddings = AsyncMock(return_value=upsert_result) + + analyzer = WebResumeAnalyzer( + extractor=extractor, + chain=chain, + storage=storage, + chunker=MarkdownChunker(chunk_size=200, chunk_overlap=50), + embedder=MockEmbeddingProvider(dim=16), + core_client=core_client, + analyzed_key_template="analyzed/web-resume/{resume_id}/summary.md", + ) + return analyzer, extractor, chain, storage, core_client + + +@pytest.mark.asyncio +async def test_happy_path() -> None: + analyzer, extractor, chain, storage, core_client = _make_analyzer( + extract_result=ExtractedSource(text="본문" * 30, source_type="WEB"), + analysis=DocumentAnalysisResult( + summary="요약", tech_stack=["TypeScript"], markdown="## 개요\nweb" + ), + upsert_result=2, + ) + result = await analyzer.analyze( + resume_id=11, url="https://example.com/me", analyzed_document_id=99 + ) + + extractor.extract.assert_awaited_once_with("https://example.com/me") + chain.analyze.assert_awaited_once() + storage.put_text.assert_awaited_once() + core_client.upsert_embeddings.assert_awaited_once() + assert core_client.upsert_embeddings.await_args.kwargs["document_id"] == 99 + assert result.summary == "요약" + assert result.embedding_chunk_count == 2 + assert result.document_path == "analyzed/web-resume/11/summary.md" + + +@pytest.mark.asyncio +async def test_fetch_error_translates_to_domain_error() -> None: + analyzer, _, chain, storage, core_client = _make_analyzer( + extract_result=WebFetchError( + code="WEB_HTTP_STATUS", message="404", retriable=False + ), + ) + with pytest.raises(WebResumeAnalyzeError) as exc_info: + await analyzer.analyze( + resume_id=1, url="https://example.com/x", analyzed_document_id=1 + ) + assert exc_info.value.code == "WEB_HTTP_STATUS" + chain.analyze.assert_not_called() + storage.put_text.assert_not_called() + core_client.upsert_embeddings.assert_not_called() diff --git a/ai/uv.lock b/ai/uv.lock index f2fca866..bc87a385 100644 --- a/ai/uv.lock +++ b/ai/uv.lock @@ -174,6 +174,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/64/b4/17d4b0b2a2dc85a6df63d1157e028ed19f90d4cd97c36717afef2bc2f395/attrs-26.1.0-py3-none-any.whl", hash = "sha256:c647aa4a12dfbad9333ca4e71fe62ddc36f4e63b2d260a37a8b83d2f043ac309", size = 67548, upload-time = "2026-03-19T14:22:23.645Z" }, ] +[[package]] +name = "babel" +version = "2.18.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7d/b2/51899539b6ceeeb420d40ed3cd4b7a40519404f9baf3d4ac99dc413a834b/babel-2.18.0.tar.gz", hash = "sha256:b80b99a14bd085fcacfa15c9165f651fbb3406e66cc603abf11c5750937c992d", size = 9959554, upload-time = "2026-02-01T12:30:56.078Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/77/f5/21d2de20e8b8b0408f0681956ca2c69f1320a3848ac50e6e7f39c6159675/babel-2.18.0-py3-none-any.whl", hash = "sha256:e2b422b277c2b9a9630c1d7903c2a00d0830c409c59ac8cae9081c92f1aeba35", size = 10196845, upload-time = "2026-02-01T12:30:53.445Z" }, +] + [[package]] name = "black" version = "26.3.1" @@ -238,6 +247,51 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/9a/3c/c17fb3ca2d9c3acff52e30b309f538586f9f5b9c9cf454f3845fc9af4881/certifi-2026.2.25-py3-none-any.whl", hash = "sha256:027692e4402ad994f1c42e52a4997a9763c646b73e4096e4d5d6db8af1d6f0fa", size = 153684, upload-time = "2026-02-25T02:54:15.766Z" }, ] +[[package]] +name = "cffi" +version = "2.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pycparser", marker = "implementation_name != 'PyPy'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/eb/56/b1ba7935a17738ae8453301356628e8147c79dbb825bcbc73dc7401f9846/cffi-2.0.0.tar.gz", hash = "sha256:44d1b5909021139fe36001ae048dbdde8214afa20200eda0f64c068cac5d5529", size = 523588, upload-time = "2025-09-08T23:24:04.541Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4b/8d/a0a47a0c9e413a658623d014e91e74a50cdd2c423f7ccfd44086ef767f90/cffi-2.0.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:00bdf7acc5f795150faa6957054fbbca2439db2f775ce831222b66f192f03beb", size = 185230, upload-time = "2025-09-08T23:23:00.879Z" }, + { url = "https://files.pythonhosted.org/packages/4a/d2/a6c0296814556c68ee32009d9c2ad4f85f2707cdecfd7727951ec228005d/cffi-2.0.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:45d5e886156860dc35862657e1494b9bae8dfa63bf56796f2fb56e1679fc0bca", size = 181043, upload-time = "2025-09-08T23:23:02.231Z" }, + { url = "https://files.pythonhosted.org/packages/b0/1e/d22cc63332bd59b06481ceaac49d6c507598642e2230f201649058a7e704/cffi-2.0.0-cp313-cp313-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:07b271772c100085dd28b74fa0cd81c8fb1a3ba18b21e03d7c27f3436a10606b", size = 212446, upload-time = "2025-09-08T23:23:03.472Z" }, + { url = "https://files.pythonhosted.org/packages/a9/f5/a2c23eb03b61a0b8747f211eb716446c826ad66818ddc7810cc2cc19b3f2/cffi-2.0.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d48a880098c96020b02d5a1f7d9251308510ce8858940e6fa99ece33f610838b", size = 220101, upload-time = "2025-09-08T23:23:04.792Z" }, + { url = "https://files.pythonhosted.org/packages/f2/7f/e6647792fc5850d634695bc0e6ab4111ae88e89981d35ac269956605feba/cffi-2.0.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:f93fd8e5c8c0a4aa1f424d6173f14a892044054871c771f8566e4008eaa359d2", size = 207948, upload-time = "2025-09-08T23:23:06.127Z" }, + { url = "https://files.pythonhosted.org/packages/cb/1e/a5a1bd6f1fb30f22573f76533de12a00bf274abcdc55c8edab639078abb6/cffi-2.0.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:dd4f05f54a52fb558f1ba9f528228066954fee3ebe629fc1660d874d040ae5a3", size = 206422, upload-time = "2025-09-08T23:23:07.753Z" }, + { url = "https://files.pythonhosted.org/packages/98/df/0a1755e750013a2081e863e7cd37e0cdd02664372c754e5560099eb7aa44/cffi-2.0.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c8d3b5532fc71b7a77c09192b4a5a200ea992702734a2e9279a37f2478236f26", size = 219499, upload-time = "2025-09-08T23:23:09.648Z" }, + { url = "https://files.pythonhosted.org/packages/50/e1/a969e687fcf9ea58e6e2a928ad5e2dd88cc12f6f0ab477e9971f2309b57c/cffi-2.0.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d9b29c1f0ae438d5ee9acb31cadee00a58c46cc9c0b2f9038c6b0b3470877a8c", size = 222928, upload-time = "2025-09-08T23:23:10.928Z" }, + { url = "https://files.pythonhosted.org/packages/36/54/0362578dd2c9e557a28ac77698ed67323ed5b9775ca9d3fe73fe191bb5d8/cffi-2.0.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6d50360be4546678fc1b79ffe7a66265e28667840010348dd69a314145807a1b", size = 221302, upload-time = "2025-09-08T23:23:12.42Z" }, + { url = "https://files.pythonhosted.org/packages/eb/6d/bf9bda840d5f1dfdbf0feca87fbdb64a918a69bca42cfa0ba7b137c48cb8/cffi-2.0.0-cp313-cp313-win32.whl", hash = "sha256:74a03b9698e198d47562765773b4a8309919089150a0bb17d829ad7b44b60d27", size = 172909, upload-time = "2025-09-08T23:23:14.32Z" }, + { url = "https://files.pythonhosted.org/packages/37/18/6519e1ee6f5a1e579e04b9ddb6f1676c17368a7aba48299c3759bbc3c8b3/cffi-2.0.0-cp313-cp313-win_amd64.whl", hash = "sha256:19f705ada2530c1167abacb171925dd886168931e0a7b78f5bffcae5c6b5be75", size = 183402, upload-time = "2025-09-08T23:23:15.535Z" }, + { url = "https://files.pythonhosted.org/packages/cb/0e/02ceeec9a7d6ee63bb596121c2c8e9b3a9e150936f4fbef6ca1943e6137c/cffi-2.0.0-cp313-cp313-win_arm64.whl", hash = "sha256:256f80b80ca3853f90c21b23ee78cd008713787b1b1e93eae9f3d6a7134abd91", size = 177780, upload-time = "2025-09-08T23:23:16.761Z" }, + { url = "https://files.pythonhosted.org/packages/92/c4/3ce07396253a83250ee98564f8d7e9789fab8e58858f35d07a9a2c78de9f/cffi-2.0.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:fc33c5141b55ed366cfaad382df24fe7dcbc686de5be719b207bb248e3053dc5", size = 185320, upload-time = "2025-09-08T23:23:18.087Z" }, + { url = "https://files.pythonhosted.org/packages/59/dd/27e9fa567a23931c838c6b02d0764611c62290062a6d4e8ff7863daf9730/cffi-2.0.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c654de545946e0db659b3400168c9ad31b5d29593291482c43e3564effbcee13", size = 181487, upload-time = "2025-09-08T23:23:19.622Z" }, + { url = "https://files.pythonhosted.org/packages/d6/43/0e822876f87ea8a4ef95442c3d766a06a51fc5298823f884ef87aaad168c/cffi-2.0.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:24b6f81f1983e6df8db3adc38562c83f7d4a0c36162885ec7f7b77c7dcbec97b", size = 220049, upload-time = "2025-09-08T23:23:20.853Z" }, + { url = "https://files.pythonhosted.org/packages/b4/89/76799151d9c2d2d1ead63c2429da9ea9d7aac304603de0c6e8764e6e8e70/cffi-2.0.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:12873ca6cb9b0f0d3a0da705d6086fe911591737a59f28b7936bdfed27c0d47c", size = 207793, upload-time = "2025-09-08T23:23:22.08Z" }, + { url = "https://files.pythonhosted.org/packages/bb/dd/3465b14bb9e24ee24cb88c9e3730f6de63111fffe513492bf8c808a3547e/cffi-2.0.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:d9b97165e8aed9272a6bb17c01e3cc5871a594a446ebedc996e2397a1c1ea8ef", size = 206300, upload-time = "2025-09-08T23:23:23.314Z" }, + { url = "https://files.pythonhosted.org/packages/47/d9/d83e293854571c877a92da46fdec39158f8d7e68da75bf73581225d28e90/cffi-2.0.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:afb8db5439b81cf9c9d0c80404b60c3cc9c3add93e114dcae767f1477cb53775", size = 219244, upload-time = "2025-09-08T23:23:24.541Z" }, + { url = "https://files.pythonhosted.org/packages/2b/0f/1f177e3683aead2bb00f7679a16451d302c436b5cbf2505f0ea8146ef59e/cffi-2.0.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:737fe7d37e1a1bffe70bd5754ea763a62a066dc5913ca57e957824b72a85e205", size = 222828, upload-time = "2025-09-08T23:23:26.143Z" }, + { url = "https://files.pythonhosted.org/packages/c6/0f/cafacebd4b040e3119dcb32fed8bdef8dfe94da653155f9d0b9dc660166e/cffi-2.0.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:38100abb9d1b1435bc4cc340bb4489635dc2f0da7456590877030c9b3d40b0c1", size = 220926, upload-time = "2025-09-08T23:23:27.873Z" }, + { url = "https://files.pythonhosted.org/packages/3e/aa/df335faa45b395396fcbc03de2dfcab242cd61a9900e914fe682a59170b1/cffi-2.0.0-cp314-cp314-win32.whl", hash = "sha256:087067fa8953339c723661eda6b54bc98c5625757ea62e95eb4898ad5e776e9f", size = 175328, upload-time = "2025-09-08T23:23:44.61Z" }, + { url = "https://files.pythonhosted.org/packages/bb/92/882c2d30831744296ce713f0feb4c1cd30f346ef747b530b5318715cc367/cffi-2.0.0-cp314-cp314-win_amd64.whl", hash = "sha256:203a48d1fb583fc7d78a4c6655692963b860a417c0528492a6bc21f1aaefab25", size = 185650, upload-time = "2025-09-08T23:23:45.848Z" }, + { url = "https://files.pythonhosted.org/packages/9f/2c/98ece204b9d35a7366b5b2c6539c350313ca13932143e79dc133ba757104/cffi-2.0.0-cp314-cp314-win_arm64.whl", hash = "sha256:dbd5c7a25a7cb98f5ca55d258b103a2054f859a46ae11aaf23134f9cc0d356ad", size = 180687, upload-time = "2025-09-08T23:23:47.105Z" }, + { url = "https://files.pythonhosted.org/packages/3e/61/c768e4d548bfa607abcda77423448df8c471f25dbe64fb2ef6d555eae006/cffi-2.0.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:9a67fc9e8eb39039280526379fb3a70023d77caec1852002b4da7e8b270c4dd9", size = 188773, upload-time = "2025-09-08T23:23:29.347Z" }, + { url = "https://files.pythonhosted.org/packages/2c/ea/5f76bce7cf6fcd0ab1a1058b5af899bfbef198bea4d5686da88471ea0336/cffi-2.0.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7a66c7204d8869299919db4d5069a82f1561581af12b11b3c9f48c584eb8743d", size = 185013, upload-time = "2025-09-08T23:23:30.63Z" }, + { url = "https://files.pythonhosted.org/packages/be/b4/c56878d0d1755cf9caa54ba71e5d049479c52f9e4afc230f06822162ab2f/cffi-2.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:7cc09976e8b56f8cebd752f7113ad07752461f48a58cbba644139015ac24954c", size = 221593, upload-time = "2025-09-08T23:23:31.91Z" }, + { url = "https://files.pythonhosted.org/packages/e0/0d/eb704606dfe8033e7128df5e90fee946bbcb64a04fcdaa97321309004000/cffi-2.0.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:92b68146a71df78564e4ef48af17551a5ddd142e5190cdf2c5624d0c3ff5b2e8", size = 209354, upload-time = "2025-09-08T23:23:33.214Z" }, + { url = "https://files.pythonhosted.org/packages/d8/19/3c435d727b368ca475fb8742ab97c9cb13a0de600ce86f62eab7fa3eea60/cffi-2.0.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:b1e74d11748e7e98e2f426ab176d4ed720a64412b6a15054378afdb71e0f37dc", size = 208480, upload-time = "2025-09-08T23:23:34.495Z" }, + { url = "https://files.pythonhosted.org/packages/d0/44/681604464ed9541673e486521497406fadcc15b5217c3e326b061696899a/cffi-2.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:28a3a209b96630bca57cce802da70c266eb08c6e97e5afd61a75611ee6c64592", size = 221584, upload-time = "2025-09-08T23:23:36.096Z" }, + { url = "https://files.pythonhosted.org/packages/25/8e/342a504ff018a2825d395d44d63a767dd8ebc927ebda557fecdaca3ac33a/cffi-2.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:7553fb2090d71822f02c629afe6042c299edf91ba1bf94951165613553984512", size = 224443, upload-time = "2025-09-08T23:23:37.328Z" }, + { url = "https://files.pythonhosted.org/packages/e1/5e/b666bacbbc60fbf415ba9988324a132c9a7a0448a9a8f125074671c0f2c3/cffi-2.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6c6c373cfc5c83a975506110d17457138c8c63016b563cc9ed6e056a82f13ce4", size = 223437, upload-time = "2025-09-08T23:23:38.945Z" }, + { url = "https://files.pythonhosted.org/packages/a0/1d/ec1a60bd1a10daa292d3cd6bb0b359a81607154fb8165f3ec95fe003b85c/cffi-2.0.0-cp314-cp314t-win32.whl", hash = "sha256:1fc9ea04857caf665289b7a75923f2c6ed559b8298a1b8c49e59f7dd95c8481e", size = 180487, upload-time = "2025-09-08T23:23:40.423Z" }, + { url = "https://files.pythonhosted.org/packages/bf/41/4c1168c74fac325c0c8156f04b6749c8b6a8f405bbf91413ba088359f60d/cffi-2.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:d68b6cef7827e8641e8ef16f4494edda8b36104d79773a334beaa1e3521430f6", size = 191726, upload-time = "2025-09-08T23:23:41.742Z" }, + { url = "https://files.pythonhosted.org/packages/ae/3a/dbeec9d1ee0844c679f6bb5d6ad4e9f198b1224f4e7a32825f47f6192b0c/cffi-2.0.0-cp314-cp314t-win_arm64.whl", hash = "sha256:0a1527a803f0a659de1af2e1fd700213caba79377e27e4693648c2923da066f9", size = 184195, upload-time = "2025-09-08T23:23:43.004Z" }, +] + [[package]] name = "charset-normalizer" version = "3.4.6" @@ -316,6 +370,73 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, ] +[[package]] +name = "courlan" +version = "1.3.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "babel" }, + { name = "tld" }, + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/6f/54/6d6ceeff4bed42e7a10d6064d35ee43a810e7b3e8beb4abeae8cff4713ae/courlan-1.3.2.tar.gz", hash = "sha256:0b66f4db3a9c39a6e22dd247c72cfaa57d68ea660e94bb2c84ec7db8712af190", size = 206382, upload-time = "2024-10-29T16:40:20.994Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8e/ca/6a667ccbe649856dcd3458bab80b016681b274399d6211187c6ab969fc50/courlan-1.3.2-py3-none-any.whl", hash = "sha256:d0dab52cf5b5b1000ee2839fbc2837e93b2514d3cb5bb61ae158a55b7a04c6be", size = 33848, upload-time = "2024-10-29T16:40:18.325Z" }, +] + +[[package]] +name = "cryptography" +version = "48.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cffi", marker = "platform_python_implementation != 'PyPy'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9f/a9/db8f313fdcd85d767d4973515e1db101f9c71f95fced83233de224673757/cryptography-48.0.0.tar.gz", hash = "sha256:5c3932f4436d1cccb036cb0eaef46e6e2db91035166f1ad6505c3c9d5a635920", size = 832984, upload-time = "2026-05-04T22:59:38.133Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/df/3d/01f6dd9190170a5a241e0e98c2d04be3664a9e6f5b9b872cde63aff1c3dd/cryptography-48.0.0-cp311-abi3-macosx_10_9_universal2.whl", hash = "sha256:0c558d2cdffd8f4bbb30fc7134c74d2ca9a476f830bb053074498fbc86f41ed6", size = 8001587, upload-time = "2026-05-04T22:57:36.803Z" }, + { url = "https://files.pythonhosted.org/packages/b2/6e/e90527eef33f309beb811cf7c982c3aeffcce8e3edb178baa4ca3ae4a6fa/cryptography-48.0.0-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f5333311663ea94f75dd408665686aaf426563556bb5283554a3539177e03b8c", size = 4690433, upload-time = "2026-05-04T22:57:40.373Z" }, + { url = "https://files.pythonhosted.org/packages/90/04/673510ed51ddff56575f306cf1617d80411ee76831ccd3097599140efdfe/cryptography-48.0.0-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7995ef305d7165c3f11ae07f2517e5a4f1d5c18da1376a0a9ed496336b69e5f3", size = 4710620, upload-time = "2026-05-04T22:57:42.935Z" }, + { url = "https://files.pythonhosted.org/packages/14/d5/e9c4ef932c8d800490c34d8bd589d64a31d5890e27ec9e9ad532be893294/cryptography-48.0.0-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:40ba1f85eaa6959837b1d51c9767e230e14612eea4ef110ee8854ada22da1bf5", size = 4696283, upload-time = "2026-05-04T22:57:45.294Z" }, + { url = "https://files.pythonhosted.org/packages/0c/29/174b9dfb60b12d59ecfc6cfa04bc88c21b42a54f01b8aae09bb6e51e4c7f/cryptography-48.0.0-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:369a6348999f94bbd53435c894377b20ab95f25a9065c283570e70150d8abc3c", size = 5296573, upload-time = "2026-05-04T22:57:47.933Z" }, + { url = "https://files.pythonhosted.org/packages/95/38/0d29a6fd7d0d1373f0c0c88a04ba20e359b257753ac497564cd660fc1d55/cryptography-48.0.0-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:a0e692c683f4df67815a2d258b324e66f4738bd7a96a218c826dce4f4bd05d8f", size = 4743677, upload-time = "2026-05-04T22:57:50.067Z" }, + { url = "https://files.pythonhosted.org/packages/30/be/eef653013d5c63b6a490529e0316f9ac14a37602965d4903efed1399f32b/cryptography-48.0.0-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:18349bbc56f4743c8b12dc32e2bccb2cf83ee8b69a3bba74ef8ae857e26b3d25", size = 4330808, upload-time = "2026-05-04T22:57:52.301Z" }, + { url = "https://files.pythonhosted.org/packages/84/9e/500463e87abb7a0a0f9f256ec21123ecde0a7b5541a15e840ea54551fd81/cryptography-48.0.0-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:7e8eac43dfca5c4cccc6dad9a80504436fca53bb9bc3100a2386d730fbe6b602", size = 4695941, upload-time = "2026-05-04T22:57:54.603Z" }, + { url = "https://files.pythonhosted.org/packages/e3/dc/7303087450c2ec9e7fbb750e17c2abfbc658f23cbd0e54009509b7cc4091/cryptography-48.0.0-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:9ccdac7d40688ecb5a3b4a604b8a88c8002e3442d6c60aead1db2a89a041560c", size = 5252579, upload-time = "2026-05-04T22:57:57.207Z" }, + { url = "https://files.pythonhosted.org/packages/d0/c0/7101d3b7215edcdc90c45da544961fd8ed2d6448f77577460fa75a8443f7/cryptography-48.0.0-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:bd72e68b06bb1e96913f97dd4901119bc17f39d4586a5adf2d3e47bc2b9d58b5", size = 4743326, upload-time = "2026-05-04T22:57:59.535Z" }, + { url = "https://files.pythonhosted.org/packages/ac/d8/5b833bad13016f562ab9d063d68199a4bd121d18458e439515601d3357ec/cryptography-48.0.0-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:59baa2cb386c4f0b9905bd6eb4c2a79a69a128408fd31d32ca4d7102d4156321", size = 4826672, upload-time = "2026-05-04T22:58:01.996Z" }, + { url = "https://files.pythonhosted.org/packages/98/e1/7074eb8bf3c135558c73fc2bcf0f5633f912e6fb87e868a55c454080ef09/cryptography-48.0.0-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:9249e3cd978541d665967ac2cb2787fd6a62bddf1e75b3e347a594d7dacf4f74", size = 4972574, upload-time = "2026-05-04T22:58:03.968Z" }, + { url = "https://files.pythonhosted.org/packages/04/70/e5a1b41d325f797f39427aa44ef8baf0be500065ab6d8e10369d850d4a4f/cryptography-48.0.0-cp311-abi3-win32.whl", hash = "sha256:9c459db21422be75e2809370b829a87eb37f74cd785fc4aa9ea1e5f43b47cda4", size = 3294868, upload-time = "2026-05-04T22:58:06.467Z" }, + { url = "https://files.pythonhosted.org/packages/f4/ac/8ac51b4a5fc5932eb7ee5c517ba7dc8cd834f0048962b6b352f00f41ebf9/cryptography-48.0.0-cp311-abi3-win_amd64.whl", hash = "sha256:5b012212e08b8dd5edc78ef54da83dd9892fd9105323b3993eff6bea65dc21d7", size = 3817107, upload-time = "2026-05-04T22:58:08.845Z" }, + { url = "https://files.pythonhosted.org/packages/6b/84/70e3feea9feea87fd7cbe77efb2712ae1e3e6edf10749dc6e95f4e60e455/cryptography-48.0.0-cp314-cp314t-macosx_10_9_universal2.whl", hash = "sha256:3cb07a3ed6431663cd321ea8a000a1314c74211f823e4177fefa2255e057d1ec", size = 7986556, upload-time = "2026-05-04T22:58:11.172Z" }, + { url = "https://files.pythonhosted.org/packages/89/6e/18e07a618bb5442ba10cf4df16e99c071365528aa570dfcb8c02e25a303b/cryptography-48.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:8c7378637d7d88016fa6791c159f698b3d3eed28ebf844ac36b9dc04a14dae18", size = 4684776, upload-time = "2026-05-04T22:58:13.712Z" }, + { url = "https://files.pythonhosted.org/packages/be/6a/4ea3b4c6c6759794d5ee2103c304a5076dc4b19ae1f9fe47dba439e159e9/cryptography-48.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:cc90c0b39b2e3c65ef52c804b72e3c58f8a04ab2a1871272798e5f9572c17d20", size = 4698121, upload-time = "2026-05-04T22:58:16.448Z" }, + { url = "https://files.pythonhosted.org/packages/2f/59/6ff6ad6cae03bb887da2a5860b2c9805f8dac969ef01ce563336c49bd1d1/cryptography-48.0.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:76341972e1eff8b4bea859f09c0d3e64b96ce931b084f9b9b7db8ef364c30eff", size = 4690042, upload-time = "2026-05-04T22:58:18.544Z" }, + { url = "https://files.pythonhosted.org/packages/ca/b4/fc334ed8cfd705aca282fe4d8f5ae64a8e0f74932e9feecb344610cf6e4d/cryptography-48.0.0-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:55b7718303bf06a5753dcdccf2f3945cf18ad7bffde41b61226e4db31ab89a9c", size = 5282526, upload-time = "2026-05-04T22:58:20.75Z" }, + { url = "https://files.pythonhosted.org/packages/11/08/9f8c5386cc4cd90d8255c7cdd0f5baf459a08502a09de30dc51f553d38dc/cryptography-48.0.0-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:a64697c641c7b1b2178e573cbc31c7c6684cd56883a478d75143dbb7118036db", size = 4733116, upload-time = "2026-05-04T22:58:23.627Z" }, + { url = "https://files.pythonhosted.org/packages/b8/77/99307d7574045699f8805aa500fa0fb83422d115b5400a064ddd306d7750/cryptography-48.0.0-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:561215ea3879cb1cbbf272867e2efda62476f240fb58c64de6b393ae19246741", size = 4316030, upload-time = "2026-05-04T22:58:25.581Z" }, + { url = "https://files.pythonhosted.org/packages/fd/36/a608b98337af3cb2aff4818e406649d30572b7031918b04c87d979495348/cryptography-48.0.0-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:ad64688338ed4bc1a6618076ba75fd7194a5f1797ac60b47afe926285adb3166", size = 4689640, upload-time = "2026-05-04T22:58:27.747Z" }, + { url = "https://files.pythonhosted.org/packages/dd/a6/825010a291b4438aecc1f568bc428189fc1175515223632477c07dc0a6df/cryptography-48.0.0-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:906cbf0670286c6e0044156bc7d4af9cbb0ef6db9f73e52c3ec56ba6bdde5336", size = 5237657, upload-time = "2026-05-04T22:58:29.848Z" }, + { url = "https://files.pythonhosted.org/packages/b9/09/4e76a09b4caa29aad535ddc806f5d4c5d01885bd978bd984fbc6ca032cae/cryptography-48.0.0-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:ea8990436d914540a40ab24b6a77c0969695ed52f4a4874c5137ccf7045a7057", size = 4732362, upload-time = "2026-05-04T22:58:32.009Z" }, + { url = "https://files.pythonhosted.org/packages/18/78/444fa04a77d0cb95f417dda20d450e13c56ba8e5220fc892a1658f44f882/cryptography-48.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:c18684a7f0cc9a3cb60328f496b8e3372def7c5d2df39ac267878b05565aaaae", size = 4819580, upload-time = "2026-05-04T22:58:34.254Z" }, + { url = "https://files.pythonhosted.org/packages/38/85/ea67067c70a1fd4be2c63d35eeed82658023021affccc7b17705f8527dd2/cryptography-48.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:9be5aafa5736574f8f15f262adc81b2a9869e2cfe9014d52a44633905b40d52c", size = 4963283, upload-time = "2026-05-04T22:58:36.376Z" }, + { url = "https://files.pythonhosted.org/packages/75/54/cc6d0f3deac3e81c7f847e8a189a12b6cdd65059b43dad25d4316abd849a/cryptography-48.0.0-cp314-cp314t-win32.whl", hash = "sha256:c17dfe85494deaeddc5ce251aebd1d60bbe6afc8b62071bb0b469431a000124f", size = 3270954, upload-time = "2026-05-04T22:58:38.791Z" }, + { url = "https://files.pythonhosted.org/packages/49/67/cc947e288c0758a4e5473d1dcb743037ab7785541265a969240b8885441a/cryptography-48.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:27241b1dc9962e056062a8eef1991d02c3a24569c95975bd2322a8a52c6e5e12", size = 3797313, upload-time = "2026-05-04T22:58:40.746Z" }, + { url = "https://files.pythonhosted.org/packages/f2/63/61d4a4e1c6b6bab6ce1e213cd36a24c415d90e76d78c5eb8577c5541d2e8/cryptography-48.0.0-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:58d00498e8933e4a194f3076aee1b4a97dfec1a6da444535755822fe5d8b0b86", size = 7983482, upload-time = "2026-05-04T22:58:43.769Z" }, + { url = "https://files.pythonhosted.org/packages/d5/ac/f5b5995b87770c693e2596559ffafe195b4033a57f14a82268a2842953f3/cryptography-48.0.0-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:614d0949f4790582d2cc25553abd09dd723025f0c0e7c67376a1d77196743d6e", size = 4683266, upload-time = "2026-05-04T22:58:46.064Z" }, + { url = "https://files.pythonhosted.org/packages/ec/c6/8b14f67e18338fbc4adb76f66c001f5c3610b3e2d1837f268f47a347dbbb/cryptography-48.0.0-cp39-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7ce4bfae76319a532a2dc68f82cc32f5676ee792a983187dac07183690e5c66f", size = 4696228, upload-time = "2026-05-04T22:58:48.22Z" }, + { url = "https://files.pythonhosted.org/packages/ea/73/f808fbae9514bd91b47875b003f13e284c8c6bdfd904b7944e803937eec1/cryptography-48.0.0-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:2eb992bbd4661238c5a397594c83f5b4dc2bc5b848c365c8f991b6780efcc5c7", size = 4689097, upload-time = "2026-05-04T22:58:50.9Z" }, + { url = "https://files.pythonhosted.org/packages/93/01/d86632d7d28db8ae83221995752eeb6639ffb374c2d22955648cf8d52797/cryptography-48.0.0-cp39-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:22a5cb272895dce158b2cacdfdc3debd299019659f42947dbdac6f32d68fe832", size = 5283582, upload-time = "2026-05-04T22:58:53.017Z" }, + { url = "https://files.pythonhosted.org/packages/02/e1/50edc7a50334807cc4791fc4a0ce7468b4a1416d9138eab358bfc9a3d70b/cryptography-48.0.0-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:2b4d59804e8408e2fea7d1fbaf218e5ec984325221db76e6a241a9abd6cdd95c", size = 4730479, upload-time = "2026-05-04T22:58:55.611Z" }, + { url = "https://files.pythonhosted.org/packages/6f/af/99a582b1b1641ff5911ac559beb45097cf79efd4ead4657f578ef1af2d47/cryptography-48.0.0-cp39-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:984a20b0f62a26f48a3396c72e4bc34c66e356d356bf370053066b3b6d54634a", size = 4326481, upload-time = "2026-05-04T22:58:57.607Z" }, + { url = "https://files.pythonhosted.org/packages/90/ee/89aa26a06ef0a7d7611788ffd571a7c50e368cc6a4d5eef8b4884e866edb/cryptography-48.0.0-cp39-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:5a5ed8fde7a1d09376ca0b40e68cd59c69fe23b1f9768bd5824f54681626032a", size = 4688713, upload-time = "2026-05-04T22:59:00.077Z" }, + { url = "https://files.pythonhosted.org/packages/70/ba/bcb1b0bb7a33d4c7c0c4d4c7874b4a62ae4f56113a5f4baefa362dfb1f0f/cryptography-48.0.0-cp39-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:8cd666227ef7af430aa5914a9910e0ddd703e75f039cef0825cd0da71b6b711a", size = 5238165, upload-time = "2026-05-04T22:59:02.317Z" }, + { url = "https://files.pythonhosted.org/packages/c9/70/ca4003b1ce5ca3dc3186ada51908c8a9b9ff7d5cab83cc0d43ee14ec144f/cryptography-48.0.0-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:9071196d81abc88b3516ac8cdfad32e2b66dd4a5393a8e68a961e9161ddc6239", size = 4729947, upload-time = "2026-05-04T22:59:05.255Z" }, + { url = "https://files.pythonhosted.org/packages/44/a0/4ec7cf774207905aef1a8d11c3750d5a1db805eb380ee4e16df317870128/cryptography-48.0.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:1e2d54c8be6152856a36f0882ab231e70f8ec7f14e93cf87db8a2ed056bf160c", size = 4822059, upload-time = "2026-05-04T22:59:07.802Z" }, + { url = "https://files.pythonhosted.org/packages/1e/75/a2e55f99c16fcac7b5d6c1eb19ad8e00799854d6be5ca845f9259eae1681/cryptography-48.0.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:a5da777e32ffed6f85a7b2b3f7c5cbc88c146bfcd0a1d7baf5fcc6c52ee35dd4", size = 4960575, upload-time = "2026-05-04T22:59:09.851Z" }, + { url = "https://files.pythonhosted.org/packages/b8/23/6e6f32143ab5d8b36ca848a502c4bcd477ae75b9e1677e3530d669062578/cryptography-48.0.0-cp39-abi3-win32.whl", hash = "sha256:77a2ccbbe917f6710e05ba9adaa25fb5075620bf3ea6fb751997875aff4ae4bd", size = 3279117, upload-time = "2026-05-04T22:59:12.019Z" }, + { url = "https://files.pythonhosted.org/packages/9d/9a/0fea98a70cf1749d41d738836f6349d97945f7c89433a259a6c2642eefeb/cryptography-48.0.0-cp39-abi3-win_amd64.whl", hash = "sha256:16cd65b9330583e4619939b3a3843eec1e6e789744bb01e7c7e2e62e33c239c8", size = 3792100, upload-time = "2026-05-04T22:59:14.884Z" }, +] + [[package]] name = "dataclasses-json" version = "0.6.7" @@ -329,6 +450,21 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/c3/be/d0d44e092656fe7a06b55e6103cbce807cdbdee17884a5367c68c9860853/dataclasses_json-0.6.7-py3-none-any.whl", hash = "sha256:0dbf33f26c8d5305befd61b39d2b3414e8a407bedc2834dea9b8d642666fb40a", size = 28686, upload-time = "2024-06-09T16:20:16.715Z" }, ] +[[package]] +name = "dateparser" +version = "1.4.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "python-dateutil" }, + { name = "pytz" }, + { name = "regex" }, + { name = "tzlocal" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/46/2d/a0ccdb78788064fa0dc901b8524e50615c42be1d78b78d646d0b28d09180/dateparser-1.4.0.tar.gz", hash = "sha256:97a21840d5ecdf7630c584f673338a5afac5dfe84f647baf4d7e8df98f9354a4", size = 321512, upload-time = "2026-03-26T09:56:10.292Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b4/0b/3c3bb7cbe757279e693a0be6049048012f794d01f81099609ecd53b899f0/dateparser-1.4.0-py3-none-any.whl", hash = "sha256:7902b8e85d603494bf70a5a0b1decdddb2270b9c6e6b2bc8a57b93476c0df378", size = 300379, upload-time = "2026-03-26T09:56:08.409Z" }, +] + [[package]] name = "dill" version = "0.4.1" @@ -450,6 +586,45 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/9a/9a/e35b4a917281c0b8419d4207f4334c8e8c5dbf4f3f5f9ada73958d937dcc/frozenlist-1.8.0-py3-none-any.whl", hash = "sha256:0c18a16eab41e82c295618a77502e17b195883241c563b00f0aa5106fc4eaa0d", size = 13409, upload-time = "2025-10-06T05:38:16.721Z" }, ] +[[package]] +name = "google-auth" +version = "2.52.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cryptography" }, + { name = "pyasn1-modules" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d4/f8/80d2493cbedece1c623dc3e3cb1883300871af0dcdae254409522985ac23/google_auth-2.52.0.tar.gz", hash = "sha256:01f30e1a9e3638698d89464f5e603ce29d18e1c0e63ec31ac570aba4e164aaf5", size = 335027, upload-time = "2026-05-07T19:45:24.033Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ee/fc/2cdc74252746f547f81ff3f02d4d4234a3f411b5de5b61af97e633a060b9/google_auth-2.52.0-py3-none-any.whl", hash = "sha256:aee92803ba0ff93a70a3b8a35c7b4797837751cd6380b63ff38372b98f3ed627", size = 245614, upload-time = "2026-05-07T19:45:21.914Z" }, +] + +[package.optional-dependencies] +requests = [ + { name = "requests" }, +] + +[[package]] +name = "google-genai" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "distro" }, + { name = "google-auth", extra = ["requests"] }, + { name = "httpx" }, + { name = "pydantic" }, + { name = "requests" }, + { name = "sniffio" }, + { name = "tenacity" }, + { name = "typing-extensions" }, + { name = "websockets" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/02/8e/dfa4b34dd4c0baffccf6466fc68d6d35011662d43e7d79accb902320db74/google_genai-2.3.0.tar.gz", hash = "sha256:e877c750a4ccacdd9928fc3aa8ca8820ce85cade0ca51bd83feceacf5959b579", size = 546930, upload-time = "2026-05-15T06:22:36.264Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b4/6e/aa6b30b09f58b946750fc4089c5248fbd3576f746e0e818d88633559dc84/google_genai-2.3.0-py3-none-any.whl", hash = "sha256:89d3c71c9f5f5b931b405b88a5837aea2bd4d27ed90323b9599f5760bbb91d92", size = 805484, upload-time = "2026-05-15T06:22:34.247Z" }, +] + [[package]] name = "greenlet" version = "3.3.2" @@ -490,6 +665,22 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" }, ] +[[package]] +name = "htmldate" +version = "1.9.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "charset-normalizer" }, + { name = "dateparser" }, + { name = "lxml" }, + { name = "python-dateutil" }, + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9d/10/ead9dabc999f353c3aa5d0dc0835b1e355215a5ecb489a7f4ef2ddad5e33/htmldate-1.9.4.tar.gz", hash = "sha256:1129063e02dd0354b74264de71e950c0c3fcee191178321418ccad2074cc8ed0", size = 44690, upload-time = "2025-11-04T17:46:44.983Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a1/bd/adfcdaaad5805c0c5156aeefd64c1e868c05e9c1cd6fd21751f168cd88c7/htmldate-1.9.4-py3-none-any.whl", hash = "sha256:1b94bcc4e08232a5b692159903acf95548b6a7492dddca5bb123d89d6325921c", size = 31558, upload-time = "2025-11-04T17:46:43.258Z" }, +] + [[package]] name = "httpcore" version = "1.0.9" @@ -660,6 +851,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/9e/6a/a83720e953b1682d2d109d3c2dbb0bc9bf28cc1cbc205be4ef4be5da709d/jsonpointer-3.1.1-py3-none-any.whl", hash = "sha256:8ff8b95779d071ba472cf5bc913028df06031797532f08a7d5b602d8b2a488ca", size = 7659, upload-time = "2026-03-23T22:32:31.568Z" }, ] +[[package]] +name = "justext" +version = "3.0.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "lxml", extra = ["html-clean"] }, +] +sdist = { url = "https://files.pythonhosted.org/packages/49/f3/45890c1b314f0d04e19c1c83d534e611513150939a7cf039664d9ab1e649/justext-3.0.2.tar.gz", hash = "sha256:13496a450c44c4cd5b5a75a5efcd9996066d2a189794ea99a49949685a0beb05", size = 828521, upload-time = "2025-02-25T20:21:49.934Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f2/ac/52f4e86d1924a7fc05af3aeb34488570eccc39b4af90530dd6acecdf16b5/justext-3.0.2-py2.py3-none-any.whl", hash = "sha256:62b1c562b15c3c6265e121cc070874243a443bfd53060e869393f09d6b6cc9a7", size = 837940, upload-time = "2025-02-25T20:21:44.179Z" }, +] + [[package]] name = "langchain" version = "1.2.13" @@ -849,6 +1052,85 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/1a/94/1f5d72655ab6534129540843776c40eff757387b88e798d8b3bf7e313fd4/langsmith-0.7.22-py3-none-any.whl", hash = "sha256:6e9d5148314d74e86748cb9d3898632cad0320c9323d95f70f969e5bc078eee4", size = 359927, upload-time = "2026-03-19T22:45:21.603Z" }, ] +[[package]] +name = "lxml" +version = "6.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/28/30/9abc9e34c657c33834eaf6cd02124c61bdf5944d802aa48e69be8da3585d/lxml-6.1.0.tar.gz", hash = "sha256:bfd57d8008c4965709a919c3e9a98f76c2c7cb319086b3d26858250620023b13", size = 4197006, upload-time = "2026-04-18T04:32:51.613Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/08/03/69347590f1cf4a6d5a4944bb6099e6d37f334784f16062234e1f892fdb1d/lxml-6.1.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:a0092f2b107b69601adf562a57c956fbb596e05e3e6651cabd3054113b007e45", size = 8559689, upload-time = "2026-04-18T04:31:57.785Z" }, + { url = "https://files.pythonhosted.org/packages/3f/58/25e00bb40b185c974cfe156c110474d9a8a8390d5f7c92a4e328189bb60e/lxml-6.1.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:fc7140d7a7386e6b545d41b7358f4d02b656d4053f5fa6859f92f4b9c2572c4d", size = 4617892, upload-time = "2026-04-18T04:32:01.78Z" }, + { url = "https://files.pythonhosted.org/packages/f5/54/92ad98a94ac318dc4f97aaac22ff8d1b94212b2ae8af5b6e9b354bf825f7/lxml-6.1.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:419c58fc92cc3a2c3fa5f78c63dbf5da70c1fa9c1b25f25727ecee89a96c7de2", size = 4923489, upload-time = "2026-04-18T04:33:31.401Z" }, + { url = "https://files.pythonhosted.org/packages/15/3b/a20aecfab42bdf4f9b390590d345857ad3ffd7c51988d1c89c53a0c73faf/lxml-6.1.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:37fabd1452852636cf38ecdcc9dd5ca4bba7a35d6c53fa09725deeb894a87491", size = 5082162, upload-time = "2026-04-18T04:33:34.262Z" }, + { url = "https://files.pythonhosted.org/packages/45/26/2cdb3d281ac1bd175603e290cbe4bad6eff127c0f8de90bafd6f8548f0fd/lxml-6.1.0-cp313-cp313-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a2853c8b2170cc6cd54a6b4d50d2c1a8a7aeca201f23804b4898525c7a152cfc", size = 4993247, upload-time = "2026-04-18T04:33:36.674Z" }, + { url = "https://files.pythonhosted.org/packages/f6/05/d735aef963740022a08185c84821f689fc903acb3d50326e6b1e9886cc22/lxml-6.1.0-cp313-cp313-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8e369cbd690e788c8d15e56222d91a09c6a417f49cbc543040cba0fe2e25a79e", size = 5613042, upload-time = "2026-04-18T04:33:39.205Z" }, + { url = "https://files.pythonhosted.org/packages/ee/b8/ead7c10efff731738c72e59ed6eb5791854879fbed7ae98781a12006263a/lxml-6.1.0-cp313-cp313-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e69aa6805905807186eb00e66c6d97a935c928275182eb02ee40ba00da9623b2", size = 5228304, upload-time = "2026-04-18T04:33:41.647Z" }, + { url = "https://files.pythonhosted.org/packages/6b/10/e9842d2ec322ea65f0a7270aa0315a53abed06058b88ef1b027f620e7a5f/lxml-6.1.0-cp313-cp313-manylinux_2_28_i686.whl", hash = "sha256:4bd1bdb8a9e0e2dd229de19b5f8aebac80e916921b4b2c6ef8a52bc131d0c1f9", size = 5341578, upload-time = "2026-04-18T04:33:44.596Z" }, + { url = "https://files.pythonhosted.org/packages/89/54/40d9403d7c2775fa7301d3ddd3464689bfe9ba71acc17dfff777071b4fdc/lxml-6.1.0-cp313-cp313-manylinux_2_31_armv7l.whl", hash = "sha256:cbd7b79cdcb4986ad78a2662625882747f09db5e4cd7b2ae178a88c9c51b3dfe", size = 4700209, upload-time = "2026-04-18T04:33:47.552Z" }, + { url = "https://files.pythonhosted.org/packages/85/b2/bbdcc2cf45dfc7dfffef4fd97e5c47b15919b6a365247d95d6f684ef5e82/lxml-6.1.0-cp313-cp313-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:43e4d297f11080ec9d64a4b1ad7ac02b4484c9f0e2179d9c4ef78e886e747b88", size = 5232365, upload-time = "2026-04-18T04:33:50.249Z" }, + { url = "https://files.pythonhosted.org/packages/48/5a/b06875665e53aaba7127611a7bed3b7b9658e20b22bc2dd217a0b7ab0091/lxml-6.1.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:cc16682cc987a3da00aa56a3aa3075b08edb10d9b1e476938cfdbee8f3b67181", size = 5043654, upload-time = "2026-04-18T04:33:52.71Z" }, + { url = "https://files.pythonhosted.org/packages/e9/9c/e71a069d09641c1a7abeb30e693f828c7c90a41cbe3d650b2d734d876f85/lxml-6.1.0-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:d6d8efe71429635f0559579092bb5e60560d7b9115ee38c4adbea35632e7fa24", size = 4769326, upload-time = "2026-04-18T04:33:55.244Z" }, + { url = "https://files.pythonhosted.org/packages/cc/06/7a9cd84b3d4ed79adf35f874750abb697dec0b4a81a836037b36e47c091a/lxml-6.1.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:7e39ab3a28af7784e206d8606ec0e4bcad0190f63a492bca95e94e5a4aef7f6e", size = 5635879, upload-time = "2026-04-18T04:33:58.509Z" }, + { url = "https://files.pythonhosted.org/packages/cc/f0/9d57916befc1e54c451712c7ee48e9e74e80ae4d03bdce49914e0aee42cd/lxml-6.1.0-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:9eb667bf50856c4a58145f8ca2d5e5be160191e79eb9e30855a476191b3c3495", size = 5224048, upload-time = "2026-04-18T04:34:00.943Z" }, + { url = "https://files.pythonhosted.org/packages/99/75/90c4eefda0c08c92221fe0753db2d6699a4c628f76ff4465ec20dea84cc1/lxml-6.1.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:7f4a77d6f7edf9230cee3e1f7f6764722a41604ee5681844f18db9a81ea0ec33", size = 5250241, upload-time = "2026-04-18T04:34:03.365Z" }, + { url = "https://files.pythonhosted.org/packages/5e/73/16596f7e4e38fa33084b9ccbccc22a15f82a290a055126f2c1541236d2ff/lxml-6.1.0-cp313-cp313-win32.whl", hash = "sha256:28902146ffbe5222df411c5d19e5352490122e14447e98cd118907ee3fd6ee62", size = 3596938, upload-time = "2026-04-18T04:31:56.206Z" }, + { url = "https://files.pythonhosted.org/packages/8e/63/981401c5680c1eb30893f00a19641ac80db5d1e7086c62cb4b13ed813038/lxml-6.1.0-cp313-cp313-win_amd64.whl", hash = "sha256:4a1503c56e4e2b38dc76f2f2da7bae69670c0f1933e27cfa34b2fa5876410b16", size = 3995728, upload-time = "2026-04-18T04:31:58.763Z" }, + { url = "https://files.pythonhosted.org/packages/e7/e8/c358a38ac3e541d16a1b527e4e9cb78c0419b0506a070ace11777e5e8404/lxml-6.1.0-cp313-cp313-win_arm64.whl", hash = "sha256:e0af85773850417d994d019741239b901b22c6680206f46a34766926e466141d", size = 3658372, upload-time = "2026-04-18T04:32:03.629Z" }, + { url = "https://files.pythonhosted.org/packages/eb/45/cee4cf203ef0bab5c52afc118da61d6b460c928f2893d40023cfa27e0b80/lxml-6.1.0-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:ab863fd37458fed6456525f297d21239d987800c46e67da5ef04fc6b3dd93ac8", size = 8576713, upload-time = "2026-04-18T04:32:06.831Z" }, + { url = "https://files.pythonhosted.org/packages/8a/a7/eda05babeb7e046839204eaf254cd4d7c9130ce2bbf0d9e90ea41af5654d/lxml-6.1.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:6fd8b1df8254ff4fd93fd31da1fc15770bde23ac045be9bb1f87425702f61cc9", size = 4623874, upload-time = "2026-04-18T04:32:10.755Z" }, + { url = "https://files.pythonhosted.org/packages/e7/e9/db5846de9b436b91890a62f29d80cd849ea17948a49bf532d5278ee69a9e/lxml-6.1.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:47024feaae386a92a146af0d2aeed65229bf6fff738e6a11dda6b0015fb8fd03", size = 4949535, upload-time = "2026-04-18T04:34:06.657Z" }, + { url = "https://files.pythonhosted.org/packages/5a/ba/0d3593373dcae1d68f40dc3c41a5a92f2544e68115eb2f62319a4c2a6500/lxml-6.1.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:3f00972f84450204cd5d93a5395965e348956aaceaadec693a22ec743f8ae3eb", size = 5086881, upload-time = "2026-04-18T04:34:09.556Z" }, + { url = "https://files.pythonhosted.org/packages/43/76/759a7484539ad1af0d125a9afe9c3fb5f82a8779fd1f5f56319d9e4ea2fd/lxml-6.1.0-cp314-cp314-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:97faa0860e13b05b15a51fb4986421ef7a30f0b3334061c416e0981e9450ca4c", size = 5031305, upload-time = "2026-04-18T04:34:12.336Z" }, + { url = "https://files.pythonhosted.org/packages/dc/b9/c1f0daf981a11e47636126901fd4ab82429e18c57aeb0fc3ad2940b42d8b/lxml-6.1.0-cp314-cp314-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:972a6451204798675407beaad97b868d0c733d9a74dafefc63120b81b8c2de28", size = 5647522, upload-time = "2026-04-18T04:34:14.89Z" }, + { url = "https://files.pythonhosted.org/packages/31/e6/1f533dcd205275363d9ba3511bcec52fa2df86abf8abe6a5f2c599f0dc31/lxml-6.1.0-cp314-cp314-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fe022f20bc4569ec66b63b3fb275a3d628d9d32da6326b2982584104db6d3086", size = 5239310, upload-time = "2026-04-18T04:34:17.652Z" }, + { url = "https://files.pythonhosted.org/packages/c3/8c/4175fb709c78a6e315ed814ed33be3defd8b8721067e70419a6cf6f971da/lxml-6.1.0-cp314-cp314-manylinux_2_28_i686.whl", hash = "sha256:75c4c7c619a744f972f4451bf5adf6d0fb00992a1ffc9fd78e13b0bc817cc99f", size = 5350799, upload-time = "2026-04-18T04:34:20.529Z" }, + { url = "https://files.pythonhosted.org/packages/fd/77/6ffdebc5994975f0dde4acb59761902bd9d9bb84422b9a0bd239a7da9ca8/lxml-6.1.0-cp314-cp314-manylinux_2_31_armv7l.whl", hash = "sha256:3648f20d25102a22b6061c688beb3a805099ea4beb0a01ce62975d926944d292", size = 4697693, upload-time = "2026-04-18T04:34:23.541Z" }, + { url = "https://files.pythonhosted.org/packages/f8/f1/565f36bd5c73294602d48e04d23f81ff4c8736be6ba5e1d1ec670ac9be80/lxml-6.1.0-cp314-cp314-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:77b9f99b17cbf14026d1e618035077060fc7195dd940d025149f3e2e830fbfcb", size = 5250708, upload-time = "2026-04-18T04:34:26.001Z" }, + { url = "https://files.pythonhosted.org/packages/5a/11/a68ab9dd18c5c499404deb4005f4bc4e0e88e5b72cd755ad96efec81d18d/lxml-6.1.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:32662519149fd7a9db354175aa5e417d83485a8039b8aaa62f873ceee7ea4cad", size = 5084737, upload-time = "2026-04-18T04:34:28.32Z" }, + { url = "https://files.pythonhosted.org/packages/ab/78/e8f41e2c74f4af564e6a0348aea69fb6daaefa64bc071ef469823d22cc18/lxml-6.1.0-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:73d658216fc173cf2c939e90e07b941c5e12736b0bf6a99e7af95459cfe8eabb", size = 4737817, upload-time = "2026-04-18T04:34:30.784Z" }, + { url = "https://files.pythonhosted.org/packages/06/2d/aa4e117aa2ce2f3b35d9ff246be74a2f8e853baba5d2a92c64744474603a/lxml-6.1.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:ac4db068889f8772a4a698c5980ec302771bb545e10c4b095d4c8be26749616f", size = 5670753, upload-time = "2026-04-18T04:34:33.675Z" }, + { url = "https://files.pythonhosted.org/packages/08/f5/dd745d50c0409031dbfcc4881740542a01e54d6f0110bd420fa7782110b8/lxml-6.1.0-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:45e9dfbd1b661eb64ba0d4dbe762bd210c42d86dd1e5bd2bdf89d634231beb43", size = 5238071, upload-time = "2026-04-18T04:34:36.12Z" }, + { url = "https://files.pythonhosted.org/packages/3e/74/ad424f36d0340a904665867dab310a3f1f4c96ff4039698de83b77f44c1f/lxml-6.1.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:89e8d73d09ac696a5ba42ec69787913d53284f12092f651506779314f10ba585", size = 5264319, upload-time = "2026-04-18T04:34:39.035Z" }, + { url = "https://files.pythonhosted.org/packages/53/36/a15d8b3514ec889bfd6aa3609107fcb6c9189f8dc347f1c0b81eded8d87c/lxml-6.1.0-cp314-cp314-win32.whl", hash = "sha256:ebe33f4ec1b2de38ceb225a1749a2965855bffeef435ba93cd2d5d540783bf2f", size = 3657139, upload-time = "2026-04-18T04:32:20.006Z" }, + { url = "https://files.pythonhosted.org/packages/1a/a4/263ebb0710851a3c6c937180a9a86df1206fdfe53cc43005aa2237fd7736/lxml-6.1.0-cp314-cp314-win_amd64.whl", hash = "sha256:398443df51c538bd578529aa7e5f7afc6c292644174b47961f3bf87fe5741120", size = 4064195, upload-time = "2026-04-18T04:32:23.876Z" }, + { url = "https://files.pythonhosted.org/packages/80/68/2000f29d323b6c286de077ad20b429fc52272e44eae6d295467043e56012/lxml-6.1.0-cp314-cp314-win_arm64.whl", hash = "sha256:8c8984e1d8c4b3949e419158fda14d921ff703a9ed8a47236c6eb7a2b6cb4946", size = 3741870, upload-time = "2026-04-18T04:32:27.922Z" }, + { url = "https://files.pythonhosted.org/packages/30/e9/21383c7c8d43799f0da90224c0d7c921870d476ec9b3e01e1b2c0b8237c5/lxml-6.1.0-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:1081dd10bc6fa437db2500e13993abf7cc30716d0a2f40e65abb935f02ec559c", size = 8827548, upload-time = "2026-04-18T04:32:15.094Z" }, + { url = "https://files.pythonhosted.org/packages/a5/01/c6bc11cd587030dd4f719f65c5657960649fe3e19196c844c75bf32cd0d6/lxml-6.1.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:dabecc48db5f42ba348d1f5d5afdc54c6c4cc758e676926c7cd327045749517d", size = 4735866, upload-time = "2026-04-18T04:32:18.924Z" }, + { url = "https://files.pythonhosted.org/packages/f3/01/757132fff5f4acf25463b5298f1a46099f3a94480b806547b29ce5e385de/lxml-6.1.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:e3dd5fe19c9e0ac818a9c7f132a5e43c1339ec1cbbfecb1a938bd3a47875b7c9", size = 4969476, upload-time = "2026-04-18T04:34:41.889Z" }, + { url = "https://files.pythonhosted.org/packages/fd/fb/1bc8b9d27ed64be7c8903db6c89e74dc8c2cd9ec630a7462e4654316dc5b/lxml-6.1.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:9e7b0a4ca6dcc007a4cef00a761bba2dea959de4bd2df98f926b33c92ca5dfb9", size = 5103719, upload-time = "2026-04-18T04:34:44.797Z" }, + { url = "https://files.pythonhosted.org/packages/d5/e7/5bf82fa28133536a54601aae633b14988e89ed61d4c1eb6b899b023233aa/lxml-6.1.0-cp314-cp314t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5d27bbe326c6b539c64b42638b18bc6003a8d88f76213a97ac9ed4f885efeab7", size = 5027890, upload-time = "2026-04-18T04:34:47.634Z" }, + { url = "https://files.pythonhosted.org/packages/2d/20/e048db5d4b4ea0366648aa595f26bb764b2670903fc585b87436d0a5032c/lxml-6.1.0-cp314-cp314t-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c4e425db0c5445ef0ad56b0eec54f89b88b2d884656e536a90b2f52aecb4ca86", size = 5596008, upload-time = "2026-04-18T04:34:51.503Z" }, + { url = "https://files.pythonhosted.org/packages/9a/c2/d10807bc8da4824b39e5bd01b5d05c077b6fd01bd91584167edf6b269d22/lxml-6.1.0-cp314-cp314t-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4b89b098105b8599dc57adac95d1813409ac476d3c948a498775d3d0c6124bfb", size = 5224451, upload-time = "2026-04-18T04:34:54.263Z" }, + { url = "https://files.pythonhosted.org/packages/3c/15/2ebea45bea427e7f0057e9ce7b2d62c5aba20c6b001cca89ed0aadb3ad41/lxml-6.1.0-cp314-cp314t-manylinux_2_28_i686.whl", hash = "sha256:c4a699432846df86cc3de502ee85f445ebad748a1c6021d445f3e514d2cd4b1c", size = 5312135, upload-time = "2026-04-18T04:34:56.818Z" }, + { url = "https://files.pythonhosted.org/packages/31/e2/87eeae151b0be2a308d49a7ec444ff3eb192b14251e62addb29d0bf3778f/lxml-6.1.0-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:30e7b2ed63b6c8e97cca8af048589a788ab5c9c905f36d9cf1c2bb549f450d2f", size = 4639126, upload-time = "2026-04-18T04:34:59.704Z" }, + { url = "https://files.pythonhosted.org/packages/a3/51/8a3f6a20902ad604dd746ec7b4000311b240d389dac5e9d95adefd349e0c/lxml-6.1.0-cp314-cp314t-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:022981127642fe19866d2907d76241bb07ed21749601f727d5d5dd1ce5d1b773", size = 5232579, upload-time = "2026-04-18T04:35:02.658Z" }, + { url = "https://files.pythonhosted.org/packages/6d/d2/650d619bdbe048d2c3f2c31edb00e35670a5e2d65b4fe3b61bce37b19121/lxml-6.1.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:23cad0cc86046d4222f7f418910e46b89971c5a45d3c8abfad0f64b7b05e4a9b", size = 5084206, upload-time = "2026-04-18T04:35:05.175Z" }, + { url = "https://files.pythonhosted.org/packages/dd/8a/672ca1a3cbeabd1f511ca275a916c0514b747f4b85bdaae103b8fa92f307/lxml-6.1.0-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:21c3302068f50d1e8728c67c87ba92aa87043abee517aa2576cca1855326b405", size = 4758906, upload-time = "2026-04-18T04:35:08.098Z" }, + { url = "https://files.pythonhosted.org/packages/be/f1/ef4b691da85c916cb2feb1eec7414f678162798ac85e042fa164419ac05c/lxml-6.1.0-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:be10838781cb3be19251e276910cd508fe127e27c3242e50521521a0f3781690", size = 5620553, upload-time = "2026-04-18T04:35:11.23Z" }, + { url = "https://files.pythonhosted.org/packages/59/17/94e81def74107809755ac2782fdad4404420f1c92ca83433d117a6d5acf0/lxml-6.1.0-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:2173a7bffe97667bbf0767f8a99e587740a8c56fdf3befac4b09cb29a80276fd", size = 5229458, upload-time = "2026-04-18T04:35:14.254Z" }, + { url = "https://files.pythonhosted.org/packages/21/55/c4be91b0f830a871fc1b0d730943d56013b683d4671d5198260e2eae722b/lxml-6.1.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:c6854e9cf99c84beb004eecd7d3a3868ef1109bf2b1df92d7bc11e96a36c2180", size = 5247861, upload-time = "2026-04-18T04:35:17.006Z" }, + { url = "https://files.pythonhosted.org/packages/c2/ca/77123e4d77df3cb1e968ade7b1f808f5d3a5c1c96b18a33895397de292c1/lxml-6.1.0-cp314-cp314t-win32.whl", hash = "sha256:00750d63ef0031a05331b9223463b1c7c02b9004cef2346a5b2877f0f9494dd2", size = 3897377, upload-time = "2026-04-18T04:32:07.656Z" }, + { url = "https://files.pythonhosted.org/packages/64/ce/3554833989d074267c063209bae8b09815e5656456a2d332b947806b05ff/lxml-6.1.0-cp314-cp314t-win_amd64.whl", hash = "sha256:80410c3a7e3c617af04de17caa9f9f20adaa817093293d69eae7d7d0522836f5", size = 4392701, upload-time = "2026-04-18T04:32:12.113Z" }, + { url = "https://files.pythonhosted.org/packages/2b/a0/9b916c68c0e57752c07f8f64b30138d9d4059dbeb27b90274dedbea128ff/lxml-6.1.0-cp314-cp314t-win_arm64.whl", hash = "sha256:26dd9f57ee3bd41e7d35b4c98a2ffd89ed11591649f421f0ec19f67d50ec67ac", size = 3817120, upload-time = "2026-04-18T04:32:15.803Z" }, +] + +[package.optional-dependencies] +html-clean = [ + { name = "lxml-html-clean" }, +] + +[[package]] +name = "lxml-html-clean" +version = "0.4.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "lxml" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9a/a4/5c62acfacd69ff4f5db395100f5cfb9b54e7ac8c69a235e4e939fd13f021/lxml_html_clean-0.4.4.tar.gz", hash = "sha256:58f39a9d632711202ed1d6d0b9b47a904e306c85de5761543b90e3e3f736acfb", size = 23899, upload-time = "2026-02-27T09:35:52.911Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d9/76/7ffc1d3005cf7749123bc47cb3ea343cd97b0ac2211bab40f57283577d0e/lxml_html_clean-0.4.4-py3-none-any.whl", hash = "sha256:ce2ef506614ecb85ee1c5fe0a2aa45b06a19514ec7949e9c8f34f06925cfabcb", size = 14565, upload-time = "2026-02-27T09:35:51.86Z" }, +] + [[package]] name = "marshmallow" version = "3.26.2" @@ -1211,6 +1493,27 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/5b/5a/bc7b4a4ef808fa59a816c17b20c4bef6884daebbdf627ff2a161da67da19/propcache-0.4.1-py3-none-any.whl", hash = "sha256:af2a6052aeb6cf17d3e46ee169099044fd8224cbaf75c76a2ef596e8163e2237", size = 13305, upload-time = "2025-10-08T19:49:00.792Z" }, ] +[[package]] +name = "pyasn1" +version = "0.6.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5c/5f/6583902b6f79b399c9c40674ac384fd9cd77805f9e6205075f828ef11fb2/pyasn1-0.6.3.tar.gz", hash = "sha256:697a8ecd6d98891189184ca1fa05d1bb00e2f84b5977c481452050549c8a72cf", size = 148685, upload-time = "2026-03-17T01:06:53.382Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5d/a0/7d793dce3fa811fe047d6ae2431c672364b462850c6235ae306c0efd025f/pyasn1-0.6.3-py3-none-any.whl", hash = "sha256:a80184d120f0864a52a073acc6fc642847d0be408e7c7252f31390c0f4eadcde", size = 83997, upload-time = "2026-03-17T01:06:52.036Z" }, +] + +[[package]] +name = "pyasn1-modules" +version = "0.4.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pyasn1" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e9/e6/78ebbb10a8c8e4b61a59249394a4a594c1a7af95593dc933a349c8d00964/pyasn1_modules-0.4.2.tar.gz", hash = "sha256:677091de870a80aae844b1ca6134f54652fa2c8c5a52aa396440ac3106e941e6", size = 307892, upload-time = "2025-03-28T02:41:22.17Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/47/8d/d529b5d697919ba8c11ad626e835d4039be708a35b0d22de83a269a6682c/pyasn1_modules-0.4.2-py3-none-any.whl", hash = "sha256:29253a9207ce32b64c3ac6600edc75368f98473906e8fd1043bd6b5b1de2c14a", size = 181259, upload-time = "2025-03-28T02:41:19.028Z" }, +] + [[package]] name = "pycodestyle" version = "2.14.0" @@ -1220,6 +1523,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d7/27/a58ddaf8c588a3ef080db9d0b7e0b97215cee3a45df74f3a94dbbf5c893a/pycodestyle-2.14.0-py2.py3-none-any.whl", hash = "sha256:dd6bf7cb4ee77f8e016f9c8e74a35ddd9f67e1d5fd4184d86c3b98e07099f42d", size = 31594, upload-time = "2025-06-20T18:49:47.491Z" }, ] +[[package]] +name = "pycparser" +version = "3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1b/7d/92392ff7815c21062bea51aa7b87d45576f649f16458d78b7cf94b9ab2e6/pycparser-3.0.tar.gz", hash = "sha256:600f49d217304a5902ac3c37e1281c9fe94e4d0489de643a9504c5cdfdfc6b29", size = 103492, upload-time = "2026-01-21T14:26:51.89Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0c/c3/44f3fbbfa403ea2a7c779186dc20772604442dde72947e7d01069cbe98e3/pycparser-3.0-py3-none-any.whl", hash = "sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992", size = 48172, upload-time = "2026-01-21T14:26:50.693Z" }, +] + [[package]] name = "pydantic" version = "2.12.5" @@ -1420,6 +1732,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/c6/78/397db326746f0a342855b81216ae1f0a32965deccfd7c830a2dbc66d2483/pytokens-0.4.1-py3-none-any.whl", hash = "sha256:26cef14744a8385f35d0e095dc8b3a7583f6c953c2e3d269c7f82484bf5ad2de", size = 13729, upload-time = "2026-01-30T01:03:45.029Z" }, ] +[[package]] +name = "pytz" +version = "2026.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ff/46/dd499ec9038423421951e4fad73051febaa13d2df82b4064f87af8b8c0c3/pytz-2026.2.tar.gz", hash = "sha256:0e60b47b29f21574376f218fe21abc009894a2321ea16c6754f3cad6eb7cdd6a", size = 320861, upload-time = "2026-05-04T01:35:29.667Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ec/dd/96da98f892250475bdf2328112d7468abdd4acc7b902b6af23f4ed958ea0/pytz-2026.2-py2.py3-none-any.whl", hash = "sha256:04156e608bee23d3792fd45c94ae47fae1036688e75032eea2e3bf0323d1f126", size = 510141, upload-time = "2026-05-04T01:35:27.408Z" }, +] + [[package]] name = "pyyaml" version = "6.0.3" @@ -1633,15 +1954,18 @@ dependencies = [ { name = "aiofiles" }, { name = "boto3" }, { name = "fastapi" }, + { name = "google-genai" }, { name = "httpx" }, { name = "langchain" }, { name = "langchain-community" }, { name = "langchain-core" }, { name = "langchain-openai" }, + { name = "langchain-text-splitters" }, { name = "pydantic" }, { name = "pydantic-settings" }, { name = "pypdf" }, { name = "structlog" }, + { name = "trafilatura" }, { name = "uvicorn", extra = ["standard"] }, ] @@ -1662,11 +1986,13 @@ requires-dist = [ { name = "boto3", specifier = ">=1.42.77" }, { name = "fastapi", specifier = ">=0.135.2" }, { name = "flake8", marker = "extra == 'dev'", specifier = ">=7.3.0" }, + { name = "google-genai", specifier = ">=1.0.0" }, { name = "httpx", specifier = ">=0.28.1" }, { name = "langchain", specifier = ">=1.2.13" }, { name = "langchain-community", specifier = ">=0.4.1" }, { name = "langchain-core", specifier = ">=1.2.22" }, { name = "langchain-openai", specifier = ">=0.3.0" }, + { name = "langchain-text-splitters", specifier = ">=0.3.0" }, { name = "pydantic", specifier = ">=2.12.5" }, { name = "pydantic-settings", specifier = ">=2.13.1" }, { name = "pylint", marker = "extra == 'dev'", specifier = ">=4.0.5" }, @@ -1674,6 +2000,7 @@ requires-dist = [ { name = "pytest", marker = "extra == 'dev'", specifier = ">=9.0.2" }, { name = "pytest-asyncio", marker = "extra == 'dev'", specifier = ">=1.3.0" }, { name = "structlog", specifier = ">=25.5.0" }, + { name = "trafilatura", specifier = ">=2.0.0" }, { name = "uvicorn", extras = ["standard"], specifier = ">=0.42.0" }, ] provides-extras = ["dev"] @@ -1748,6 +2075,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/af/df/c7891ef9d2712ad774777271d39fdef63941ffba0a9d59b7ad1fd2765e57/tiktoken-0.12.0-cp314-cp314t-win_amd64.whl", hash = "sha256:f61c0aea5565ac82e2ec50a05e02a6c44734e91b51c10510b084ea1b8e633a71", size = 920667, upload-time = "2025-10-06T20:22:34.444Z" }, ] +[[package]] +name = "tld" +version = "0.13.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5c/5d/76b4383ac4e5b5e254e50c09807b3e13820bed6d6c11cd540264988d6802/tld-0.13.2.tar.gz", hash = "sha256:d983fa92b9d717400742fca844e29d5e18271079c7bcfabf66d01b39b4a14345", size = 467175, upload-time = "2026-03-06T23:50:34.498Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9e/90/39a85a4b63c84213e78b3c17d22e1bf45328acf8ebb33ef93be30d0a3911/tld-0.13.2-py2.py3-none-any.whl", hash = "sha256:9b8fdbdb880e7ba65b216a4937f2c94c49a7226723783d5838fc958ac76f4e0c", size = 296743, upload-time = "2026-03-06T23:50:32.465Z" }, +] + [[package]] name = "tomlkit" version = "0.14.0" @@ -1769,6 +2105,24 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/16/e1/3079a9ff9b8e11b846c6ac5c8b5bfb7ff225eee721825310c91b3b50304f/tqdm-4.67.3-py3-none-any.whl", hash = "sha256:ee1e4c0e59148062281c49d80b25b67771a127c85fc9676d3be5f243206826bf", size = 78374, upload-time = "2026-02-03T17:35:50.982Z" }, ] +[[package]] +name = "trafilatura" +version = "2.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "charset-normalizer" }, + { name = "courlan" }, + { name = "htmldate" }, + { name = "justext" }, + { name = "lxml" }, + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/06/25/e3ebeefdebfdfae8c4a4396f5a6ea51fc6fa0831d63ce338e5090a8003dc/trafilatura-2.0.0.tar.gz", hash = "sha256:ceb7094a6ecc97e72fea73c7dba36714c5c5b577b6470e4520dca893706d6247", size = 253404, upload-time = "2024-12-03T15:23:24.16Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8a/b6/097367f180b6383a3581ca1b86fcae284e52075fa941d1232df35293363c/trafilatura-2.0.0-py3-none-any.whl", hash = "sha256:77eb5d1e993747f6f20938e1de2d840020719735690c840b9a1024803a4cd51d", size = 132557, upload-time = "2024-12-03T15:23:21.41Z" }, +] + [[package]] name = "typing-extensions" version = "4.15.0" @@ -1803,6 +2157,27 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7", size = 14611, upload-time = "2025-10-01T02:14:40.154Z" }, ] +[[package]] +name = "tzdata" +version = "2026.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ba/19/1b9b0e29f30c6d35cb345486df41110984ea67ae69dddbc0e8a100999493/tzdata-2026.2.tar.gz", hash = "sha256:9173fde7d80d9018e02a662e168e5a2d04f87c41ea174b139fbef642eda62d10", size = 198254, upload-time = "2026-04-24T15:22:08.651Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ce/e4/dccd7f47c4b64213ac01ef921a1337ee6e30e8c6466046018326977efd95/tzdata-2026.2-py2.py3-none-any.whl", hash = "sha256:bbe9af844f658da81a5f95019480da3a89415801f6cc966806612cc7169bffe7", size = 349321, upload-time = "2026-04-24T15:22:05.876Z" }, +] + +[[package]] +name = "tzlocal" +version = "5.3.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "tzdata", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/8b/2e/c14812d3d4d9cd1773c6be938f89e5735a1f11a9f184ac3639b93cef35d5/tzlocal-5.3.1.tar.gz", hash = "sha256:cceffc7edecefea1f595541dbd6e990cb1ea3d19bf01b2809f362a03dd7921fd", size = 30761, upload-time = "2025-03-05T21:17:41.549Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c2/14/e2a54fabd4f08cd7af1c07030603c3356b74da07f7cc056e600436edfa17/tzlocal-5.3.1-py3-none-any.whl", hash = "sha256:eb1a66c3ef5847adf7a834f1be0800581b683b5608e74f86ecbcef8ab91bb85d", size = 18026, upload-time = "2025-03-05T21:17:39.857Z" }, +] + [[package]] name = "urllib3" version = "2.6.3" diff --git a/infra/rabbitmq/definitions.json b/infra/rabbitmq/definitions.json index 88e92eb7..31972b86 100644 --- a/infra/rabbitmq/definitions.json +++ b/infra/rabbitmq/definitions.json @@ -57,6 +57,13 @@ "auto_delete": false, "arguments": {} }, + { + "name": "ai.analyze.web", + "vhost": "/", + "durable": true, + "auto_delete": false, + "arguments": {} + }, { "name": "ai.generate.questions", "vhost": "/", @@ -108,6 +115,13 @@ "destination_type": "queue", "routing_key": "analyze.resume" }, + { + "source": "stackup.core-to-ai", + "vhost": "/", + "destination": "ai.analyze.web", + "destination_type": "queue", + "routing_key": "analyze.web" + }, { "source": "stackup.core-to-ai", "vhost": "/", From 5cfa7b52f1334fbda52c465339524ec51c2e1553 Mon Sep 17 00:00:00 2001 From: jmj Date: Wed, 20 May 2026 18:22:44 +0900 Subject: [PATCH 02/12] =?UTF-8?q?docs(messaging):=20AI=20=EA=B5=AC?= =?UTF-8?q?=ED=98=84=20=EC=A0=95=ED=95=A9=20=E2=80=94=20analyze.web=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80,=20=EC=BD=9C=EB=B0=B1=20=ED=95=84=EB=93=9C?= =?UTF-8?q?=20+=20=EB=82=B4=EB=B6=80=20API=20=EB=B6=80=EB=A1=9D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - docs/messaging.md: ai.analyze.web 큐/RK 추가, analyze.resume·repository envelope 필드를 AI 구현(filePath, analyzedDocumentId, repoFullName)에 맞춰 갱신. callback.analysis는 documentPath + targetType WEB 추가. 스모크 테스트 페이로드도 신 스키마로. - docs/messaging.md §10 신설: AI ↔ Core 내부 API(github-token, embeddings) - docs/api-conventions.md §2.6 + §10: /api/internal/* 컨벤션과 두 endpoint의 요청·응답·에러 코드 명시. X-Internal-API-Key 헤더 인증. - docs/environment.md §4 AI Server: LLM 게이트웨이, storage, Core internal, GitHub repo, web fetch, embedding, MD 키 템플릿 변수 카탈로그 갱신. - docs/environment.md §7: AI 큐 매핑에 repository/web 큐 추가. - infra/CLAUDE.md §5.1: ai.analyze.web 표 항목 추가. --- docs/api-conventions.md | 84 +++++++++++++++++++++++++++++++++++++++++ docs/environment.md | 57 +++++++++++++++++++++++----- docs/messaging.md | 80 ++++++++++++++++++++++++++++----------- infra/CLAUDE.md | 1 + 4 files changed, 192 insertions(+), 30 deletions(-) diff --git a/docs/api-conventions.md b/docs/api-conventions.md index 2a81e0b2..48b83a9b 100644 --- a/docs/api-conventions.md +++ b/docs/api-conventions.md @@ -91,6 +91,17 @@ GET /api/system/health 헬스체크 (PG/MQ/S3/AI 상태) GET /api/system/version 버전 정보 ``` +### 2.6 내부 API (서비스 간 호출 전용) + +`/api/internal/*` prefix. **외부에서 호출 금지** — Spring Security에서 외부 패킷을 차단하고 `X-Internal-API-Key` 헤더로 검증. AI Server와 RealTime Server만 호출자. + +``` +GET /api/internal/users/{userId}/github-token AI → Core +PUT /api/internal/documents/{documentId}/embeddings AI → Core +``` + +자세한 요청·응답 스키마와 인증 규약은 §10 내부 API 부록 참조. + --- ## 3. 요청 규약 @@ -301,3 +312,76 @@ HTTP/1.1 202 Accepted 2. SSE 끊기면 `statusUrl` 폴링 fallback (5초 간격) 상세: [`event-stream.md`](./event-stream.md) + +--- + +## 10. 내부 API 부록 (서비스 간 호출) + +`/api/internal/*` endpoint는 다음 규약을 따른다. + +### 10.1 인증 +- 모든 요청에 `X-Internal-API-Key: {key}` 헤더 필수 +- 키는 환경변수 `CORE_INTERNAL_API_KEY` (Core/AI/RealTime 동일 값) +- JWT 사용자 인증 불필요. 단 호출자가 신뢰 영역 안에 있어야 함 (Docker 내부망, K8s ClusterIP 등) +- 키 누락/불일치 → `401 Unauthorized`, body: `{ "code": "INTERNAL_AUTH_FAILED" }` + +### 10.2 `GET /api/internal/users/{userId}/github-token` + +분석 시점에 사용자 GitHub access token을 짧게 위임. AES-256으로 저장된 평문 token을 복호화해 반환 (메모리/로그/응답 body에만 노출, DB·이벤트엔벨로프에는 절대 동봉하지 않음). + +**Path parameter** +| 이름 | 타입 | 설명 | +|------|------|------| +| `userId` | long | `users.id` | + +**Response 200** +```json +{ "accessToken": "ghp_xxx..." } +``` + +**Response 404** — 사용자 없음 또는 token 누락 (`USER_NOT_FOUND`) +**Response 401/403** — 인증 실패 (`INTERNAL_AUTH_FAILED`) +**Response 5xx** — Core 일시 장애 (호출자는 retriable로 간주) + +### 10.3 `PUT /api/internal/documents/{documentId}/embeddings` + +`analyzed_documents` 한 건에 대한 chunk + embedding을 `document_embeddings` 테이블에 idempotent upsert. AI가 분석 결과를 callback 발행 직전에 호출한다. + +**Path parameter** +| 이름 | 타입 | 설명 | +|------|------|------| +| `documentId` | long | `analyzed_documents.id` (Core가 publish 직전 미리 생성) | + +**Request body** +```json +{ + "model": "gemini-embedding-001", + "dim": 1536, + "chunks": [ + { "chunkIndex": 0, "chunkText": "...", "embedding": [0.012, -0.003, ...] }, + { "chunkIndex": 1, "chunkText": "...", "embedding": [...] } + ] +} +``` + +| 필드 | 타입 | 비고 | +|------|------|------| +| `model` | string | 임베딩 모델 ID (감사용) | +| `dim` | int | 임베딩 차원. DB 컬럼 차원과 일치해야 함 (불일치 → 400) | +| `chunks[].chunkIndex` | int | 0-base, 같은 document 내 unique | +| `chunks[].chunkText` | string | 원문 청크 (≤ N KB, 컬럼 길이 제한) | +| `chunks[].embedding` | float[] | 길이 == `dim` | + +**Response 200** +```json +{ "upserted": 18 } +``` + +**Response 400** — `dim` 불일치, payload 검증 실패 (`EMBEDDING_BAD_REQUEST`) +**Response 404** — `analyzed_documents.id` 없음 (`DOCUMENT_NOT_FOUND`) +**Response 401/403** — `INTERNAL_AUTH_FAILED` +**Response 5xx** — Core 일시 장애 (retriable) + +**Idempotency** +- 같은 `(documentId, chunkIndex)` 조합 재호출 시 row를 덮어씀 (INSERT ... ON CONFLICT UPDATE) +- 부분 재시도 시 누락 청크가 발생하지 않도록 AI는 전체 청크를 한 번에 보내는 것을 권장 diff --git a/docs/environment.md b/docs/environment.md index f28568cb..6c5966c0 100644 --- a/docs/environment.md +++ b/docs/environment.md @@ -92,18 +92,55 @@ AI_SERVER_BASE_URL=http://ai:8000 AI_SERVER_PORT=8000 AI_LOG_LEVEL=INFO -# ===== LLM ===== +# ===== LLM (Mindlogic CNU AC Gateway 단일 경로) ===== +LLM_API_KEY= # 학교 발급 키 +LLM_BASE_URL=https://factchat-cloud.mindlogic.ai/v1/gateway +LLM_PRO_MODEL=gemini-3.1-pro-preview +LLM_PRO_TEMPERATURE=0.2 + +# (외부 직접 호출용, fallback) GEMINI_API_KEY= OPENAI_API_KEY= # Whisper STT에도 사용 -LLM_DEFAULT_PROVIDER=gemini # gemini | openai -LLM_PRO_MODEL=gemini-3.1-pro -LLM_FLASH_MODEL=gemini-3.1-flash -# ===== Embedding ===== -EMBEDDING_MODEL=text-embedding-004 -EMBEDDING_DIM=768 # 모델별 차원수 (DB 스키마와 일치 필수) +# ===== Object Storage ===== +STORAGE_BACKEND=s3 # s3 | local +STORAGE_LOCAL_ROOT=./var/storage # backend=local일 때만 +S3_ENDPOINT_URL=http://localhost:38060 +S3_ACCESS_KEY=minioadmin +S3_SECRET_KEY=minioadmin +S3_BUCKET_NAME=stackup +S3_REGION=us-east-1 -# ===== STT/TTS ===== +# ===== Core 내부 API (github-token, embeddings upsert) ===== +CORE_INTERNAL_BASE_URL=http://localhost:38010 +CORE_INTERNAL_API_KEY= +CORE_INTERNAL_TIMEOUT_SEC=10 + +# ===== GitHub Repo 분석 ===== +GITHUB_API_BASE_URL=https://api.github.com +GITHUB_FALLBACK_TOKEN= # 사용자별 토큰 미발급 시 public 한정 호출 (옵션) +REPO_MAX_SOURCE_FILES=8 +REPO_MAX_SOURCE_FILE_BYTES=50000 +REPO_FETCH_TIMEOUT_SEC=30 + +# ===== Web 이력서 fetch ===== +WEB_FETCH_TIMEOUT_SEC=20 +WEB_MAX_HTML_BYTES=2000000 + +# ===== Embedding ===== +EMBEDDING_PROVIDER=gemini # mock | gemini | openai | ollama (개발/테스트는 mock 권장) +EMBEDDING_MODEL=gemini-embedding-001 +EMBEDDING_DIM=1536 # DB 컬럼 차원과 일치 필수 +EMBEDDING_CHUNK_SIZE=1000 +EMBEDDING_CHUNK_OVERLAP=200 +EMBEDDING_BATCH_SIZE=32 + +# ===== Markdown 산출물 키 템플릿 ===== +ANALYZED_RESUME_MD_KEY_TEMPLATE=analyzed/resume/{resume_id}/summary.md +ANALYZED_REPOSITORY_MD_KEY_TEMPLATE=analyzed/repository/{repository_id}/summary.md +ANALYZED_WEB_RESUME_MD_KEY_TEMPLATE=analyzed/web-resume/{resume_id}/summary.md + +# ===== STT/TTS (Phase 2) ===== STT_PROVIDER=whisper-api # whisper-api | whisper-self-hosted WHISPER_MODEL=whisper-1 # OpenAI Whisper API 모델 TTS_PROVIDER= # 결정 시 추가 @@ -148,7 +185,9 @@ VITE_SENTRY_DSN= # 옵션 | 변수 | 기본값 | 용도 | |------|--------|------| | `RABBITMQ_URL` | `amqp://stackup:stackup@localhost:5672/` | AMQP 연결 | -| `AI_QUEUE_RESUME` | `ai.analyze.resume` | 이력서 분석 큐 | +| `AI_QUEUE_RESUME` | `ai.analyze.resume` | 이력서(PDF) 분석 큐 | +| `AI_QUEUE_REPOSITORY` | `ai.analyze.repository` | 레포 분석 큐 | +| `AI_QUEUE_WEB` | `ai.analyze.web` | 웹 이력서(URL) 분석 큐 | | `AI_QUEUE_PREFETCH` | `10` | consumer prefetch | | `AI_CALLBACK_EXCHANGE` | `stackup.ai-to-core` | 콜백 발행 exchange | | `AI_CALLBACK_ROUTING_ANALYSIS` | `callback.analysis` | 콜백 routing key | diff --git a/docs/messaging.md b/docs/messaging.md index 9a64eb7f..f882608d 100644 --- a/docs/messaging.md +++ b/docs/messaging.md @@ -20,6 +20,7 @@ |-------|----------|-------------|----------| | `ai.analyze.repository` | `stackup.core-to-ai` | `analyze.repository` | AI Server | | `ai.analyze.resume` | `stackup.core-to-ai` | `analyze.resume` | AI Server | +| `ai.analyze.web` | `stackup.core-to-ai` | `analyze.web` | AI Server | | `ai.generate.questions` | `stackup.core-to-ai` | `generate.questions` | AI Server | | `ai.generate.followup` | `stackup.core-to-ai` | `generate.followup` | AI Server | | `core.callback.analysis` | `stackup.ai-to-core` | `callback.analysis` | Core Server | @@ -43,7 +44,7 @@ ``` `action` ∈ `analyze | generate | callback | realtime` -`aggregate` ∈ `resume | repository | questions | followup | analysis | feedback | session` +`aggregate` ∈ `resume | repository | web | questions | followup | analysis | feedback | session` 새 routing key 추가 시 본 패턴 유지. @@ -99,14 +100,15 @@ | Use Case | Request RK | Callback RK | Callback Queue | |----------|------------|-------------|-----------------| -| 이력서 분석 (US-09) | `analyze.resume` | `callback.analysis` | `core.callback.analysis` | +| 이력서(PDF) 분석 (US-09) | `analyze.resume` | `callback.analysis` | `core.callback.analysis` | +| 웹 이력서(URL) 분석 (US-09) | `analyze.web` | `callback.analysis` | `core.callback.analysis` | | 레포 분석 (US-10) | `analyze.repository` | `callback.analysis` | `core.callback.analysis` | | 질문 풀 생성 (US-18) | `generate.questions` | `callback.questions` | `core.callback.questions` | | 꼬리질문 생성 (US-19) | `generate.followup` | `callback.questions` | `core.callback.questions` | | 피드백 생성 (US-24) | `generate.feedback` *(예정)* | `callback.feedback` *(예정)* | `core.callback.feedback` *(예정)* | | 세션 알림 (RT2 SSE) | `realtime.session.notify` | (없음 — 단방향 push) | `q.realtime.session.notify` | -> `callback.analysis` 큐는 resume/repo 두 use case가 공유. consumer는 `payload.targetType` 으로 분기한다. +> `callback.analysis` 큐는 resume/web/repo 세 use case가 공유. consumer는 `payload.targetType` 으로 분기한다. --- @@ -118,45 +120,67 @@ "messageType": "analyze.resume", "payload": { "resumeId": 42, - "s3Key": "resumes/raw/123/abc.pdf" + "filePath": "resumes/raw/123/abc.pdf", + "analyzedDocumentId": 101 }, "context": { "userId": 123 } } ``` +| 필드 | 설명 | +|------|------| +| `resumeId` | `resumes.id` | +| `filePath` | 객체 스토리지 키 (Core가 업로드 시 저장) | +| `analyzedDocumentId` | Core가 publish 직전 `analyzed_documents`에 PROCESSING row를 미리 생성하고 그 id를 전달 — AI는 embedding upsert 시 이 id를 사용 | + ### 5.2 `analyze.repository` ```json { "messageType": "analyze.repository", "payload": { "repositoryId": 7, - "githubFullName": "octocat/hello-world", + "repoFullName": "octocat/hello-world", "defaultBranch": "main", - "githubAccessTokenEncrypted": "..." + "analyzedDocumentId": 102 }, "context": { "userId": 123 } } ``` -> `githubAccessTokenEncrypted`는 Core가 Cipher로 한 번 더 sealing해서 보냄. AI Server는 같은 키로 복호화 (또는 GitHub access token 위임을 위한 별도 short-lived token 발급 검토). +> 구 스펙(`githubAccessTokenEncrypted` envelope 동봉)은 폐기. AI Server는 분석 시점에 Core 내부 API `GET /api/internal/users/{userId}/github-token` 으로 평문 토큰을 짧게 위임받아 사용. envelope에는 비밀이 절대 포함되지 않는다. -### 5.3 `callback.analysis` (성공) +### 5.3 `analyze.web` +```json +{ + "messageType": "analyze.web", + "payload": { + "resumeId": 42, + "url": "https://example.com/me", + "analyzedDocumentId": 103 + }, + "context": { "userId": 123 } +} +``` + +> 웹 이력서(URL)는 AI Server가 trafilatura로 본문을 추출 → 동일 분석 체인으로 처리. resume 도메인을 재사용하므로 callback의 `targetType` 은 `WEB`. + +### 5.4 `callback.analysis` (성공) ```json { "messageType": "callback.analysis", "payload": { "targetType": "RESUME", "targetId": 42, + "status": "ANALYZED", "summary": "Java/Spring 3년차, 결제 시스템 개발...", "techStack": ["Java", "Spring Boot", "PostgreSQL"], - "documentS3Key": "analyzed/resume/42/summary.md", - "embeddingChunkCount": 18, - "status": "ANALYZED" + "documentPath": "analyzed/resume/42/summary.md", + "embeddingChunkCount": 18 } } ``` -### 5.4 `callback.analysis` (실패) +### 5.5 `callback.analysis` (실패) ```json { "messageType": "callback.analysis", @@ -171,9 +195,10 @@ } ``` -→ `targetType` ∈ `RESUME | REPOSITORY` +→ `targetType` ∈ `RESUME | REPOSITORY | WEB` +→ `documentPath` 는 객체 스토리지 키 (bucket 제외). Core는 같은 storage 추상화로 fetch. -### 5.5 `generate.questions` +### 5.6 `generate.questions` ```json { "messageType": "generate.questions", @@ -188,7 +213,7 @@ } ``` -### 5.6 `callback.questions` (질문 풀) +### 5.7 `callback.questions` (질문 풀) ```json { "messageType": "callback.questions", @@ -203,7 +228,7 @@ } ``` -### 5.7 `generate.followup` +### 5.8 `generate.followup` ```json { "messageType": "generate.followup", @@ -217,7 +242,7 @@ } ``` -### 5.8 `callback.questions` (꼬리질문) +### 5.9 `callback.questions` (꼬리질문) ```json { "messageType": "callback.questions", @@ -242,7 +267,7 @@ > `callback.questions` 큐는 두 종류(`POOL`, `FOLLOWUP`)를 받으므로 consumer는 `payload.kind`로 분기. -### 5.9 `generate.feedback` *(예정)* +### 5.10 `generate.feedback` *(예정)* ```json { "messageType": "generate.feedback", @@ -250,7 +275,7 @@ } ``` -### 5.10 `callback.feedback` *(예정)* +### 5.11 `callback.feedback` *(예정)* ```json { "messageType": "callback.feedback", @@ -268,7 +293,7 @@ } ``` -### 5.11 `realtime.session.notify` +### 5.12 `realtime.session.notify` ```json { "messageType": "realtime.session.notify", @@ -350,9 +375,22 @@ docker exec stackup-rabbitmq rabbitmqadmin \ -u stackup -p stackup \ publish exchange=stackup.core-to-ai \ routing_key=analyze.resume \ - payload='{"messageId":"smoke-1","messageType":"analyze.resume","version":"v1","traceId":"local-test","publishedAt":"2026-04-27T15:00:00Z","publisher":"manual","payload":{"resumeId":1,"s3Key":"resumes/raw/1/test.pdf"},"context":{"userId":1}}' + payload='{"messageId":"smoke-1","messageType":"analyze.resume","version":"v1","traceId":"local-test","publishedAt":"2026-04-27T15:00:00Z","publisher":"manual","payload":{"resumeId":1,"filePath":"resumes/raw/1/test.pdf","analyzedDocumentId":1},"context":{"userId":1}}' ``` +--- + +## 10. AI ↔ Core 내부 API (RabbitMQ 외) + +분석 파이프라인 일부는 동기적 데이터 위임이 필요해 Core가 내부 전용 REST endpoint를 노출한다. 모두 `X-Internal-API-Key` 헤더 검증. + +| Method | Path | 호출자 | 용도 | +|--------|------|--------|------| +| `GET` | `/api/internal/users/{userId}/github-token` | AI | 사용자별 GitHub access token을 분석 시점에 짧게 위임 (envelope에 비밀 미동봉) | +| `PUT` | `/api/internal/documents/{documentId}/embeddings` | AI | 청크 + 임베딩을 `document_embeddings`에 idempotent upsert | + +요청·응답 스키마 및 인증 규약은 [`/docs/api-conventions.md §10`](./api-conventions.md) 참조. + 큐 상태 확인: ```bash docker exec stackup-rabbitmq rabbitmqctl list_queues -q name messages consumers diff --git a/infra/CLAUDE.md b/infra/CLAUDE.md index 056e6053..f1e2b37f 100644 --- a/infra/CLAUDE.md +++ b/infra/CLAUDE.md @@ -108,6 +108,7 @@ docker exec -i stackup-postgres psql -U stackup stackup < backup.sql |-------|----------|-------------|----------| | `ai.analyze.repository` | `stackup.core-to-ai` | `analyze.repository` | AI Server | | `ai.analyze.resume` | `stackup.core-to-ai` | `analyze.resume` | AI Server | +| `ai.analyze.web` | `stackup.core-to-ai` | `analyze.web` | AI Server | | `ai.generate.questions` | `stackup.core-to-ai` | `generate.questions` | AI Server | | `ai.generate.followup` | `stackup.core-to-ai` | `generate.followup` | AI Server | | `core.callback.analysis` | `stackup.ai-to-core` | `callback.analysis` | Core Server | From e6786e6cb0b86d37afebf32cdffaf467f7177cf1 Mon Sep 17 00:00:00 2001 From: jmj Date: Wed, 20 May 2026 18:39:57 +0900 Subject: [PATCH 03/12] =?UTF-8?q?feat(core):=20analyze.*=20=EB=B0=9C?= =?UTF-8?q?=ED=96=89=20+=20callback.analysis=20=EC=86=8C=EB=B9=84=20+=20?= =?UTF-8?q?=EB=82=B4=EB=B6=80=20API?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 스프린트 1 분석 파이프라인의 Core 측 통합. - Flyway V4: analyzed_documents 라이프사이클 컬럼(analysis_status, error_code, error_message, embedding_chunk_count), document_embeddings 테이블(pgvector ivfflat 인덱스 포함), processed_messages 테이블 추가. - AnalysisStatus 열거형 + AnalyzedDocument 도메인 메서드(forResume/ forRepository, markAnalyzed/markFailed) - Resume / GithubRepository 에 markAnalyzing/Analyzed/Failed 도메인 메서드 - ProcessedMessage 엔티티 (common/messaging/domain) — 비동기 멱등성 - DocumentEmbeddingRepository 인터페이스 + JdbcTemplate 기반 pgvector upsert 구현 (vector literal 직렬화 포함) 발행 측: - AnalyzeResumePayload, AnalyzeRepositoryPayload (camelCase 필드로 AI 측 pydantic camel_config 와 정합) - AnalysisRequestService: AnalyzedDocument(PROCESSING) row 생성 후 도메인 이벤트로 트랜잭션 커밋 이후 발행 (transactional outbox 간이판) - InternalAnalysisTriggerController: /api/internal/analyses/{resume|repository}/{id} e2e 검증 트리거 endpoint 소비 측: - AnalysisCallbackEnvelope (구체 타입 — generic 추론 회피) - AnalysisCallbackPayload - AnalysisCallbackHandler: @RabbitListener on core.callback.analysis, 도메인 갱신은 AnalysisCallbackService 에 위임 - 멱등 처리: envelope.messageId → processed_messages INSERT - 성공 시 AnalyzedDocument.markAnalyzed + 소스 도메인 ANALYZED + SSE 푸시 - 실패 시 markFailed + SSE 푸시 내부 API: - /api/internal/users/{userId}/github-token (github 슬라이스에 위치 — user → github 순환 의존 회피) - PUT /api/internal/documents/{documentId}/embeddings: 청크/임베딩 idempotent upsert. dim 불일치 검증, 존재하지 않는 document 시 404. - InternalApiKeyAuthenticationFilter: X-Internal-API-Key 헤더 검증, 유효 시 INTERNAL 권한 부여. SecurityConfig 에서 /api/internal/** 은 hasAuthority("INTERNAL") 로 게이트. - SecurityProperties.internalApiKey 추가, application*.yml 동기화. 테스트: - StackupApplicationTests 에 신규 repository @MockitoBean 추가 - ArchUnit 룰 통과 (도메인 의존 방향, 순환 없음, presentation→JpaRepository 금지, @Transactional application-only, entity는 ..domain..) --- backend/gradlew | 0 .../config/properties/SecurityProperties.java | 3 +- .../common/exception/ApiErrorCode.java | 2 + .../messaging/domain/ProcessedMessage.java | 43 ++++++ .../domain/ProcessedMessageRepository.java | 8 + .../InternalApiKeyAuthenticationFilter.java | 73 +++++++++ .../common/security/SecurityConfig.java | 5 + .../application/AnalysisCallbackService.java | 139 ++++++++++++++++++ .../application/AnalysisRequestService.java | 109 ++++++++++++++ .../application/DocumentEmbeddingService.java | 32 ++++ .../dto/AnalysisCallbackEnvelope.java | 22 +++ .../dto/AnalysisCallbackPayload.java | 26 ++++ .../dto/AnalyzeRepositoryPayload.java | 9 ++ .../application/dto/AnalyzeResumePayload.java | 8 + .../dto/EmbeddingUpsertCommand.java | 13 ++ .../document/domain/AnalysisStatus.java | 7 + .../document/domain/AnalyzedDocument.java | 50 ++++++- .../domain/DocumentEmbeddingRepository.java | 13 ++ .../AnalysisCallbackHandler.java | 33 +++++ .../JdbcDocumentEmbeddingRepository.java | 76 ++++++++++ .../InternalAnalysisTriggerController.java | 42 ++++++ .../InternalDocumentController.java | 66 +++++++++ .../InternalGithubTokenService.java | 25 ++++ .../github/domain/GithubRepository.java | 13 ++ .../InternalGithubTokenController.java | 28 ++++ .../stackup/stackup/resume/domain/Resume.java | 12 ++ .../src/main/resources/application-local.yml | 1 + .../src/main/resources/application-test.yml | 1 + backend/src/main/resources/application.yml | 1 + .../db/migration/V4__analysis_pipeline.sql | 61 ++++++++ .../stackup/StackupApplicationTests.java | 20 +++ .../application/OAuthStateServiceTest.java | 3 +- .../application/RefreshTokenServiceTest.java | 3 +- .../infrastructure/GithubTokenCipherTest.java | 3 +- 34 files changed, 945 insertions(+), 5 deletions(-) mode change 100644 => 100755 backend/gradlew create mode 100644 backend/src/main/java/com/stackup/stackup/common/messaging/domain/ProcessedMessage.java create mode 100644 backend/src/main/java/com/stackup/stackup/common/messaging/domain/ProcessedMessageRepository.java create mode 100644 backend/src/main/java/com/stackup/stackup/common/security/InternalApiKeyAuthenticationFilter.java create mode 100644 backend/src/main/java/com/stackup/stackup/document/application/AnalysisCallbackService.java create mode 100644 backend/src/main/java/com/stackup/stackup/document/application/AnalysisRequestService.java create mode 100644 backend/src/main/java/com/stackup/stackup/document/application/DocumentEmbeddingService.java create mode 100644 backend/src/main/java/com/stackup/stackup/document/application/dto/AnalysisCallbackEnvelope.java create mode 100644 backend/src/main/java/com/stackup/stackup/document/application/dto/AnalysisCallbackPayload.java create mode 100644 backend/src/main/java/com/stackup/stackup/document/application/dto/AnalyzeRepositoryPayload.java create mode 100644 backend/src/main/java/com/stackup/stackup/document/application/dto/AnalyzeResumePayload.java create mode 100644 backend/src/main/java/com/stackup/stackup/document/application/dto/EmbeddingUpsertCommand.java create mode 100644 backend/src/main/java/com/stackup/stackup/document/domain/AnalysisStatus.java create mode 100644 backend/src/main/java/com/stackup/stackup/document/domain/DocumentEmbeddingRepository.java create mode 100644 backend/src/main/java/com/stackup/stackup/document/infrastructure/AnalysisCallbackHandler.java create mode 100644 backend/src/main/java/com/stackup/stackup/document/infrastructure/JdbcDocumentEmbeddingRepository.java create mode 100644 backend/src/main/java/com/stackup/stackup/document/presentation/InternalAnalysisTriggerController.java create mode 100644 backend/src/main/java/com/stackup/stackup/document/presentation/InternalDocumentController.java create mode 100644 backend/src/main/java/com/stackup/stackup/github/application/InternalGithubTokenService.java create mode 100644 backend/src/main/java/com/stackup/stackup/github/presentation/InternalGithubTokenController.java create mode 100644 backend/src/main/resources/db/migration/V4__analysis_pipeline.sql diff --git a/backend/gradlew b/backend/gradlew old mode 100644 new mode 100755 diff --git a/backend/src/main/java/com/stackup/stackup/common/config/properties/SecurityProperties.java b/backend/src/main/java/com/stackup/stackup/common/config/properties/SecurityProperties.java index 36d0734a..a6c64204 100644 --- a/backend/src/main/java/com/stackup/stackup/common/config/properties/SecurityProperties.java +++ b/backend/src/main/java/com/stackup/stackup/common/config/properties/SecurityProperties.java @@ -23,6 +23,7 @@ public record SecurityProperties( @NotBlank String refreshTokenCookiePath, @NotBlank String refreshTokenCookieSameSite, boolean refreshTokenCookieSecure, - boolean refreshTokenCookieHttpOnly + boolean refreshTokenCookieHttpOnly, + String internalApiKey ) { } diff --git a/backend/src/main/java/com/stackup/stackup/common/exception/ApiErrorCode.java b/backend/src/main/java/com/stackup/stackup/common/exception/ApiErrorCode.java index e95296ea..f21e728a 100644 --- a/backend/src/main/java/com/stackup/stackup/common/exception/ApiErrorCode.java +++ b/backend/src/main/java/com/stackup/stackup/common/exception/ApiErrorCode.java @@ -26,6 +26,8 @@ public enum ApiErrorCode { DOC_NOT_ANALYZED(HttpStatus.UNPROCESSABLE_CONTENT, "아직 분석되지 않은 문서입니다."), DOC_ANALYSIS_FAILED(HttpStatus.INTERNAL_SERVER_ERROR, "문서 분석에 실패했습니다."), DOC_NOT_FOUND(HttpStatus.NOT_FOUND, "분석 문서를 찾을 수 없습니다."), + INTERNAL_AUTH_FAILED(HttpStatus.UNAUTHORIZED, "내부 API 인증에 실패했습니다."), + EMBEDDING_BAD_REQUEST(HttpStatus.BAD_REQUEST, "임베딩 요청 페이로드가 올바르지 않습니다."), SESSION_INVALID_STATE(HttpStatus.UNPROCESSABLE_CONTENT, "세션 상태가 올바르지 않습니다."), SESSION_MAX_REACHED(HttpStatus.UNPROCESSABLE_CONTENT, "세션 제한에 도달했습니다."), diff --git a/backend/src/main/java/com/stackup/stackup/common/messaging/domain/ProcessedMessage.java b/backend/src/main/java/com/stackup/stackup/common/messaging/domain/ProcessedMessage.java new file mode 100644 index 00000000..bf224336 --- /dev/null +++ b/backend/src/main/java/com/stackup/stackup/common/messaging/domain/ProcessedMessage.java @@ -0,0 +1,43 @@ +package com.stackup.stackup.common.messaging.domain; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.Id; +import jakarta.persistence.Index; +import jakarta.persistence.Table; +import java.time.Instant; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@Entity +@Table( + name = "processed_messages", + indexes = { + @Index(name = "idx_processed_messages_processed_at", columnList = "processed_at") + } +) +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class ProcessedMessage { + + @Id + @Column(name = "message_id", length = 64) + private String messageId; + + @Column(nullable = false, length = 100) + private String consumer; + + @Column(name = "processed_at", nullable = false) + private Instant processedAt; + + private ProcessedMessage(String messageId, String consumer, Instant processedAt) { + this.messageId = messageId; + this.consumer = consumer; + this.processedAt = processedAt; + } + + public static ProcessedMessage of(String messageId, String consumer) { + return new ProcessedMessage(messageId, consumer, Instant.now()); + } +} diff --git a/backend/src/main/java/com/stackup/stackup/common/messaging/domain/ProcessedMessageRepository.java b/backend/src/main/java/com/stackup/stackup/common/messaging/domain/ProcessedMessageRepository.java new file mode 100644 index 00000000..9b2ad3ed --- /dev/null +++ b/backend/src/main/java/com/stackup/stackup/common/messaging/domain/ProcessedMessageRepository.java @@ -0,0 +1,8 @@ +package com.stackup.stackup.common.messaging.domain; + +import org.springframework.data.jpa.repository.JpaRepository; + +public interface ProcessedMessageRepository extends JpaRepository { + + boolean existsById(String messageId); +} diff --git a/backend/src/main/java/com/stackup/stackup/common/security/InternalApiKeyAuthenticationFilter.java b/backend/src/main/java/com/stackup/stackup/common/security/InternalApiKeyAuthenticationFilter.java new file mode 100644 index 00000000..dfa9cea9 --- /dev/null +++ b/backend/src/main/java/com/stackup/stackup/common/security/InternalApiKeyAuthenticationFilter.java @@ -0,0 +1,73 @@ +package com.stackup.stackup.common.security; + +import com.stackup.stackup.common.config.properties.SecurityProperties; +import com.stackup.stackup.common.exception.ApiErrorCode; +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import java.io.IOException; +import java.util.List; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.stereotype.Component; +import org.springframework.web.filter.OncePerRequestFilter; + +/** + * /api/internal/* 경로는 X-Internal-API-Key 헤더로 인증한다. + * 유효 시 INTERNAL 권한을 가진 Authentication 을 SecurityContext 에 주입. + * 그 외 경로에서는 아무것도 하지 않고 통과 (JWT 필터가 이어서 처리). + */ +@Component +public class InternalApiKeyAuthenticationFilter extends OncePerRequestFilter { + + public static final String HEADER = "X-Internal-API-Key"; + public static final String PRINCIPAL = "internal"; + public static final String AUTHORITY = "INTERNAL"; + + private final SecurityProperties properties; + + public InternalApiKeyAuthenticationFilter(SecurityProperties properties) { + this.properties = properties; + } + + @Override + protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain) + throws ServletException, IOException { + if (!request.getRequestURI().startsWith("/api/internal/")) { + chain.doFilter(request, response); + return; + } + + String provided = request.getHeader(HEADER); + String expected = properties.internalApiKey(); + if (expected == null || expected.isBlank() || provided == null || !constantTimeEquals(provided, expected)) { + // 인증 실패 — 권한 부여 X. SecurityConfig 에서 hasAuthority("INTERNAL") 로 차단 후 401. + request.setAttribute(JwtAuthenticationFilter.AUTH_ERROR_CODE_ATTRIBUTE, ApiErrorCode.INTERNAL_AUTH_FAILED); + chain.doFilter(request, response); + return; + } + + UsernamePasswordAuthenticationToken auth = new UsernamePasswordAuthenticationToken( + PRINCIPAL, + null, + List.of(new SimpleGrantedAuthority(AUTHORITY)) + ); + SecurityContextHolder.getContext().setAuthentication(auth); + chain.doFilter(request, response); + } + + private static boolean constantTimeEquals(String a, String b) { + byte[] x = a.getBytes(); + byte[] y = b.getBytes(); + if (x.length != y.length) { + return false; + } + int diff = 0; + for (int i = 0; i < x.length; i++) { + diff |= x[i] ^ y[i]; + } + return diff == 0; + } +} diff --git a/backend/src/main/java/com/stackup/stackup/common/security/SecurityConfig.java b/backend/src/main/java/com/stackup/stackup/common/security/SecurityConfig.java index 2995f5f2..7bb5619a 100644 --- a/backend/src/main/java/com/stackup/stackup/common/security/SecurityConfig.java +++ b/backend/src/main/java/com/stackup/stackup/common/security/SecurityConfig.java @@ -29,13 +29,16 @@ public class SecurityConfig { private static final Logger log = LoggerFactory.getLogger(SecurityConfig.class); private final JwtAuthenticationFilter jwtAuthenticationFilter; + private final InternalApiKeyAuthenticationFilter internalApiKeyAuthenticationFilter; private final CorsProperties corsProperties; public SecurityConfig( JwtAuthenticationFilter jwtAuthenticationFilter, + InternalApiKeyAuthenticationFilter internalApiKeyAuthenticationFilter, CorsProperties corsProperties ) { this.jwtAuthenticationFilter = jwtAuthenticationFilter; + this.internalApiKeyAuthenticationFilter = internalApiKeyAuthenticationFilter; this.corsProperties = corsProperties; } @@ -63,9 +66,11 @@ public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Excepti "/api/swagger-ui.html", "/actuator/**" ).permitAll() + .requestMatchers("/api/internal/**").hasAuthority(InternalApiKeyAuthenticationFilter.AUTHORITY) .requestMatchers("/api/**").authenticated() .anyRequest().permitAll() ) + .addFilterBefore(internalApiKeyAuthenticationFilter, UsernamePasswordAuthenticationFilter.class) .addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class) .build(); } diff --git a/backend/src/main/java/com/stackup/stackup/document/application/AnalysisCallbackService.java b/backend/src/main/java/com/stackup/stackup/document/application/AnalysisCallbackService.java new file mode 100644 index 00000000..b7717705 --- /dev/null +++ b/backend/src/main/java/com/stackup/stackup/document/application/AnalysisCallbackService.java @@ -0,0 +1,139 @@ +package com.stackup.stackup.document.application; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.json.JsonMapper; +import com.stackup.stackup.common.messaging.domain.ProcessedMessage; +import com.stackup.stackup.common.messaging.domain.ProcessedMessageRepository; +import com.stackup.stackup.common.sse.SseEventPublisher; +import com.stackup.stackup.common.sse.SseEventType; +import com.stackup.stackup.document.application.dto.AnalysisCallbackEnvelope; +import com.stackup.stackup.document.application.dto.AnalysisCallbackPayload; +import com.stackup.stackup.document.domain.AnalyzedDocument; +import com.stackup.stackup.document.domain.AnalyzedDocumentRepository; +import com.stackup.stackup.github.domain.GithubRepository; +import com.stackup.stackup.resume.domain.Resume; +import java.util.List; +import lombok.RequiredArgsConstructor; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.dao.DataIntegrityViolationException; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class AnalysisCallbackService { + + private static final Logger log = LoggerFactory.getLogger(AnalysisCallbackService.class); + private static final String CONSUMER_NAME = "core.callback.analysis"; + + private static final JsonMapper JSON = JsonMapper.builder().build(); + + private final AnalyzedDocumentRepository documentRepository; + private final ProcessedMessageRepository processedMessageRepository; + private final SseEventPublisher sseEventPublisher; + + @Transactional + public void apply(AnalysisCallbackEnvelope envelope) { + if (envelope == null || envelope.payload() == null) { + log.warn("callback.analysis envelope or payload is null — skip"); + return; + } + AnalysisCallbackPayload payload = envelope.payload(); + if (isProcessed(envelope.messageId())) { + log.info("callback.analysis duplicate, skip. messageId={}", envelope.messageId()); + return; + } + log.info("callback.analysis received. messageId={}, targetType={}, targetId={}, status={}, traceId={}", + envelope.messageId(), payload.targetType(), payload.targetId(), payload.status(), envelope.traceId()); + + Long documentId = envelope.context() == null ? null : envelope.context().documentId(); + if (documentId == null) { + log.warn("callback.analysis missing documentId in context. messageId={}", envelope.messageId()); + markProcessed(envelope.messageId()); + return; + } + + AnalyzedDocument doc = documentRepository.findById(documentId).orElse(null); + if (doc == null) { + log.warn("callback.analysis document not found. id={}, messageId={}", documentId, envelope.messageId()); + markProcessed(envelope.messageId()); + return; + } + + if (payload.isAnalyzed()) { + applyAnalyzed(doc, payload); + } else if (payload.isFailed()) { + applyFailed(doc, payload); + } else { + log.warn("callback.analysis unknown status={}, treat as FAILED. messageId={}", + payload.status(), envelope.messageId()); + applyFailed(doc, payload); + } + publishSse(doc, payload); + markProcessed(envelope.messageId()); + } + + private void applyAnalyzed(AnalyzedDocument doc, AnalysisCallbackPayload payload) { + String techStackJson = serializeTechStack(payload.techStack()); + int chunkCount = payload.embeddingChunkCount() == null ? 0 : payload.embeddingChunkCount(); + doc.markAnalyzed(payload.documentPath(), payload.summary(), techStackJson, chunkCount); + + Resume resume = doc.getResume(); + if (resume != null) { + resume.markAnalyzed(); + } + GithubRepository repo = doc.getRepository(); + if (repo != null) { + repo.markAnalyzed(); + } + } + + private void applyFailed(AnalyzedDocument doc, AnalysisCallbackPayload payload) { + doc.markFailed(payload.errorCode(), payload.errorMessage()); + Resume resume = doc.getResume(); + if (resume != null) { + resume.markFailed(); + } + GithubRepository repo = doc.getRepository(); + if (repo != null) { + repo.markFailed(); + } + } + + private void publishSse(AnalyzedDocument doc, AnalysisCallbackPayload payload) { + SseEventType type = doc.getRepository() != null ? SseEventType.REPO_STATE : SseEventType.DOC_STATE; + sseEventPublisher.publishToDocument(doc.getId(), type, payload); + } + + private boolean isProcessed(String messageId) { + if (messageId == null || messageId.isBlank()) { + return false; + } + return processedMessageRepository.existsById(messageId); + } + + private void markProcessed(String messageId) { + if (messageId == null || messageId.isBlank()) { + return; + } + try { + processedMessageRepository.save(ProcessedMessage.of(messageId, CONSUMER_NAME)); + } catch (DataIntegrityViolationException ignored) { + // race: 다른 worker 가 동시에 INSERT. 둘 다 같은 작업 처리 → 무시. + } + } + + private String serializeTechStack(List techStack) { + if (techStack == null) { + return null; + } + try { + return JSON.writeValueAsString(techStack); + } catch (JsonProcessingException e) { + log.warn("techStack serialize failed, store as empty array", e); + return "[]"; + } + } +} diff --git a/backend/src/main/java/com/stackup/stackup/document/application/AnalysisRequestService.java b/backend/src/main/java/com/stackup/stackup/document/application/AnalysisRequestService.java new file mode 100644 index 00000000..77d26b35 --- /dev/null +++ b/backend/src/main/java/com/stackup/stackup/document/application/AnalysisRequestService.java @@ -0,0 +1,109 @@ +package com.stackup.stackup.document.application; + +import com.stackup.stackup.common.config.properties.RabbitMqProperties; +import com.stackup.stackup.common.messaging.MessageContext; +import com.stackup.stackup.common.messaging.RabbitMessagePublisher; +import com.stackup.stackup.document.application.dto.AnalyzeRepositoryPayload; +import com.stackup.stackup.document.application.dto.AnalyzeResumePayload; +import com.stackup.stackup.document.domain.AnalyzedDocument; +import com.stackup.stackup.document.domain.AnalyzedDocumentRepository; +import com.stackup.stackup.github.domain.GithubRepository; +import com.stackup.stackup.github.domain.GithubRepositoryRepository; +import com.stackup.stackup.resume.domain.Resume; +import com.stackup.stackup.resume.domain.ResumeRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.context.ApplicationEventPublisher; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.transaction.event.TransactionPhase; +import org.springframework.transaction.event.TransactionalEventListener; + +/** + * Resume / GithubRepository 분석을 트리거한다. + * - AnalyzedDocument 를 PROCESSING 상태로 미리 생성 + * - 소스 도메인 status 를 ANALYZING 으로 전환 + * - DB commit 이후 core-to-ai exchange 로 analyze.{resume|repository} 발행 + * + * commit 이후 발행을 위해 도메인 이벤트 + {@code @TransactionalEventListener(AFTER_COMMIT)} 패턴. + * (메시지는 발행됐는데 DB 가 롤백되는 케이스를 방지) + */ +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class AnalysisRequestService { + + private final ResumeRepository resumeRepository; + private final GithubRepositoryRepository githubRepositoryRepository; + private final AnalyzedDocumentRepository analyzedDocumentRepository; + private final RabbitMessagePublisher publisher; + private final RabbitMqProperties properties; + private final ApplicationEventPublisher events; + + @Transactional + public AnalysisHandle requestResumeAnalysis(Long userId, Long resumeId) { + Resume resume = resumeRepository.findById(resumeId) + .orElseThrow(() -> new IllegalArgumentException("resume not found: " + resumeId)); + if (!resume.getUser().getId().equals(userId)) { + throw new IllegalArgumentException("resume does not belong to user"); + } + AnalyzedDocument doc = analyzedDocumentRepository.save(AnalyzedDocument.forResume(resume)); + resume.markAnalyzing(); + + events.publishEvent(new ResumeAnalysisRequestedEvent( + userId, + doc.getId(), + new AnalyzeResumePayload(resume.getId(), resume.getFilePath(), doc.getId()) + )); + return new AnalysisHandle(doc.getId(), resume.getId(), null); + } + + @Transactional + public AnalysisHandle requestRepositoryAnalysis(Long userId, Long repositoryId) { + GithubRepository repo = githubRepositoryRepository.findById(repositoryId) + .orElseThrow(() -> new IllegalArgumentException("repository not found: " + repositoryId)); + if (!repo.getUser().getId().equals(userId)) { + throw new IllegalArgumentException("repository does not belong to user"); + } + AnalyzedDocument doc = analyzedDocumentRepository.save(AnalyzedDocument.forRepository(repo)); + repo.markAnalyzing(); + + events.publishEvent(new RepositoryAnalysisRequestedEvent( + userId, + doc.getId(), + new AnalyzeRepositoryPayload( + repo.getId(), + repo.getRepoFullName(), + repo.getDefaultBranch() == null ? "main" : repo.getDefaultBranch(), + doc.getId() + ) + )); + return new AnalysisHandle(doc.getId(), null, repo.getId()); + } + + @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) + public void onResumeAnalysisRequested(ResumeAnalysisRequestedEvent event) { + publisher.publishToAi( + properties.routingKeys().analyzeResume(), + event.payload(), + new MessageContext(event.userId(), null, event.documentId(), null) + ); + } + + @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) + public void onRepositoryAnalysisRequested(RepositoryAnalysisRequestedEvent event) { + publisher.publishToAi( + properties.routingKeys().analyzeRepository(), + event.payload(), + new MessageContext(event.userId(), null, event.documentId(), event.payload().repositoryId()) + ); + } + + public record AnalysisHandle(Long analyzedDocumentId, Long resumeId, Long repositoryId) { + } + + record ResumeAnalysisRequestedEvent(Long userId, Long documentId, AnalyzeResumePayload payload) { + } + + record RepositoryAnalysisRequestedEvent(Long userId, Long documentId, AnalyzeRepositoryPayload payload) { + } +} diff --git a/backend/src/main/java/com/stackup/stackup/document/application/DocumentEmbeddingService.java b/backend/src/main/java/com/stackup/stackup/document/application/DocumentEmbeddingService.java new file mode 100644 index 00000000..4057dc4a --- /dev/null +++ b/backend/src/main/java/com/stackup/stackup/document/application/DocumentEmbeddingService.java @@ -0,0 +1,32 @@ +package com.stackup.stackup.document.application; + +import com.stackup.stackup.common.exception.ApiErrorCode; +import com.stackup.stackup.common.exception.DomainException; +import com.stackup.stackup.document.application.dto.EmbeddingUpsertCommand; +import com.stackup.stackup.document.domain.AnalyzedDocumentRepository; +import com.stackup.stackup.document.domain.DocumentEmbeddingRepository; +import com.stackup.stackup.document.domain.DocumentEmbeddingRepository.EmbeddingChunk; +import java.util.List; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class DocumentEmbeddingService { + + private final AnalyzedDocumentRepository documentRepository; + private final DocumentEmbeddingRepository embeddingRepository; + + @Transactional + public int upsert(EmbeddingUpsertCommand command) { + if (!documentRepository.existsById(command.documentId())) { + throw new DomainException(ApiErrorCode.DOC_NOT_FOUND); + } + List mapped = command.chunks().stream() + .map(c -> new EmbeddingChunk(c.chunkIndex(), c.chunkText(), c.embedding())) + .toList(); + return embeddingRepository.upsertAll(command.documentId(), command.model(), mapped); + } +} diff --git a/backend/src/main/java/com/stackup/stackup/document/application/dto/AnalysisCallbackEnvelope.java b/backend/src/main/java/com/stackup/stackup/document/application/dto/AnalysisCallbackEnvelope.java new file mode 100644 index 00000000..04c0bf17 --- /dev/null +++ b/backend/src/main/java/com/stackup/stackup/document/application/dto/AnalysisCallbackEnvelope.java @@ -0,0 +1,22 @@ +package com.stackup.stackup.document.application.dto; + +import com.stackup.stackup.common.messaging.MessageContext; +import java.time.Instant; + +/** + * {@link com.stackup.stackup.common.messaging.MessageEnvelope} 의 callback.analysis 전용 구체 타입. + * + * generic 타입은 Spring AMQP {@code JacksonJsonMessageConverter} 가 method 선언으로 + * payload 타입을 추론하지 못할 수 있어, listener 시그니처에서는 본 구체 타입을 사용한다. + */ +public record AnalysisCallbackEnvelope( + String messageId, + String messageType, + String version, + String traceId, + Instant publishedAt, + String publisher, + AnalysisCallbackPayload payload, + MessageContext context +) { +} diff --git a/backend/src/main/java/com/stackup/stackup/document/application/dto/AnalysisCallbackPayload.java b/backend/src/main/java/com/stackup/stackup/document/application/dto/AnalysisCallbackPayload.java new file mode 100644 index 00000000..ec03a8da --- /dev/null +++ b/backend/src/main/java/com/stackup/stackup/document/application/dto/AnalysisCallbackPayload.java @@ -0,0 +1,26 @@ +package com.stackup.stackup.document.application.dto; + +import com.fasterxml.jackson.annotation.JsonInclude; +import java.util.List; + +@JsonInclude(JsonInclude.Include.NON_NULL) +public record AnalysisCallbackPayload( + String targetType, + Long targetId, + String status, + String summary, + List techStack, + String documentPath, + Integer embeddingChunkCount, + String errorCode, + String errorMessage, + Boolean retriable +) { + public boolean isAnalyzed() { + return "ANALYZED".equals(status); + } + + public boolean isFailed() { + return "FAILED".equals(status); + } +} diff --git a/backend/src/main/java/com/stackup/stackup/document/application/dto/AnalyzeRepositoryPayload.java b/backend/src/main/java/com/stackup/stackup/document/application/dto/AnalyzeRepositoryPayload.java new file mode 100644 index 00000000..f6184a17 --- /dev/null +++ b/backend/src/main/java/com/stackup/stackup/document/application/dto/AnalyzeRepositoryPayload.java @@ -0,0 +1,9 @@ +package com.stackup.stackup.document.application.dto; + +public record AnalyzeRepositoryPayload( + Long repositoryId, + String repoFullName, + String defaultBranch, + Long analyzedDocumentId +) { +} diff --git a/backend/src/main/java/com/stackup/stackup/document/application/dto/AnalyzeResumePayload.java b/backend/src/main/java/com/stackup/stackup/document/application/dto/AnalyzeResumePayload.java new file mode 100644 index 00000000..ea832950 --- /dev/null +++ b/backend/src/main/java/com/stackup/stackup/document/application/dto/AnalyzeResumePayload.java @@ -0,0 +1,8 @@ +package com.stackup.stackup.document.application.dto; + +public record AnalyzeResumePayload( + Long resumeId, + String filePath, + Long analyzedDocumentId +) { +} diff --git a/backend/src/main/java/com/stackup/stackup/document/application/dto/EmbeddingUpsertCommand.java b/backend/src/main/java/com/stackup/stackup/document/application/dto/EmbeddingUpsertCommand.java new file mode 100644 index 00000000..1fc03a9f --- /dev/null +++ b/backend/src/main/java/com/stackup/stackup/document/application/dto/EmbeddingUpsertCommand.java @@ -0,0 +1,13 @@ +package com.stackup.stackup.document.application.dto; + +import java.util.List; + +public record EmbeddingUpsertCommand( + long documentId, + String model, + int dim, + List chunks +) { + public record Chunk(int chunkIndex, String chunkText, float[] embedding) { + } +} diff --git a/backend/src/main/java/com/stackup/stackup/document/domain/AnalysisStatus.java b/backend/src/main/java/com/stackup/stackup/document/domain/AnalysisStatus.java new file mode 100644 index 00000000..7f7688f5 --- /dev/null +++ b/backend/src/main/java/com/stackup/stackup/document/domain/AnalysisStatus.java @@ -0,0 +1,7 @@ +package com.stackup.stackup.document.domain; + +public enum AnalysisStatus { + PROCESSING, + ANALYZED, + FAILED +} diff --git a/backend/src/main/java/com/stackup/stackup/document/domain/AnalyzedDocument.java b/backend/src/main/java/com/stackup/stackup/document/domain/AnalyzedDocument.java index 10cdbd11..7a372c17 100644 --- a/backend/src/main/java/com/stackup/stackup/document/domain/AnalyzedDocument.java +++ b/backend/src/main/java/com/stackup/stackup/document/domain/AnalyzedDocument.java @@ -43,7 +43,7 @@ public class AnalyzedDocument extends BaseSoftDeleteEntity { @JoinColumn(name = "repository_id") private GithubRepository repository; - @Column(name = "document_path", nullable = false, length = 1000) + @Column(name = "document_path", length = 1000) private String documentPath; @Column(length = 2000) @@ -55,4 +55,52 @@ public class AnalyzedDocument extends BaseSoftDeleteEntity { @Column(nullable = false, length = 20) @Enumerated(EnumType.STRING) private DocumentStatus status = DocumentStatus.ACTIVE; + + @Column(name = "analysis_status", nullable = false, length = 20) + @Enumerated(EnumType.STRING) + private AnalysisStatus analysisStatus = AnalysisStatus.PROCESSING; + + @Column(name = "error_code", length = 50) + private String errorCode; + + @Column(name = "error_message", length = 1000) + private String errorMessage; + + @Column(name = "embedding_chunk_count", nullable = false) + private int embeddingChunkCount = 0; + + private AnalyzedDocument(Resume resume, GithubRepository repository) { + this.resume = resume; + this.repository = repository; + } + + public static AnalyzedDocument forResume(Resume resume) { + if (resume == null) { + throw new IllegalArgumentException("resume must not be null"); + } + return new AnalyzedDocument(resume, null); + } + + public static AnalyzedDocument forRepository(GithubRepository repository) { + if (repository == null) { + throw new IllegalArgumentException("repository must not be null"); + } + return new AnalyzedDocument(null, repository); + } + + public void markAnalyzed(String documentPath, String summary, String techStackJson, int embeddingChunkCount) { + this.analysisStatus = AnalysisStatus.ANALYZED; + this.documentPath = documentPath; + this.summary = summary; + this.techStack = techStackJson; + this.embeddingChunkCount = embeddingChunkCount; + this.errorCode = null; + this.errorMessage = null; + } + + public void markFailed(String errorCode, String errorMessage) { + this.analysisStatus = AnalysisStatus.FAILED; + this.errorCode = errorCode; + this.errorMessage = errorMessage; + } } diff --git a/backend/src/main/java/com/stackup/stackup/document/domain/DocumentEmbeddingRepository.java b/backend/src/main/java/com/stackup/stackup/document/domain/DocumentEmbeddingRepository.java new file mode 100644 index 00000000..fbb91d98 --- /dev/null +++ b/backend/src/main/java/com/stackup/stackup/document/domain/DocumentEmbeddingRepository.java @@ -0,0 +1,13 @@ +package com.stackup.stackup.document.domain; + +import java.util.List; + +public interface DocumentEmbeddingRepository { + + int upsertAll(long documentId, String model, List chunks); + + int countByDocumentId(long documentId); + + record EmbeddingChunk(int chunkIndex, String chunkText, float[] embedding) { + } +} diff --git a/backend/src/main/java/com/stackup/stackup/document/infrastructure/AnalysisCallbackHandler.java b/backend/src/main/java/com/stackup/stackup/document/infrastructure/AnalysisCallbackHandler.java new file mode 100644 index 00000000..48ffeea9 --- /dev/null +++ b/backend/src/main/java/com/stackup/stackup/document/infrastructure/AnalysisCallbackHandler.java @@ -0,0 +1,33 @@ +package com.stackup.stackup.document.infrastructure; + +import com.stackup.stackup.document.application.AnalysisCallbackService; +import com.stackup.stackup.document.application.dto.AnalysisCallbackEnvelope; +import lombok.RequiredArgsConstructor; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.amqp.rabbit.annotation.RabbitListener; +import org.springframework.stereotype.Component; + +/** + * stackup.ai-to-core / callback.analysis 큐 consumer. + * 메시지 라우팅·역직렬화만 담당하고 실제 도메인 갱신은 application 서비스에 위임. + */ +@Component +@RequiredArgsConstructor +public class AnalysisCallbackHandler { + + private static final Logger log = LoggerFactory.getLogger(AnalysisCallbackHandler.class); + + private final AnalysisCallbackService callbackService; + + @RabbitListener(queues = "${app.messaging.rabbitmq.queues.names.core-callback-analysis}") + public void handle(AnalysisCallbackEnvelope envelope) { + try { + callbackService.apply(envelope); + } catch (RuntimeException e) { + log.error("callback.analysis processing failed. messageId={}", + envelope == null ? null : envelope.messageId(), e); + throw e; + } + } +} diff --git a/backend/src/main/java/com/stackup/stackup/document/infrastructure/JdbcDocumentEmbeddingRepository.java b/backend/src/main/java/com/stackup/stackup/document/infrastructure/JdbcDocumentEmbeddingRepository.java new file mode 100644 index 00000000..b3553bf2 --- /dev/null +++ b/backend/src/main/java/com/stackup/stackup/document/infrastructure/JdbcDocumentEmbeddingRepository.java @@ -0,0 +1,76 @@ +package com.stackup.stackup.document.infrastructure; + +import com.stackup.stackup.document.domain.DocumentEmbeddingRepository; +import java.sql.PreparedStatement; +import java.util.List; +import javax.sql.DataSource; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.stereotype.Repository; + +/** + * pgvector 의존이 strong-typed JPA 매핑보다 native query 가 단순해서 JdbcTemplate 사용. + * AI 서버가 같은 documentId·chunkIndex 로 재호출 시 idempotent upsert. + */ +@Repository +public class JdbcDocumentEmbeddingRepository implements DocumentEmbeddingRepository { + + private static final String UPSERT_SQL = """ + INSERT INTO document_embeddings (document_id, chunk_index, chunk_text, embedding, model) + VALUES (?, ?, ?, ?::vector, ?) + ON CONFLICT (document_id, chunk_index) + DO UPDATE SET + chunk_text = EXCLUDED.chunk_text, + embedding = EXCLUDED.embedding, + model = EXCLUDED.model + """; + + private static final String COUNT_SQL = + "SELECT count(*) FROM document_embeddings WHERE document_id = ?"; + + private final JdbcTemplate jdbc; + + public JdbcDocumentEmbeddingRepository(DataSource dataSource) { + this.jdbc = new JdbcTemplate(dataSource); + } + + @Override + public int upsertAll(long documentId, String model, List chunks) { + if (chunks == null || chunks.isEmpty()) { + return 0; + } + int[][] perBatch = jdbc.batchUpdate(UPSERT_SQL, chunks, chunks.size(), + (PreparedStatement ps, EmbeddingChunk c) -> { + ps.setLong(1, documentId); + ps.setInt(2, c.chunkIndex()); + ps.setString(3, c.chunkText()); + ps.setString(4, toVectorLiteral(c.embedding())); + ps.setString(5, model); + }); + int total = 0; + for (int[] batch : perBatch) { + for (int n : batch) { + total += Math.max(n, 0); + } + } + return total == 0 ? chunks.size() : total; + } + + @Override + public int countByDocumentId(long documentId) { + Integer n = jdbc.queryForObject(COUNT_SQL, Integer.class, documentId); + return n == null ? 0 : n; + } + + private static String toVectorLiteral(float[] embedding) { + StringBuilder sb = new StringBuilder(embedding.length * 8 + 2); + sb.append('['); + for (int i = 0; i < embedding.length; i++) { + if (i > 0) { + sb.append(','); + } + sb.append(embedding[i]); + } + sb.append(']'); + return sb.toString(); + } +} diff --git a/backend/src/main/java/com/stackup/stackup/document/presentation/InternalAnalysisTriggerController.java b/backend/src/main/java/com/stackup/stackup/document/presentation/InternalAnalysisTriggerController.java new file mode 100644 index 00000000..a8ad13ae --- /dev/null +++ b/backend/src/main/java/com/stackup/stackup/document/presentation/InternalAnalysisTriggerController.java @@ -0,0 +1,42 @@ +package com.stackup.stackup.document.presentation; + +import com.stackup.stackup.document.application.AnalysisRequestService; +import com.stackup.stackup.document.application.AnalysisRequestService.AnalysisHandle; +import jakarta.validation.constraints.NotNull; +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +/** + * 내부 트리거 endpoint. 운영용 외부 API 가 들어오기 전 e2e 검증에 사용. + * 실제 사용자 흐름(이력서 업로드 등)에서는 AnalysisRequestService 를 직접 호출. + */ +@RestController +@RequestMapping("/api/internal/analyses") +@RequiredArgsConstructor +public class InternalAnalysisTriggerController { + + private final AnalysisRequestService service; + + @PostMapping("/resume/{resumeId}") + public AnalysisHandle triggerResume( + @PathVariable Long resumeId, + @RequestBody TriggerRequest request + ) { + return service.requestResumeAnalysis(request.userId(), resumeId); + } + + @PostMapping("/repository/{repositoryId}") + public AnalysisHandle triggerRepository( + @PathVariable Long repositoryId, + @RequestBody TriggerRequest request + ) { + return service.requestRepositoryAnalysis(request.userId(), repositoryId); + } + + public record TriggerRequest(@NotNull Long userId) { + } +} diff --git a/backend/src/main/java/com/stackup/stackup/document/presentation/InternalDocumentController.java b/backend/src/main/java/com/stackup/stackup/document/presentation/InternalDocumentController.java new file mode 100644 index 00000000..45eeac62 --- /dev/null +++ b/backend/src/main/java/com/stackup/stackup/document/presentation/InternalDocumentController.java @@ -0,0 +1,66 @@ +package com.stackup.stackup.document.presentation; + +import com.stackup.stackup.common.exception.ApiErrorCode; +import com.stackup.stackup.common.exception.DomainException; +import com.stackup.stackup.document.application.DocumentEmbeddingService; +import com.stackup.stackup.document.application.dto.EmbeddingUpsertCommand; +import jakarta.validation.Valid; +import jakarta.validation.constraints.NotEmpty; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Positive; +import java.util.List; +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PutMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +/** + * AI 서버 전용. /api/internal/documents/{documentId}/embeddings. + * 청크 + 임베딩을 pgvector 에 idempotent upsert. + */ +@RestController +@RequestMapping("/api/internal/documents") +@RequiredArgsConstructor +public class InternalDocumentController { + + private final DocumentEmbeddingService embeddingService; + + @PutMapping("/{documentId}/embeddings") + public UpsertResponse upsert( + @PathVariable Long documentId, + @Valid @RequestBody UpsertRequest request + ) { + for (ChunkRequest c : request.chunks()) { + if (c.embedding() == null || c.embedding().length != request.dim()) { + throw new DomainException(ApiErrorCode.EMBEDDING_BAD_REQUEST); + } + } + + List mapped = request.chunks().stream() + .map(c -> new EmbeddingUpsertCommand.Chunk(c.chunkIndex(), c.chunkText(), c.embedding())) + .toList(); + int upserted = embeddingService.upsert( + new EmbeddingUpsertCommand(documentId, request.model(), request.dim(), mapped) + ); + return new UpsertResponse(upserted); + } + + public record UpsertRequest( + String model, + @Positive int dim, + @NotNull @NotEmpty List<@Valid ChunkRequest> chunks + ) { + } + + public record ChunkRequest( + int chunkIndex, + String chunkText, + float[] embedding + ) { + } + + public record UpsertResponse(int upserted) { + } +} diff --git a/backend/src/main/java/com/stackup/stackup/github/application/InternalGithubTokenService.java b/backend/src/main/java/com/stackup/stackup/github/application/InternalGithubTokenService.java new file mode 100644 index 00000000..4597ef5e --- /dev/null +++ b/backend/src/main/java/com/stackup/stackup/github/application/InternalGithubTokenService.java @@ -0,0 +1,25 @@ +package com.stackup.stackup.github.application; + +import com.stackup.stackup.common.exception.ApiErrorCode; +import com.stackup.stackup.common.exception.DomainException; +import com.stackup.stackup.github.infrastructure.GithubTokenCipher; +import com.stackup.stackup.user.domain.User; +import com.stackup.stackup.user.domain.UserRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class InternalGithubTokenService { + + private final UserRepository userRepository; + private final GithubTokenCipher tokenCipher; + + public String fetchPlainAccessToken(Long userId) { + User user = userRepository.findById(userId) + .orElseThrow(() -> new DomainException(ApiErrorCode.USER_NOT_FOUND)); + return tokenCipher.decrypt(user.getEncryptedGithubAccessToken()); + } +} diff --git a/backend/src/main/java/com/stackup/stackup/github/domain/GithubRepository.java b/backend/src/main/java/com/stackup/stackup/github/domain/GithubRepository.java index 0e1e0b61..c589806d 100644 --- a/backend/src/main/java/com/stackup/stackup/github/domain/GithubRepository.java +++ b/backend/src/main/java/com/stackup/stackup/github/domain/GithubRepository.java @@ -64,4 +64,17 @@ public class GithubRepository extends BaseSoftDeleteEntity { @Column(name = "last_synced_at") private Instant lastSyncedAt; + + public void markAnalyzing() { + this.status = RepositoryStatus.ANALYZING; + } + + public void markAnalyzed() { + this.status = RepositoryStatus.ANALYZED; + this.lastSyncedAt = Instant.now(); + } + + public void markFailed() { + this.status = RepositoryStatus.FAILED; + } } diff --git a/backend/src/main/java/com/stackup/stackup/github/presentation/InternalGithubTokenController.java b/backend/src/main/java/com/stackup/stackup/github/presentation/InternalGithubTokenController.java new file mode 100644 index 00000000..34361c87 --- /dev/null +++ b/backend/src/main/java/com/stackup/stackup/github/presentation/InternalGithubTokenController.java @@ -0,0 +1,28 @@ +package com.stackup.stackup.github.presentation; + +import com.stackup.stackup.github.application.InternalGithubTokenService; +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +/** + * AI 서버 전용. /api/internal/users/{userId}/github-token. + * URL prefix 는 users 도메인이지만 토큰 처리는 github 도메인 책임이라 본 패키지에 둔다. + */ +@RestController +@RequestMapping("/api/internal/users") +@RequiredArgsConstructor +public class InternalGithubTokenController { + + private final InternalGithubTokenService tokenService; + + @GetMapping("/{userId}/github-token") + public GithubTokenResponse fetchGithubToken(@PathVariable Long userId) { + return new GithubTokenResponse(tokenService.fetchPlainAccessToken(userId)); + } + + public record GithubTokenResponse(String accessToken) { + } +} diff --git a/backend/src/main/java/com/stackup/stackup/resume/domain/Resume.java b/backend/src/main/java/com/stackup/stackup/resume/domain/Resume.java index bd07ad0a..d86197b8 100644 --- a/backend/src/main/java/com/stackup/stackup/resume/domain/Resume.java +++ b/backend/src/main/java/com/stackup/stackup/resume/domain/Resume.java @@ -53,4 +53,16 @@ public class Resume extends BaseSoftDeleteEntity { @Column(nullable = false, length = 20) @Enumerated(EnumType.STRING) private ResumeStatus status = ResumeStatus.PENDING; + + public void markAnalyzing() { + this.status = ResumeStatus.ANALYZING; + } + + public void markAnalyzed() { + this.status = ResumeStatus.ANALYZED; + } + + public void markFailed() { + this.status = ResumeStatus.FAILED; + } } diff --git a/backend/src/main/resources/application-local.yml b/backend/src/main/resources/application-local.yml index 7653cec3..f01e43a5 100644 --- a/backend/src/main/resources/application-local.yml +++ b/backend/src/main/resources/application-local.yml @@ -13,6 +13,7 @@ app: security: jwt-secret: ${JWT_SECRET:local-development-jwt-secret-must-be-replaced} encryption-key: ${ENCRYPTION_KEY:local-development-encryption-key-must-be-replaced} + internal-api-key: ${CORE_INTERNAL_API_KEY:local-development-internal-api-key} github: client-id: ${GITHUB_OAUTH_CLIENT_ID:local-github-client-id} client-secret: ${GITHUB_OAUTH_CLIENT_SECRET:local-github-client-secret} diff --git a/backend/src/main/resources/application-test.yml b/backend/src/main/resources/application-test.yml index 5fdfeacf..2124d230 100644 --- a/backend/src/main/resources/application-test.yml +++ b/backend/src/main/resources/application-test.yml @@ -12,6 +12,7 @@ app: security: jwt-secret: test-jwt-secret encryption-key: test-encryption-key + internal-api-key: test-internal-api-key github: client-id: test-github-client-id client-secret: test-github-client-secret diff --git a/backend/src/main/resources/application.yml b/backend/src/main/resources/application.yml index 3ce77217..509950b4 100644 --- a/backend/src/main/resources/application.yml +++ b/backend/src/main/resources/application.yml @@ -50,6 +50,7 @@ app: refresh-token-cookie-same-site: ${REFRESH_TOKEN_COOKIE_SAME_SITE:Strict} refresh-token-cookie-secure: ${REFRESH_TOKEN_COOKIE_SECURE:true} refresh-token-cookie-http-only: ${REFRESH_TOKEN_COOKIE_HTTP_ONLY:true} + internal-api-key: ${CORE_INTERNAL_API_KEY:} github: client-id: ${GITHUB_OAUTH_CLIENT_ID:} client-secret: ${GITHUB_OAUTH_CLIENT_SECRET:} diff --git a/backend/src/main/resources/db/migration/V4__analysis_pipeline.sql b/backend/src/main/resources/db/migration/V4__analysis_pipeline.sql new file mode 100644 index 00000000..74d5724d --- /dev/null +++ b/backend/src/main/resources/db/migration/V4__analysis_pipeline.sql @@ -0,0 +1,61 @@ +-- pgvector 확장은 infra/postgres/init.sql 에서 보장 (Docker 이미지 init) +-- 미보장 환경에서도 안전하도록 IF NOT EXISTS +CREATE EXTENSION IF NOT EXISTS vector; + +-- ============================================================================= +-- analyzed_documents: 분석 라이프사이클 컬럼 추가 +-- ============================================================================= +-- document_path 는 publish 시점에 비어있고 callback ANALYZED 시 채워짐 +ALTER TABLE analyzed_documents + ALTER COLUMN document_path DROP NOT NULL; + +ALTER TABLE analyzed_documents + ADD COLUMN analysis_status VARCHAR(20) NOT NULL DEFAULT 'PROCESSING', + ADD COLUMN error_code VARCHAR(50), + ADD COLUMN error_message VARCHAR(1000), + ADD COLUMN embedding_chunk_count INT NOT NULL DEFAULT 0; + +ALTER TABLE analyzed_documents + ADD CONSTRAINT chk_analyzed_documents_analysis_status + CHECK (analysis_status IN ('PROCESSING', 'ANALYZED', 'FAILED')); + +CREATE INDEX idx_analyzed_documents_analysis_status + ON analyzed_documents (analysis_status); + +-- ============================================================================= +-- document_embeddings: 청크 + pgvector 임베딩 +-- ============================================================================= +CREATE TABLE document_embeddings ( + id BIGSERIAL PRIMARY KEY, + document_id BIGINT NOT NULL REFERENCES analyzed_documents (id) ON DELETE CASCADE, + chunk_index INT NOT NULL, + chunk_text TEXT NOT NULL, + embedding vector(1536) NOT NULL, + model VARCHAR(100) NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + CONSTRAINT uq_document_embeddings_document_chunk + UNIQUE (document_id, chunk_index) +); + +CREATE INDEX idx_document_embeddings_document_id + ON document_embeddings (document_id); + +-- ANN 인덱스 (ivfflat). lists 는 데이터 양에 따라 튜닝. +-- 초기에는 lists=100 (수만 row 가정). 추후 ANALYZE 후 재생성. +CREATE INDEX idx_document_embeddings_ann + ON document_embeddings USING ivfflat (embedding vector_cosine_ops) + WITH (lists = 100); + +-- ============================================================================= +-- processed_messages: 비동기 consumer 멱등성 (RabbitMQ messageId 기록) +-- ============================================================================= +CREATE TABLE processed_messages ( + message_id VARCHAR(64) PRIMARY KEY, + consumer VARCHAR(100) NOT NULL, + processed_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE INDEX idx_processed_messages_processed_at + ON processed_messages (processed_at); + +-- 24h 이상 된 row 는 cron 으로 정리 (운영 가이드: docs/messaging.md §6) diff --git a/backend/src/test/java/com/stackup/stackup/StackupApplicationTests.java b/backend/src/test/java/com/stackup/stackup/StackupApplicationTests.java index e1eea7e1..bbe8606e 100644 --- a/backend/src/test/java/com/stackup/stackup/StackupApplicationTests.java +++ b/backend/src/test/java/com/stackup/stackup/StackupApplicationTests.java @@ -2,6 +2,11 @@ import com.stackup.stackup.auth.domain.OAuthStateRepository; import com.stackup.stackup.auth.domain.RefreshTokenRepository; +import com.stackup.stackup.common.messaging.domain.ProcessedMessageRepository; +import com.stackup.stackup.document.domain.AnalyzedDocumentRepository; +import com.stackup.stackup.document.domain.DocumentEmbeddingRepository; +import com.stackup.stackup.github.domain.GithubRepositoryRepository; +import com.stackup.stackup.resume.domain.ResumeRepository; import com.stackup.stackup.user.domain.UserRepository; import org.junit.jupiter.api.Test; import org.springframework.boot.test.context.SpringBootTest; @@ -19,6 +24,21 @@ class StackupApplicationTests { @MockitoBean private UserRepository userRepository; + @MockitoBean + private ResumeRepository resumeRepository; + + @MockitoBean + private GithubRepositoryRepository githubRepositoryRepository; + + @MockitoBean + private AnalyzedDocumentRepository analyzedDocumentRepository; + + @MockitoBean + private ProcessedMessageRepository processedMessageRepository; + + @MockitoBean + private DocumentEmbeddingRepository documentEmbeddingRepository; + @Test void contextLoads() { } diff --git a/backend/src/test/java/com/stackup/stackup/auth/application/OAuthStateServiceTest.java b/backend/src/test/java/com/stackup/stackup/auth/application/OAuthStateServiceTest.java index 79aeaeb4..126fbf72 100644 --- a/backend/src/test/java/com/stackup/stackup/auth/application/OAuthStateServiceTest.java +++ b/backend/src/test/java/com/stackup/stackup/auth/application/OAuthStateServiceTest.java @@ -131,7 +131,8 @@ private static SecurityProperties securityProperties() { "/api/auth", "Strict", true, - true + true, + "test-internal-api-key" ); } } diff --git a/backend/src/test/java/com/stackup/stackup/auth/application/RefreshTokenServiceTest.java b/backend/src/test/java/com/stackup/stackup/auth/application/RefreshTokenServiceTest.java index b2d0de35..74f2d3dd 100644 --- a/backend/src/test/java/com/stackup/stackup/auth/application/RefreshTokenServiceTest.java +++ b/backend/src/test/java/com/stackup/stackup/auth/application/RefreshTokenServiceTest.java @@ -123,7 +123,8 @@ private RefreshTokenService service() { "/api/auth", "Strict", true, - true + true, + "test-internal-api-key" ), new SecureRandom(), Clock.fixed(NOW, ZoneOffset.UTC) diff --git a/backend/src/test/java/com/stackup/stackup/github/infrastructure/GithubTokenCipherTest.java b/backend/src/test/java/com/stackup/stackup/github/infrastructure/GithubTokenCipherTest.java index 720b560c..fb4dfdc8 100644 --- a/backend/src/test/java/com/stackup/stackup/github/infrastructure/GithubTokenCipherTest.java +++ b/backend/src/test/java/com/stackup/stackup/github/infrastructure/GithubTokenCipherTest.java @@ -49,7 +49,8 @@ private static SecurityProperties securityProperties(String encryptionKey) { "/api/auth", "Strict", true, - true + true, + "test-internal-api-key" ); } From f8ced7a49866b3b4a78ff48ab20e7ec96ec02e53 Mon Sep 17 00:00:00 2001 From: jmj Date: Wed, 20 May 2026 18:43:53 +0900 Subject: [PATCH 04/12] =?UTF-8?q?feat(core):=20RealTime=20=EC=95=8C?= =?UTF-8?q?=EB=A6=BC=20publish=20=EC=9D=B8=ED=94=84=EB=9D=BC=20(stackup.re?= =?UTF-8?q?altime=20exchange)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Core → RealTime 단방향 push 채널을 wiring. - RabbitMqProperties: exchanges.names.realtime, routingKeys.realtimeSessionNotify - RabbitMqConfig: stackup.realtime TopicExchange 빈 declare (q.realtime.session.notify 큐 + binding 은 RealTime 서버 / definitions.json 이 자체 관리 — Core 는 exchange 만) - RealtimeNotifyPublisher: publishSessionNotify(sessionId, userId, eventType, data) 헬퍼. envelope.payload = {eventType, data}, context.sessionId 강제. docs/messaging.md §5.12 envelope 정합. - application.yml: realtime 키 / RK 추가 - 테스트 fixture (RabbitMessagePublisherTest) 정합 분석 콜백 흐름에서는 사용하지 않음 (세션 전 단계라 sessionId 없음 — Core 자체 SseEventPublisher 가 /api/stream/documents/{id} 로 push). 본 publisher 는 US-13+ 세션/꼬리질문/피드백 단계에서 활용. --- .../config/properties/RabbitMqProperties.java | 6 +- .../common/messaging/RabbitMqConfig.java | 13 ++++ .../messaging/RealtimeNotifyPublisher.java | 69 +++++++++++++++++++ backend/src/main/resources/application.yml | 2 + .../messaging/RabbitMessagePublisherTest.java | 5 +- 5 files changed, 91 insertions(+), 4 deletions(-) create mode 100644 backend/src/main/java/com/stackup/stackup/common/messaging/RealtimeNotifyPublisher.java diff --git a/backend/src/main/java/com/stackup/stackup/common/config/properties/RabbitMqProperties.java b/backend/src/main/java/com/stackup/stackup/common/config/properties/RabbitMqProperties.java index f82438d2..59a75be8 100644 --- a/backend/src/main/java/com/stackup/stackup/common/config/properties/RabbitMqProperties.java +++ b/backend/src/main/java/com/stackup/stackup/common/config/properties/RabbitMqProperties.java @@ -43,7 +43,8 @@ public record Exchanges( public record Names( @NotBlank String coreToAi, - @NotBlank String aiToCore + @NotBlank String aiToCore, + @NotBlank String realtime ) { } } @@ -71,7 +72,8 @@ public record RoutingKeyProperties( @NotBlank String generateQuestions, @NotBlank String generateFollowup, @NotBlank String callbackAnalysis, - @NotBlank String callbackQuestions + @NotBlank String callbackQuestions, + @NotBlank String realtimeSessionNotify ) { } } diff --git a/backend/src/main/java/com/stackup/stackup/common/messaging/RabbitMqConfig.java b/backend/src/main/java/com/stackup/stackup/common/messaging/RabbitMqConfig.java index 9481d827..497da3b4 100644 --- a/backend/src/main/java/com/stackup/stackup/common/messaging/RabbitMqConfig.java +++ b/backend/src/main/java/com/stackup/stackup/common/messaging/RabbitMqConfig.java @@ -39,6 +39,15 @@ public TopicExchange aiToCoreExchange() { ); } + @Bean + public TopicExchange realtimeExchange() { + return new TopicExchange( + properties.exchanges().names().realtime(), + properties.exchanges().durable(), + properties.exchanges().autoDelete() + ); + } + @Bean public Queue aiAnalyzeResumeQueue() { return new Queue(properties.queues().names().aiAnalyzeResume(), properties.queues().durable()); @@ -73,6 +82,7 @@ public Queue coreCallbackQuestionsQueue() { public Declarables rabbitDeclarables( TopicExchange coreToAiExchange, TopicExchange aiToCoreExchange, + TopicExchange realtimeExchange, Queue aiAnalyzeResumeQueue, Queue aiAnalyzeRepositoryQueue, Queue aiGenerateQuestionsQueue, @@ -83,6 +93,7 @@ public Declarables rabbitDeclarables( return new Declarables( coreToAiExchange, aiToCoreExchange, + realtimeExchange, aiAnalyzeResumeQueue, aiAnalyzeRepositoryQueue, aiGenerateQuestionsQueue, @@ -95,6 +106,8 @@ public Declarables rabbitDeclarables( BindingBuilder.bind(aiGenerateFollowupQueue).to(coreToAiExchange).with(properties.routingKeys().generateFollowup()), BindingBuilder.bind(coreCallbackAnalysisQueue).to(aiToCoreExchange).with(properties.routingKeys().callbackAnalysis()), BindingBuilder.bind(coreCallbackQuestionsQueue).to(aiToCoreExchange).with(properties.routingKeys().callbackQuestions()) + // 주의: q.realtime.session.notify 큐 + binding 은 RealTime 서버가 자체 declare + // (또는 infra/rabbitmq/definitions.json). Core 는 exchange 만 declare. ); } diff --git a/backend/src/main/java/com/stackup/stackup/common/messaging/RealtimeNotifyPublisher.java b/backend/src/main/java/com/stackup/stackup/common/messaging/RealtimeNotifyPublisher.java new file mode 100644 index 00000000..a434594f --- /dev/null +++ b/backend/src/main/java/com/stackup/stackup/common/messaging/RealtimeNotifyPublisher.java @@ -0,0 +1,69 @@ +package com.stackup.stackup.common.messaging; + +import com.stackup.stackup.common.config.properties.RabbitMqProperties; +import com.stackup.stackup.common.trace.TraceContext; +import java.time.Instant; +import java.util.UUID; +import org.springframework.amqp.core.MessagePostProcessor; +import org.springframework.amqp.rabbit.core.RabbitTemplate; +import org.springframework.stereotype.Component; + +/** + * Core → RealTime 알림 발행자. + * stackup.realtime exchange / routing key realtime.session.notify 로 publish. + * + * RealTime 서버는 q.realtime.session.notify 큐 consumer 로 envelope.context.sessionId 가 + * 가리키는 SSE 구독자에게 fan-out 한다 (docs/messaging.md §5.12). + */ +@Component +public class RealtimeNotifyPublisher { + + public record RealtimeNotifyPayload(String eventType, Object data) { + } + + private final RabbitMqProperties properties; + private final RabbitTemplate rabbitTemplate; + + public RealtimeNotifyPublisher(RabbitMqProperties properties, RabbitTemplate rabbitTemplate) { + this.properties = properties; + this.rabbitTemplate = rabbitTemplate; + } + + public MessageEnvelope publishSessionNotify( + long sessionId, + Long userId, + String eventType, + Object data + ) { + var payload = new RealtimeNotifyPayload(eventType, data); + var context = new MessageContext(userId, sessionId, null, null); + var envelope = new MessageEnvelope<>( + UUID.randomUUID().toString(), + "realtime.session.notify", + properties.version(), + TraceContext.getTraceId(), + Instant.now(), + properties.publisher(), + payload, + context + ); + + rabbitTemplate.convertAndSend( + properties.exchanges().names().realtime(), + properties.routingKeys().realtimeSessionNotify(), + envelope, + withEnvelopeHeaders(envelope) + ); + return envelope; + } + + private MessagePostProcessor withEnvelopeHeaders(MessageEnvelope envelope) { + return message -> { + message.getMessageProperties().setContentType(properties.message().contentType()); + message.getMessageProperties().setContentEncoding(properties.message().contentEncoding()); + message.getMessageProperties().setMessageId(envelope.messageId()); + message.getMessageProperties().setHeader(properties.message().traceIdHeader(), envelope.traceId()); + return message; + }; + } +} diff --git a/backend/src/main/resources/application.yml b/backend/src/main/resources/application.yml index 509950b4..a6a78e60 100644 --- a/backend/src/main/resources/application.yml +++ b/backend/src/main/resources/application.yml @@ -86,6 +86,7 @@ app: names: core-to-ai: stackup.core-to-ai ai-to-core: stackup.ai-to-core + realtime: stackup.realtime queues: durable: true names: @@ -102,6 +103,7 @@ app: generate-followup: generate.followup callback-analysis: callback.analysis callback-questions: callback.questions + realtime-session-notify: realtime.session.notify sse: timeout: 30m keep-alive-interval: 30s diff --git a/backend/src/test/java/com/stackup/stackup/common/messaging/RabbitMessagePublisherTest.java b/backend/src/test/java/com/stackup/stackup/common/messaging/RabbitMessagePublisherTest.java index 8d602d07..29fc4a33 100644 --- a/backend/src/test/java/com/stackup/stackup/common/messaging/RabbitMessagePublisherTest.java +++ b/backend/src/test/java/com/stackup/stackup/common/messaging/RabbitMessagePublisherTest.java @@ -82,7 +82,7 @@ private RabbitMqProperties rabbitMqProperties() { new RabbitMqProperties.Exchanges( true, false, - new RabbitMqProperties.Exchanges.Names("stackup.core-to-ai", "stackup.ai-to-core") + new RabbitMqProperties.Exchanges.Names("stackup.core-to-ai", "stackup.ai-to-core", "stackup.realtime") ), new RabbitMqProperties.Queues( true, @@ -101,7 +101,8 @@ private RabbitMqProperties rabbitMqProperties() { "generate.questions", "generate.followup", "callback.analysis", - "callback.questions" + "callback.questions", + "realtime.session.notify" ) ); } From ad873d852202ccd7ca3f08cbf2b94ef3ce5c9773 Mon Sep 17 00:00:00 2001 From: jmj Date: Wed, 20 May 2026 18:48:16 +0900 Subject: [PATCH 05/12] =?UTF-8?q?chore(compose):=20AI=20=EC=84=9C=EB=B9=84?= =?UTF-8?q?=EC=8A=A4=EC=97=90=20Core=20=EB=82=B4=EB=B6=80=20API=20+=20?= =?UTF-8?q?=EC=9E=84=EB=B2=A0=EB=94=A9=20=ED=99=98=EA=B2=BD=EB=B3=80?= =?UTF-8?q?=EC=88=98=20=EC=A3=BC=EC=9E=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 스프린트 1 분석 파이프라인 e2e 를 위해 stackup-ai 컨테이너에 다음을 주입. - CORE_INTERNAL_BASE_URL: 호스트에서 동작하는 Core(38010) 도달용 (extra_hosts: host.docker.internal → host-gateway 매핑 추가) - CORE_INTERNAL_API_KEY: X-Internal-API-Key 헤더 값 - EMBEDDING_PROVIDER (default mock): LLM 키 없이도 청킹+임베딩 흐름 검증 가능 - EMBEDDING_MODEL / EMBEDDING_DIM / GEMINI_API_KEY --- docker-compose.yml | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/docker-compose.yml b/docker-compose.yml index aa52d5ca..a0c82547 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -87,6 +87,16 @@ services: LLM_API_KEY: ${LLM_API_KEY:-} LLM_BASE_URL: ${LLM_BASE_URL:-https://factchat-cloud.mindlogic.ai/v1/gateway} LLM_PRO_MODEL: ${LLM_PRO_MODEL:-gemini-3.1-pro-preview} + # AI → Core 내부 API + CORE_INTERNAL_BASE_URL: ${CORE_INTERNAL_BASE_URL:-http://host.docker.internal:38010} + CORE_INTERNAL_API_KEY: ${CORE_INTERNAL_API_KEY:-local-development-internal-api-key} + # 임베딩 + EMBEDDING_PROVIDER: ${EMBEDDING_PROVIDER:-mock} + EMBEDDING_MODEL: ${EMBEDDING_MODEL:-gemini-embedding-001} + EMBEDDING_DIM: ${EMBEDDING_DIM:-1536} + GEMINI_API_KEY: ${GEMINI_API_KEY:-} + extra_hosts: + - "host.docker.internal:host-gateway" depends_on: rabbitmq: condition: service_healthy From f7df2666461b8e1a68ee0c7e6603e14920b54683 Mon Sep 17 00:00:00 2001 From: jmj Date: Fri, 22 May 2026 20:57:36 +0900 Subject: [PATCH 06/12] =?UTF-8?q?docs(messaging):=20=EB=A9=94=EC=8B=9C?= =?UTF-8?q?=EC=A7=95=20=EA=B4=80=EB=A0=A8=20=EC=A3=BC=EC=84=9D=20=EC=88=98?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../common/messaging/RealtimeNotifyPublisher.java | 9 ++------- .../security/InternalApiKeyAuthenticationFilter.java | 7 ++----- .../document/application/AnalysisRequestService.java | 11 ++--------- .../application/dto/AnalysisCallbackEnvelope.java | 6 ------ .../infrastructure/AnalysisCallbackHandler.java | 5 +---- .../JdbcDocumentEmbeddingRepository.java | 6 ++---- .../InternalAnalysisTriggerController.java | 5 +---- .../presentation/InternalGithubTokenController.java | 5 +---- 8 files changed, 11 insertions(+), 43 deletions(-) diff --git a/backend/src/main/java/com/stackup/stackup/common/messaging/RealtimeNotifyPublisher.java b/backend/src/main/java/com/stackup/stackup/common/messaging/RealtimeNotifyPublisher.java index a434594f..51558f19 100644 --- a/backend/src/main/java/com/stackup/stackup/common/messaging/RealtimeNotifyPublisher.java +++ b/backend/src/main/java/com/stackup/stackup/common/messaging/RealtimeNotifyPublisher.java @@ -8,13 +8,8 @@ import org.springframework.amqp.rabbit.core.RabbitTemplate; import org.springframework.stereotype.Component; -/** - * Core → RealTime 알림 발행자. - * stackup.realtime exchange / routing key realtime.session.notify 로 publish. - * - * RealTime 서버는 q.realtime.session.notify 큐 consumer 로 envelope.context.sessionId 가 - * 가리키는 SSE 구독자에게 fan-out 한다 (docs/messaging.md §5.12). - */ +// Core -> 실시간 서버 알림 +// 실시간 서버는 SSE로 구독한 컴포넌트에게 알려줌 @Component public class RealtimeNotifyPublisher { diff --git a/backend/src/main/java/com/stackup/stackup/common/security/InternalApiKeyAuthenticationFilter.java b/backend/src/main/java/com/stackup/stackup/common/security/InternalApiKeyAuthenticationFilter.java index dfa9cea9..badcb5ba 100644 --- a/backend/src/main/java/com/stackup/stackup/common/security/InternalApiKeyAuthenticationFilter.java +++ b/backend/src/main/java/com/stackup/stackup/common/security/InternalApiKeyAuthenticationFilter.java @@ -14,11 +14,8 @@ import org.springframework.stereotype.Component; import org.springframework.web.filter.OncePerRequestFilter; -/** - * /api/internal/* 경로는 X-Internal-API-Key 헤더로 인증한다. - * 유효 시 INTERNAL 권한을 가진 Authentication 을 SecurityContext 에 주입. - * 그 외 경로에서는 아무것도 하지 않고 통과 (JWT 필터가 이어서 처리). - */ +// 유효한 경우 INTERNAL 권한을 가진 인증객체를 SecurityContext에 주입함 +// 그거 아니면 패스 - jwt가 알아서한다 @Component public class InternalApiKeyAuthenticationFilter extends OncePerRequestFilter { diff --git a/backend/src/main/java/com/stackup/stackup/document/application/AnalysisRequestService.java b/backend/src/main/java/com/stackup/stackup/document/application/AnalysisRequestService.java index 77d26b35..00f9af44 100644 --- a/backend/src/main/java/com/stackup/stackup/document/application/AnalysisRequestService.java +++ b/backend/src/main/java/com/stackup/stackup/document/application/AnalysisRequestService.java @@ -18,15 +18,8 @@ import org.springframework.transaction.event.TransactionPhase; import org.springframework.transaction.event.TransactionalEventListener; -/** - * Resume / GithubRepository 분석을 트리거한다. - * - AnalyzedDocument 를 PROCESSING 상태로 미리 생성 - * - 소스 도메인 status 를 ANALYZING 으로 전환 - * - DB commit 이후 core-to-ai exchange 로 analyze.{resume|repository} 발행 - * - * commit 이후 발행을 위해 도메인 이벤트 + {@code @TransactionalEventListener(AFTER_COMMIT)} 패턴. - * (메시지는 발행됐는데 DB 가 롤백되는 케이스를 방지) - */ +// 이력서 리포 분석 트리거 +// CRUD 및 상태관리 수행 @Service @RequiredArgsConstructor @Transactional(readOnly = true) diff --git a/backend/src/main/java/com/stackup/stackup/document/application/dto/AnalysisCallbackEnvelope.java b/backend/src/main/java/com/stackup/stackup/document/application/dto/AnalysisCallbackEnvelope.java index 04c0bf17..ef8687f6 100644 --- a/backend/src/main/java/com/stackup/stackup/document/application/dto/AnalysisCallbackEnvelope.java +++ b/backend/src/main/java/com/stackup/stackup/document/application/dto/AnalysisCallbackEnvelope.java @@ -3,12 +3,6 @@ import com.stackup.stackup.common.messaging.MessageContext; import java.time.Instant; -/** - * {@link com.stackup.stackup.common.messaging.MessageEnvelope} 의 callback.analysis 전용 구체 타입. - * - * generic 타입은 Spring AMQP {@code JacksonJsonMessageConverter} 가 method 선언으로 - * payload 타입을 추론하지 못할 수 있어, listener 시그니처에서는 본 구체 타입을 사용한다. - */ public record AnalysisCallbackEnvelope( String messageId, String messageType, diff --git a/backend/src/main/java/com/stackup/stackup/document/infrastructure/AnalysisCallbackHandler.java b/backend/src/main/java/com/stackup/stackup/document/infrastructure/AnalysisCallbackHandler.java index 48ffeea9..d6c97cf9 100644 --- a/backend/src/main/java/com/stackup/stackup/document/infrastructure/AnalysisCallbackHandler.java +++ b/backend/src/main/java/com/stackup/stackup/document/infrastructure/AnalysisCallbackHandler.java @@ -8,10 +8,7 @@ import org.springframework.amqp.rabbit.annotation.RabbitListener; import org.springframework.stereotype.Component; -/** - * stackup.ai-to-core / callback.analysis 큐 consumer. - * 메시지 라우팅·역직렬화만 담당하고 실제 도메인 갱신은 application 서비스에 위임. - */ +// 도메인 갱신은 application 서비스가 처리 @Component @RequiredArgsConstructor public class AnalysisCallbackHandler { diff --git a/backend/src/main/java/com/stackup/stackup/document/infrastructure/JdbcDocumentEmbeddingRepository.java b/backend/src/main/java/com/stackup/stackup/document/infrastructure/JdbcDocumentEmbeddingRepository.java index b3553bf2..e299ab06 100644 --- a/backend/src/main/java/com/stackup/stackup/document/infrastructure/JdbcDocumentEmbeddingRepository.java +++ b/backend/src/main/java/com/stackup/stackup/document/infrastructure/JdbcDocumentEmbeddingRepository.java @@ -7,10 +7,8 @@ import org.springframework.jdbc.core.JdbcTemplate; import org.springframework.stereotype.Repository; -/** - * pgvector 의존이 strong-typed JPA 매핑보다 native query 가 단순해서 JdbcTemplate 사용. - * AI 서버가 같은 documentId·chunkIndex 로 재호출 시 idempotent upsert. - */ + +// 네이티브 쿼리가 더 쉬워서 JPA 대신 씀 @Repository public class JdbcDocumentEmbeddingRepository implements DocumentEmbeddingRepository { diff --git a/backend/src/main/java/com/stackup/stackup/document/presentation/InternalAnalysisTriggerController.java b/backend/src/main/java/com/stackup/stackup/document/presentation/InternalAnalysisTriggerController.java index a8ad13ae..d13efedc 100644 --- a/backend/src/main/java/com/stackup/stackup/document/presentation/InternalAnalysisTriggerController.java +++ b/backend/src/main/java/com/stackup/stackup/document/presentation/InternalAnalysisTriggerController.java @@ -10,10 +10,7 @@ import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; -/** - * 내부 트리거 endpoint. 운영용 외부 API 가 들어오기 전 e2e 검증에 사용. - * 실제 사용자 흐름(이력서 업로드 등)에서는 AnalysisRequestService 를 직접 호출. - */ +// 운영환경에서는 AnalysisRequestService 직접 호출 @RestController @RequestMapping("/api/internal/analyses") @RequiredArgsConstructor diff --git a/backend/src/main/java/com/stackup/stackup/github/presentation/InternalGithubTokenController.java b/backend/src/main/java/com/stackup/stackup/github/presentation/InternalGithubTokenController.java index 34361c87..a82450d3 100644 --- a/backend/src/main/java/com/stackup/stackup/github/presentation/InternalGithubTokenController.java +++ b/backend/src/main/java/com/stackup/stackup/github/presentation/InternalGithubTokenController.java @@ -7,10 +7,7 @@ import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; -/** - * AI 서버 전용. /api/internal/users/{userId}/github-token. - * URL prefix 는 users 도메인이지만 토큰 처리는 github 도메인 책임이라 본 패키지에 둔다. - */ +// AI 서버 전용 @RestController @RequestMapping("/api/internal/users") @RequiredArgsConstructor From c25e01ccad5a9dda1d7d2c7451561d562a45c378 Mon Sep 17 00:00:00 2001 From: jmj Date: Fri, 22 May 2026 21:15:16 +0900 Subject: [PATCH 07/12] =?UTF-8?q?fix(auth):=20GithubUserService=20record?= =?UTF-8?q?=20=E2=86=92=20class=20(Spring=20AOP=20=ED=98=B8=ED=99=98)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit record 는 implicit final 이라 @Transactional 메서드에 대한 Spring AOP CGLIB 프록시 생성이 실패하고, 운영 컨테이너에서 부팅 중 BeanCreationException 으로 죽음 (Caused by: Cannot subclass final class GithubUserService). 테스트 프로파일에서는 DataSource/Hibernate autoconfig 제외로 TransactionManager 가 없어 AOP advisor 가 활성화되지 않아 contextLoads 가 통과한 탓에 그동안 감지 못함. 일반 class + 생성자 주입으로 변환. 동작 변경 없음. --- .../stackup/auth/application/GithubUserService.java | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/backend/src/main/java/com/stackup/stackup/auth/application/GithubUserService.java b/backend/src/main/java/com/stackup/stackup/auth/application/GithubUserService.java index cae6a99c..b8bfa9e4 100644 --- a/backend/src/main/java/com/stackup/stackup/auth/application/GithubUserService.java +++ b/backend/src/main/java/com/stackup/stackup/auth/application/GithubUserService.java @@ -8,8 +8,16 @@ import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +// record 는 implicit final 이라 Spring AOP(@Transactional) CGLIB 프록시 생성 실패. +// 일반 class + 생성자 주입으로 전환. @Service -public record GithubUserService(UserRepository userRepository) { +public class GithubUserService { + + private final UserRepository userRepository; + + public GithubUserService(UserRepository userRepository) { + this.userRepository = userRepository; + } @Transactional public GithubUserUpsertResult upsertGithubUser( From f5f17ced902f636bec9a83530f6b10008cca4e70 Mon Sep 17 00:00:00 2001 From: jmj Date: Fri, 22 May 2026 21:18:15 +0900 Subject: [PATCH 08/12] =?UTF-8?q?fix(user):=20UserService=20record=20?= =?UTF-8?q?=E2=86=92=20class=20(Spring=20AOP=20=ED=98=B8=ED=99=98)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../stackup/stackup/user/application/UserService.java | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/backend/src/main/java/com/stackup/stackup/user/application/UserService.java b/backend/src/main/java/com/stackup/stackup/user/application/UserService.java index d42925d3..78d80ba5 100644 --- a/backend/src/main/java/com/stackup/stackup/user/application/UserService.java +++ b/backend/src/main/java/com/stackup/stackup/user/application/UserService.java @@ -8,8 +8,15 @@ import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +// record 는 final → Spring AOP CGLIB 프록시 실패. class 로 전환. @Service -public record UserService(UserRepository userRepository) { +public class UserService { + + private final UserRepository userRepository; + + public UserService(UserRepository userRepository) { + this.userRepository = userRepository; + } @Transactional(readOnly = true) public UserProfileResult getCurrentUser(Long userId) { From 44c27dc74763a8e88223b163ee93bf3359260cea Mon Sep 17 00:00:00 2001 From: jmj Date: Fri, 22 May 2026 21:21:42 +0900 Subject: [PATCH 09/12] =?UTF-8?q?fix(analysis):=20@Transactional=20?= =?UTF-8?q?=ED=81=B4=EB=9E=98=EC=8A=A4=20=EB=A0=88=EB=B2=A8=20=EC=A0=9C?= =?UTF-8?q?=EA=B1=B0=20=E2=80=94=20Spring=207=20TransactionalEventListener?= =?UTF-8?q?=20=EC=A0=9C=EC=95=BD=20=ED=98=B8=ED=99=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../document/application/AnalysisRequestService.java | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/backend/src/main/java/com/stackup/stackup/document/application/AnalysisRequestService.java b/backend/src/main/java/com/stackup/stackup/document/application/AnalysisRequestService.java index 00f9af44..60a7c438 100644 --- a/backend/src/main/java/com/stackup/stackup/document/application/AnalysisRequestService.java +++ b/backend/src/main/java/com/stackup/stackup/document/application/AnalysisRequestService.java @@ -18,11 +18,11 @@ import org.springframework.transaction.event.TransactionPhase; import org.springframework.transaction.event.TransactionalEventListener; -// 이력서 리포 분석 트리거 -// CRUD 및 상태관리 수행 +// 이력서 리포 분석 트리거 +// CRUD 및 상태관리 수행 +// 클래스 레벨 @Transactional 은 두지 않음 — @TransactionalEventListener 메서드와 충돌 (Spring 7 제약). @Service @RequiredArgsConstructor -@Transactional(readOnly = true) public class AnalysisRequestService { private final ResumeRepository resumeRepository; From 7fb4cf5f220337c42a90e39a97f1088ca09784c4 Mon Sep 17 00:00:00 2001 From: jmj Date: Fri, 22 May 2026 21:23:23 +0900 Subject: [PATCH 10/12] =?UTF-8?q?fix(analysis):=20@TransactionalEventListe?= =?UTF-8?q?ner=20=EB=A9=94=EC=84=9C=EB=93=9C=EC=97=90=20NOT=5FSUPPORTED=20?= =?UTF-8?q?=EB=AA=85=EC=8B=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../stackup/document/application/AnalysisRequestService.java | 3 +++ 1 file changed, 3 insertions(+) diff --git a/backend/src/main/java/com/stackup/stackup/document/application/AnalysisRequestService.java b/backend/src/main/java/com/stackup/stackup/document/application/AnalysisRequestService.java index 60a7c438..b7043bcc 100644 --- a/backend/src/main/java/com/stackup/stackup/document/application/AnalysisRequestService.java +++ b/backend/src/main/java/com/stackup/stackup/document/application/AnalysisRequestService.java @@ -14,6 +14,7 @@ import lombok.RequiredArgsConstructor; import org.springframework.context.ApplicationEventPublisher; import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Propagation; import org.springframework.transaction.annotation.Transactional; import org.springframework.transaction.event.TransactionPhase; import org.springframework.transaction.event.TransactionalEventListener; @@ -73,6 +74,7 @@ public AnalysisHandle requestRepositoryAnalysis(Long userId, Long repositoryId) return new AnalysisHandle(doc.getId(), null, repo.getId()); } + @Transactional(propagation = Propagation.NOT_SUPPORTED) @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) public void onResumeAnalysisRequested(ResumeAnalysisRequestedEvent event) { publisher.publishToAi( @@ -82,6 +84,7 @@ public void onResumeAnalysisRequested(ResumeAnalysisRequestedEvent event) { ); } + @Transactional(propagation = Propagation.NOT_SUPPORTED) @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) public void onRepositoryAnalysisRequested(RepositoryAnalysisRequestedEvent event) { publisher.publishToAi( From 8c5515b87b3b79f54f1549356646aab4b548ca6b Mon Sep 17 00:00:00 2001 From: jmj Date: Fri, 22 May 2026 21:29:02 +0900 Subject: [PATCH 11/12] =?UTF-8?q?fix(document):=20tech=5Fstack=20JSONB=20?= =?UTF-8?q?=EB=A7=A4=ED=95=91=20@JdbcTypeCode(SqlTypes.JSON)=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../com/stackup/stackup/document/domain/AnalyzedDocument.java | 3 +++ 1 file changed, 3 insertions(+) diff --git a/backend/src/main/java/com/stackup/stackup/document/domain/AnalyzedDocument.java b/backend/src/main/java/com/stackup/stackup/document/domain/AnalyzedDocument.java index 7a372c17..6fefdd59 100644 --- a/backend/src/main/java/com/stackup/stackup/document/domain/AnalyzedDocument.java +++ b/backend/src/main/java/com/stackup/stackup/document/domain/AnalyzedDocument.java @@ -18,6 +18,8 @@ import lombok.AccessLevel; import lombok.Getter; import lombok.NoArgsConstructor; +import org.hibernate.annotations.JdbcTypeCode; +import org.hibernate.type.SqlTypes; @Getter @Entity @@ -50,6 +52,7 @@ public class AnalyzedDocument extends BaseSoftDeleteEntity { private String summary; @Column(name = "tech_stack", columnDefinition = "jsonb") + @JdbcTypeCode(SqlTypes.JSON) private String techStack; @Column(nullable = false, length = 20) From 8112bfe99aa0cbef600dc5da17690c4b0c4b6b25 Mon Sep 17 00:00:00 2001 From: jmj Date: Fri, 22 May 2026 21:34:59 +0900 Subject: [PATCH 12/12] =?UTF-8?q?fix(ai):=20MessageContext=EC=97=90=20docu?= =?UTF-8?q?ment=5Fid/repository=5Fid=20=EC=B6=94=EA=B0=80=20=E2=80=94=20Co?= =?UTF-8?q?re=EA=B0=80=20=EB=B3=B4=EB=82=B8=20context=20=ED=95=84=EB=93=9C?= =?UTF-8?q?=20forward=20=EB=B3=B4=EC=9E=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ai/src/ai_server/model/envelope.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/ai/src/ai_server/model/envelope.py b/ai/src/ai_server/model/envelope.py index 7a5af61c..8d28f112 100644 --- a/ai/src/ai_server/model/envelope.py +++ b/ai/src/ai_server/model/envelope.py @@ -13,6 +13,8 @@ class MessageContext(BaseModel): user_id: int | None = None session_id: int | None = None + document_id: int | None = None + repository_id: int | None = None class Envelope(BaseModel, Generic[PayloadT]):