Skip to content

Commit a4ad359

Browse files
authored
Merge pull request #26 from Team-StackUp/feature/sprint1-analysis-integration
Feature/analysis integration - 수신 처리 인프라 통합
2 parents 36a064b + 8112bfe commit a4ad359

99 files changed

Lines changed: 5839 additions & 139 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

ai/.env.example

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,3 +18,29 @@ LLM_API_KEY=
1818
LLM_BASE_URL=https://factchat-cloud.mindlogic.ai/v1/gateway
1919
LLM_PRO_MODEL=gemini-3.1-pro-preview
2020
LLM_PRO_TEMPERATURE=0.2
21+
22+
# Github 관련
23+
CORE_INTERNAL_BASE_URL=http://localhost:38010
24+
CORE_INTERNAL_API_KEY=
25+
CORE_INTERNAL_TIMEOUT_SEC=10
26+
27+
GITHUB_FALLBACK_TOKEN=
28+
GITHUB_API_BASE_URL=https://api.github.com
29+
REPO_MAX_SOURCE_FILES=8
30+
REPO_MAX_SOURCE_FILE_BYTES=50000
31+
REPO_FETCH_TIMEOUT_SEC=30
32+
33+
# 웹 이력서 관련
34+
WEB_FETCH_TIMEOUT_SEC=20
35+
WEB_MAX_HTML_BYTES=2000000
36+
37+
# 개발 및 테스트는 mock
38+
# 운영은 gemini
39+
EMBEDDING_PROVIDER=gemini
40+
EMBEDDING_MODEL=gemini-embedding-001
41+
EMBEDDING_DIM=1536
42+
EMBEDDING_CHUNK_SIZE=1000
43+
EMBEDDING_CHUNK_OVERLAP=200
44+
EMBEDDING_BATCH_SIZE=32
45+
46+
GEMINI_API_KEY=

ai/CLAUDE.md

Lines changed: 15 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -88,11 +88,13 @@ ai/
8888

8989
본 서버는 RabbitMQ **consumer**로 작동.
9090

91-
| Queue | Bind |
92-
|-------|------|
93-
| `q.ai.resume` | `ai.request.resume.*` |
94-
| `q.ai.repo` | `ai.request.repo.*` |
95-
| `q.ai.session` | `ai.request.session.*` |
91+
| Queue | Routing key | 상태 |
92+
|-------|-------------|------|
93+
| `ai.analyze.resume` | `analyze.resume` | 본 구현 (PDF → MD) |
94+
| `ai.analyze.repository` | `analyze.repository` | 본 구현 (GitHub README + tree + 소스 sampling) |
95+
| `ai.analyze.web` | `analyze.web` | 본 구현 (URL → trafilatura) |
96+
| `ai.generate.questions` | `generate.questions` | 큐만, 코드 미구현 |
97+
| `ai.generate.followup` | `generate.followup` | 큐만, 코드 미구현 |
9698

9799
콜백 발행: `ai.callback.{type}` 익스체인지.
98100
상세 envelope/스키마/재시도: [`/docs/messaging.md`](../docs/messaging.md).
@@ -162,11 +164,14 @@ chain = prompt | llm | PydanticOutputParser(pydantic_object=...)
162164

163165
## 7. RAG 파이프라인
164166

165-
### 7.1 인제스트
166-
1. 마크다운 입력
167-
2. 청킹 (LangChain `RecursiveCharacterTextSplitter`, chunk_size=1000, overlap=200)
168-
3. 임베딩 생성 (Gemini `text-embedding-004` 또는 OpenAI `text-embedding-3-small`)
169-
4. Core API 호출 → pgvector INSERT
167+
### 7.1 인제스트 (본 구현)
168+
1. 마크다운 입력 (`analyzer/_embedding_step.chunk_embed_and_upsert`)
169+
2. 청킹 — `rag/chunker.MarkdownChunker` (`RecursiveCharacterTextSplitter`, 기본 1000/200)
170+
3. 임베딩 생성 — `rag/embedder.EmbeddingProvider`. 현재 구현체:
171+
- `MockEmbeddingProvider` (default) — 차원 결정 보류, e2e 흐름 검증용
172+
- `openai` / `ollama` 구현체는 후속 PR
173+
4. Core API 호출 — `CoreClient.upsert_embeddings(document_id, model, dim, chunks)`
174+
`PUT /api/internal/documents/{id}/embeddings` (idempotent upsert)
170175

171176
### 7.2 검색
172177
1. 쿼리 텍스트 → 임베딩

ai/pyproject.toml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,9 @@ dependencies = [
1616
"boto3>=1.42.77",
1717
"aiofiles>=24.1.0",
1818
"pypdf>=5.1.0",
19+
"trafilatura>=2.0.0",
20+
"langchain-text-splitters>=0.3.0",
21+
"google-genai>=1.0.0",
1922
"langchain>=1.2.13",
2023
"langchain-core>=1.2.22",
2124
"langchain-community>=0.4.1",

ai/src/ai_server/analyzer/__init__.py

Whitespace-only changes.
Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
# 공통 임베딩 모듈
2+
from __future__ import annotations
3+
4+
import structlog
5+
6+
from ai_server.core.client import (
7+
CoreClient,
8+
CoreEmbeddingUpsertError,
9+
EmbeddingChunkPayload,
10+
)
11+
from ai_server.rag.chunker import MarkdownChunker
12+
from ai_server.rag.embedder import EmbeddingError, EmbeddingProvider
13+
14+
log = structlog.get_logger(__name__)
15+
16+
17+
class EmbeddingStepError(Exception):
18+
def __init__(self, *, code: str, message: str, retriable: bool) -> None:
19+
super().__init__(message)
20+
self.code = code
21+
self.message = message
22+
self.retriable = retriable
23+
24+
25+
async def chunk_embed_and_upsert(
26+
*,
27+
document_id: int,
28+
markdown: str,
29+
chunker: MarkdownChunker,
30+
embedder: EmbeddingProvider,
31+
core_client: CoreClient,
32+
log_prefix: str = "analyze",
33+
) -> int:
34+
chunks = chunker.split(markdown)
35+
log.info(
36+
f"{log_prefix}.chunk.done",
37+
document_id=document_id,
38+
chunk_count=len(chunks),
39+
)
40+
41+
if not chunks:
42+
return 0
43+
44+
try:
45+
vectors = await embedder.embed([c.text for c in chunks])
46+
except EmbeddingError as err:
47+
raise EmbeddingStepError(
48+
code=err.code, message=err.message, retriable=err.retriable
49+
) from err
50+
51+
if len(vectors) != len(chunks):
52+
raise EmbeddingStepError(
53+
code="EMBED_COUNT_MISMATCH",
54+
message=(f"embedder가 chunk {len(chunks)}개 중 {len(vectors)}개만 반환"),
55+
retriable=True,
56+
)
57+
58+
payloads = [
59+
EmbeddingChunkPayload(
60+
chunk_index=chunks[i].index,
61+
chunk_text=chunks[i].text,
62+
embedding=vectors[i],
63+
)
64+
for i in range(len(chunks))
65+
]
66+
67+
try:
68+
upserted = await core_client.upsert_embeddings(
69+
document_id=document_id,
70+
model=embedder.model,
71+
dim=embedder.dim,
72+
chunks=payloads,
73+
)
74+
except CoreEmbeddingUpsertError as err:
75+
raise EmbeddingStepError(
76+
code=err.code, message=err.message, retriable=err.retriable
77+
) from err
78+
79+
log.info(
80+
f"{log_prefix}.embed.upserted",
81+
document_id=document_id,
82+
chunk_count=upserted,
83+
model=embedder.model,
84+
dim=embedder.dim,
85+
)
86+
return upserted
Lines changed: 150 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,150 @@
1+
from __future__ import annotations
2+
3+
from dataclasses import dataclass
4+
5+
import structlog
6+
7+
from ai_server.analyzer._embedding_step import (
8+
EmbeddingStepError,
9+
chunk_embed_and_upsert,
10+
)
11+
from ai_server.analyzer.sources.github_repo import (
12+
GitHubRepoSourceExtractor,
13+
RepositoryFetchError,
14+
)
15+
from ai_server.chain.document_analysis_chain import DocumentAnalyzer
16+
from ai_server.core.client import CoreClient, CoreTokenError
17+
from ai_server.rag.chunker import MarkdownChunker
18+
from ai_server.rag.embedder import EmbeddingProvider
19+
from ai_server.storage.base import ObjectStorage
20+
21+
log = structlog.get_logger(__name__)
22+
23+
24+
class RepositoryAnalyzeError(Exception):
25+
def __init__(self, *, code: str, message: str, retriable: bool) -> None:
26+
super().__init__(message)
27+
self.code = code
28+
self.message = message
29+
self.retriable = retriable
30+
31+
32+
@dataclass(frozen=True)
33+
class RepositoryAnalysisResult:
34+
summary: str
35+
tech_stack: list[str]
36+
document_path: str
37+
embedding_chunk_count: int
38+
39+
40+
# Core에서 사용자별 GitHub token 수령 → 레포 fetch → LLM 분석 → 마크다운 저장 → 청킹·임베딩
41+
class RepositoryAnalyzer:
42+
def __init__(
43+
self,
44+
*,
45+
extractor: GitHubRepoSourceExtractor,
46+
core_client: CoreClient,
47+
chain: DocumentAnalyzer,
48+
storage: ObjectStorage,
49+
chunker: MarkdownChunker,
50+
embedder: EmbeddingProvider,
51+
analyzed_key_template: str,
52+
) -> None:
53+
self._extractor = extractor
54+
self._core_client = core_client
55+
self._chain = chain
56+
self._storage = storage
57+
self._chunker = chunker
58+
self._embedder = embedder
59+
self._analyzed_key_template = analyzed_key_template
60+
61+
async def analyze(
62+
self,
63+
*,
64+
repository_id: int,
65+
repo_full_name: str,
66+
default_branch: str = "main",
67+
user_id: int | None,
68+
analyzed_document_id: int,
69+
) -> RepositoryAnalysisResult:
70+
if user_id is None:
71+
raise RepositoryAnalyzeError(
72+
code="MISSING_USER_ID",
73+
message="envelope.context.userId 없이는 GitHub token을 가져올 수 없음",
74+
retriable=False,
75+
)
76+
77+
log.info(
78+
"repository.token.fetch",
79+
user_id=user_id,
80+
repository_id=repository_id,
81+
)
82+
try:
83+
access_token = await self._core_client.fetch_github_token(user_id)
84+
except CoreTokenError as err:
85+
raise RepositoryAnalyzeError(
86+
code=err.code, message=err.message, retriable=err.retriable
87+
) from err
88+
89+
log.info(
90+
"repository.extract.start",
91+
repository_id=repository_id,
92+
repo_full_name=repo_full_name,
93+
default_branch=default_branch,
94+
)
95+
try:
96+
source = await self._extractor.extract(
97+
repo_full_name,
98+
access_token=access_token,
99+
)
100+
except RepositoryFetchError as err:
101+
raise RepositoryAnalyzeError(
102+
code=err.code, message=err.message, retriable=err.retriable
103+
) from err
104+
105+
if not source.text.strip():
106+
raise RepositoryAnalyzeError(
107+
code="EMPTY_REPO_CONTENT",
108+
message="레포에서 추출된 텍스트가 비어 있음",
109+
retriable=False,
110+
)
111+
112+
log.info(
113+
"repository.llm.start",
114+
repository_id=repository_id,
115+
text_chars=len(source.text),
116+
)
117+
analysis = await self._chain.analyze(
118+
text=source.text,
119+
source_type=source.source_type,
120+
)
121+
122+
out_key = self._analyzed_key_template.format(repository_id=repository_id)
123+
await self._storage.put_text(out_key, analysis.markdown)
124+
log.info(
125+
"repository.markdown.saved",
126+
repository_id=repository_id,
127+
key=out_key,
128+
md_chars=len(analysis.markdown),
129+
)
130+
131+
try:
132+
chunk_count = await chunk_embed_and_upsert(
133+
document_id=analyzed_document_id,
134+
markdown=analysis.markdown,
135+
chunker=self._chunker,
136+
embedder=self._embedder,
137+
core_client=self._core_client,
138+
log_prefix="repository",
139+
)
140+
except EmbeddingStepError as err:
141+
raise RepositoryAnalyzeError(
142+
code=err.code, message=err.message, retriable=err.retriable
143+
) from err
144+
145+
return RepositoryAnalysisResult(
146+
summary=analysis.summary,
147+
tech_stack=list(analysis.tech_stack),
148+
document_path=out_key,
149+
embedding_chunk_count=chunk_count,
150+
)

0 commit comments

Comments
 (0)