김OO
" + "프론트엔드 개발자. React 포트폴리오.
diff --git a/ai/Dockerfile b/ai/Dockerfile index ce3a4b3b..433b73b8 100644 --- a/ai/Dockerfile +++ b/ai/Dockerfile @@ -7,6 +7,9 @@ WORKDIR /app COPY pyproject.toml uv.lock ./ RUN uv sync --frozen --no-dev +# 웹 이력서(JS 렌더링 SPA) 폴백용 헤드리스 chromium + 시스템 의존성. +RUN uv run playwright install --with-deps chromium + COPY src/ src/ EXPOSE 38030 diff --git a/ai/pyproject.toml b/ai/pyproject.toml index d6ad403a..8d7c78cb 100644 --- a/ai/pyproject.toml +++ b/ai/pyproject.toml @@ -16,7 +16,9 @@ dependencies = [ "boto3>=1.42.77", "aiofiles>=24.1.0", "pypdf>=5.1.0", + "pymupdf>=1.24.0", "trafilatura>=2.0.0", + "playwright>=1.49.0", "langchain-text-splitters>=0.3.0", "google-genai>=1.0.0", "langchain>=1.2.13", diff --git a/ai/src/ai_server/analyzer/sources/github_repo.py b/ai/src/ai_server/analyzer/sources/github_repo.py index 70ea5e47..123b9b42 100644 --- a/ai/src/ai_server/analyzer/sources/github_repo.py +++ b/ai/src/ai_server/analyzer/sources/github_repo.py @@ -59,6 +59,13 @@ def __init__(self, *, code: str, message: str, retriable: bool) -> None: self.retriable = retriable +@dataclass(frozen=True) +class ContributionInfo: + login: str + commit_count: int + authored_files: list[str] + + @dataclass(frozen=True) class _RepoConfig: api_base_url: str @@ -66,6 +73,7 @@ class _RepoConfig: max_files: int max_file_bytes: int timeout_sec: float + max_contrib_commits: int # 리드미, 주요 소스를 읽는다 @@ -78,6 +86,7 @@ def __init__( max_files: int = 8, max_file_bytes: int = 50_000, timeout_sec: float = 30.0, + max_contrib_commits: int = 15, client: httpx.AsyncClient | None = None, ) -> None: self._cfg = _RepoConfig( @@ -86,6 +95,7 @@ def __init__( max_files=max_files, max_file_bytes=max_file_bytes, timeout_sec=timeout_sec, + max_contrib_commits=max_contrib_commits, ) self._client = client @@ -109,12 +119,18 @@ async def extract( ) effective_token = access_token or self._cfg.fallback_token + # 기여도 분석은 지원자 본인 token 이 있을 때만 의미 있음(GET /user = 지원자). + has_user_token = bool(access_token) if self._client is not None: - return await self._extract_with_client(self._client, owner_repo) + return await self._extract_with_client( + self._client, owner_repo, has_user_token=has_user_token + ) async with self._build_client(effective_token) as client: - return await self._extract_with_client(client, owner_repo) + return await self._extract_with_client( + client, owner_repo, has_user_token=has_user_token + ) def _build_client(self, token: str) -> httpx.AsyncClient: headers = { @@ -133,15 +149,24 @@ async def _extract_with_client( self, client: httpx.AsyncClient, owner_repo: str, + *, + has_user_token: bool = False, ) -> ExtractedSource: repo_info = await self._fetch_json(client, f"/repos/{owner_repo}") default_branch = repo_info.get("default_branch") or "main" + contribution: ContributionInfo | None = None + if has_user_token: + contribution = await self._fetch_contribution( + client, owner_repo, default_branch + ) + 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) + authored = contribution.authored_files if contribution else [] + picked = _select_files(tree_paths, self._cfg.max_files, prioritized=authored) snippets = await self._fetch_snippets( client, owner_repo, default_branch, picked ) @@ -151,6 +176,16 @@ async def _extract_with_client( if desc := repo_info.get("description"): parts.append(f"\n> {desc}") + # 다중 기여자 레포에서 "지원자가 실제 쓴 코드" 를 구분하기 위한 기여 요약. + if contribution and contribution.commit_count: + parts.append("\n## 지원자 기여\n") + parts.append( + f"- 지원자(@{contribution.login}) 커밋 {contribution.commit_count}개" + ) + if contribution.authored_files: + top = contribution.authored_files[:10] + parts.append("- 주요 작성 파일:\n" + "\n".join(f" - {p}" for p in top)) + if readme_text: parts.append("\n## README\n") parts.append(readme_text) @@ -175,9 +210,64 @@ async def _extract_with_client( "tree_size": len(tree_paths), "tree_truncated": truncated, "sampled_files": [p for p, _ in snippets], + "contributor_login": contribution.login if contribution else None, + "contrib_commit_count": ( + contribution.commit_count if contribution else 0 + ), }, ) + # 지원자 token 으로 GET /user → login 확보 후, 그 login 이 author 인 커밋을 + # 집계해 기여 파일을 추린다. 실패(권한/rate limit/공개레포)는 None 으로 graceful. + async def _fetch_contribution( + self, + client: httpx.AsyncClient, + owner_repo: str, + branch: str, + ) -> ContributionInfo | None: + try: + me = await self._fetch_json(client, "/user") + except RepositoryFetchError as err: + log.warning("repo.contrib.user_skip", code=err.code) + return None + login = me.get("login") if isinstance(me, dict) else None + if not login: + return None + + try: + commits = await self._fetch_json( + client, + f"/repos/{owner_repo}/commits" + f"?author={login}&sha={branch}&per_page=100", + ) + except RepositoryFetchError as err: + log.warning("repo.contrib.commits_skip", code=err.code) + return None + if not isinstance(commits, list) or not commits: + return ContributionInfo(login=login, commit_count=0, authored_files=[]) + + file_counter: dict[str, int] = {} + for commit in commits[: self._cfg.max_contrib_commits]: + sha = commit.get("sha") if isinstance(commit, dict) else None + if not sha: + continue + try: + detail = await self._fetch_json( + client, f"/repos/{owner_repo}/commits/{sha}" + ) + except RepositoryFetchError: + continue + files = detail.get("files") if isinstance(detail, dict) else None + for f in files or []: + path = f.get("filename") if isinstance(f, dict) else None + if path: + file_counter[path] = file_counter.get(path, 0) + 1 + + authored = sorted(file_counter, key=lambda p: file_counter[p], reverse=True) + return ContributionInfo( + login=login, commit_count=len(commits), authored_files=authored + ) + async def _fetch_json(self, client: httpx.AsyncClient, path: str) -> dict: resp = await client.get(path) if resp.status_code == 404: @@ -267,10 +357,26 @@ async def _fetch_snippets( return out -def _select_files(paths: list[str], cap: int) -> list[str]: - """우선순위 → 디렉토리당 대표 텍스트 파일 1개씩, 합쳐서 cap까지.""" +def _select_files( + paths: list[str], cap: int, *, prioritized: list[str] | None = None +) -> list[str]: + """지원자 기여 파일 → 설정파일 우선순위 → 디렉토리당 대표 텍스트 파일, cap까지.""" chosen: list[str] = [] seen: set[str] = set() + tree = set(paths) + + # 지원자가 실제 작성한 텍스트 파일을 가장 먼저 (트리에 존재 + 텍스트 확장자). + for p in prioritized or []: + if p in seen or p not in tree: + continue + if "." not in p.rsplit("/", 1)[-1]: + continue + if ("." + p.rsplit(".", 1)[-1].lower()) not in _TEXT_EXTENSIONS: + continue + chosen.append(p) + seen.add(p) + if len(chosen) >= cap: + return chosen for needle in _PRIORITY_FILES: for p in paths: diff --git a/ai/src/ai_server/analyzer/sources/pdf.py b/ai/src/ai_server/analyzer/sources/pdf.py index 8dcf38b3..92af3c9f 100644 --- a/ai/src/ai_server/analyzer/sources/pdf.py +++ b/ai/src/ai_server/analyzer/sources/pdf.py @@ -2,12 +2,16 @@ import asyncio import io +from typing import Protocol +import structlog from pypdf import PdfReader from ai_server.analyzer.sources.base import ExtractedSource, SourceExtractor from ai_server.storage.base import ObjectStorage +log = structlog.get_logger(__name__) + def _extract_pdf_text(data: bytes) -> str: reader = PdfReader(io.BytesIO(data)) @@ -17,17 +21,44 @@ def _extract_pdf_text(data: bytes) -> str: return "\n\n".join(p for p in parts if p).strip() +# 이미지/스캔 PDF 의 텍스트를 비전 모델로 추출하는 어댑터 (구현체는 chain/pdf_vision). +class VisionPdfReader(Protocol): + async def extract_text(self, pdf_bytes: bytes) -> str: ... + + # PDF 를 읽어 페이지 텍스트를 이어붙인다. +# 텍스트 레이어가 비거나 너무 짧으면(스캔/이미지 PDF) vision_reader 로 폴백. class PdfSourceExtractor(SourceExtractor): - def __init__(self, storage: ObjectStorage) -> None: + def __init__( + self, + storage: ObjectStorage, + *, + vision_reader: VisionPdfReader | None = None, + min_text_chars: int = 50, + ) -> None: self._storage = storage + self._vision_reader = vision_reader + self._min_text_chars = min_text_chars async def extract(self, locator: str) -> ExtractedSource: data = await self._storage.get_bytes(locator) text = await asyncio.to_thread(_extract_pdf_text, data) + used_vision = False + + # 텍스트가 빈약하면(스캔/이미지 PDF) 비전 모델로 추출 시도. + if len(text.strip()) < self._min_text_chars and self._vision_reader is not None: + try: + vision_text = await self._vision_reader.extract_text(data) + except Exception as exc: # noqa: BLE001 + log.warning("pdf.vision.failed", locator=locator, error=str(exc)) + vision_text = "" + if vision_text.strip(): + text = vision_text.strip() + used_vision = True + return ExtractedSource( text=text, source_type="PDF", - metadata={"locator": locator, "bytes": len(data)}, + metadata={"locator": locator, "bytes": len(data), "vision": used_vision}, ) diff --git a/ai/src/ai_server/analyzer/sources/web.py b/ai/src/ai_server/analyzer/sources/web.py index ca44f9c5..130d4590 100644 --- a/ai/src/ai_server/analyzer/sources/web.py +++ b/ai/src/ai_server/analyzer/sources/web.py @@ -26,10 +26,12 @@ def __init__( *, timeout_sec: float = 20.0, max_html_bytes: int = 2_000_000, + enable_render_fallback: bool = True, client: httpx.AsyncClient | None = None, ) -> None: self._timeout_sec = timeout_sec self._max_html_bytes = max_html_bytes + self._enable_render_fallback = enable_render_fallback self._client = client async def extract(self, locator: str) -> ExtractedSource: @@ -43,6 +45,15 @@ async def extract(self, locator: str) -> ExtractedSource: html, final_url, content_type = await self._fetch_html(url) text = await asyncio.to_thread(_extract_main_text, html, final_url) + rendered = False + + # 본문이 비면 JS 렌더링 SPA(React 포폴 등)일 가능성 → Playwright 로 렌더 후 재추출. + if not text.strip() and self._enable_render_fallback: + rendered_html = await self._render(url) + if rendered_html: + html = rendered_html + text = await asyncio.to_thread(_extract_main_text, html, final_url) + rendered = True if not text.strip(): raise WebFetchError( @@ -60,9 +71,35 @@ async def extract(self, locator: str) -> ExtractedSource: "content_type": content_type, "html_bytes": len(html.encode("utf-8")), "text_chars": len(text), + "rendered": rendered, }, ) + async def _render(self, url: str) -> str | None: + """헤드리스 chromium 으로 페이지를 렌더해 최종 HTML 을 반환. + playwright 미설치/브라우저 미설치/렌더 실패는 None 으로 graceful.""" + try: + from playwright.async_api import async_playwright + except ImportError: + log.warning("web.render.playwright_unavailable", url=url) + return None + try: + async with async_playwright() as p: + browser = await p.chromium.launch(headless=True) + try: + page = await browser.new_page() + await page.goto( + url, + wait_until="networkidle", + timeout=int(self._timeout_sec * 1000), + ) + return await page.content() + finally: + await browser.close() + except Exception as exc: # noqa: BLE001 + log.warning("web.render.failed", url=url, error=str(exc)) + return None + 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) diff --git a/ai/src/ai_server/chain/document_analysis_chain.py b/ai/src/ai_server/chain/document_analysis_chain.py index ac949df2..f3c5dc2f 100644 --- a/ai/src/ai_server/chain/document_analysis_chain.py +++ b/ai/src/ai_server/chain/document_analysis_chain.py @@ -14,9 +14,32 @@ from ai_server.observability.llm_logging_callback import CoreAiLogCallback +class Project(BaseModel): + name: str + role: str = "" + contribution: str = "" + stack: list[str] = Field(default_factory=list) + source_quote: str = Field("", description="원문에서 그대로 따온 근거 인용") + + +class Experience(BaseModel): + title: str + detail: str = "" + source_quote: str = Field("", description="원문 근거 인용") + + +class Skill(BaseModel): + name: str + evidence: str = Field("", description="원문 근거 인용") + + class DocumentAnalysisResult(BaseModel): summary: str = Field(..., description="2~4 sentence Korean summary") tech_stack: list[str] = Field(default_factory=list) + # 구조화 추출 (AI 내부용 — 콜백 계약에는 포함되지 않음, markdown 에 녹여 사용). + projects: list[Project] = Field(default_factory=list) + experiences: list[Experience] = Field(default_factory=list) + skills: list[Skill] = Field(default_factory=list) markdown: str = Field(..., description="Interviewer-facing markdown") @@ -43,8 +66,11 @@ async def analyze( return result -# 프롬프트 -> LLM -> 파서 하나로 묶어서 처리함 -def build_document_analysis_chain(settings: Settings, core_client: CoreClient | None = None) -> Runnable: +# 프롬프트 -> LLM -> 파서 하나로 묶어서 처리함. +# 스키마가 커져 파싱 실패 가능성이 있으므로 with_retry 로 1회 재시도(저비용 안전장치). +def build_document_analysis_chain( + settings: Settings, core_client: CoreClient | None = None +) -> Runnable: from langchain_openai import ChatOpenAI parser = PydanticOutputParser(pydantic_object=DocumentAnalysisResult) @@ -57,11 +83,13 @@ def build_document_analysis_chain(settings: Settings, core_client: CoreClient | callbacks = [] if core_client is not None: - callbacks.append(CoreAiLogCallback( - core_client=core_client, - request_type="analyze.document", - default_model=settings.llm_pro_model, - )) + callbacks.append( + CoreAiLogCallback( + core_client=core_client, + request_type="analyze.document", + default_model=settings.llm_pro_model, + ) + ) llm = ChatOpenAI( model=settings.llm_pro_model, @@ -70,4 +98,4 @@ def build_document_analysis_chain(settings: Settings, core_client: CoreClient | base_url=settings.llm_base_url, callbacks=callbacks, ) - return prompt | llm | parser + return (prompt | llm | parser).with_retry(stop_after_attempt=2) diff --git a/ai/src/ai_server/chain/pdf_vision.py b/ai/src/ai_server/chain/pdf_vision.py new file mode 100644 index 00000000..f66cf244 --- /dev/null +++ b/ai/src/ai_server/chain/pdf_vision.py @@ -0,0 +1,84 @@ +from __future__ import annotations + +import asyncio +import base64 + +import structlog +from langchain_core.messages import HumanMessage + +from ai_server.chain.prompts.pdf_vision import VISION_PROMPT +from ai_server.config.settings import Settings +from ai_server.core.client import CoreClient +from ai_server.observability.llm_logging_callback import CoreAiLogCallback + +log = structlog.get_logger(__name__) + + +def _render_pdf_to_pngs(data: bytes, max_pages: int, dpi: int) -> list[bytes]: + import fitz # pymupdf + + images: list[bytes] = [] + with fitz.open(stream=data, filetype="pdf") as doc: + for i, page in enumerate(doc): + if i >= max_pages: + break + pix = page.get_pixmap(dpi=dpi) + images.append(pix.tobytes("png")) + return images + + +# 게이트웨이 멀티모달 모델로 PDF 페이지 이미지에서 텍스트를 추출한다 (호출 1회). +class LlmVisionPdfReader: + def __init__(self, llm, *, max_pages: int = 5, dpi: int = 150) -> None: + self._llm = llm + self._max_pages = max_pages + self._dpi = dpi + + async def extract_text(self, pdf_bytes: bytes) -> str: + images = await asyncio.to_thread( + _render_pdf_to_pngs, pdf_bytes, self._max_pages, self._dpi + ) + if not images: + return "" + content: list[dict] = [{"type": "text", "text": VISION_PROMPT}] + for png in images: + b64 = base64.b64encode(png).decode("ascii") + content.append( + { + "type": "image_url", + "image_url": {"url": f"data:image/png;base64,{b64}"}, + } + ) + resp = await self._llm.ainvoke([HumanMessage(content=content)]) + text = resp.content + if isinstance(text, list): # 일부 provider 는 content 를 블록 리스트로 반환 + text = "".join(b.get("text", "") for b in text if isinstance(b, dict)) + return text if isinstance(text, str) else "" + + +def build_vision_pdf_reader( + settings: Settings, core_client: CoreClient | None = None +) -> LlmVisionPdfReader: + from langchain_openai import ChatOpenAI + + callbacks = [] + if core_client is not None: + callbacks.append( + CoreAiLogCallback( + core_client=core_client, + request_type="analyze.pdf_vision", + default_model=settings.llm_pro_model, + ) + ) + llm = ChatOpenAI( + model=settings.llm_pro_model, # 멀티모달(gemini-3.1-pro) — 게이트웨이 경유 + temperature=0.0, + api_key=settings.llm_api_key or None, + base_url=settings.llm_base_url, + callbacks=callbacks, + ) + return LlmVisionPdfReader( + llm, + max_pages=settings.pdf_vision_max_pages, + dpi=settings.pdf_vision_dpi, + ) diff --git a/ai/src/ai_server/chain/prompts/document_analysis.py b/ai/src/ai_server/chain/prompts/document_analysis.py index 6cc9784c..c6ce8492 100644 --- a/ai/src/ai_server/chain/prompts/document_analysis.py +++ b/ai/src/ai_server/chain/prompts/document_analysis.py @@ -1,10 +1,17 @@ # 문서 분석 프롬프트 템플릿 -# source_type 으로 PDF, 웹, 리포지토리 구분함 +# source_type 으로 PDF, 웹, 리포지토리 구분함. +# 2단계 지향: ① 원문 근거(source_quote)와 함께 사실을 구조화 추출 → +# ② 추출 결과만 근거로 summary·markdown 생성 (환각 차단). SYSTEM_PROMPT = ( "당신은 IT 직군 채용을 진행하는 시니어 면접관입니다. " - "지원자 자료(이력서·레포 README·기술 블로그 등)를 분석하여 면접 준비에 사용할 " + "지원자 자료(이력서·레포 README·기술 블로그 등)를 분석하여 면접 준비에 쓸 " "구조화된 결과를 산출하세요.\n" - "- 사실에 근거해서 작성하고, 자료에 없는 내용을 추측해 채우지 마세요.\n" + "작업 순서:\n" + "1) 먼저 자료에서 사실을 구조화 추출합니다(projects·experiences·skills). " + "각 항목에는 반드시 원문에서 그대로 따온 짧은 근거 인용(source_quote/evidence)을 답니다. " + "근거를 찾을 수 없는 항목은 추출하지 마세요.\n" + "2) 그 다음, **추출한 항목만을 근거로** summary 와 markdown 을 작성합니다. " + "추출에 없는 내용을 summary/markdown 에 새로 지어내지 마세요.\n" "- 한국어로 작성하되 기술 용어는 영문 원어를 그대로 둡니다.\n" "- 응답은 반드시 지정된 JSON 스키마를 따릅니다." ) @@ -15,9 +22,14 @@ "{text}\n" "---\n\n" "요구 사항:\n" - "1. summary: 2~4문장 한국어 요약.\n" - "2. tech_stack: 자료에 명시된 핵심 기술 5~15개. 영문 표기.\n" - "3. markdown: 면접관이 훑어볼 한국어 마크다운. 섹션 구조는 " - "'## 개요', '## 주요 경험', '## 기술', '## 추가 메모' 사용.\n\n" + "1. projects: 자료에 나타난 프로젝트. 각 항목에 name, role, contribution, " + "stack, source_quote(원문 인용).\n" + "2. experiences: 경력/활동. 각 항목에 title, detail, source_quote.\n" + "3. skills: 핵심 기술. 각 항목에 name, evidence(원문 인용).\n" + "4. tech_stack: skills/projects 에서 도출한 핵심 기술 5~15개. 영문 표기.\n" + "5. summary: 위 추출 내용만 근거로 한 2~4문장 한국어 요약.\n" + "6. markdown: 면접관이 훑어볼 한국어 마크다운. 섹션 구조는 " + "'## 개요', '## 주요 경험', '## 기술', '## 추가 메모' 사용. " + "추출된 projects/experiences/skills 를 반영하되 추측은 넣지 마세요.\n\n" "{format_instructions}" ) diff --git a/ai/src/ai_server/chain/prompts/pdf_vision.py b/ai/src/ai_server/chain/prompts/pdf_vision.py new file mode 100644 index 00000000..44a5438a --- /dev/null +++ b/ai/src/ai_server/chain/prompts/pdf_vision.py @@ -0,0 +1,10 @@ +# 이미지/스캔 PDF 비전 추출 프롬프트. +# 렌더된 페이지 이미지를 받아 원문 텍스트를 그대로 옮긴다 (요약/해석 금지). +VISION_PROMPT = ( + "다음은 지원자 이력서 PDF 를 페이지별로 렌더한 이미지입니다. " + "각 이미지에 보이는 텍스트를 **있는 그대로** 추출해 한국어/영문 원문 그대로 옮기세요.\n" + "- 요약하거나 해석하지 말고, 보이는 내용을 그대로 전사합니다.\n" + "- 표/목록 구조는 가능한 한 유지합니다.\n" + "- 이미지에 없는 내용을 지어내지 마세요.\n" + "- 텍스트만 출력하세요(설명 문구 없이)." +) diff --git a/ai/src/ai_server/config/settings.py b/ai/src/ai_server/config/settings.py index 42fa0fa5..01d8eaa2 100644 --- a/ai/src/ai_server/config/settings.py +++ b/ai/src/ai_server/config/settings.py @@ -130,6 +130,10 @@ class Settings(BaseSettings): embedding_chunk_overlap: int = 200 embedding_batch_size: int = 32 + # PDF Vision (이미지/스캔 PDF 폴백 — 게이트웨이 멀티모달) + pdf_vision_max_pages: int = 5 + pdf_vision_dpi: int = 150 + gemini_api_key: str = "" diff --git a/ai/src/ai_server/messaging/runner.py b/ai/src/ai_server/messaging/runner.py index a270b04d..ff1489fe 100644 --- a/ai/src/ai_server/messaging/runner.py +++ b/ai/src/ai_server/messaging/runner.py @@ -13,6 +13,7 @@ LlmDocumentAnalyzer, build_document_analysis_chain, ) +from ai_server.chain.pdf_vision import build_vision_pdf_reader from ai_server.chain.followup_generation_chain import ( LlmFollowupGenerator, build_followup_generation_chain, @@ -97,10 +98,13 @@ def __init__(self, settings: Settings) -> None: gemini_api_key=settings.gemini_api_key, ) reranker = build_reranker(settings, core_client=core_client) + vision_pdf_reader = build_vision_pdf_reader(settings, core_client=core_client) # 이력서 PDF resume_analyzer = ResumeAnalyzer( - extractor=PdfSourceExtractor(storage=storage), + extractor=PdfSourceExtractor( + storage=storage, vision_reader=vision_pdf_reader + ), chain=chain_analyzer, storage=storage, chunker=chunker, diff --git a/ai/tests/test_document_chain.py b/ai/tests/test_document_chain.py index 32317641..2c2f4b46 100644 --- a/ai/tests/test_document_chain.py +++ b/ai/tests/test_document_chain.py @@ -33,3 +33,29 @@ async def fake_invoke(_: dict) -> dict: analyzer = LlmDocumentAnalyzer(RunnableLambda(fake_invoke)) with pytest.raises(TypeError): await analyzer.analyze(text="x", source_type="PDF") + + +def test_parser_parses_structured_extraction_with_source_quotes() -> None: + from langchain_core.output_parsers import PydanticOutputParser + + parser = PydanticOutputParser(pydantic_object=DocumentAnalysisResult) + obj = parser.parse( + '{"summary":"백엔드","tech_stack":["Go"],' + '"projects":[{"name":"결제","role":"BE","contribution":"분산락","stack":["Go"],' + '"source_quote":"결제 시스템에서 분산락을 도입"}],' + '"experiences":[{"title":"스타트업","detail":"3년","source_quote":"3년 차"}],' + '"skills":[{"name":"Kafka","evidence":"Kafka 로 처리량 3배"}],' + '"markdown":"## 개요\\n..."}' + ) + assert obj.projects[0].name == "결제" + assert obj.projects[0].source_quote == "결제 시스템에서 분산락을 도입" + assert obj.experiences[0].title == "스타트업" + assert obj.skills[0].evidence == "Kafka 로 처리량 3배" + + +def test_result_backward_compatible_defaults() -> None: + # 구조화 필드 없이도(기존 호출부) 생성 가능 — 콜백 계약 불변 보장 + r = DocumentAnalysisResult(summary="s", tech_stack=["x"], markdown="## 개요") + assert r.projects == [] + assert r.experiences == [] + assert r.skills == [] diff --git a/ai/tests/test_github_repo_extractor.py b/ai/tests/test_github_repo_extractor.py index 9fa165ab..9ecb6b7c 100644 --- a/ai/tests/test_github_repo_extractor.py +++ b/ai/tests/test_github_repo_extractor.py @@ -142,6 +142,84 @@ async def test_extract_rejects_malformed_locator() -> None: assert exc_info.value.code == "INVALID_REPO_LOCATOR" +@pytest.mark.asyncio +async def test_extract_includes_contributor_analysis_with_user_token() -> None: + routes = { + "/repos/user/repo": {"default_branch": "dev", "description": "demo"}, + "/user": {"login": "alice"}, + "/repos/user/repo/commits?author=alice&sha=dev&per_page=100": [ + {"sha": "c1"}, + {"sha": "c2"}, + ], + "/repos/user/repo/commits/c1": {"files": [{"filename": "src/pay.py"}]}, + "/repos/user/repo/commits/c2": { + "files": [{"filename": "src/pay.py"}, {"filename": "src/util.py"}] + }, + "/repos/user/repo/readme": 404, + "/repos/user/repo/git/trees/dev?recursive=1": { + "tree": [ + {"path": "src/pay.py", "type": "blob"}, + {"path": "src/util.py", "type": "blob"}, + {"path": "src/other.py", "type": "blob"}, + ], + "truncated": False, + }, + "/repos/user/repo/contents/src/pay.py?ref=dev": { + "encoding": "base64", + "content": _b64("def pay(): ..."), + }, + "/repos/user/repo/contents/src/util.py?ref=dev": { + "encoding": "base64", + "content": _b64("def util(): ..."), + }, + "/repos/user/repo/contents/src/other.py?ref=dev": { + "encoding": "base64", + "content": _b64("def other(): ..."), + }, + } + client, _ = _make_client(routes) + extractor = GitHubRepoSourceExtractor( + api_base_url="https://api.github.com", client=client + ) + + result = await extractor.extract("user/repo", access_token="alice-token") + + assert "## 지원자 기여" in result.text + assert "@alice" in result.text + assert "커밋 2개" in result.text + assert result.metadata["contributor_login"] == "alice" + assert result.metadata["contrib_commit_count"] == 2 + # 기여 파일(src/pay.py)이 샘플링 우선순위에 들어감 + assert "src/pay.py" in result.metadata["sampled_files"] + + +@pytest.mark.asyncio +async def test_extract_skips_contribution_without_user_token() -> 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, getter = _make_client(routes) + extractor = GitHubRepoSourceExtractor( + api_base_url="https://api.github.com", client=client + ) + result = await extractor.extract("user/repo") # access_token 없음 + # /user 를 호출하지 않음 (기여도 분석 스킵) + called_paths = [c.args[0] for c in getter.call_args_list] + assert "/user" not in called_paths + assert result.metadata["contrib_commit_count"] == 0 + + +def test_select_files_prioritizes_contributed_files_first() -> None: + paths = ["package.json", "src/index.ts", "src/feature/pay.ts"] + picked = _select_files(paths, cap=2, prioritized=["src/feature/pay.ts"]) + assert picked[0] == "src/feature/pay.ts" # 기여 파일이 최우선 + + def test_select_files_prioritizes_manifests_then_one_per_dir() -> None: paths = [ "package.json", diff --git a/ai/tests/test_pdf_extractor.py b/ai/tests/test_pdf_extractor.py index 1d4bb988..0ed23dfa 100644 --- a/ai/tests/test_pdf_extractor.py +++ b/ai/tests/test_pdf_extractor.py @@ -64,3 +64,53 @@ async def test_extract_returns_empty_on_empty_pdf() -> None: assert result.text == "" assert result.source_type == "PDF" + + +@pytest.mark.asyncio +async def test_extract_falls_back_to_vision_when_text_sparse() -> None: + storage = AsyncMock() + storage.get_bytes.return_value = b"PDF-BYTES" + vision = AsyncMock() + vision.extract_text = AsyncMock(return_value="비전으로 추출한 이력서 본문") + + with patch("ai_server.analyzer.sources.pdf._extract_pdf_text", return_value=""): + extractor = PdfSourceExtractor(storage=storage, vision_reader=vision) + result = await extractor.extract("scanned.pdf") + + vision.extract_text.assert_awaited_once_with(b"PDF-BYTES") + assert result.text == "비전으로 추출한 이력서 본문" + assert result.metadata["vision"] is True + + +@pytest.mark.asyncio +async def test_extract_skips_vision_when_text_sufficient() -> None: + storage = AsyncMock() + storage.get_bytes.return_value = b"PDF-BYTES" + vision = AsyncMock() + vision.extract_text = AsyncMock(return_value="should not be used") + long_text = "충분히 긴 텍스트 레이어가 있는 일반 PDF 입니다. " * 3 + + with patch( + "ai_server.analyzer.sources.pdf._extract_pdf_text", return_value=long_text + ): + extractor = PdfSourceExtractor(storage=storage, vision_reader=vision) + result = await extractor.extract("normal.pdf") + + vision.extract_text.assert_not_awaited() + assert "충분히 긴 텍스트 레이어" in result.text + assert result.metadata["vision"] is False + + +@pytest.mark.asyncio +async def test_extract_vision_failure_falls_back_gracefully() -> None: + storage = AsyncMock() + storage.get_bytes.return_value = b"PDF-BYTES" + vision = AsyncMock() + vision.extract_text = AsyncMock(side_effect=RuntimeError("vision down")) + + with patch("ai_server.analyzer.sources.pdf._extract_pdf_text", return_value=""): + extractor = PdfSourceExtractor(storage=storage, vision_reader=vision) + result = await extractor.extract("scanned.pdf") + + assert result.text == "" # 비전 실패 → 원래 텍스트(빈값)로 graceful + assert result.metadata["vision"] is False diff --git a/ai/tests/test_pdf_vision.py b/ai/tests/test_pdf_vision.py new file mode 100644 index 00000000..9fbb295b --- /dev/null +++ b/ai/tests/test_pdf_vision.py @@ -0,0 +1,58 @@ +from types import SimpleNamespace +from unittest.mock import AsyncMock, patch + +import pytest + +from ai_server.chain.pdf_vision import LlmVisionPdfReader + + +@pytest.mark.asyncio +async def test_vision_reader_sends_images_and_returns_text() -> None: + fake_llm = SimpleNamespace( + ainvoke=AsyncMock(return_value=SimpleNamespace(content="추출된 텍스트")) + ) + reader = LlmVisionPdfReader(fake_llm, max_pages=3, dpi=100) + + with patch( + "ai_server.chain.pdf_vision._render_pdf_to_pngs", + return_value=[b"png-a", b"png-b"], + ): + out = await reader.extract_text(b"PDF") + + assert out == "추출된 텍스트" + # LLM 에 텍스트 프롬프트 + 이미지 2장이 한 메시지로 전달됨 + msgs = fake_llm.ainvoke.await_args.args[0] + content = msgs[0].content + assert content[0]["type"] == "text" + image_blocks = [c for c in content if c["type"] == "image_url"] + assert len(image_blocks) == 2 + assert image_blocks[0]["image_url"]["url"].startswith("data:image/png;base64,") + + +@pytest.mark.asyncio +async def test_vision_reader_empty_when_no_pages() -> None: + fake_llm = SimpleNamespace(ainvoke=AsyncMock()) + reader = LlmVisionPdfReader(fake_llm) + with patch("ai_server.chain.pdf_vision._render_pdf_to_pngs", return_value=[]): + out = await reader.extract_text(b"PDF") + assert out == "" + fake_llm.ainvoke.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_vision_reader_handles_block_list_content() -> None: + # 일부 provider 는 content 를 [{type,text}] 블록 리스트로 반환 + fake_llm = SimpleNamespace( + ainvoke=AsyncMock( + return_value=SimpleNamespace( + content=[ + {"type": "text", "text": "조각1 "}, + {"type": "text", "text": "조각2"}, + ] + ) + ) + ) + reader = LlmVisionPdfReader(fake_llm) + with patch("ai_server.chain.pdf_vision._render_pdf_to_pngs", return_value=[b"png"]): + out = await reader.extract_text(b"PDF") + assert out == "조각1 조각2" diff --git a/ai/tests/test_web_extractor.py b/ai/tests/test_web_extractor.py index 3bd75c66..93619f02 100644 --- a/ai/tests/test_web_extractor.py +++ b/ai/tests/test_web_extractor.py @@ -100,13 +100,40 @@ async def test_rejects_oversized_html() -> None: @pytest.mark.asyncio async def test_raises_on_empty_body() -> None: client = _make_client(body="
") - extractor = WebSourceExtractor(client=client) + extractor = WebSourceExtractor(client=client, enable_render_fallback=False) with pytest.raises(WebFetchError) as exc_info: await extractor.extract("https://example.com/r") assert exc_info.value.code == "EMPTY_WEB_BODY" assert exc_info.value.retriable is False +@pytest.mark.asyncio +async def test_empty_body_falls_back_to_render() -> None: + # 1차 fetch = JS 셸(본문 없음) → 렌더 폴백으로 본문 확보 + client = _make_client(body='') + extractor = WebSourceExtractor(client=client) + rendered_html = ( + "프론트엔드 개발자. React 포트폴리오.