Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions ai/CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,7 @@ ai/
| `ai.analyze.resume` | `analyze.resume` | 본 구현 (PDF → MD) |
| `ai.analyze.repository` | `analyze.repository` | 본 구현 (GitHub README + tree + 소스 sampling) |
| `ai.analyze.web` | `analyze.web` | 본 구현 (URL → trafilatura) |
| `ai.analyze.cover_letter` | `analyze.cover_letter` | 본 구현 (자소서 문항 inline 텍스트 → MD, `TextSourceExtractor`) |
| `ai.generate.questions` | `generate.questions` | 본 구현 (Pro 모델, 질문 풀 생성, US-18) |
| `ai.generate.followup` | `generate.followup` | 본 구현 (Flash 모델, 답변 평가+꼬리질문, US-19) |
| `ai.generate.tts` | `generate.tts` | 본 구현 (질문 음성화, OpenAI TTS → S3 → `callback.tts`) |
Expand Down
2 changes: 2 additions & 0 deletions ai/src/ai_server/analyzer/sources/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,13 +8,15 @@
RepositoryFetchError,
)
from ai_server.analyzer.sources.pdf import PdfSourceExtractor
from ai_server.analyzer.sources.text import TextSourceExtractor
from ai_server.analyzer.sources.web import WebFetchError, WebSourceExtractor

__all__ = [
"ExtractedSource",
"SourceExtractor",
"SourceType",
"PdfSourceExtractor",
"TextSourceExtractor",
"GitHubRepoSourceExtractor",
"RepositoryFetchError",
"WebSourceExtractor",
Expand Down
2 changes: 1 addition & 1 deletion ai/src/ai_server/analyzer/sources/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@

from pydantic import BaseModel, Field

SourceType = Literal["PDF", "REPOSITORY", "WEB"]
SourceType = Literal["PDF", "REPOSITORY", "WEB", "COVER_LETTER"]


# 모든 Source Extractor가 공통으로 반환하는 결과 모델
Expand Down
22 changes: 22 additions & 0 deletions ai/src/ai_server/analyzer/sources/text.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
from __future__ import annotations

from ai_server.analyzer.sources.base import (
ExtractedSource,
SourceExtractor,
SourceType,
)


# inline 텍스트(자소서 문항 마크다운 등)를 그대로 ExtractedSource 로 감싼다.
# locator 자체가 본문 — S3/URL fetch 없음. 자소서처럼 Core 가 본문을 직접 실어 보낼 때 사용.
class TextSourceExtractor(SourceExtractor):

def __init__(self, *, source_type: SourceType = "COVER_LETTER") -> None:
self._source_type = source_type

async def extract(self, locator: str) -> ExtractedSource:
return ExtractedSource(
text=locator or "",
source_type=self._source_type,
metadata={"length": len(locator or "")},
)
6 changes: 5 additions & 1 deletion ai/src/ai_server/chain/prompts/document_analysis.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,10 @@
"5. summary: 위 추출 내용만 근거로 한 2~4문장 한국어 요약.\n"
"6. markdown: 면접관이 훑어볼 한국어 마크다운. 섹션 구조는 "
"'## 개요', '## 주요 경험', '## 기술', '## 추가 메모' 사용. "
"추출된 projects/experiences/skills 를 반영하되 추측은 넣지 마세요.\n\n"
"추출된 projects/experiences/skills 를 반영하되 추측은 넣지 마세요.\n"
"※ 출처 유형이 COVER_LETTER(자기소개서)이면: 기술 나열보다 **지원동기·가치관·성장 경험·"
"직무 적합성·핵심 강점/소재**를 우선 추출하세요. experiences 에 자소서 문항별 핵심 주장과 "
"근거 일화를 담고, tech_stack 은 자소서에 실제 언급된 기술만(없으면 빈 배열). markdown 에는 "
"면접에서 파고들 만한 주장·수치·경험을 정리해 질문의 근거가 되게 하세요.\n\n"
"{format_instructions}"
)
6 changes: 6 additions & 0 deletions ai/src/ai_server/config/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ class Settings(BaseSettings):
# AI consumer (큐 이름, prefetch, 콜백 라우팅 등)
ai_queue_resume: str = "ai.analyze.resume"
ai_queue_repository: str = "ai.analyze.repository"
ai_queue_cover_letter: str = "ai.analyze.cover_letter"
ai_queue_web: str = "ai.analyze.web"
ai_queue_questions: str = "ai.generate.questions"
ai_queue_followup: str = "ai.generate.followup"
Expand Down Expand Up @@ -110,6 +111,11 @@ class Settings(BaseSettings):
analyzed_web_resume_md_key_template: str = (
"analyzed/web-resume/{resume_id}/summary.md"
)
# 자소서 분석 마크다운 키. ResumeAnalyzer 재사용으로 placeholder 이름은 resume_id 를 따른다
# (실제 값은 cover_letter_id).
analyzed_cover_letter_md_key_template: str = (
"analyzed/cover-letter/{resume_id}/summary.md"
)

# Core 서버 internal API (사용자별 GitHub access_token 조회 등)
core_internal_base_url: str = "http://localhost:38010"
Expand Down
152 changes: 152 additions & 0 deletions ai/src/ai_server/messaging/consumers/cover_letter_consumer.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,152 @@
from __future__ import annotations

import structlog
from aio_pika.abc import AbstractIncomingMessage

from ai_server.analyzer.resume_analyzer import ResumeAnalyzeError, ResumeAnalyzer
from ai_server.messaging.idempotency import LruIdempotencyStore
from ai_server.messaging.progress import AnalysisProgressNotifier
from ai_server.messaging.publisher import CallbackPublisher
from ai_server.model.envelope import Envelope
from ai_server.model.messages.analyze import (
AnalysisCallbackPayload,
CoverLetterAnalyzeRequest,
)

log = structlog.get_logger(__name__)


# 자소서 분석 consumer. 본문이 inline(content)이라는 점만 빼면 이력서와 동일한 분석·임베딩
# 파이프라인을 탄다 — ResumeAnalyzer 를 TextSourceExtractor 와 자소서 키 템플릿으로 재사용.
class CoverLetterConsumer:
def __init__(
self,
*,
analyzer: ResumeAnalyzer,
publisher: CallbackPublisher,
idempotency: LruIdempotencyStore,
callback_routing_key: str,
progress_notifier: AnalysisProgressNotifier | None = None,
) -> None:
self._analyzer = analyzer
self._publisher = publisher
self._idempotency = idempotency
self._callback_routing_key = callback_routing_key
self._progress = progress_notifier

async def handle(self, message: AbstractIncomingMessage) -> None:
async with message.process(requeue=False):
try:
envelope = Envelope[CoverLetterAnalyzeRequest].model_validate_json(
message.body
)
except Exception as exc: # parse error → DLQ-ready (auto NACK on raise)
log.error(
"cover_letter.parse.failed",
error=str(exc),
delivery_tag=message.delivery_tag,
)
raise

if self._idempotency.is_seen_then_mark(envelope.message_id):
log.info(
"cover_letter.idempotent.skip",
message_id=envelope.message_id,
trace_id=envelope.trace_id,
)
return

req = envelope.payload
log.info(
"cover_letter.analyze.start",
message_id=envelope.message_id,
cover_letter_id=req.cover_letter_id,
trace_id=envelope.trace_id,
)

payload = await self._run_and_build_payload(
req, envelope.trace_id, user_id=envelope.context.user_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(
"cover_letter.analyze.done",
message_id=envelope.message_id,
cover_letter_id=req.cover_letter_id,
status=payload.status,
trace_id=envelope.trace_id,
)

async def _run_and_build_payload(
self,
req: CoverLetterAnalyzeRequest,
trace_id: str,
*,
user_id: int | None,
) -> AnalysisCallbackPayload:
progress = (
self._progress.emitter_for(
user_id=user_id,
target_type="COVER_LETTER",
target_id=req.cover_letter_id,
trace_id=trace_id,
)
if self._progress is not None
else None
)
try:
# ResumeAnalyzer 의 resume_id/file_path 는 각각 식별자/추출 locator 로 일반화돼 있어
# 자소서는 cover_letter_id 와 inline content 를 그대로 넘긴다.
result = await self._analyzer.analyze(
resume_id=req.cover_letter_id,
file_path=req.content,
analyzed_document_id=req.analyzed_document_id,
progress=progress,
)
except ResumeAnalyzeError as err:
log.warning(
"cover_letter.analyze.domain_failed",
cover_letter_id=req.cover_letter_id,
code=err.code,
retriable=err.retriable,
trace_id=trace_id,
)
return AnalysisCallbackPayload(
target_type="COVER_LETTER",
target_id=req.cover_letter_id,
status="FAILED",
error_code=err.code,
error_message=err.message,
retriable=err.retriable,
)
except Exception as exc:
log.exception(
"cover_letter.analyze.unexpected_failed",
cover_letter_id=req.cover_letter_id,
trace_id=trace_id,
)
return AnalysisCallbackPayload(
target_type="COVER_LETTER",
target_id=req.cover_letter_id,
status="FAILED",
error_code="UNEXPECTED",
error_message=str(exc),
retriable=True,
)

return AnalysisCallbackPayload(
target_type="COVER_LETTER",
target_id=req.cover_letter_id,
status="ANALYZED",
summary=result.summary,
tech_stack=result.tech_stack,
document_path=result.document_path,
embedding_chunk_count=result.embedding_chunk_count,
)
25 changes: 25 additions & 0 deletions ai/src/ai_server/messaging/runner.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
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.text import TextSourceExtractor
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 (
Expand Down Expand Up @@ -46,6 +47,7 @@
from ai_server.messaging.consumers.voice_consumer import VoiceConsumer
from ai_server.voice.stt.factory import build_stt_provider
from ai_server.voice.tts.factory import build_tts_provider
from ai_server.messaging.consumers.cover_letter_consumer import CoverLetterConsumer
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
Expand Down Expand Up @@ -123,6 +125,17 @@ def __init__(self, settings: Settings) -> None:
analyzed_key_template=settings.analyzed_resume_md_key_template,
)

# 자소서(inline 텍스트) — 이력서와 동일 분석·임베딩 파이프라인, extractor/키만 다름.
cover_letter_analyzer = ResumeAnalyzer(
extractor=TextSourceExtractor(source_type="COVER_LETTER"),
chain=chain_analyzer,
storage=storage,
chunker=chunker,
embedder=embedder,
core_client=core_client,
analyzed_key_template=settings.analyzed_cover_letter_md_key_template,
)

# 리포지토리
repo_analyzer = RepositoryAnalyzer(
extractor=GitHubRepoSourceExtractor(
Expand Down Expand Up @@ -174,6 +187,13 @@ def __init__(self, settings: Settings) -> None:
idempotency=self._idempotency,
callback_routing_key=settings.ai_callback_routing_analysis,
)
self._cover_letter_consumer = CoverLetterConsumer(
analyzer=cover_letter_analyzer,
publisher=self._publisher,
idempotency=self._idempotency,
callback_routing_key=settings.ai_callback_routing_analysis,
progress_notifier=self._progress_notifier,
)

# 질문 풀 생성 (US-18)
question_generator = LlmQuestionGenerator(
Expand Down Expand Up @@ -300,6 +320,11 @@ async def start(self) -> None:
queue_name=self._settings.ai_queue_web,
handler=self._web_consumer.handle,
)
await self._start_consumer(
channel,
queue_name=self._settings.ai_queue_cover_letter,
handler=self._cover_letter_consumer.handle,
)
await self._start_consumer(
channel,
queue_name=self._settings.ai_queue_questions,
Expand Down
11 changes: 10 additions & 1 deletion ai/src/ai_server/model/messages/analyze.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@

from ai_server.model._config import camel_config

TargetType = Literal["RESUME", "REPOSITORY", "WEB"]
TargetType = Literal["RESUME", "REPOSITORY", "WEB", "COVER_LETTER"]
AnalysisStatus = Literal["ANALYZED", "FAILED"]


Expand Down Expand Up @@ -33,6 +33,15 @@ class WebResumeAnalyzeRequest(BaseModel):
analyzed_document_id: int


class CoverLetterAnalyzeRequest(BaseModel):
model_config = camel_config()

cover_letter_id: int
# 문항을 합친 마크다운 본문(S3 가 아니라 inline). TextSourceExtractor 가 그대로 사용.
content: str
analyzed_document_id: int


class AnalysisCallbackPayload(BaseModel):
model_config = camel_config()

Expand Down
Loading