diff --git a/.github/workflows/deploy-app.yml b/.github/workflows/deploy-app.yml index 3420e543..9ef8c7be 100644 --- a/.github/workflows/deploy-app.yml +++ b/.github/workflows/deploy-app.yml @@ -43,25 +43,32 @@ jobs: --exclude='**/node_modules' \ "$GITHUB_WORKSPACE/" "$DEPLOY_DIR/" - - name: Ensure .env exists and contains current LLM_API_KEY + - name: Ensure .env exists and contains current secrets env: LLM_API_KEY: ${{ secrets.LLM_API_KEY }} + GEMINI_API_KEY: ${{ secrets.GEMINI_API_KEY }} run: | cd "$DEPLOY_DIR" if [ ! -f .env ]; then cp .env.example .env fi - # Write LLM_API_KEY via a temp file to keep the secret off the - # process command line. - tmp=$(mktemp) - trap 'rm -f "$tmp"' EXIT - awk -v key="$LLM_API_KEY" ' - BEGIN { written = 0 } - /^LLM_API_KEY=/ { print "LLM_API_KEY=" key; written = 1; next } - { print } - END { if (!written) print "LLM_API_KEY=" key } - ' .env > "$tmp" - mv "$tmp" .env + # 비밀 값은 임시 파일을 거쳐서 .env에 주입 — 명령줄 노출 회피. + # 키마다 같은 awk 블록을 한 번씩 적용. + inject_secret() { + local name="$1" + local value="$2" + local tmp + tmp=$(mktemp) + awk -v k="$name" -v v="$value" ' + BEGIN { written = 0 } + $0 ~ "^" k "=" { print k "=" v; written = 1; next } + { print } + END { if (!written) print k "=" v } + ' .env > "$tmp" + mv "$tmp" .env + } + inject_secret LLM_API_KEY "$LLM_API_KEY" + inject_secret GEMINI_API_KEY "$GEMINI_API_KEY" chmod 600 .env - name: Build and restart app services diff --git a/ai/.env.example b/ai/.env.example index 043be9a1..708db850 100644 --- a/ai/.env.example +++ b/ai/.env.example @@ -33,3 +33,14 @@ 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 ac7ea660..68c09f19 100644 --- a/ai/CLAUDE.md +++ b/ai/CLAUDE.md @@ -164,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 eea3e136..543cfeba 100644 --- a/ai/pyproject.toml +++ b/ai/pyproject.toml @@ -17,6 +17,8 @@ dependencies = [ "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/_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 index d08ca797..f02b9c27 100644 --- a/ai/src/ai_server/analyzer/repository_analyzer.py +++ b/ai/src/ai_server/analyzer/repository_analyzer.py @@ -4,12 +4,18 @@ 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__) @@ -28,10 +34,10 @@ class RepositoryAnalysisResult: summary: str tech_stack: list[str] document_path: str + embedding_chunk_count: int -# 코어서버에서 토큰 받아오고 레포 가져온다 -# 이후 LLM 분석하고 마크다운 저장 +# Core에서 사용자별 GitHub token 수령 → 레포 fetch → LLM 분석 → 마크다운 저장 → 청킹·임베딩 class RepositoryAnalyzer: def __init__( self, @@ -40,12 +46,16 @@ def __init__( 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( @@ -55,6 +65,7 @@ async def analyze( repo_full_name: str, default_branch: str = "main", user_id: int | None, + analyzed_document_id: int, ) -> RepositoryAnalysisResult: if user_id is None: raise RepositoryAnalyzeError( @@ -72,9 +83,7 @@ async def analyze( 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, + code=err.code, message=err.message, retriable=err.retriable ) from err log.info( @@ -90,9 +99,7 @@ async def analyze( ) except RepositoryFetchError as err: raise RepositoryAnalyzeError( - code=err.code, - message=err.message, - retriable=err.retriable, + code=err.code, message=err.message, retriable=err.retriable ) from err if not source.text.strip(): @@ -121,8 +128,23 @@ async def analyze( 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 index 658b055b..c82f0309 100644 --- a/ai/src/ai_server/analyzer/resume_analyzer.py +++ b/ai/src/ai_server/analyzer/resume_analyzer.py @@ -4,8 +4,15 @@ 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__) @@ -24,9 +31,10 @@ class ResumeAnalysisResult: summary: str tech_stack: list[str] document_path: str + embedding_chunk_count: int -# 스토리지에서 가져오고 LLM을 통해 분석함 +# 스토리지에서 가져오고 LLM을 통해 분석 후 청킹·임베딩까지 처리 class ResumeAnalyzer: def __init__( self, @@ -34,11 +42,17 @@ def __init__( 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( @@ -46,6 +60,7 @@ async def analyze( *, resume_id: int, file_path: str, + analyzed_document_id: int, ) -> ResumeAnalysisResult: log.info( "resume.extract.start", @@ -81,8 +96,23 @@ async def analyze( 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/github_repo.py b/ai/src/ai_server/analyzer/sources/github_repo.py index 2692b895..70ea5e47 100644 --- a/ai/src/ai_server/analyzer/sources/github_repo.py +++ b/ai/src/ai_server/analyzer/sources/github_repo.py @@ -11,7 +11,7 @@ log = structlog.get_logger(__name__) -# 프로젝트 설정 파일을 먼저 읽는다 +# 프로젝트 설정 파일을 먼저 읽는다 _PRIORITY_FILES: tuple[str, ...] = ( "package.json", "pyproject.toml", @@ -68,7 +68,7 @@ class _RepoConfig: timeout_sec: float -# 리드미, 주요 소스를 읽는다 +# 리드미, 주요 소스를 읽는다 class GitHubRepoSourceExtractor(SourceExtractor): def __init__( self, @@ -87,7 +87,7 @@ def __init__( max_file_bytes=max_file_bytes, timeout_sec=timeout_sec, ) - self._client = client + self._client = client async def extract( self, diff --git a/ai/src/ai_server/analyzer/sources/web.py b/ai/src/ai_server/analyzer/sources/web.py index 8ed4c72f..ca44f9c5 100644 --- a/ai/src/ai_server/analyzer/sources/web.py +++ b/ai/src/ai_server/analyzer/sources/web.py @@ -19,7 +19,7 @@ def __init__(self, *, code: str, message: str, retriable: bool) -> None: self.retriable = retriable -# 라이브러리로 본문 추출 +# 라이브러리로 본문 추출 class WebSourceExtractor(SourceExtractor): def __init__( self, diff --git a/ai/src/ai_server/analyzer/web_resume_analyzer.py b/ai/src/ai_server/analyzer/web_resume_analyzer.py index d8735b77..b5da258d 100644 --- a/ai/src/ai_server/analyzer/web_resume_analyzer.py +++ b/ai/src/ai_server/analyzer/web_resume_analyzer.py @@ -4,9 +4,16 @@ 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__) @@ -25,9 +32,10 @@ class WebResumeAnalysisResult: summary: str tech_stack: list[str] document_path: str + embedding_chunk_count: int -# 웹포폴 -> 라이브러리로 텍스트만 - > LLM -> 마크다운 +# 웹포폴 -> 라이브러리로 텍스트만 -> LLM -> 마크다운 -> 청킹·임베딩·pgvector upsert class WebResumeAnalyzer: def __init__( self, @@ -35,11 +43,17 @@ def __init__( 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( @@ -47,15 +61,14 @@ async def analyze( *, 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, + code=err.code, message=err.message, retriable=err.retriable ) from err log.info( @@ -64,8 +77,7 @@ async def analyze( text_chars=len(source.text), ) analysis = await self._chain.analyze( - text=source.text, - source_type=source.source_type, + text=source.text, source_type=source.source_type ) out_key = self._analyzed_key_template.format(resume_id=resume_id) @@ -77,8 +89,23 @@ async def analyze( 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/config/settings.py b/ai/src/ai_server/config/settings.py index 724a0f6a..3d1a90f0 100644 --- a/ai/src/ai_server/config/settings.py +++ b/ai/src/ai_server/config/settings.py @@ -68,6 +68,16 @@ class Settings(BaseSettings): 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 + + gemini_api_key: str = "" + def get_settings() -> Settings: return Settings() diff --git a/ai/src/ai_server/core/__init__.py b/ai/src/ai_server/core/__init__.py index 02053ab9..7dbd25bb 100644 --- a/ai/src/ai_server/core/__init__.py +++ b/ai/src/ai_server/core/__init__.py @@ -1,11 +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 index 73cefb6f..bfc3028e 100644 --- a/ai/src/ai_server/core/client.py +++ b/ai/src/ai_server/core/client.py @@ -1,5 +1,6 @@ from __future__ import annotations +from dataclasses import dataclass from typing import Protocol import httpx @@ -16,10 +17,34 @@ def __init__(self, *, code: str, message: str, retriable: bool) -> None: self.retriable = retriable -# 코어 서버 API 호출용 +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__( @@ -35,7 +60,7 @@ def __init__( self._base_url = base_url.rstrip("/") self._api_key = api_key self._timeout_sec = timeout_sec - self._client = client + self._client = client async def fetch_github_token(self, user_id: int) -> str: path = f"/api/internal/users/{user_id}/github-token" @@ -104,3 +129,93 @@ async def _do_fetch(self, client: httpx.AsyncClient, path: str) -> str: 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 index 553ddbb4..97f50ce2 100644 --- a/ai/src/ai_server/messaging/consumers/repository_consumer.py +++ b/ai/src/ai_server/messaging/consumers/repository_consumer.py @@ -98,6 +98,7 @@ async def _run_and_build_payload( 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( @@ -137,5 +138,5 @@ async def _run_and_build_payload( summary=result.summary, tech_stack=result.tech_stack, document_path=result.document_path, - embedding_chunk_count=0, + 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 c9ae62ff..8aa90944 100644 --- a/ai/src/ai_server/messaging/consumers/resume_consumer.py +++ b/ai/src/ai_server/messaging/consumers/resume_consumer.py @@ -86,6 +86,7 @@ async def _run_and_build_payload( 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( @@ -125,5 +126,5 @@ async def _run_and_build_payload( summary=result.summary, tech_stack=result.tech_stack, document_path=result.document_path, - embedding_chunk_count=0, + 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 index 51a6f74f..830b4a89 100644 --- a/ai/src/ai_server/messaging/consumers/web_consumer.py +++ b/ai/src/ai_server/messaging/consumers/web_consumer.py @@ -90,6 +90,7 @@ async def _run_and_build_payload( 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( @@ -129,5 +130,5 @@ async def _run_and_build_payload( summary=result.summary, tech_stack=result.tech_stack, document_path=result.document_path, - embedding_chunk_count=0, + 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 98710c9b..3b437b94 100644 --- a/ai/src/ai_server/messaging/runner.py +++ b/ai/src/ai_server/messaging/runner.py @@ -16,6 +16,8 @@ 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 @@ -48,20 +50,35 @@ def __init__(self, settings: Settings) -> None: 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, ) # 리포지토리 - core_client = HttpCoreClient( - base_url=settings.core_internal_base_url, - api_key=settings.core_internal_api_key, - timeout_sec=settings.core_internal_timeout_sec, - ) repo_analyzer = RepositoryAnalyzer( extractor=GitHubRepoSourceExtractor( api_base_url=settings.github_api_base_url, @@ -73,6 +90,8 @@ def __init__(self, settings: Settings) -> None: core_client=core_client, chain=chain_analyzer, storage=storage, + chunker=chunker, + embedder=embedder, analyzed_key_template=settings.analyzed_repository_md_key_template, ) @@ -84,6 +103,9 @@ def __init__(self, settings: Settings) -> None: ), chain=chain_analyzer, storage=storage, + chunker=chunker, + embedder=embedder, + core_client=core_client, analyzed_key_template=settings.analyzed_web_resume_md_key_template, ) @@ -145,6 +167,6 @@ async def stop(self) -> None: try: await queue.cancel(tag) log.info("ai.consumer.stopped", consumer_tag=tag) - except Exception: + 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 299e976a..aff39b25 100644 --- a/ai/src/ai_server/model/messages/analyze.py +++ b/ai/src/ai_server/model/messages/analyze.py @@ -13,6 +13,7 @@ class ResumeAnalyzeRequest(BaseModel): resume_id: int file_path: str + analyzed_document_id: int class RepositoryAnalyzeRequest(BaseModel): @@ -21,6 +22,7 @@ class RepositoryAnalyzeRequest(BaseModel): repository_id: int repo_full_name: str default_branch: str = "main" + analyzed_document_id: int class WebResumeAnalyzeRequest(BaseModel): @@ -28,6 +30,7 @@ class WebResumeAnalyzeRequest(BaseModel): resume_id: int url: str + analyzed_document_id: int class AnalysisCallbackPayload(BaseModel): 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/s3.py b/ai/src/ai_server/storage/s3.py index f1235a80..24ebe37b 100644 --- a/ai/src/ai_server/storage/s3.py +++ b/ai/src/ai_server/storage/s3.py @@ -96,6 +96,7 @@ async def exists(self, key: str) -> bool: 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 index 7b7e492e..9f71227e 100644 --- a/ai/tests/test_core_client.py +++ b/ai/tests/test_core_client.py @@ -3,7 +3,12 @@ import httpx import pytest -from ai_server.core.client import CoreTokenError, HttpCoreClient +from ai_server.core.client import ( + CoreEmbeddingUpsertError, + CoreTokenError, + EmbeddingChunkPayload, + HttpCoreClient, +) def _make_client( @@ -120,3 +125,119 @@ async def test_invalid_json_translates_to_bad_response() -> None: 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_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_messages_analyze.py b/ai/tests/test_messages_analyze.py index c8e211a2..1f200fbe 100644 --- a/ai/tests/test_messages_analyze.py +++ b/ai/tests/test_messages_analyze.py @@ -11,10 +11,15 @@ def test_resume_request_parses() -> None: req = ResumeAnalyzeRequest.model_validate( - {"resumeId": 42, "filePath": "resumes/raw/123/abc.pdf"} + { + "resumeId": 42, + "filePath": "resumes/raw/123/abc.pdf", + "analyzedDocumentId": 77, + } ) assert req.resume_id == 42 assert req.file_path == "resumes/raw/123/abc.pdf" + assert req.analyzed_document_id == 77 def test_resume_request_requires_fields() -> None: @@ -23,9 +28,13 @@ def test_resume_request_requires_fields() -> None: def test_resume_request_serializes_camel_case() -> None: - req = ResumeAnalyzeRequest(resume_id=7, file_path="r/7.pdf") + 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"} + assert dumped == { + "resumeId": 7, + "filePath": "r/7.pdf", + "analyzedDocumentId": 1, + } def test_callback_success_serializes_camel_case() -> None: @@ -74,31 +83,45 @@ def test_callback_target_type_must_be_known() -> None: def test_repository_request_parses_with_default_branch() -> None: req = RepositoryAnalyzeRequest.model_validate( - {"repositoryId": 5, "repoFullName": "user/repo"} + { + "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" + 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"} + { + "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: diff --git a/ai/tests/test_repository_analyzer.py b/ai/tests/test_repository_analyzer.py index d0a017ab..d1a7633e 100644 --- a/ai/tests/test_repository_analyzer.py +++ b/ai/tests/test_repository_analyzer.py @@ -10,6 +10,8 @@ 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( @@ -17,6 +19,7 @@ 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): @@ -35,110 +38,114 @@ def _make_analyzer( 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_then_extracts_with_it() -> None: +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", source_type="REPOSITORY"), + extract_result=ExtractedSource( + text="README + tree" * 30, source_type="REPOSITORY" + ), analysis=DocumentAnalysisResult( - summary="요약", tech_stack=["Go"], markdown="## 개요\nx" + 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_with( - text="README + tree", source_type="REPOSITORY" - ) - storage.put_text.assert_awaited_once_with( - "analyzed/repository/7/summary.md", "## 개요\nx" - ) - assert result.summary == "요약" + 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, extractor, _, storage, core_client = _make_analyzer() + 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" - assert exc_info.value.retriable is False core_client.fetch_github_token.assert_not_called() - extractor.extract.assert_not_called() - storage.put_text.assert_not_called() @pytest.mark.asyncio -async def test_token_fetch_error_translates_to_domain_error() -> None: - analyzer, extractor, _, storage, _ = _make_analyzer( +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) + 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" - assert exc_info.value.retriable is False - extractor.extract.assert_not_called() - storage.put_text.assert_not_called() - - -@pytest.mark.asyncio -async def test_token_fetch_retriable_propagates_retriable() -> None: - analyzer, _, _, _, _ = _make_analyzer( - token_result=CoreTokenError( - code="CORE_UNAVAILABLE", message="5xx", retriable=True - ), - ) - with pytest.raises(RepositoryAnalyzeError) as exc_info: - await analyzer.analyze(repository_id=1, repo_full_name="user/repo", user_id=42) - assert exc_info.value.code == "CORE_UNAVAILABLE" - assert exc_info.value.retriable is True @pytest.mark.asyncio -async def test_fetch_error_translates_to_domain_error() -> None: - analyzer, _, chain, storage, _ = _make_analyzer( +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) + 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" - chain.analyze.assert_not_called() 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, _, chain, storage, _ = _make_analyzer( + 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) + 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" - chain.analyze.assert_not_called() 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 index 278ab284..909085b8 100644 --- a/ai/tests/test_repository_consumer.py +++ b/ai/tests/test_repository_consumer.py @@ -29,6 +29,7 @@ def _request_envelope(message_id: str = "req-1", repository_id: int = 7) -> byte "repositoryId": repository_id, "repoFullName": "user/repo", "defaultBranch": "main", + "analyzedDocumentId": 88, }, "context": {"userId": 1}, } @@ -77,6 +78,7 @@ async def test_happy_path_publishes_analyzed_callback() -> None: summary="요약", tech_stack=["Go"], document_path="analyzed/repository/7/summary.md", + embedding_chunk_count=3, ) ) consumer, publisher = _make_consumer(analyzer) @@ -87,6 +89,7 @@ async def test_happy_path_publishes_analyzed_callback() -> None: repo_full_name="user/repo", default_branch="main", user_id=1, + analyzed_document_id=88, ) payload = _captured_payload(publisher) assert payload.status == "ANALYZED" @@ -95,7 +98,7 @@ async def test_happy_path_publishes_analyzed_callback() -> None: assert payload.summary == "요약" assert payload.tech_stack == ["Go"] assert payload.document_path == "analyzed/repository/7/summary.md" - assert payload.embedding_chunk_count == 0 + assert payload.embedding_chunk_count == 3 @pytest.mark.asyncio @@ -137,6 +140,7 @@ async def test_idempotent_duplicate_skipped() -> None: summary="x", tech_stack=[], document_path="analyzed/repository/7/summary.md", + embedding_chunk_count=0, ) ) consumer, publisher = _make_consumer(analyzer) diff --git a/ai/tests/test_resume_analyzer.py b/ai/tests/test_resume_analyzer.py index b17c05bd..e4ad0e71 100644 --- a/ai/tests/test_resume_analyzer.py +++ b/ai/tests/test_resume_analyzer.py @@ -2,64 +2,107 @@ import pytest -from ai_server.analyzer.resume_analyzer import ( - ResumeAnalyzeError, - ResumeAnalyzer, -) +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, -) -> tuple[ResumeAnalyzer, AsyncMock, AsyncMock, AsyncMock]: + 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 + return analyzer, extractor, chain, storage, core_client @pytest.mark.asyncio -async def test_happy_path_extracts_analyzes_and_saves() -> None: - analyzer, extractor, chain, storage = _make_analyzer( +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="## 개요\nx" + 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 ) - result = await analyzer.analyze(resume_id=42, file_path="r/raw/1/a.pdf") extractor.extract.assert_awaited_once_with("r/raw/1/a.pdf") - chain.analyze.assert_awaited_once_with(text="hello", source_type="PDF") - storage.put_text.assert_awaited_once_with( - "analyzed/resume/42/summary.md", "## 개요\nx" - ) + 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_domain_error_and_skips_llm() -> None: - analyzer, _, chain, storage = _make_analyzer( +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") + 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 8aa73c10..1f00f471 100644 --- a/ai/tests/test_resume_consumer.py +++ b/ai/tests/test_resume_consumer.py @@ -28,6 +28,7 @@ def _request_envelope(message_id: str = "req-1", resume_id: int = 42) -> bytes: "payload": { "resumeId": resume_id, "filePath": "resumes/raw/123/abc.pdf", + "analyzedDocumentId": 77, }, "context": {"userId": 123}, } @@ -80,6 +81,7 @@ async def test_happy_path_publishes_analyzed_callback() -> None: summary="요약", tech_stack=["Python", "FastAPI"], document_path="analyzed/resume/42/summary.md", + embedding_chunk_count=4, ) ) consumer, publisher = _make_consumer(analyzer) @@ -87,7 +89,7 @@ async def test_happy_path_publishes_analyzed_callback() -> None: await consumer.handle(_incoming_message(_request_envelope())) analyzer.analyze.assert_awaited_once_with( - resume_id=42, file_path="resumes/raw/123/abc.pdf" + resume_id=42, file_path="resumes/raw/123/abc.pdf", analyzed_document_id=77 ) payload = _captured_payload(publisher) assert payload.status == "ANALYZED" @@ -96,7 +98,7 @@ async def test_happy_path_publishes_analyzed_callback() -> None: assert payload.summary == "요약" assert payload.tech_stack == ["Python", "FastAPI"] assert payload.document_path == "analyzed/resume/42/summary.md" - assert payload.embedding_chunk_count == 0 + assert payload.embedding_chunk_count == 4 @pytest.mark.asyncio @@ -141,7 +143,10 @@ 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" + summary="요약", + tech_stack=[], + document_path="analyzed/resume/42/summary.md", + embedding_chunk_count=0, ) ) idem = LruIdempotencyStore(max_size=16) diff --git a/ai/tests/test_web_consumer.py b/ai/tests/test_web_consumer.py index 9782f2ba..880f2f27 100644 --- a/ai/tests/test_web_consumer.py +++ b/ai/tests/test_web_consumer.py @@ -25,7 +25,11 @@ def _request_envelope(message_id: str = "req-1", resume_id: int = 11) -> bytes: "traceId": "trace-w", "publishedAt": datetime(2026, 5, 14, tzinfo=timezone.utc), "publisher": "core-server", - "payload": {"resumeId": resume_id, "url": "https://example.com/me"}, + "payload": { + "resumeId": resume_id, + "url": "https://example.com/me", + "analyzedDocumentId": 99, + }, "context": {"userId": 1}, } ) @@ -69,13 +73,14 @@ async def test_happy_path_publishes_analyzed_callback() -> None: 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" + resume_id=11, url="https://example.com/me", analyzed_document_id=99 ) payload = _captured_payload(publisher) assert payload.status == "ANALYZED" diff --git a/ai/tests/test_web_resume_analyzer.py b/ai/tests/test_web_resume_analyzer.py index 6ccaf4f9..57b13d0a 100644 --- a/ai/tests/test_web_resume_analyzer.py +++ b/ai/tests/test_web_resume_analyzer.py @@ -9,13 +9,16 @@ 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, -) -> tuple[WebResumeAnalyzer, AsyncMock, AsyncMock, AsyncMock]: + upsert_result: int = 2, +) -> tuple[WebResumeAnalyzer, AsyncMock, AsyncMock, AsyncMock, AsyncMock]: extractor = AsyncMock() if isinstance(extract_result, Exception): extractor.extract = AsyncMock(side_effect=extract_result) @@ -25,45 +28,57 @@ def _make_analyzer( 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 + return analyzer, extractor, chain, storage, core_client @pytest.mark.asyncio async def test_happy_path() -> None: - analyzer, extractor, chain, storage = _make_analyzer( - extract_result=ExtractedSource(text="본문 텍스트", source_type="WEB"), + 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 ) - result = await analyzer.analyze(resume_id=11, url="https://example.com/me") extractor.extract.assert_awaited_once_with("https://example.com/me") - chain.analyze.assert_awaited_once_with(text="본문 텍스트", source_type="WEB") - storage.put_text.assert_awaited_once_with( - "analyzed/web-resume/11/summary.md", "## 개요\nweb" - ) + 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.tech_stack == ["TypeScript"] + 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 = _make_analyzer( + 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") + await analyzer.analyze( + resume_id=1, url="https://example.com/x", analyzed_document_id=1 + ) assert exc_info.value.code == "WEB_HTTP_STATUS" - 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() diff --git a/ai/uv.lock b/ai/uv.lock index 78798338..bc87a385 100644 --- a/ai/uv.lock +++ b/ai/uv.lock @@ -247,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" @@ -339,6 +384,59 @@ 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" @@ -488,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" @@ -1356,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" @@ -1365,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" @@ -1787,11 +1954,13 @@ 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" }, @@ -1817,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" },