김OO
" + "백엔드 개발자. Spring Boot 3년차.
diff --git a/ai/.env.example b/ai/.env.example index 59d079e4..043be9a1 100644 --- a/ai/.env.example +++ b/ai/.env.example @@ -18,3 +18,18 @@ LLM_API_KEY= LLM_BASE_URL=https://factchat-cloud.mindlogic.ai/v1/gateway LLM_PRO_MODEL=gemini-3.1-pro-preview LLM_PRO_TEMPERATURE=0.2 + +# Github 관련 +CORE_INTERNAL_BASE_URL=http://localhost:38010 +CORE_INTERNAL_API_KEY= +CORE_INTERNAL_TIMEOUT_SEC=10 + +GITHUB_FALLBACK_TOKEN= +GITHUB_API_BASE_URL=https://api.github.com +REPO_MAX_SOURCE_FILES=8 +REPO_MAX_SOURCE_FILE_BYTES=50000 +REPO_FETCH_TIMEOUT_SEC=30 + +# 웹 이력서 관련 +WEB_FETCH_TIMEOUT_SEC=20 +WEB_MAX_HTML_BYTES=2000000 diff --git a/ai/CLAUDE.md b/ai/CLAUDE.md index fb5b3fbc..ac7ea660 100644 --- a/ai/CLAUDE.md +++ b/ai/CLAUDE.md @@ -88,11 +88,13 @@ ai/ 본 서버는 RabbitMQ **consumer**로 작동. -| Queue | Bind | -|-------|------| -| `q.ai.resume` | `ai.request.resume.*` | -| `q.ai.repo` | `ai.request.repo.*` | -| `q.ai.session` | `ai.request.session.*` | +| Queue | Routing key | 상태 | +|-------|-------------|------| +| `ai.analyze.resume` | `analyze.resume` | 본 구현 (PDF → MD) | +| `ai.analyze.repository` | `analyze.repository` | 본 구현 (GitHub README + tree + 소스 sampling) | +| `ai.analyze.web` | `analyze.web` | 본 구현 (URL → trafilatura) | +| `ai.generate.questions` | `generate.questions` | 큐만, 코드 미구현 | +| `ai.generate.followup` | `generate.followup` | 큐만, 코드 미구현 | 콜백 발행: `ai.callback.{type}` 익스체인지. 상세 envelope/스키마/재시도: [`/docs/messaging.md`](../docs/messaging.md). diff --git a/ai/pyproject.toml b/ai/pyproject.toml index 380d3396..eea3e136 100644 --- a/ai/pyproject.toml +++ b/ai/pyproject.toml @@ -16,6 +16,7 @@ dependencies = [ "boto3>=1.42.77", "aiofiles>=24.1.0", "pypdf>=5.1.0", + "trafilatura>=2.0.0", "langchain>=1.2.13", "langchain-core>=1.2.22", "langchain-community>=0.4.1", diff --git a/ai/src/ai_server/analyzer/repository_analyzer.py b/ai/src/ai_server/analyzer/repository_analyzer.py new file mode 100644 index 00000000..d08ca797 --- /dev/null +++ b/ai/src/ai_server/analyzer/repository_analyzer.py @@ -0,0 +1,128 @@ +from __future__ import annotations + +from dataclasses import dataclass + +import structlog + +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.storage.base import ObjectStorage + +log = structlog.get_logger(__name__) + + +class RepositoryAnalyzeError(Exception): + def __init__(self, *, code: str, message: str, retriable: bool) -> None: + super().__init__(message) + self.code = code + self.message = message + self.retriable = retriable + + +@dataclass(frozen=True) +class RepositoryAnalysisResult: + summary: str + tech_stack: list[str] + document_path: str + + +# 코어서버에서 토큰 받아오고 레포 가져온다 +# 이후 LLM 분석하고 마크다운 저장 +class RepositoryAnalyzer: + def __init__( + self, + *, + extractor: GitHubRepoSourceExtractor, + core_client: CoreClient, + chain: DocumentAnalyzer, + storage: ObjectStorage, + analyzed_key_template: str, + ) -> None: + self._extractor = extractor + self._core_client = core_client + self._chain = chain + self._storage = storage + self._analyzed_key_template = analyzed_key_template + + async def analyze( + self, + *, + repository_id: int, + repo_full_name: str, + default_branch: str = "main", + user_id: int | None, + ) -> RepositoryAnalysisResult: + if user_id is None: + raise RepositoryAnalyzeError( + code="MISSING_USER_ID", + message="envelope.context.userId 없이는 GitHub token을 가져올 수 없음", + retriable=False, + ) + + log.info( + "repository.token.fetch", + user_id=user_id, + repository_id=repository_id, + ) + try: + access_token = await self._core_client.fetch_github_token(user_id) + except CoreTokenError as err: + raise RepositoryAnalyzeError( + code=err.code, + message=err.message, + retriable=err.retriable, + ) from err + + log.info( + "repository.extract.start", + repository_id=repository_id, + repo_full_name=repo_full_name, + default_branch=default_branch, + ) + try: + source = await self._extractor.extract( + repo_full_name, + access_token=access_token, + ) + except RepositoryFetchError as err: + raise RepositoryAnalyzeError( + code=err.code, + message=err.message, + retriable=err.retriable, + ) from err + + if not source.text.strip(): + raise RepositoryAnalyzeError( + code="EMPTY_REPO_CONTENT", + message="레포에서 추출된 텍스트가 비어 있음", + retriable=False, + ) + + log.info( + "repository.llm.start", + repository_id=repository_id, + text_chars=len(source.text), + ) + analysis = await self._chain.analyze( + text=source.text, + source_type=source.source_type, + ) + + out_key = self._analyzed_key_template.format(repository_id=repository_id) + await self._storage.put_text(out_key, analysis.markdown) + log.info( + "repository.markdown.saved", + repository_id=repository_id, + key=out_key, + md_chars=len(analysis.markdown), + ) + + return RepositoryAnalysisResult( + summary=analysis.summary, + tech_stack=list(analysis.tech_stack), + document_path=out_key, + ) diff --git a/ai/src/ai_server/analyzer/sources/__init__.py b/ai/src/ai_server/analyzer/sources/__init__.py index d8929a8d..53344b6e 100644 --- a/ai/src/ai_server/analyzer/sources/__init__.py +++ b/ai/src/ai_server/analyzer/sources/__init__.py @@ -3,11 +3,20 @@ SourceExtractor, SourceType, ) +from ai_server.analyzer.sources.github_repo import ( + GitHubRepoSourceExtractor, + RepositoryFetchError, +) from ai_server.analyzer.sources.pdf import PdfSourceExtractor +from ai_server.analyzer.sources.web import WebFetchError, WebSourceExtractor __all__ = [ "ExtractedSource", "SourceExtractor", "SourceType", "PdfSourceExtractor", + "GitHubRepoSourceExtractor", + "RepositoryFetchError", + "WebSourceExtractor", + "WebFetchError", ] diff --git a/ai/src/ai_server/analyzer/sources/github_repo.py b/ai/src/ai_server/analyzer/sources/github_repo.py new file mode 100644 index 00000000..2692b895 --- /dev/null +++ b/ai/src/ai_server/analyzer/sources/github_repo.py @@ -0,0 +1,303 @@ +from __future__ import annotations + +import base64 +from dataclasses import dataclass + +import httpx +import structlog + +from ai_server.analyzer.sources.base import ExtractedSource, SourceExtractor + +log = structlog.get_logger(__name__) + + +# 프로젝트 설정 파일을 먼저 읽는다 +_PRIORITY_FILES: tuple[str, ...] = ( + "package.json", + "pyproject.toml", + "requirements.txt", + "go.mod", + "build.gradle", + "build.gradle.kts", + "pom.xml", + "Cargo.toml", + "composer.json", + "Gemfile", + "Dockerfile", +) + +_TEXT_EXTENSIONS: frozenset[str] = frozenset( + { + ".py", + ".ts", + ".tsx", + ".js", + ".jsx", + ".go", + ".java", + ".kt", + ".rs", + ".rb", + ".php", + ".cs", + ".swift", + ".md", + ".yaml", + ".yml", + ".toml", + ".json", + ".sh", + } +) + + +class RepositoryFetchError(Exception): + def __init__(self, *, code: str, message: str, retriable: bool) -> None: + super().__init__(message) + self.code = code + self.message = message + self.retriable = retriable + + +@dataclass(frozen=True) +class _RepoConfig: + api_base_url: str + fallback_token: str + max_files: int + max_file_bytes: int + timeout_sec: float + + +# 리드미, 주요 소스를 읽는다 +class GitHubRepoSourceExtractor(SourceExtractor): + def __init__( + self, + *, + api_base_url: str, + fallback_token: str = "", + max_files: int = 8, + max_file_bytes: int = 50_000, + timeout_sec: float = 30.0, + client: httpx.AsyncClient | None = None, + ) -> None: + self._cfg = _RepoConfig( + api_base_url=api_base_url.rstrip("/"), + fallback_token=fallback_token, + max_files=max_files, + max_file_bytes=max_file_bytes, + timeout_sec=timeout_sec, + ) + self._client = client + + async def extract( + self, + locator: str, + *, + access_token: str | None = None, + ) -> ExtractedSource: + """`locator`는 "owner/repo" 형태의 full name. + + `access_token`이 주어지면 그 token으로 호출(권장 — Core에서 받은 값). + None이면 fallback_token(public repo용)으로 호출. + """ + owner_repo = locator.strip().strip("/") + if "/" not in owner_repo: + raise RepositoryFetchError( + code="INVALID_REPO_LOCATOR", + message=f"locator must be 'owner/repo', got: {locator!r}", + retriable=False, + ) + + effective_token = access_token or self._cfg.fallback_token + + if self._client is not None: + return await self._extract_with_client(self._client, owner_repo) + + async with self._build_client(effective_token) as client: + return await self._extract_with_client(client, owner_repo) + + def _build_client(self, token: str) -> httpx.AsyncClient: + headers = { + "Accept": "application/vnd.github+json", + "X-GitHub-Api-Version": "2022-11-28", + } + if token: + headers["Authorization"] = f"Bearer {token}" + return httpx.AsyncClient( + base_url=self._cfg.api_base_url, + headers=headers, + timeout=self._cfg.timeout_sec, + ) + + async def _extract_with_client( + self, + client: httpx.AsyncClient, + owner_repo: str, + ) -> ExtractedSource: + repo_info = await self._fetch_json(client, f"/repos/{owner_repo}") + default_branch = repo_info.get("default_branch") or "main" + + readme_text = await self._fetch_readme(client, owner_repo) + tree_paths, truncated = await self._fetch_tree( + client, owner_repo, default_branch + ) + picked = _select_files(tree_paths, self._cfg.max_files) + snippets = await self._fetch_snippets( + client, owner_repo, default_branch, picked + ) + + parts: list[str] = [] + parts.append(f"# {owner_repo} (branch: {default_branch})") + if desc := repo_info.get("description"): + parts.append(f"\n> {desc}") + + if readme_text: + parts.append("\n## README\n") + parts.append(readme_text) + + parts.append("\n## File tree (truncated)\n") + parts.append("\n".join(f"- {p}" for p in tree_paths[:200])) + if truncated: + parts.append("\n(tree truncated by GitHub API)") + + if snippets: + parts.append("\n## Sampled sources\n") + for path, body in snippets: + parts.append(f"\n### `{path}`\n```\n{body}\n```") + + text = "\n".join(parts).strip() + return ExtractedSource( + text=text, + source_type="REPOSITORY", + metadata={ + "locator": owner_repo, + "default_branch": default_branch, + "tree_size": len(tree_paths), + "tree_truncated": truncated, + "sampled_files": [p for p, _ in snippets], + }, + ) + + async def _fetch_json(self, client: httpx.AsyncClient, path: str) -> dict: + resp = await client.get(path) + if resp.status_code == 404: + raise RepositoryFetchError( + code="REPO_NOT_FOUND", + message=f"GitHub returned 404 for {path}", + retriable=False, + ) + if resp.status_code in (401, 403): + raise RepositoryFetchError( + code="REPO_AUTH_OR_RATE", + message=f"GitHub auth/rate-limit: {resp.status_code} {resp.text[:200]}", + retriable=True, + ) + resp.raise_for_status() + return resp.json() + + async def _fetch_readme( + self, + client: httpx.AsyncClient, + owner_repo: str, + ) -> str: + try: + data = await self._fetch_json(client, f"/repos/{owner_repo}/readme") + except RepositoryFetchError as err: + if err.code == "REPO_NOT_FOUND": + return "" # README 없는 레포는 정상 + raise + if data.get("encoding") != "base64": + return "" + try: + return base64.b64decode(data.get("content", "")).decode( + "utf-8", errors="replace" + ) + except Exception: + return "" + + async def _fetch_tree( + self, + client: httpx.AsyncClient, + owner_repo: str, + branch: str, + ) -> tuple[list[str], bool]: + data = await self._fetch_json( + client, + f"/repos/{owner_repo}/git/trees/{branch}?recursive=1", + ) + tree = data.get("tree", []) + paths = [ + item["path"] + for item in tree + if item.get("type") == "blob" and item.get("path") + ] + return paths, bool(data.get("truncated")) + + async def _fetch_snippets( + self, + client: httpx.AsyncClient, + owner_repo: str, + branch: str, + picked: list[str], + ) -> list[tuple[str, str]]: + out: list[tuple[str, str]] = [] + for path in picked: + try: + data = await self._fetch_json( + client, f"/repos/{owner_repo}/contents/{path}?ref={branch}" + ) + except RepositoryFetchError as err: + log.warning( + "repo.snippet.skip", + path=path, + code=err.code, + ) + continue + if isinstance(data, list) or data.get("encoding") != "base64": + continue + try: + body = base64.b64decode(data.get("content", "")).decode( + "utf-8", errors="replace" + ) + except Exception: # noqa: BLE001 + continue + if len(body.encode("utf-8")) > self._cfg.max_file_bytes: + body = body[: self._cfg.max_file_bytes] + "\n... (truncated)" + out.append((path, body)) + return out + + +def _select_files(paths: list[str], cap: int) -> list[str]: + """우선순위 → 디렉토리당 대표 텍스트 파일 1개씩, 합쳐서 cap까지.""" + chosen: list[str] = [] + seen: set[str] = set() + + for needle in _PRIORITY_FILES: + for p in paths: + if p == needle or p.endswith("/" + needle): + if p not in seen: + chosen.append(p) + seen.add(p) + break + if len(chosen) >= cap: + return chosen + + by_dir: dict[str, str] = {} + for p in paths: + if p in seen: + continue + if "." not in p.rsplit("/", 1)[-1]: + continue + ext = "." + p.rsplit(".", 1)[-1].lower() + if ext not in _TEXT_EXTENSIONS: + continue + d = p.rsplit("/", 1)[0] if "/" in p else "" + if d not in by_dir: + by_dir[d] = p + for p in by_dir.values(): + if p not in seen: + chosen.append(p) + seen.add(p) + if len(chosen) >= cap: + break + return chosen diff --git a/ai/src/ai_server/analyzer/sources/web.py b/ai/src/ai_server/analyzer/sources/web.py new file mode 100644 index 00000000..8ed4c72f --- /dev/null +++ b/ai/src/ai_server/analyzer/sources/web.py @@ -0,0 +1,128 @@ +from __future__ import annotations + +import asyncio + +import httpx +import structlog +import trafilatura + +from ai_server.analyzer.sources.base import ExtractedSource, SourceExtractor + +log = structlog.get_logger(__name__) + + +class WebFetchError(Exception): + def __init__(self, *, code: str, message: str, retriable: bool) -> None: + super().__init__(message) + self.code = code + self.message = message + self.retriable = retriable + + +# 라이브러리로 본문 추출 +class WebSourceExtractor(SourceExtractor): + def __init__( + self, + *, + timeout_sec: float = 20.0, + max_html_bytes: int = 2_000_000, + client: httpx.AsyncClient | None = None, + ) -> None: + self._timeout_sec = timeout_sec + self._max_html_bytes = max_html_bytes + self._client = client + + async def extract(self, locator: str) -> ExtractedSource: + url = locator.strip() + if not (url.startswith("http://") or url.startswith("https://")): + raise WebFetchError( + code="INVALID_WEB_URL", + message=f"locator must be http(s) URL, got: {locator!r}", + retriable=False, + ) + + html, final_url, content_type = await self._fetch_html(url) + text = await asyncio.to_thread(_extract_main_text, html, final_url) + + if not text.strip(): + raise WebFetchError( + code="EMPTY_WEB_BODY", + message="추출된 본문이 비어 있음 — JS 렌더링 페이지이거나 비어있는 문서일 가능성", + retriable=False, + ) + + return ExtractedSource( + text=text, + source_type="WEB", + metadata={ + "locator": url, + "final_url": final_url, + "content_type": content_type, + "html_bytes": len(html.encode("utf-8")), + "text_chars": len(text), + }, + ) + + async def _fetch_html(self, url: str) -> tuple[str, str, str]: + if self._client is not None: + return await self._do_fetch(self._client, url) + async with httpx.AsyncClient( + timeout=self._timeout_sec, + follow_redirects=True, + headers={ + "User-Agent": "StackUp-AI/1.0 (+resume web extractor)", + "Accept": "text/html,application/xhtml+xml", + }, + ) as client: + return await self._do_fetch(client, url) + + async def _do_fetch( + self, + client: httpx.AsyncClient, + url: str, + ) -> tuple[str, str, str]: + try: + resp = await client.get(url) + except httpx.HTTPError as exc: + raise WebFetchError( + code="WEB_FETCH_FAILED", + message=f"HTTP 요청 실패: {exc}", + retriable=True, + ) from exc + + if resp.status_code >= 400: + raise WebFetchError( + code="WEB_HTTP_STATUS", + message=f"HTTP {resp.status_code}", + retriable=resp.status_code >= 500, + ) + + content_type = resp.headers.get("content-type", "") + if "html" not in content_type.lower(): + raise WebFetchError( + code="WEB_NOT_HTML", + message=f"HTML이 아님 (content-type={content_type})", + retriable=False, + ) + + raw = resp.content + if len(raw) > self._max_html_bytes: + raise WebFetchError( + code="WEB_HTML_TOO_LARGE", + message=f"HTML이 한도({self._max_html_bytes}B)를 초과: {len(raw)}B", + retriable=False, + ) + + html = resp.text + return html, str(resp.url), content_type + + +def _extract_main_text(html: str, url: str) -> str: + result = trafilatura.extract( + html, + url=url, + include_comments=False, + include_tables=True, + favor_recall=True, + ) + return (result or "").strip() diff --git a/ai/src/ai_server/analyzer/web_resume_analyzer.py b/ai/src/ai_server/analyzer/web_resume_analyzer.py new file mode 100644 index 00000000..d8735b77 --- /dev/null +++ b/ai/src/ai_server/analyzer/web_resume_analyzer.py @@ -0,0 +1,84 @@ +from __future__ import annotations + +from dataclasses import dataclass + +import structlog + +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.storage.base import ObjectStorage + +log = structlog.get_logger(__name__) + + +class WebResumeAnalyzeError(Exception): + def __init__(self, *, code: str, message: str, retriable: bool) -> None: + super().__init__(message) + self.code = code + self.message = message + self.retriable = retriable + + +@dataclass(frozen=True) +class WebResumeAnalysisResult: + summary: str + tech_stack: list[str] + document_path: str + + +# 웹포폴 -> 라이브러리로 텍스트만 - > LLM -> 마크다운 +class WebResumeAnalyzer: + def __init__( + self, + *, + extractor: SourceExtractor, + chain: DocumentAnalyzer, + storage: ObjectStorage, + analyzed_key_template: str, + ) -> None: + self._extractor = extractor + self._chain = chain + self._storage = storage + self._analyzed_key_template = analyzed_key_template + + async def analyze( + self, + *, + resume_id: int, + url: str, + ) -> WebResumeAnalysisResult: + log.info("web_resume.extract.start", resume_id=resume_id, url=url) + try: + source = await self._extractor.extract(url) + except WebFetchError as err: + raise WebResumeAnalyzeError( + code=err.code, + message=err.message, + retriable=err.retriable, + ) from err + + log.info( + "web_resume.llm.start", + resume_id=resume_id, + text_chars=len(source.text), + ) + analysis = await self._chain.analyze( + text=source.text, + source_type=source.source_type, + ) + + out_key = self._analyzed_key_template.format(resume_id=resume_id) + await self._storage.put_text(out_key, analysis.markdown) + log.info( + "web_resume.markdown.saved", + resume_id=resume_id, + key=out_key, + md_chars=len(analysis.markdown), + ) + + return WebResumeAnalysisResult( + summary=analysis.summary, + tech_stack=list(analysis.tech_stack), + document_path=out_key, + ) diff --git a/ai/src/ai_server/config/settings.py b/ai/src/ai_server/config/settings.py index 9b719897..724a0f6a 100644 --- a/ai/src/ai_server/config/settings.py +++ b/ai/src/ai_server/config/settings.py @@ -20,6 +20,8 @@ class Settings(BaseSettings): # AI consumer (큐 이름, prefetch, 콜백 라우팅 등) ai_queue_resume: str = "ai.analyze.resume" + ai_queue_repository: str = "ai.analyze.repository" + ai_queue_web: str = "ai.analyze.web" ai_queue_prefetch: int = 10 ai_callback_exchange: str = "stackup.ai-to-core" ai_callback_routing_analysis: str = "callback.analysis" @@ -42,6 +44,29 @@ class Settings(BaseSettings): llm_pro_temperature: float = 0.2 analyzed_resume_md_key_template: str = "analyzed/resume/{resume_id}/summary.md" + analyzed_repository_md_key_template: str = ( + "analyzed/repository/{repository_id}/summary.md" + ) + analyzed_web_resume_md_key_template: str = ( + "analyzed/web-resume/{resume_id}/summary.md" + ) + + # Core 서버 internal API (사용자별 GitHub access_token 조회 등) + core_internal_base_url: str = "http://localhost:38010" + core_internal_api_key: str = "" + core_internal_timeout_sec: float = 10.0 + + # GitHub repo 분석용 + github_api_base_url: str = "https://api.github.com" + # 사용자별 token은 Core에서 받음. 아래 token은 fallback (public repo 한정). + github_fallback_token: str = "" + repo_max_source_files: int = 8 + repo_max_source_file_bytes: int = 50_000 + repo_fetch_timeout_sec: float = 30.0 + + # 웹 이력서 fetch + web_fetch_timeout_sec: float = 20.0 + web_max_html_bytes: int = 2_000_000 # 2MB 상한 def get_settings() -> Settings: diff --git a/ai/src/ai_server/core/__init__.py b/ai/src/ai_server/core/__init__.py new file mode 100644 index 00000000..02053ab9 --- /dev/null +++ b/ai/src/ai_server/core/__init__.py @@ -0,0 +1,11 @@ +from ai_server.core.client import ( + CoreClient, + CoreTokenError, + HttpCoreClient, +) + +__all__ = [ + "CoreClient", + "CoreTokenError", + "HttpCoreClient", +] diff --git a/ai/src/ai_server/core/client.py b/ai/src/ai_server/core/client.py new file mode 100644 index 00000000..73cefb6f --- /dev/null +++ b/ai/src/ai_server/core/client.py @@ -0,0 +1,106 @@ +from __future__ import annotations + +from typing import Protocol + +import httpx +import structlog + +log = structlog.get_logger(__name__) + + +class CoreTokenError(Exception): + def __init__(self, *, code: str, message: str, retriable: bool) -> None: + super().__init__(message) + self.code = code + self.message = message + self.retriable = retriable + + +# 코어 서버 API 호출용 +class CoreClient(Protocol): + async def fetch_github_token(self, user_id: int) -> str: ... + + +class HttpCoreClient: + def __init__( + self, + *, + base_url: str, + api_key: str, + timeout_sec: float = 10.0, + client: httpx.AsyncClient | None = None, + ) -> None: + if not base_url: + raise ValueError("Core internal base_url 누락") + self._base_url = base_url.rstrip("/") + self._api_key = api_key + self._timeout_sec = timeout_sec + self._client = client + + async def fetch_github_token(self, user_id: int) -> str: + path = f"/api/internal/users/{user_id}/github-token" + if self._client is not None: + return await self._do_fetch(self._client, path) + async with self._build_client() as client: + return await self._do_fetch(client, path) + + def _build_client(self) -> httpx.AsyncClient: + return httpx.AsyncClient( + base_url=self._base_url, + headers={"X-Internal-API-Key": self._api_key}, + timeout=self._timeout_sec, + ) + + async def _do_fetch(self, client: httpx.AsyncClient, path: str) -> str: + try: + resp = await client.get(path) + except httpx.HTTPError as exc: + raise CoreTokenError( + code="CORE_UNAVAILABLE", + message=f"Core API 호출 실패: {exc}", + retriable=True, + ) from exc + + status = resp.status_code + if status == 404: + raise CoreTokenError( + code="USER_NOT_FOUND", + message=f"Core에 사용자 또는 token 없음: {path}", + retriable=False, + ) + if status in (401, 403): + raise CoreTokenError( + code="CORE_AUTH_FAILED", + message=f"Core internal 인증 실패: {status}", + retriable=False, + ) + if status >= 500: + raise CoreTokenError( + code="CORE_UNAVAILABLE", + message=f"Core 5xx: {status}", + retriable=True, + ) + if status >= 400: + raise CoreTokenError( + code="CORE_BAD_REQUEST", + message=f"Core {status}: {resp.text[:200]}", + retriable=False, + ) + + try: + data = resp.json() + except ValueError as exc: + raise CoreTokenError( + code="CORE_BAD_RESPONSE", + message=f"Core 응답 JSON 파싱 실패: {exc}", + retriable=True, + ) from exc + + token = data.get("accessToken") or data.get("access_token") + if not token or not isinstance(token, str): + raise CoreTokenError( + code="CORE_TOKEN_EMPTY", + message="Core 응답에 accessToken 없음", + retriable=True, + ) + return token diff --git a/ai/src/ai_server/messaging/consumers/repository_consumer.py b/ai/src/ai_server/messaging/consumers/repository_consumer.py new file mode 100644 index 00000000..553ddbb4 --- /dev/null +++ b/ai/src/ai_server/messaging/consumers/repository_consumer.py @@ -0,0 +1,141 @@ +from __future__ import annotations + +import structlog +from aio_pika.abc import AbstractIncomingMessage + +from ai_server.analyzer.repository_analyzer import ( + RepositoryAnalyzeError, + RepositoryAnalyzer, +) +from ai_server.messaging.idempotency import LruIdempotencyStore +from ai_server.messaging.publisher import CallbackPublisher +from ai_server.model.envelope import Envelope +from ai_server.model.messages.analyze import ( + AnalysisCallbackPayload, + RepositoryAnalyzeRequest, +) + +log = structlog.get_logger(__name__) + + +class RepositoryConsumer: + def __init__( + self, + *, + analyzer: RepositoryAnalyzer, + publisher: CallbackPublisher, + idempotency: LruIdempotencyStore, + callback_routing_key: str, + ) -> None: + self._analyzer = analyzer + self._publisher = publisher + self._idempotency = idempotency + self._callback_routing_key = callback_routing_key + + async def handle(self, message: AbstractIncomingMessage) -> None: + async with message.process(requeue=False): + try: + envelope = Envelope[RepositoryAnalyzeRequest].model_validate_json( + message.body + ) + except Exception as exc: + log.error( + "repository.parse.failed", + error=str(exc), + delivery_tag=message.delivery_tag, + ) + raise + + if self._idempotency.is_seen_then_mark(envelope.message_id): + log.info( + "repository.idempotent.skip", + message_id=envelope.message_id, + trace_id=envelope.trace_id, + ) + return + + req = envelope.payload + user_id = envelope.context.user_id + log.info( + "repository.analyze.start", + message_id=envelope.message_id, + repository_id=req.repository_id, + repo_full_name=req.repo_full_name, + user_id=user_id, + trace_id=envelope.trace_id, + ) + + payload = await self._run_and_build_payload( + req, user_id=user_id, trace_id=envelope.trace_id + ) + + await self._publisher.publish( + routing_key=self._callback_routing_key, + message_type="callback.analysis", + payload=payload, + trace_id=envelope.trace_id, + correlation_id=envelope.message_id, + context=envelope.context, + ) + log.info( + "repository.analyze.done", + message_id=envelope.message_id, + repository_id=req.repository_id, + status=payload.status, + trace_id=envelope.trace_id, + ) + + async def _run_and_build_payload( + self, + req: RepositoryAnalyzeRequest, + *, + user_id: int | None, + trace_id: str, + ) -> AnalysisCallbackPayload: + try: + result = await self._analyzer.analyze( + repository_id=req.repository_id, + repo_full_name=req.repo_full_name, + default_branch=req.default_branch, + user_id=user_id, + ) + except RepositoryAnalyzeError as err: + log.warning( + "repository.analyze.domain_failed", + repository_id=req.repository_id, + code=err.code, + retriable=err.retriable, + trace_id=trace_id, + ) + return AnalysisCallbackPayload( + target_type="REPOSITORY", + target_id=req.repository_id, + status="FAILED", + error_code=err.code, + error_message=err.message, + retriable=err.retriable, + ) + except Exception as exc: + log.exception( + "repository.analyze.unexpected_failed", + repository_id=req.repository_id, + trace_id=trace_id, + ) + return AnalysisCallbackPayload( + target_type="REPOSITORY", + target_id=req.repository_id, + status="FAILED", + error_code="UNEXPECTED", + error_message=str(exc), + retriable=True, + ) + + return AnalysisCallbackPayload( + target_type="REPOSITORY", + target_id=req.repository_id, + status="ANALYZED", + summary=result.summary, + tech_stack=result.tech_stack, + document_path=result.document_path, + embedding_chunk_count=0, + ) diff --git a/ai/src/ai_server/messaging/consumers/web_consumer.py b/ai/src/ai_server/messaging/consumers/web_consumer.py new file mode 100644 index 00000000..51a6f74f --- /dev/null +++ b/ai/src/ai_server/messaging/consumers/web_consumer.py @@ -0,0 +1,133 @@ +from __future__ import annotations + +import structlog +from aio_pika.abc import AbstractIncomingMessage + +from ai_server.analyzer.web_resume_analyzer import ( + WebResumeAnalyzeError, + WebResumeAnalyzer, +) +from ai_server.messaging.idempotency import LruIdempotencyStore +from ai_server.messaging.publisher import CallbackPublisher +from ai_server.model.envelope import Envelope +from ai_server.model.messages.analyze import ( + AnalysisCallbackPayload, + WebResumeAnalyzeRequest, +) + +log = structlog.get_logger(__name__) + + +class WebResumeConsumer: + def __init__( + self, + *, + analyzer: WebResumeAnalyzer, + publisher: CallbackPublisher, + idempotency: LruIdempotencyStore, + callback_routing_key: str, + ) -> None: + self._analyzer = analyzer + self._publisher = publisher + self._idempotency = idempotency + self._callback_routing_key = callback_routing_key + + async def handle(self, message: AbstractIncomingMessage) -> None: + async with message.process(requeue=False): + try: + envelope = Envelope[WebResumeAnalyzeRequest].model_validate_json( + message.body + ) + except Exception as exc: + log.error( + "web_resume.parse.failed", + error=str(exc), + delivery_tag=message.delivery_tag, + ) + raise + + if self._idempotency.is_seen_then_mark(envelope.message_id): + log.info( + "web_resume.idempotent.skip", + message_id=envelope.message_id, + trace_id=envelope.trace_id, + ) + return + + req = envelope.payload + log.info( + "web_resume.analyze.start", + message_id=envelope.message_id, + resume_id=req.resume_id, + url=req.url, + trace_id=envelope.trace_id, + ) + + payload = await self._run_and_build_payload(req, envelope.trace_id) + + await self._publisher.publish( + routing_key=self._callback_routing_key, + message_type="callback.analysis", + payload=payload, + trace_id=envelope.trace_id, + correlation_id=envelope.message_id, + context=envelope.context, + ) + log.info( + "web_resume.analyze.done", + message_id=envelope.message_id, + resume_id=req.resume_id, + status=payload.status, + trace_id=envelope.trace_id, + ) + + async def _run_and_build_payload( + self, + req: WebResumeAnalyzeRequest, + trace_id: str, + ) -> AnalysisCallbackPayload: + try: + result = await self._analyzer.analyze( + resume_id=req.resume_id, + url=req.url, + ) + except WebResumeAnalyzeError as err: + log.warning( + "web_resume.analyze.domain_failed", + resume_id=req.resume_id, + code=err.code, + retriable=err.retriable, + trace_id=trace_id, + ) + return AnalysisCallbackPayload( + target_type="WEB", + target_id=req.resume_id, + status="FAILED", + error_code=err.code, + error_message=err.message, + retriable=err.retriable, + ) + except Exception as exc: + log.exception( + "web_resume.analyze.unexpected_failed", + resume_id=req.resume_id, + trace_id=trace_id, + ) + return AnalysisCallbackPayload( + target_type="WEB", + target_id=req.resume_id, + status="FAILED", + error_code="UNEXPECTED", + error_message=str(exc), + retriable=True, + ) + + return AnalysisCallbackPayload( + target_type="WEB", + target_id=req.resume_id, + status="ANALYZED", + summary=result.summary, + tech_stack=result.tech_stack, + document_path=result.document_path, + embedding_chunk_count=0, + ) diff --git a/ai/src/ai_server/messaging/runner.py b/ai/src/ai_server/messaging/runner.py index 92ac3738..98710c9b 100644 --- a/ai/src/ai_server/messaging/runner.py +++ b/ai/src/ai_server/messaging/runner.py @@ -3,15 +3,22 @@ import structlog from aio_pika.abc import AbstractRobustQueue +from ai_server.analyzer.repository_analyzer import RepositoryAnalyzer from ai_server.analyzer.resume_analyzer import ResumeAnalyzer +from ai_server.analyzer.sources.github_repo import GitHubRepoSourceExtractor from ai_server.analyzer.sources.pdf import PdfSourceExtractor +from ai_server.analyzer.sources.web import WebSourceExtractor +from ai_server.analyzer.web_resume_analyzer import WebResumeAnalyzer from ai_server.chain.document_analysis_chain import ( LlmDocumentAnalyzer, build_document_analysis_chain, ) from ai_server.config.settings import Settings +from ai_server.core.client import HttpCoreClient from ai_server.messaging.connection import RabbitConnection +from ai_server.messaging.consumers.repository_consumer import RepositoryConsumer from ai_server.messaging.consumers.resume_consumer import ResumeConsumer +from ai_server.messaging.consumers.web_consumer import WebResumeConsumer from ai_server.messaging.idempotency import LruIdempotencyStore from ai_server.messaging.publisher import CallbackPublisher from ai_server.storage.factory import build_storage @@ -39,42 +46,105 @@ def __init__(self, settings: Settings) -> None: storage = build_storage(settings) chain = build_document_analysis_chain(settings) - analyzer = ResumeAnalyzer( + chain_analyzer = LlmDocumentAnalyzer(chain) + + # 이력서 PDF + resume_analyzer = ResumeAnalyzer( extractor=PdfSourceExtractor(storage=storage), - chain=LlmDocumentAnalyzer(chain), + chain=chain_analyzer, storage=storage, 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, + fallback_token=settings.github_fallback_token, + max_files=settings.repo_max_source_files, + max_file_bytes=settings.repo_max_source_file_bytes, + timeout_sec=settings.repo_fetch_timeout_sec, + ), + core_client=core_client, + chain=chain_analyzer, + storage=storage, + analyzed_key_template=settings.analyzed_repository_md_key_template, + ) + + # 웹 이력서 + web_analyzer = WebResumeAnalyzer( + extractor=WebSourceExtractor( + timeout_sec=settings.web_fetch_timeout_sec, + max_html_bytes=settings.web_max_html_bytes, + ), + chain=chain_analyzer, + storage=storage, + analyzed_key_template=settings.analyzed_web_resume_md_key_template, + ) + self._resume_consumer = ResumeConsumer( - analyzer=analyzer, + analyzer=resume_analyzer, + publisher=self._publisher, + idempotency=self._idempotency, + callback_routing_key=settings.ai_callback_routing_analysis, + ) + self._repository_consumer = RepositoryConsumer( + analyzer=repo_analyzer, publisher=self._publisher, idempotency=self._idempotency, callback_routing_key=settings.ai_callback_routing_analysis, ) - self._resume_queue: AbstractRobustQueue | None = None - self._consumer_tag: str | None = None + self._web_consumer = WebResumeConsumer( + analyzer=web_analyzer, + publisher=self._publisher, + idempotency=self._idempotency, + callback_routing_key=settings.ai_callback_routing_analysis, + ) + + self._consumers: list[tuple[AbstractRobustQueue, str]] = [] async def start(self) -> None: await self._connection.open() await self._publisher.open() channel = self._connection.channel + + await self._start_consumer( + channel, + queue_name=self._settings.ai_queue_resume, + handler=self._resume_consumer.handle, + ) + await self._start_consumer( + channel, + queue_name=self._settings.ai_queue_repository, + handler=self._repository_consumer.handle, + ) + await self._start_consumer( + channel, + queue_name=self._settings.ai_queue_web, + handler=self._web_consumer.handle, + ) + + async def _start_consumer(self, channel, *, queue_name, handler) -> None: queue = await channel.declare_queue( - self._settings.ai_queue_resume, + queue_name, durable=True, - passive=True, # 정의 파일이 이미 선언함 - ) - self._resume_queue = queue - self._consumer_tag = await queue.consume(self._resume_consumer.handle) - log.info( - "ai.consumer.started", - queue=self._settings.ai_queue_resume, - consumer_tag=self._consumer_tag, + passive=True, ) + tag = await queue.consume(handler) + self._consumers.append((queue, tag)) + log.info("ai.consumer.started", queue=queue_name, consumer_tag=tag) async def stop(self) -> None: - if self._resume_queue is not None and self._consumer_tag is not None: - await self._resume_queue.cancel(self._consumer_tag) - log.info("ai.consumer.stopped", consumer_tag=self._consumer_tag) + for queue, tag in self._consumers: + try: + await queue.cancel(tag) + log.info("ai.consumer.stopped", consumer_tag=tag) + except Exception: + log.exception("ai.consumer.cancel_failed", consumer_tag=tag) await self._connection.close() diff --git a/ai/src/ai_server/model/messages/analyze.py b/ai/src/ai_server/model/messages/analyze.py index 6970a0b1..299e976a 100644 --- a/ai/src/ai_server/model/messages/analyze.py +++ b/ai/src/ai_server/model/messages/analyze.py @@ -4,7 +4,7 @@ from ai_server.model._config import camel_config -TargetType = Literal["RESUME", "REPOSITORY"] +TargetType = Literal["RESUME", "REPOSITORY", "WEB"] AnalysisStatus = Literal["ANALYZED", "FAILED"] @@ -15,6 +15,21 @@ class ResumeAnalyzeRequest(BaseModel): file_path: str +class RepositoryAnalyzeRequest(BaseModel): + model_config = camel_config() + + repository_id: int + repo_full_name: str + default_branch: str = "main" + + +class WebResumeAnalyzeRequest(BaseModel): + model_config = camel_config() + + resume_id: int + url: str + + class AnalysisCallbackPayload(BaseModel): model_config = camel_config() diff --git a/ai/tests/test_core_client.py b/ai/tests/test_core_client.py new file mode 100644 index 00000000..7b7e492e --- /dev/null +++ b/ai/tests/test_core_client.py @@ -0,0 +1,122 @@ +from unittest.mock import AsyncMock, MagicMock + +import httpx +import pytest + +from ai_server.core.client import CoreTokenError, HttpCoreClient + + +def _make_client( + *, + status: int = 200, + json_body: dict | None = None, + text: str = "", + raise_exc: Exception | None = None, +) -> MagicMock: + client = MagicMock() + resp = MagicMock(spec=httpx.Response) + resp.status_code = status + resp.text = text + if json_body is None: + resp.json = MagicMock(side_effect=ValueError("no json")) + else: + resp.json = MagicMock(return_value=json_body) + if raise_exc is not None: + client.get = AsyncMock(side_effect=raise_exc) + else: + client.get = AsyncMock(return_value=resp) + return client + + +def _build(client: MagicMock) -> HttpCoreClient: + return HttpCoreClient(base_url="http://core:38010", api_key="k", client=client) + + +@pytest.mark.asyncio +async def test_returns_access_token_on_200_camel() -> None: + client = _make_client(json_body={"accessToken": "tok-1"}) + core = _build(client) + token = await core.fetch_github_token(user_id=42) + assert token == "tok-1" + client.get.assert_awaited_once_with("/api/internal/users/42/github-token") + + +@pytest.mark.asyncio +async def test_returns_access_token_on_snake_case_response() -> None: + client = _make_client(json_body={"access_token": "tok-s"}) + core = _build(client) + assert await core.fetch_github_token(user_id=1) == "tok-s" + + +@pytest.mark.asyncio +async def test_404_translates_to_user_not_found() -> None: + client = _make_client(status=404) + core = _build(client) + with pytest.raises(CoreTokenError) as exc_info: + await core.fetch_github_token(user_id=42) + assert exc_info.value.code == "USER_NOT_FOUND" + assert exc_info.value.retriable is False + + +@pytest.mark.asyncio +async def test_401_translates_to_core_auth_failed() -> None: + client = _make_client(status=401) + core = _build(client) + with pytest.raises(CoreTokenError) as exc_info: + await core.fetch_github_token(user_id=42) + assert exc_info.value.code == "CORE_AUTH_FAILED" + assert exc_info.value.retriable is False + + +@pytest.mark.asyncio +async def test_5xx_translates_to_retriable_unavailable() -> None: + client = _make_client(status=503) + core = _build(client) + with pytest.raises(CoreTokenError) as exc_info: + await core.fetch_github_token(user_id=42) + assert exc_info.value.code == "CORE_UNAVAILABLE" + assert exc_info.value.retriable is True + + +@pytest.mark.asyncio +async def test_other_4xx_non_retriable() -> None: + client = _make_client(status=400, text="bad") + core = _build(client) + with pytest.raises(CoreTokenError) as exc_info: + await core.fetch_github_token(user_id=42) + assert exc_info.value.code == "CORE_BAD_REQUEST" + assert exc_info.value.retriable is False + + +@pytest.mark.asyncio +async def test_httpx_error_translates_to_retriable() -> None: + client = _make_client(raise_exc=httpx.ConnectError("dns fail")) + core = _build(client) + with pytest.raises(CoreTokenError) as exc_info: + await core.fetch_github_token(user_id=42) + assert exc_info.value.code == "CORE_UNAVAILABLE" + assert exc_info.value.retriable is True + + +@pytest.mark.asyncio +async def test_empty_token_field_treated_as_retriable() -> None: + client = _make_client(json_body={"accessToken": ""}) + core = _build(client) + with pytest.raises(CoreTokenError) as exc_info: + await core.fetch_github_token(user_id=42) + assert exc_info.value.code == "CORE_TOKEN_EMPTY" + assert exc_info.value.retriable is True + + +@pytest.mark.asyncio +async def test_invalid_json_translates_to_bad_response() -> None: + client = _make_client(status=200, json_body=None) + core = _build(client) + with pytest.raises(CoreTokenError) as exc_info: + await core.fetch_github_token(user_id=42) + assert exc_info.value.code == "CORE_BAD_RESPONSE" + + +def test_constructor_requires_base_url() -> None: + with pytest.raises(ValueError): + HttpCoreClient(base_url="", api_key="x") diff --git a/ai/tests/test_github_repo_extractor.py b/ai/tests/test_github_repo_extractor.py new file mode 100644 index 00000000..9fa165ab --- /dev/null +++ b/ai/tests/test_github_repo_extractor.py @@ -0,0 +1,191 @@ +import base64 +from unittest.mock import AsyncMock, MagicMock + +import httpx +import pytest + +from ai_server.analyzer.sources.github_repo import ( + GitHubRepoSourceExtractor, + RepositoryFetchError, + _select_files, +) + + +def _make_client(routes: dict[str, dict | int]) -> tuple[MagicMock, AsyncMock]: + """`routes`: path → response dict (JSON) or int (HTTP status).""" + client = MagicMock() + + async def _get(path: str) -> MagicMock: + body = routes.get(path) + resp = MagicMock(spec=httpx.Response) + if isinstance(body, int): + resp.status_code = body + resp.text = "" + resp.raise_for_status = MagicMock() + return resp + resp.status_code = 200 + resp.json = MagicMock(return_value=body) + resp.raise_for_status = MagicMock() + return resp + + client.get = AsyncMock(side_effect=_get) + return client, client.get + + +def _b64(text: str) -> str: + return base64.b64encode(text.encode("utf-8")).decode("ascii") + + +@pytest.mark.asyncio +async def test_extract_assembles_readme_tree_and_snippets() -> None: + routes = { + "/repos/user/repo": { + "default_branch": "dev", + "description": "demo project", + }, + "/repos/user/repo/readme": { + "encoding": "base64", + "content": _b64("# Hello\nReadme body"), + }, + "/repos/user/repo/git/trees/dev?recursive=1": { + "tree": [ + {"path": "pyproject.toml", "type": "blob"}, + {"path": "src/app.py", "type": "blob"}, + {"path": "tests/test_app.py", "type": "blob"}, + {"path": "docs", "type": "tree"}, + ], + "truncated": False, + }, + "/repos/user/repo/contents/pyproject.toml?ref=dev": { + "encoding": "base64", + "content": _b64('[project]\nname = "app"'), + }, + "/repos/user/repo/contents/src/app.py?ref=dev": { + "encoding": "base64", + "content": _b64("def main():\n return 1"), + }, + "/repos/user/repo/contents/tests/test_app.py?ref=dev": { + "encoding": "base64", + "content": _b64("def test(): pass"), + }, + } + client, _ = _make_client(routes) + extractor = GitHubRepoSourceExtractor( + api_base_url="https://api.github.com", client=client + ) + + result = await extractor.extract("user/repo") + + assert result.source_type == "REPOSITORY" + assert "user/repo" in result.text + assert "Readme body" in result.text + assert "pyproject.toml" in result.text + assert "def main()" in result.text + assert result.metadata["default_branch"] == "dev" + assert result.metadata["tree_size"] == 3 + assert "pyproject.toml" in result.metadata["sampled_files"] + + +@pytest.mark.asyncio +async def test_extract_handles_missing_readme_gracefully() -> None: + routes = { + "/repos/user/repo": {"default_branch": "main"}, + "/repos/user/repo/readme": 404, + "/repos/user/repo/git/trees/main?recursive=1": {"tree": [], "truncated": False}, + } + client, _ = _make_client(routes) + extractor = GitHubRepoSourceExtractor( + api_base_url="https://api.github.com", client=client + ) + + result = await extractor.extract("user/repo") + assert "user/repo" in result.text + assert result.metadata["tree_size"] == 0 + + +@pytest.mark.asyncio +async def test_extract_raises_on_repo_not_found() -> None: + routes = {"/repos/user/repo": 404} + client, _ = _make_client(routes) + extractor = GitHubRepoSourceExtractor( + api_base_url="https://api.github.com", client=client + ) + + with pytest.raises(RepositoryFetchError) as exc_info: + await extractor.extract("user/repo") + assert exc_info.value.code == "REPO_NOT_FOUND" + assert exc_info.value.retriable is False + + +@pytest.mark.asyncio +async def test_extract_raises_on_rate_limit_as_retriable() -> None: + routes = {"/repos/user/repo": 403} + client, _ = _make_client(routes) + extractor = GitHubRepoSourceExtractor( + api_base_url="https://api.github.com", client=client + ) + + with pytest.raises(RepositoryFetchError) as exc_info: + await extractor.extract("user/repo") + assert exc_info.value.code == "REPO_AUTH_OR_RATE" + assert exc_info.value.retriable is True + + +@pytest.mark.asyncio +async def test_extract_rejects_malformed_locator() -> None: + client, _ = _make_client({}) + extractor = GitHubRepoSourceExtractor( + api_base_url="https://api.github.com", client=client + ) + with pytest.raises(RepositoryFetchError) as exc_info: + await extractor.extract("not-a-full-name") + assert exc_info.value.code == "INVALID_REPO_LOCATOR" + + +def test_select_files_prioritizes_manifests_then_one_per_dir() -> None: + paths = [ + "package.json", + "src/index.ts", + "src/util/format.ts", + "tests/index.test.ts", + "README.md", + ] + picked = _select_files(paths, cap=4) + # manifest 우선 + assert "package.json" in picked + # 디렉토리당 1개씩 sampling (src/, tests/, root) + assert any(p.startswith("src/") for p in picked) + assert any(p.startswith("tests/") for p in picked) + assert len(picked) <= 4 + + +def test_select_files_respects_cap() -> None: + paths = [f"src/a{i}.py" for i in range(20)] + picked = _select_files(paths, cap=3) + assert len(picked) <= 3 + + +def test_build_client_includes_authorization_with_user_token() -> None: + extractor = GitHubRepoSourceExtractor(api_base_url="https://api.github.com") + client = extractor._build_client("user-token-xyz") + try: + assert client.headers.get("Authorization") == "Bearer user-token-xyz" + finally: + # 비동기 cleanup 없이 닫기 OK — 실제 연결은 안 함 + pass + + +def test_build_client_omits_authorization_when_token_empty() -> None: + extractor = GitHubRepoSourceExtractor(api_base_url="https://api.github.com") + client = extractor._build_client("") + assert "Authorization" not in client.headers + + +def test_fallback_token_used_when_no_per_call_token() -> None: + extractor = GitHubRepoSourceExtractor( + api_base_url="https://api.github.com", + fallback_token="fallback-tok", + ) + # access_token=None → fallback 사용 + client = extractor._build_client(extractor._cfg.fallback_token) + assert client.headers.get("Authorization") == "Bearer fallback-tok" diff --git a/ai/tests/test_messages_analyze.py b/ai/tests/test_messages_analyze.py index a699dd2d..c8e211a2 100644 --- a/ai/tests/test_messages_analyze.py +++ b/ai/tests/test_messages_analyze.py @@ -3,7 +3,9 @@ from ai_server.model.messages.analyze import ( AnalysisCallbackPayload, + RepositoryAnalyzeRequest, ResumeAnalyzeRequest, + WebResumeAnalyzeRequest, ) @@ -68,3 +70,45 @@ def test_callback_target_type_must_be_known() -> None: target_id=1, status="ANALYZED", ) + + +def test_repository_request_parses_with_default_branch() -> None: + req = RepositoryAnalyzeRequest.model_validate( + {"repositoryId": 5, "repoFullName": "user/repo"} + ) + assert req.repository_id == 5 + assert req.repo_full_name == "user/repo" + assert req.default_branch == "main" + + +def test_repository_request_serializes_camel_case() -> None: + req = RepositoryAnalyzeRequest( + repository_id=5, repo_full_name="user/repo", default_branch="dev" + ) + dumped = req.model_dump(by_alias=True) + assert dumped == { + "repositoryId": 5, + "repoFullName": "user/repo", + "defaultBranch": "dev", + } + + +def test_web_resume_request_parses() -> None: + req = WebResumeAnalyzeRequest.model_validate( + {"resumeId": 9, "url": "https://example.com/me"} + ) + assert req.resume_id == 9 + assert req.url == "https://example.com/me" + + +def test_callback_accepts_web_target_type() -> None: + cb = AnalysisCallbackPayload( + target_type="WEB", + target_id=9, + status="ANALYZED", + summary="ok", + document_path="analyzed/web-resume/9/summary.md", + ) + dumped = cb.model_dump(by_alias=True) + assert dumped["targetType"] == "WEB" + assert dumped["documentPath"] == "analyzed/web-resume/9/summary.md" diff --git a/ai/tests/test_repository_analyzer.py b/ai/tests/test_repository_analyzer.py new file mode 100644 index 00000000..d0a017ab --- /dev/null +++ b/ai/tests/test_repository_analyzer.py @@ -0,0 +1,144 @@ +from unittest.mock import AsyncMock + +import pytest + +from ai_server.analyzer.repository_analyzer import ( + RepositoryAnalyzeError, + RepositoryAnalyzer, +) +from ai_server.analyzer.sources.base import ExtractedSource +from ai_server.analyzer.sources.github_repo import RepositoryFetchError +from ai_server.chain.document_analysis_chain import DocumentAnalysisResult +from ai_server.core.client import CoreTokenError + + +def _make_analyzer( + *, + extract_result: ExtractedSource | Exception | None = None, + analysis: DocumentAnalysisResult | None = None, + token_result: str | Exception = "user-token", +) -> tuple[RepositoryAnalyzer, AsyncMock, AsyncMock, AsyncMock, AsyncMock]: + extractor = AsyncMock() + if isinstance(extract_result, Exception): + extractor.extract = AsyncMock(side_effect=extract_result) + else: + extractor.extract = AsyncMock(return_value=extract_result) + + chain = AsyncMock() + if analysis is not None: + chain.analyze = AsyncMock(return_value=analysis) + + storage = AsyncMock() + + core_client = AsyncMock() + if isinstance(token_result, Exception): + core_client.fetch_github_token = AsyncMock(side_effect=token_result) + else: + core_client.fetch_github_token = AsyncMock(return_value=token_result) + + analyzer = RepositoryAnalyzer( + extractor=extractor, + core_client=core_client, + chain=chain, + storage=storage, + 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: + analyzer, extractor, chain, storage, core_client = _make_analyzer( + extract_result=ExtractedSource(text="README + tree", source_type="REPOSITORY"), + analysis=DocumentAnalysisResult( + summary="요약", tech_stack=["Go"], markdown="## 개요\nx" + ), + token_result="user-abc", + ) + result = await analyzer.analyze( + repository_id=7, + repo_full_name="user/repo", + default_branch="main", + user_id=42, + ) + + 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 == "요약" + 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() + with pytest.raises(RepositoryAnalyzeError) as exc_info: + await analyzer.analyze( + repository_id=1, + repo_full_name="user/repo", + user_id=None, + ) + 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( + 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) + 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( + 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) + assert exc_info.value.code == "REPO_NOT_FOUND" + chain.analyze.assert_not_called() + storage.put_text.assert_not_called() + + +@pytest.mark.asyncio +async def test_empty_text_raises_empty_repo_content() -> None: + analyzer, _, chain, storage, _ = _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) + assert exc_info.value.code == "EMPTY_REPO_CONTENT" + chain.analyze.assert_not_called() + storage.put_text.assert_not_called() diff --git a/ai/tests/test_repository_consumer.py b/ai/tests/test_repository_consumer.py new file mode 100644 index 00000000..278ab284 --- /dev/null +++ b/ai/tests/test_repository_consumer.py @@ -0,0 +1,148 @@ +from datetime import datetime, timezone +from unittest.mock import AsyncMock, MagicMock + +import pytest + +from ai_server.analyzer.repository_analyzer import ( + RepositoryAnalysisResult, + RepositoryAnalyzeError, +) +from ai_server.messaging.consumers.repository_consumer import RepositoryConsumer +from ai_server.messaging.idempotency import LruIdempotencyStore +from ai_server.model.envelope import Envelope +from ai_server.model.messages.analyze import ( + AnalysisCallbackPayload, + RepositoryAnalyzeRequest, +) + + +def _request_envelope(message_id: str = "req-1", repository_id: int = 7) -> bytes: + env = Envelope[RepositoryAnalyzeRequest].model_validate( + { + "messageId": message_id, + "messageType": "analyze.repository", + "version": "v1", + "traceId": "trace-r", + "publishedAt": datetime(2026, 5, 14, tzinfo=timezone.utc), + "publisher": "core-server", + "payload": { + "repositoryId": repository_id, + "repoFullName": "user/repo", + "defaultBranch": "main", + }, + "context": {"userId": 1}, + } + ) + return env.model_dump_json(by_alias=True).encode("utf-8") + + +def _incoming_message(body: bytes) -> MagicMock: + msg = MagicMock() + msg.body = body + msg.delivery_tag = 1 + process_ctx = AsyncMock() + process_ctx.__aenter__.return_value = None + process_ctx.__aexit__.return_value = False + msg.process = MagicMock(return_value=process_ctx) + return msg + + +def _make_consumer( + analyzer: AsyncMock, +) -> tuple[RepositoryConsumer, AsyncMock]: + publisher = AsyncMock() + consumer = RepositoryConsumer( + analyzer=analyzer, + publisher=publisher, + idempotency=LruIdempotencyStore(max_size=16), + callback_routing_key="callback.analysis", + ) + return consumer, publisher + + +def _captured_payload(publisher: AsyncMock) -> AnalysisCallbackPayload: + publisher.publish.assert_awaited_once() + kwargs = publisher.publish.await_args.kwargs + assert kwargs["message_type"] == "callback.analysis" + payload = kwargs["payload"] + assert isinstance(payload, AnalysisCallbackPayload) + return payload + + +@pytest.mark.asyncio +async def test_happy_path_publishes_analyzed_callback() -> None: + analyzer = AsyncMock() + analyzer.analyze = AsyncMock( + return_value=RepositoryAnalysisResult( + summary="요약", + tech_stack=["Go"], + document_path="analyzed/repository/7/summary.md", + ) + ) + consumer, publisher = _make_consumer(analyzer) + await consumer.handle(_incoming_message(_request_envelope())) + + analyzer.analyze.assert_awaited_once_with( + repository_id=7, + repo_full_name="user/repo", + default_branch="main", + user_id=1, + ) + payload = _captured_payload(publisher) + assert payload.status == "ANALYZED" + assert payload.target_type == "REPOSITORY" + assert payload.target_id == 7 + assert payload.summary == "요약" + assert payload.tech_stack == ["Go"] + assert payload.document_path == "analyzed/repository/7/summary.md" + assert payload.embedding_chunk_count == 0 + + +@pytest.mark.asyncio +async def test_domain_error_publishes_failed_callback() -> None: + analyzer = AsyncMock() + analyzer.analyze = AsyncMock( + side_effect=RepositoryAnalyzeError( + code="REPO_NOT_FOUND", message="404", retriable=False + ) + ) + consumer, publisher = _make_consumer(analyzer) + await consumer.handle(_incoming_message(_request_envelope())) + + payload = _captured_payload(publisher) + assert payload.status == "FAILED" + assert payload.error_code == "REPO_NOT_FOUND" + assert payload.retriable is False + assert payload.target_type == "REPOSITORY" + + +@pytest.mark.asyncio +async def test_unexpected_error_publishes_retriable_failure() -> None: + analyzer = AsyncMock() + analyzer.analyze = AsyncMock(side_effect=RuntimeError("boom")) + consumer, publisher = _make_consumer(analyzer) + await consumer.handle(_incoming_message(_request_envelope())) + + payload = _captured_payload(publisher) + assert payload.status == "FAILED" + assert payload.error_code == "UNEXPECTED" + assert payload.retriable is True + + +@pytest.mark.asyncio +async def test_idempotent_duplicate_skipped() -> None: + analyzer = AsyncMock() + analyzer.analyze = AsyncMock( + return_value=RepositoryAnalysisResult( + summary="x", + tech_stack=[], + document_path="analyzed/repository/7/summary.md", + ) + ) + consumer, publisher = _make_consumer(analyzer) + body = _request_envelope(message_id="dup-r") + await consumer.handle(_incoming_message(body)) + await consumer.handle(_incoming_message(body)) + + assert analyzer.analyze.await_count == 1 + assert publisher.publish.await_count == 1 diff --git a/ai/tests/test_web_consumer.py b/ai/tests/test_web_consumer.py new file mode 100644 index 00000000..9782f2ba --- /dev/null +++ b/ai/tests/test_web_consumer.py @@ -0,0 +1,103 @@ +from datetime import datetime, timezone +from unittest.mock import AsyncMock, MagicMock + +import pytest + +from ai_server.analyzer.web_resume_analyzer import ( + WebResumeAnalysisResult, + WebResumeAnalyzeError, +) +from ai_server.messaging.consumers.web_consumer import WebResumeConsumer +from ai_server.messaging.idempotency import LruIdempotencyStore +from ai_server.model.envelope import Envelope +from ai_server.model.messages.analyze import ( + AnalysisCallbackPayload, + WebResumeAnalyzeRequest, +) + + +def _request_envelope(message_id: str = "req-1", resume_id: int = 11) -> bytes: + env = Envelope[WebResumeAnalyzeRequest].model_validate( + { + "messageId": message_id, + "messageType": "analyze.web", + "version": "v1", + "traceId": "trace-w", + "publishedAt": datetime(2026, 5, 14, tzinfo=timezone.utc), + "publisher": "core-server", + "payload": {"resumeId": resume_id, "url": "https://example.com/me"}, + "context": {"userId": 1}, + } + ) + return env.model_dump_json(by_alias=True).encode("utf-8") + + +def _incoming_message(body: bytes) -> MagicMock: + msg = MagicMock() + msg.body = body + msg.delivery_tag = 1 + process_ctx = AsyncMock() + process_ctx.__aenter__.return_value = None + process_ctx.__aexit__.return_value = False + msg.process = MagicMock(return_value=process_ctx) + return msg + + +def _make_consumer(analyzer: AsyncMock) -> tuple[WebResumeConsumer, AsyncMock]: + publisher = AsyncMock() + consumer = WebResumeConsumer( + analyzer=analyzer, + publisher=publisher, + idempotency=LruIdempotencyStore(max_size=16), + callback_routing_key="callback.analysis", + ) + return consumer, publisher + + +def _captured_payload(publisher: AsyncMock) -> AnalysisCallbackPayload: + publisher.publish.assert_awaited_once() + payload = publisher.publish.await_args.kwargs["payload"] + assert isinstance(payload, AnalysisCallbackPayload) + return payload + + +@pytest.mark.asyncio +async def test_happy_path_publishes_analyzed_callback() -> None: + analyzer = AsyncMock() + analyzer.analyze = AsyncMock( + return_value=WebResumeAnalysisResult( + summary="요약", + tech_stack=["React"], + document_path="analyzed/web-resume/11/summary.md", + ) + ) + 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" + ) + payload = _captured_payload(publisher) + assert payload.status == "ANALYZED" + assert payload.target_type == "WEB" + assert payload.target_id == 11 + assert payload.summary == "요약" + assert payload.document_path == "analyzed/web-resume/11/summary.md" + + +@pytest.mark.asyncio +async def test_domain_error_publishes_failed_callback() -> None: + analyzer = AsyncMock() + analyzer.analyze = AsyncMock( + side_effect=WebResumeAnalyzeError( + code="WEB_HTTP_STATUS", message="404", retriable=False + ) + ) + consumer, publisher = _make_consumer(analyzer) + await consumer.handle(_incoming_message(_request_envelope())) + + payload = _captured_payload(publisher) + assert payload.status == "FAILED" + assert payload.error_code == "WEB_HTTP_STATUS" + assert payload.retriable is False + assert payload.target_type == "WEB" diff --git a/ai/tests/test_web_extractor.py b/ai/tests/test_web_extractor.py new file mode 100644 index 00000000..3bd75c66 --- /dev/null +++ b/ai/tests/test_web_extractor.py @@ -0,0 +1,117 @@ +from unittest.mock import AsyncMock, MagicMock + +import httpx +import pytest + +from ai_server.analyzer.sources.web import WebFetchError, WebSourceExtractor + + +def _make_client( + *, + status: int = 200, + content_type: str = "text/html; charset=utf-8", + body: ( + str | bytes + ) = "
본문 텍스트입니다.
", + final_url: str = "https://example.com/r", + raise_exc: Exception | None = None, +) -> MagicMock: + client = MagicMock() + resp = MagicMock(spec=httpx.Response) + resp.status_code = status + resp.headers = {"content-type": content_type} + resp.text = body if isinstance(body, str) else body.decode("utf-8", errors="ignore") + resp.content = body.encode("utf-8") if isinstance(body, str) else body + resp.url = final_url + if raise_exc is not None: + client.get = AsyncMock(side_effect=raise_exc) + else: + client.get = AsyncMock(return_value=resp) + return client + + +@pytest.mark.asyncio +async def test_extract_returns_main_body_text() -> None: + html = ( + "백엔드 개발자. Spring Boot 3년차.