Skip to content

Commit fe62643

Browse files
authored
Merge pull request #129 from Team-StackUp/feature/cover-letter-input
feat(cover-letter): 자소서(공채) 문항별 입력 + 분석 연동
2 parents e683cac + 0035860 commit fe62643

62 files changed

Lines changed: 1832 additions & 16 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/CLAUDE.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -94,6 +94,7 @@ ai/
9494
| `ai.analyze.resume` | `analyze.resume` | 본 구현 (PDF → MD) |
9595
| `ai.analyze.repository` | `analyze.repository` | 본 구현 (GitHub README + tree + 소스 sampling) |
9696
| `ai.analyze.web` | `analyze.web` | 본 구현 (URL → trafilatura) |
97+
| `ai.analyze.cover_letter` | `analyze.cover_letter` | 본 구현 (자소서 문항 inline 텍스트 → MD, `TextSourceExtractor`) |
9798
| `ai.generate.questions` | `generate.questions` | 본 구현 (Pro 모델, 질문 풀 생성, US-18) |
9899
| `ai.generate.followup` | `generate.followup` | 본 구현 (Flash 모델, 답변 평가+꼬리질문, US-19) |
99100
| `ai.generate.tts` | `generate.tts` | 본 구현 (질문 음성화, OpenAI TTS → S3 → `callback.tts`) |

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

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,13 +8,15 @@
88
RepositoryFetchError,
99
)
1010
from ai_server.analyzer.sources.pdf import PdfSourceExtractor
11+
from ai_server.analyzer.sources.text import TextSourceExtractor
1112
from ai_server.analyzer.sources.web import WebFetchError, WebSourceExtractor
1213

1314
__all__ = [
1415
"ExtractedSource",
1516
"SourceExtractor",
1617
"SourceType",
1718
"PdfSourceExtractor",
19+
"TextSourceExtractor",
1820
"GitHubRepoSourceExtractor",
1921
"RepositoryFetchError",
2022
"WebSourceExtractor",

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55

66
from pydantic import BaseModel, Field
77

8-
SourceType = Literal["PDF", "REPOSITORY", "WEB"]
8+
SourceType = Literal["PDF", "REPOSITORY", "WEB", "COVER_LETTER"]
99

1010

1111
# 모든 Source Extractor가 공통으로 반환하는 결과 모델
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
from __future__ import annotations
2+
3+
from ai_server.analyzer.sources.base import (
4+
ExtractedSource,
5+
SourceExtractor,
6+
SourceType,
7+
)
8+
9+
10+
# inline 텍스트(자소서 문항 마크다운 등)를 그대로 ExtractedSource 로 감싼다.
11+
# locator 자체가 본문 — S3/URL fetch 없음. 자소서처럼 Core 가 본문을 직접 실어 보낼 때 사용.
12+
class TextSourceExtractor(SourceExtractor):
13+
14+
def __init__(self, *, source_type: SourceType = "COVER_LETTER") -> None:
15+
self._source_type = source_type
16+
17+
async def extract(self, locator: str) -> ExtractedSource:
18+
return ExtractedSource(
19+
text=locator or "",
20+
source_type=self._source_type,
21+
metadata={"length": len(locator or "")},
22+
)

ai/src/ai_server/chain/prompts/document_analysis.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,10 @@
3030
"5. summary: 위 추출 내용만 근거로 한 2~4문장 한국어 요약.\n"
3131
"6. markdown: 면접관이 훑어볼 한국어 마크다운. 섹션 구조는 "
3232
"'## 개요', '## 주요 경험', '## 기술', '## 추가 메모' 사용. "
33-
"추출된 projects/experiences/skills 를 반영하되 추측은 넣지 마세요.\n\n"
33+
"추출된 projects/experiences/skills 를 반영하되 추측은 넣지 마세요.\n"
34+
"※ 출처 유형이 COVER_LETTER(자기소개서)이면: 기술 나열보다 **지원동기·가치관·성장 경험·"
35+
"직무 적합성·핵심 강점/소재**를 우선 추출하세요. experiences 에 자소서 문항별 핵심 주장과 "
36+
"근거 일화를 담고, tech_stack 은 자소서에 실제 언급된 기술만(없으면 빈 배열). markdown 에는 "
37+
"면접에서 파고들 만한 주장·수치·경험을 정리해 질문의 근거가 되게 하세요.\n\n"
3438
"{format_instructions}"
3539
)

ai/src/ai_server/config/settings.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ class Settings(BaseSettings):
2121
# AI consumer (큐 이름, prefetch, 콜백 라우팅 등)
2222
ai_queue_resume: str = "ai.analyze.resume"
2323
ai_queue_repository: str = "ai.analyze.repository"
24+
ai_queue_cover_letter: str = "ai.analyze.cover_letter"
2425
ai_queue_web: str = "ai.analyze.web"
2526
ai_queue_questions: str = "ai.generate.questions"
2627
ai_queue_followup: str = "ai.generate.followup"
@@ -110,6 +111,11 @@ class Settings(BaseSettings):
110111
analyzed_web_resume_md_key_template: str = (
111112
"analyzed/web-resume/{resume_id}/summary.md"
112113
)
114+
# 자소서 분석 마크다운 키. ResumeAnalyzer 재사용으로 placeholder 이름은 resume_id 를 따른다
115+
# (실제 값은 cover_letter_id).
116+
analyzed_cover_letter_md_key_template: str = (
117+
"analyzed/cover-letter/{resume_id}/summary.md"
118+
)
113119

114120
# Core 서버 internal API (사용자별 GitHub access_token 조회 등)
115121
core_internal_base_url: str = "http://localhost:38010"
Lines changed: 152 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,152 @@
1+
from __future__ import annotations
2+
3+
import structlog
4+
from aio_pika.abc import AbstractIncomingMessage
5+
6+
from ai_server.analyzer.resume_analyzer import ResumeAnalyzeError, ResumeAnalyzer
7+
from ai_server.messaging.idempotency import LruIdempotencyStore
8+
from ai_server.messaging.progress import AnalysisProgressNotifier
9+
from ai_server.messaging.publisher import CallbackPublisher
10+
from ai_server.model.envelope import Envelope
11+
from ai_server.model.messages.analyze import (
12+
AnalysisCallbackPayload,
13+
CoverLetterAnalyzeRequest,
14+
)
15+
16+
log = structlog.get_logger(__name__)
17+
18+
19+
# 자소서 분석 consumer. 본문이 inline(content)이라는 점만 빼면 이력서와 동일한 분석·임베딩
20+
# 파이프라인을 탄다 — ResumeAnalyzer 를 TextSourceExtractor 와 자소서 키 템플릿으로 재사용.
21+
class CoverLetterConsumer:
22+
def __init__(
23+
self,
24+
*,
25+
analyzer: ResumeAnalyzer,
26+
publisher: CallbackPublisher,
27+
idempotency: LruIdempotencyStore,
28+
callback_routing_key: str,
29+
progress_notifier: AnalysisProgressNotifier | None = None,
30+
) -> None:
31+
self._analyzer = analyzer
32+
self._publisher = publisher
33+
self._idempotency = idempotency
34+
self._callback_routing_key = callback_routing_key
35+
self._progress = progress_notifier
36+
37+
async def handle(self, message: AbstractIncomingMessage) -> None:
38+
async with message.process(requeue=False):
39+
try:
40+
envelope = Envelope[CoverLetterAnalyzeRequest].model_validate_json(
41+
message.body
42+
)
43+
except Exception as exc: # parse error → DLQ-ready (auto NACK on raise)
44+
log.error(
45+
"cover_letter.parse.failed",
46+
error=str(exc),
47+
delivery_tag=message.delivery_tag,
48+
)
49+
raise
50+
51+
if self._idempotency.is_seen_then_mark(envelope.message_id):
52+
log.info(
53+
"cover_letter.idempotent.skip",
54+
message_id=envelope.message_id,
55+
trace_id=envelope.trace_id,
56+
)
57+
return
58+
59+
req = envelope.payload
60+
log.info(
61+
"cover_letter.analyze.start",
62+
message_id=envelope.message_id,
63+
cover_letter_id=req.cover_letter_id,
64+
trace_id=envelope.trace_id,
65+
)
66+
67+
payload = await self._run_and_build_payload(
68+
req, envelope.trace_id, user_id=envelope.context.user_id
69+
)
70+
71+
await self._publisher.publish(
72+
routing_key=self._callback_routing_key,
73+
message_type="callback.analysis",
74+
payload=payload,
75+
trace_id=envelope.trace_id,
76+
correlation_id=envelope.message_id,
77+
context=envelope.context,
78+
)
79+
log.info(
80+
"cover_letter.analyze.done",
81+
message_id=envelope.message_id,
82+
cover_letter_id=req.cover_letter_id,
83+
status=payload.status,
84+
trace_id=envelope.trace_id,
85+
)
86+
87+
async def _run_and_build_payload(
88+
self,
89+
req: CoverLetterAnalyzeRequest,
90+
trace_id: str,
91+
*,
92+
user_id: int | None,
93+
) -> AnalysisCallbackPayload:
94+
progress = (
95+
self._progress.emitter_for(
96+
user_id=user_id,
97+
target_type="COVER_LETTER",
98+
target_id=req.cover_letter_id,
99+
trace_id=trace_id,
100+
)
101+
if self._progress is not None
102+
else None
103+
)
104+
try:
105+
# ResumeAnalyzer 의 resume_id/file_path 는 각각 식별자/추출 locator 로 일반화돼 있어
106+
# 자소서는 cover_letter_id 와 inline content 를 그대로 넘긴다.
107+
result = await self._analyzer.analyze(
108+
resume_id=req.cover_letter_id,
109+
file_path=req.content,
110+
analyzed_document_id=req.analyzed_document_id,
111+
progress=progress,
112+
)
113+
except ResumeAnalyzeError as err:
114+
log.warning(
115+
"cover_letter.analyze.domain_failed",
116+
cover_letter_id=req.cover_letter_id,
117+
code=err.code,
118+
retriable=err.retriable,
119+
trace_id=trace_id,
120+
)
121+
return AnalysisCallbackPayload(
122+
target_type="COVER_LETTER",
123+
target_id=req.cover_letter_id,
124+
status="FAILED",
125+
error_code=err.code,
126+
error_message=err.message,
127+
retriable=err.retriable,
128+
)
129+
except Exception as exc:
130+
log.exception(
131+
"cover_letter.analyze.unexpected_failed",
132+
cover_letter_id=req.cover_letter_id,
133+
trace_id=trace_id,
134+
)
135+
return AnalysisCallbackPayload(
136+
target_type="COVER_LETTER",
137+
target_id=req.cover_letter_id,
138+
status="FAILED",
139+
error_code="UNEXPECTED",
140+
error_message=str(exc),
141+
retriable=True,
142+
)
143+
144+
return AnalysisCallbackPayload(
145+
target_type="COVER_LETTER",
146+
target_id=req.cover_letter_id,
147+
status="ANALYZED",
148+
summary=result.summary,
149+
tech_stack=result.tech_stack,
150+
document_path=result.document_path,
151+
embedding_chunk_count=result.embedding_chunk_count,
152+
)

ai/src/ai_server/messaging/runner.py

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
from ai_server.analyzer.resume_analyzer import ResumeAnalyzer
88
from ai_server.analyzer.sources.github_repo import GitHubRepoSourceExtractor
99
from ai_server.analyzer.sources.pdf import PdfSourceExtractor
10+
from ai_server.analyzer.sources.text import TextSourceExtractor
1011
from ai_server.analyzer.sources.web import WebSourceExtractor
1112
from ai_server.analyzer.web_resume_analyzer import WebResumeAnalyzer
1213
from ai_server.chain.document_analysis_chain import (
@@ -46,6 +47,7 @@
4647
from ai_server.messaging.consumers.voice_consumer import VoiceConsumer
4748
from ai_server.voice.stt.factory import build_stt_provider
4849
from ai_server.voice.tts.factory import build_tts_provider
50+
from ai_server.messaging.consumers.cover_letter_consumer import CoverLetterConsumer
4951
from ai_server.messaging.consumers.repository_consumer import RepositoryConsumer
5052
from ai_server.messaging.consumers.resume_consumer import ResumeConsumer
5153
from ai_server.messaging.consumers.web_consumer import WebResumeConsumer
@@ -123,6 +125,17 @@ def __init__(self, settings: Settings) -> None:
123125
analyzed_key_template=settings.analyzed_resume_md_key_template,
124126
)
125127

128+
# 자소서(inline 텍스트) — 이력서와 동일 분석·임베딩 파이프라인, extractor/키만 다름.
129+
cover_letter_analyzer = ResumeAnalyzer(
130+
extractor=TextSourceExtractor(source_type="COVER_LETTER"),
131+
chain=chain_analyzer,
132+
storage=storage,
133+
chunker=chunker,
134+
embedder=embedder,
135+
core_client=core_client,
136+
analyzed_key_template=settings.analyzed_cover_letter_md_key_template,
137+
)
138+
126139
# 리포지토리
127140
repo_analyzer = RepositoryAnalyzer(
128141
extractor=GitHubRepoSourceExtractor(
@@ -174,6 +187,13 @@ def __init__(self, settings: Settings) -> None:
174187
idempotency=self._idempotency,
175188
callback_routing_key=settings.ai_callback_routing_analysis,
176189
)
190+
self._cover_letter_consumer = CoverLetterConsumer(
191+
analyzer=cover_letter_analyzer,
192+
publisher=self._publisher,
193+
idempotency=self._idempotency,
194+
callback_routing_key=settings.ai_callback_routing_analysis,
195+
progress_notifier=self._progress_notifier,
196+
)
177197

178198
# 질문 풀 생성 (US-18)
179199
question_generator = LlmQuestionGenerator(
@@ -300,6 +320,11 @@ async def start(self) -> None:
300320
queue_name=self._settings.ai_queue_web,
301321
handler=self._web_consumer.handle,
302322
)
323+
await self._start_consumer(
324+
channel,
325+
queue_name=self._settings.ai_queue_cover_letter,
326+
handler=self._cover_letter_consumer.handle,
327+
)
303328
await self._start_consumer(
304329
channel,
305330
queue_name=self._settings.ai_queue_questions,

ai/src/ai_server/model/messages/analyze.py

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44

55
from ai_server.model._config import camel_config
66

7-
TargetType = Literal["RESUME", "REPOSITORY", "WEB"]
7+
TargetType = Literal["RESUME", "REPOSITORY", "WEB", "COVER_LETTER"]
88
AnalysisStatus = Literal["ANALYZED", "FAILED"]
99

1010

@@ -33,6 +33,15 @@ class WebResumeAnalyzeRequest(BaseModel):
3333
analyzed_document_id: int
3434

3535

36+
class CoverLetterAnalyzeRequest(BaseModel):
37+
model_config = camel_config()
38+
39+
cover_letter_id: int
40+
# 문항을 합친 마크다운 본문(S3 가 아니라 inline). TextSourceExtractor 가 그대로 사용.
41+
content: str
42+
analyzed_document_id: int
43+
44+
3645
class AnalysisCallbackPayload(BaseModel):
3746
model_config = camel_config()
3847

0 commit comments

Comments
 (0)