Skip to content
Merged
Empty file.
88 changes: 88 additions & 0 deletions ai/src/ai_server/analyzer/resume_analyzer.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
from __future__ import annotations

from dataclasses import dataclass

import structlog

from ai_server.analyzer.sources.base import SourceExtractor
from ai_server.chain.document_analysis_chain import DocumentAnalyzer
from ai_server.storage.base import ObjectStorage

log = structlog.get_logger(__name__)


class ResumeAnalyzeError(Exception):
def __init__(self, *, code: str, message: str, retriable: bool) -> None:
super().__init__(message)
self.code = code
self.message = message
self.retriable = retriable


@dataclass(frozen=True)
class ResumeAnalysisResult:
summary: str
tech_stack: list[str]
document_path: str


# 스토리지에서 가져오고 LLM을 통해 분석함
class ResumeAnalyzer:
def __init__(
self,
*,
extractor: SourceExtractor,
chain: DocumentAnalyzer,
storage: ObjectStorage,
analyzed_key_template: str,
) -> None:
self._extractor = extractor
self._chain = chain
self._storage = storage
self._analyzed_key_template = analyzed_key_template

async def analyze(
self,
*,
resume_id: int,
file_path: str,
) -> ResumeAnalysisResult:
log.info(
"resume.extract.start",
resume_id=resume_id,
file_path=file_path,
)
source = await self._extractor.extract(file_path)

if not source.text.strip():
raise ResumeAnalyzeError(
code="EMPTY_PDF_TEXT",
message="추출된 텍스트가 비어있음",
retriable=False,
)

log.info(
"resume.llm.start",
resume_id=resume_id,
text_chars=len(source.text),
source_type=source.source_type,
)
analysis = await self._chain.analyze(
text=source.text,
source_type=source.source_type,
)

out_key = self._analyzed_key_template.format(resume_id=resume_id)
await self._storage.put_text(out_key, analysis.markdown)
log.info(
"resume.markdown.saved",
resume_id=resume_id,
key=out_key,
md_chars=len(analysis.markdown),
)

return ResumeAnalysisResult(
summary=analysis.summary,
tech_stack=list(analysis.tech_stack),
document_path=out_key,
)
13 changes: 13 additions & 0 deletions ai/src/ai_server/analyzer/sources/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
from ai_server.analyzer.sources.base import (
ExtractedSource,
SourceExtractor,
SourceType,
)
from ai_server.analyzer.sources.pdf import PdfSourceExtractor

__all__ = [
"ExtractedSource",
"SourceExtractor",
"SourceType",
"PdfSourceExtractor",
]
22 changes: 22 additions & 0 deletions ai/src/ai_server/analyzer/sources/base.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
from __future__ import annotations

from abc import ABC, abstractmethod
from typing import Any, Literal

from pydantic import BaseModel, Field

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


# 모든 Source Extractor가 공통으로 반환하는 결과 모델
class ExtractedSource(BaseModel):
text: str
source_type: SourceType
metadata: dict[str, Any] = Field(default_factory=dict)


# 단일 입력 형태에 대한 Adapter
class SourceExtractor(ABC):

@abstractmethod
async def extract(self, locator: str) -> ExtractedSource: ...
33 changes: 33 additions & 0 deletions ai/src/ai_server/analyzer/sources/pdf.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
from __future__ import annotations

import asyncio
import io

from pypdf import PdfReader

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


def _extract_pdf_text(data: bytes) -> str:
reader = PdfReader(io.BytesIO(data))
parts: list[str] = []
for page in reader.pages:
parts.append(page.extract_text() or "")
return "\n\n".join(p for p in parts if p).strip()


# PDF 를 읽어 페이지 텍스트를 이어붙인다.
class PdfSourceExtractor(SourceExtractor):

def __init__(self, storage: ObjectStorage) -> None:
self._storage = storage

async def extract(self, locator: str) -> ExtractedSource:
data = await self._storage.get_bytes(locator)
text = await asyncio.to_thread(_extract_pdf_text, data)
return ExtractedSource(
text=text,
source_type="PDF",
metadata={"locator": locator, "bytes": len(data)},
)
13 changes: 13 additions & 0 deletions ai/src/ai_server/chain/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
from ai_server.chain.document_analysis_chain import (
DocumentAnalysisResult,
DocumentAnalyzer,
LlmDocumentAnalyzer,
build_document_analysis_chain,
)

__all__ = [
"DocumentAnalysisResult",
"DocumentAnalyzer",
"LlmDocumentAnalyzer",
"build_document_analysis_chain",
]
62 changes: 62 additions & 0 deletions ai/src/ai_server/chain/document_analysis_chain.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
from __future__ import annotations

from typing import Protocol

from langchain_core.output_parsers import PydanticOutputParser
from langchain_core.prompts import ChatPromptTemplate
from langchain_core.runnables import Runnable
from pydantic import BaseModel, Field

from ai_server.analyzer.sources.base import SourceType
from ai_server.chain.prompts.document_analysis import HUMAN_PROMPT, SYSTEM_PROMPT
from ai_server.config.settings import Settings


class DocumentAnalysisResult(BaseModel):
summary: str = Field(..., description="2~4 sentence Korean summary")
tech_stack: list[str] = Field(default_factory=list)
markdown: str = Field(..., description="Interviewer-facing markdown")


class DocumentAnalyzer(Protocol):
async def analyze(
self, *, text: str, source_type: SourceType
) -> DocumentAnalysisResult: ...


# 랭체인 파이프라인 호출을 감싼다
class LlmDocumentAnalyzer:

def __init__(self, chain: Runnable) -> None:
self._chain = chain

async def analyze(
self, *, text: str, source_type: SourceType
) -> DocumentAnalysisResult:
result = await self._chain.ainvoke({"text": text, "source_type": source_type})
if not isinstance(result, DocumentAnalysisResult):
raise TypeError(
f"chain returned {type(result).__name__}, expected DocumentAnalysisResult"
)
return result


# 프롬프트 -> LLM -> 파서 하나로 묶어서 처리함
def build_document_analysis_chain(settings: Settings) -> Runnable:
from langchain_openai import ChatOpenAI

parser = PydanticOutputParser(pydantic_object=DocumentAnalysisResult)
prompt = ChatPromptTemplate.from_messages(
[
("system", SYSTEM_PROMPT),
("human", HUMAN_PROMPT),
]
).partial(format_instructions=parser.get_format_instructions())

llm = ChatOpenAI(
model=settings.llm_pro_model,
temperature=settings.llm_pro_temperature,
api_key=settings.llm_api_key or None,
base_url=settings.llm_base_url,
)
return prompt | llm | parser
Empty file.
23 changes: 23 additions & 0 deletions ai/src/ai_server/chain/prompts/document_analysis.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
# 문서 분석 프롬프트 템플릿
# source_type 으로 PDF, 웹, 리포지토리 구분함
SYSTEM_PROMPT = (
"당신은 IT 직군 채용을 진행하는 시니어 면접관입니다. "
"지원자 자료(이력서·레포 README·기술 블로그 등)를 분석하여 면접 준비에 사용할 "
"구조화된 결과를 산출하세요.\n"
"- 사실에 근거해서 작성하고, 자료에 없는 내용을 추측해 채우지 마세요.\n"
"- 한국어로 작성하되 기술 용어는 영문 원어를 그대로 둡니다.\n"
"- 응답은 반드시 지정된 JSON 스키마를 따릅니다."
)

HUMAN_PROMPT = (
"다음은 지원자 자료입니다 (출처 유형: {source_type}).\n"
"---\n"
"{text}\n"
"---\n\n"
"요구 사항:\n"
"1. summary: 2~4문장 한국어 요약.\n"
"2. tech_stack: 자료에 명시된 핵심 기술 5~15개. 영문 표기.\n"
"3. markdown: 면접관이 훑어볼 한국어 마크다운. 섹션 구조는 "
"'## 개요', '## 주요 경험', '## 기술', '## 추가 메모' 사용.\n\n"
"{format_instructions}"
)
23 changes: 16 additions & 7 deletions ai/src/ai_server/config/settings.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
from typing import Literal

from pydantic_settings import BaseSettings, SettingsConfigDict


Expand All @@ -14,25 +16,32 @@ class Settings(BaseSettings):
debug: bool = False

# RabbitMQ
rabbitmq_url: str = "amqp://stackup:stackup@localhost:5672/"
rabbitmq_url: str = "amqp://stackup:stackup@localhost:38050/"

# AI consumer
# AI consumer (큐 이름, prefetch, 콜백 라우팅 등)
ai_queue_resume: str = "ai.analyze.resume"
ai_queue_prefetch: int = 10
ai_callback_exchange: str = "stackup.ai-to-core"
ai_callback_routing_analysis: str = "callback.analysis"
ai_publisher_name: str = "ai-server"
ai_idempotency_lru_size: int = 1024

# S3 / MinIO
s3_endpoint_url: str = "http://localhost:9000"
storage_backend: Literal["local", "s3"] = "s3"
storage_local_root: str = "./var/storage"

s3_endpoint_url: str = "http://localhost:38060"
s3_access_key: str = ""
s3_secret_key: str = ""
s3_bucket_name: str = "stackup"
s3_region: str = "us-east-1"

# 일단 충대 API 키 사용
llm_api_key: str = ""
llm_base_url: str = "https://factchat-cloud.mindlogic.ai/v1/gateway"
llm_pro_model: str = "gemini-3.1-pro-preview"
llm_pro_temperature: float = 0.2

# LLM API Keys
openai_api_key: str = ""
google_api_key: str = ""
analyzed_resume_md_key_template: str = "analyzed/resume/{resume_id}/summary.md"


def get_settings() -> Settings:
Expand Down
Loading