Skip to content

Commit 7159f7c

Browse files
authored
Merge pull request #62 from Team-StackUp/feature/analysis-quality
feat(analysis): 분석 파이프라인 고도화 — 구조화추출·기여도·Playwright·Vision PDF
2 parents 36cc9d8 + f110799 commit 7159f7c

17 files changed

Lines changed: 638 additions & 24 deletions

ai/Dockerfile

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,9 @@ WORKDIR /app
77
COPY pyproject.toml uv.lock ./
88
RUN uv sync --frozen --no-dev
99

10+
# 웹 이력서(JS 렌더링 SPA) 폴백용 헤드리스 chromium + 시스템 의존성.
11+
RUN uv run playwright install --with-deps chromium
12+
1013
COPY src/ src/
1114

1215
EXPOSE 38030

ai/pyproject.toml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,9 @@ dependencies = [
1616
"boto3>=1.42.77",
1717
"aiofiles>=24.1.0",
1818
"pypdf>=5.1.0",
19+
"pymupdf>=1.24.0",
1920
"trafilatura>=2.0.0",
21+
"playwright>=1.49.0",
2022
"langchain-text-splitters>=0.3.0",
2123
"google-genai>=1.0.0",
2224
"langchain>=1.2.13",

ai/src/ai_server/analyzer/sources/github_repo.py

Lines changed: 111 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -59,13 +59,21 @@ def __init__(self, *, code: str, message: str, retriable: bool) -> None:
5959
self.retriable = retriable
6060

6161

62+
@dataclass(frozen=True)
63+
class ContributionInfo:
64+
login: str
65+
commit_count: int
66+
authored_files: list[str]
67+
68+
6269
@dataclass(frozen=True)
6370
class _RepoConfig:
6471
api_base_url: str
6572
fallback_token: str
6673
max_files: int
6774
max_file_bytes: int
6875
timeout_sec: float
76+
max_contrib_commits: int
6977

7078

7179
# 리드미, 주요 소스를 읽는다
@@ -78,6 +86,7 @@ def __init__(
7886
max_files: int = 8,
7987
max_file_bytes: int = 50_000,
8088
timeout_sec: float = 30.0,
89+
max_contrib_commits: int = 15,
8190
client: httpx.AsyncClient | None = None,
8291
) -> None:
8392
self._cfg = _RepoConfig(
@@ -86,6 +95,7 @@ def __init__(
8695
max_files=max_files,
8796
max_file_bytes=max_file_bytes,
8897
timeout_sec=timeout_sec,
98+
max_contrib_commits=max_contrib_commits,
8999
)
90100
self._client = client
91101

@@ -109,12 +119,18 @@ async def extract(
109119
)
110120

111121
effective_token = access_token or self._cfg.fallback_token
122+
# 기여도 분석은 지원자 본인 token 이 있을 때만 의미 있음(GET /user = 지원자).
123+
has_user_token = bool(access_token)
112124

113125
if self._client is not None:
114-
return await self._extract_with_client(self._client, owner_repo)
126+
return await self._extract_with_client(
127+
self._client, owner_repo, has_user_token=has_user_token
128+
)
115129

116130
async with self._build_client(effective_token) as client:
117-
return await self._extract_with_client(client, owner_repo)
131+
return await self._extract_with_client(
132+
client, owner_repo, has_user_token=has_user_token
133+
)
118134

119135
def _build_client(self, token: str) -> httpx.AsyncClient:
120136
headers = {
@@ -133,15 +149,24 @@ async def _extract_with_client(
133149
self,
134150
client: httpx.AsyncClient,
135151
owner_repo: str,
152+
*,
153+
has_user_token: bool = False,
136154
) -> ExtractedSource:
137155
repo_info = await self._fetch_json(client, f"/repos/{owner_repo}")
138156
default_branch = repo_info.get("default_branch") or "main"
139157

158+
contribution: ContributionInfo | None = None
159+
if has_user_token:
160+
contribution = await self._fetch_contribution(
161+
client, owner_repo, default_branch
162+
)
163+
140164
readme_text = await self._fetch_readme(client, owner_repo)
141165
tree_paths, truncated = await self._fetch_tree(
142166
client, owner_repo, default_branch
143167
)
144-
picked = _select_files(tree_paths, self._cfg.max_files)
168+
authored = contribution.authored_files if contribution else []
169+
picked = _select_files(tree_paths, self._cfg.max_files, prioritized=authored)
145170
snippets = await self._fetch_snippets(
146171
client, owner_repo, default_branch, picked
147172
)
@@ -151,6 +176,16 @@ async def _extract_with_client(
151176
if desc := repo_info.get("description"):
152177
parts.append(f"\n> {desc}")
153178

179+
# 다중 기여자 레포에서 "지원자가 실제 쓴 코드" 를 구분하기 위한 기여 요약.
180+
if contribution and contribution.commit_count:
181+
parts.append("\n## 지원자 기여\n")
182+
parts.append(
183+
f"- 지원자(@{contribution.login}) 커밋 {contribution.commit_count}개"
184+
)
185+
if contribution.authored_files:
186+
top = contribution.authored_files[:10]
187+
parts.append("- 주요 작성 파일:\n" + "\n".join(f" - {p}" for p in top))
188+
154189
if readme_text:
155190
parts.append("\n## README\n")
156191
parts.append(readme_text)
@@ -175,9 +210,64 @@ async def _extract_with_client(
175210
"tree_size": len(tree_paths),
176211
"tree_truncated": truncated,
177212
"sampled_files": [p for p, _ in snippets],
213+
"contributor_login": contribution.login if contribution else None,
214+
"contrib_commit_count": (
215+
contribution.commit_count if contribution else 0
216+
),
178217
},
179218
)
180219

220+
# 지원자 token 으로 GET /user → login 확보 후, 그 login 이 author 인 커밋을
221+
# 집계해 기여 파일을 추린다. 실패(권한/rate limit/공개레포)는 None 으로 graceful.
222+
async def _fetch_contribution(
223+
self,
224+
client: httpx.AsyncClient,
225+
owner_repo: str,
226+
branch: str,
227+
) -> ContributionInfo | None:
228+
try:
229+
me = await self._fetch_json(client, "/user")
230+
except RepositoryFetchError as err:
231+
log.warning("repo.contrib.user_skip", code=err.code)
232+
return None
233+
login = me.get("login") if isinstance(me, dict) else None
234+
if not login:
235+
return None
236+
237+
try:
238+
commits = await self._fetch_json(
239+
client,
240+
f"/repos/{owner_repo}/commits"
241+
f"?author={login}&sha={branch}&per_page=100",
242+
)
243+
except RepositoryFetchError as err:
244+
log.warning("repo.contrib.commits_skip", code=err.code)
245+
return None
246+
if not isinstance(commits, list) or not commits:
247+
return ContributionInfo(login=login, commit_count=0, authored_files=[])
248+
249+
file_counter: dict[str, int] = {}
250+
for commit in commits[: self._cfg.max_contrib_commits]:
251+
sha = commit.get("sha") if isinstance(commit, dict) else None
252+
if not sha:
253+
continue
254+
try:
255+
detail = await self._fetch_json(
256+
client, f"/repos/{owner_repo}/commits/{sha}"
257+
)
258+
except RepositoryFetchError:
259+
continue
260+
files = detail.get("files") if isinstance(detail, dict) else None
261+
for f in files or []:
262+
path = f.get("filename") if isinstance(f, dict) else None
263+
if path:
264+
file_counter[path] = file_counter.get(path, 0) + 1
265+
266+
authored = sorted(file_counter, key=lambda p: file_counter[p], reverse=True)
267+
return ContributionInfo(
268+
login=login, commit_count=len(commits), authored_files=authored
269+
)
270+
181271
async def _fetch_json(self, client: httpx.AsyncClient, path: str) -> dict:
182272
resp = await client.get(path)
183273
if resp.status_code == 404:
@@ -267,10 +357,26 @@ async def _fetch_snippets(
267357
return out
268358

269359

270-
def _select_files(paths: list[str], cap: int) -> list[str]:
271-
"""우선순위 → 디렉토리당 대표 텍스트 파일 1개씩, 합쳐서 cap까지."""
360+
def _select_files(
361+
paths: list[str], cap: int, *, prioritized: list[str] | None = None
362+
) -> list[str]:
363+
"""지원자 기여 파일 → 설정파일 우선순위 → 디렉토리당 대표 텍스트 파일, cap까지."""
272364
chosen: list[str] = []
273365
seen: set[str] = set()
366+
tree = set(paths)
367+
368+
# 지원자가 실제 작성한 텍스트 파일을 가장 먼저 (트리에 존재 + 텍스트 확장자).
369+
for p in prioritized or []:
370+
if p in seen or p not in tree:
371+
continue
372+
if "." not in p.rsplit("/", 1)[-1]:
373+
continue
374+
if ("." + p.rsplit(".", 1)[-1].lower()) not in _TEXT_EXTENSIONS:
375+
continue
376+
chosen.append(p)
377+
seen.add(p)
378+
if len(chosen) >= cap:
379+
return chosen
274380

275381
for needle in _PRIORITY_FILES:
276382
for p in paths:

ai/src/ai_server/analyzer/sources/pdf.py

Lines changed: 33 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,12 +2,16 @@
22

33
import asyncio
44
import io
5+
from typing import Protocol
56

7+
import structlog
68
from pypdf import PdfReader
79

810
from ai_server.analyzer.sources.base import ExtractedSource, SourceExtractor
911
from ai_server.storage.base import ObjectStorage
1012

13+
log = structlog.get_logger(__name__)
14+
1115

1216
def _extract_pdf_text(data: bytes) -> str:
1317
reader = PdfReader(io.BytesIO(data))
@@ -17,17 +21,44 @@ def _extract_pdf_text(data: bytes) -> str:
1721
return "\n\n".join(p for p in parts if p).strip()
1822

1923

24+
# 이미지/스캔 PDF 의 텍스트를 비전 모델로 추출하는 어댑터 (구현체는 chain/pdf_vision).
25+
class VisionPdfReader(Protocol):
26+
async def extract_text(self, pdf_bytes: bytes) -> str: ...
27+
28+
2029
# PDF 를 읽어 페이지 텍스트를 이어붙인다.
30+
# 텍스트 레이어가 비거나 너무 짧으면(스캔/이미지 PDF) vision_reader 로 폴백.
2131
class PdfSourceExtractor(SourceExtractor):
2232

23-
def __init__(self, storage: ObjectStorage) -> None:
33+
def __init__(
34+
self,
35+
storage: ObjectStorage,
36+
*,
37+
vision_reader: VisionPdfReader | None = None,
38+
min_text_chars: int = 50,
39+
) -> None:
2440
self._storage = storage
41+
self._vision_reader = vision_reader
42+
self._min_text_chars = min_text_chars
2543

2644
async def extract(self, locator: str) -> ExtractedSource:
2745
data = await self._storage.get_bytes(locator)
2846
text = await asyncio.to_thread(_extract_pdf_text, data)
47+
used_vision = False
48+
49+
# 텍스트가 빈약하면(스캔/이미지 PDF) 비전 모델로 추출 시도.
50+
if len(text.strip()) < self._min_text_chars and self._vision_reader is not None:
51+
try:
52+
vision_text = await self._vision_reader.extract_text(data)
53+
except Exception as exc: # noqa: BLE001
54+
log.warning("pdf.vision.failed", locator=locator, error=str(exc))
55+
vision_text = ""
56+
if vision_text.strip():
57+
text = vision_text.strip()
58+
used_vision = True
59+
2960
return ExtractedSource(
3061
text=text,
3162
source_type="PDF",
32-
metadata={"locator": locator, "bytes": len(data)},
63+
metadata={"locator": locator, "bytes": len(data), "vision": used_vision},
3364
)

ai/src/ai_server/analyzer/sources/web.py

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,10 +26,12 @@ def __init__(
2626
*,
2727
timeout_sec: float = 20.0,
2828
max_html_bytes: int = 2_000_000,
29+
enable_render_fallback: bool = True,
2930
client: httpx.AsyncClient | None = None,
3031
) -> None:
3132
self._timeout_sec = timeout_sec
3233
self._max_html_bytes = max_html_bytes
34+
self._enable_render_fallback = enable_render_fallback
3335
self._client = client
3436

3537
async def extract(self, locator: str) -> ExtractedSource:
@@ -43,6 +45,15 @@ async def extract(self, locator: str) -> ExtractedSource:
4345

4446
html, final_url, content_type = await self._fetch_html(url)
4547
text = await asyncio.to_thread(_extract_main_text, html, final_url)
48+
rendered = False
49+
50+
# 본문이 비면 JS 렌더링 SPA(React 포폴 등)일 가능성 → Playwright 로 렌더 후 재추출.
51+
if not text.strip() and self._enable_render_fallback:
52+
rendered_html = await self._render(url)
53+
if rendered_html:
54+
html = rendered_html
55+
text = await asyncio.to_thread(_extract_main_text, html, final_url)
56+
rendered = True
4657

4758
if not text.strip():
4859
raise WebFetchError(
@@ -60,9 +71,35 @@ async def extract(self, locator: str) -> ExtractedSource:
6071
"content_type": content_type,
6172
"html_bytes": len(html.encode("utf-8")),
6273
"text_chars": len(text),
74+
"rendered": rendered,
6375
},
6476
)
6577

78+
async def _render(self, url: str) -> str | None:
79+
"""헤드리스 chromium 으로 페이지를 렌더해 최종 HTML 을 반환.
80+
playwright 미설치/브라우저 미설치/렌더 실패는 None 으로 graceful."""
81+
try:
82+
from playwright.async_api import async_playwright
83+
except ImportError:
84+
log.warning("web.render.playwright_unavailable", url=url)
85+
return None
86+
try:
87+
async with async_playwright() as p:
88+
browser = await p.chromium.launch(headless=True)
89+
try:
90+
page = await browser.new_page()
91+
await page.goto(
92+
url,
93+
wait_until="networkidle",
94+
timeout=int(self._timeout_sec * 1000),
95+
)
96+
return await page.content()
97+
finally:
98+
await browser.close()
99+
except Exception as exc: # noqa: BLE001
100+
log.warning("web.render.failed", url=url, error=str(exc))
101+
return None
102+
66103
async def _fetch_html(self, url: str) -> tuple[str, str, str]:
67104
if self._client is not None:
68105
return await self._do_fetch(self._client, url)

0 commit comments

Comments
 (0)