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
Binary file modified .DS_Store
Binary file not shown.
2 changes: 2 additions & 0 deletions app/api/v1/cover_letter.py
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ async def cover_letter_from_portfolio(
user_id=req.user_id,
job=req.job_position or None,
career=map_career_input(req.career) if req.career else None,
code_analysis_urls=req.code_analysis_urls,
)
return JobStatusResponse(job_id=str(job.job_id), status=job.status)

Expand All @@ -53,5 +54,6 @@ async def cover_letter_from_pdf(
user_id=req.user_id,
job=req.job_position or None,
career=map_career_input(req.career) if req.career else None,
code_analysis_urls=req.code_analysis_urls,
)
return JobStatusResponse(job_id=str(job.job_id), status=job.status)
2 changes: 2 additions & 0 deletions app/api/v1/portfolio.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,8 @@ async def portfolio_from_pdf(
job_id=str(job.job_id),
pdf_s3_url=req.pdf_url,
user_id=req.user_id,
job=req.job_position or None,
career=map_career_input(req.career) if req.career else None,
code_analysis_urls=req.code_analysis_urls,
)
return JobStatusResponse(job_id=str(job.job_id), status=job.status)
Expand Down
115 changes: 85 additions & 30 deletions app/chunkers/cover_letter.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
"""
cover_letter_chunker.py
────────────────────────────────────────────────────────────
Gemini 1-pass: 자소서 텍스트 → 문항 단위 청킹 + 메타 추출 동시
2-phase 전략: 대제목(1. 2. 3.) 기준 섹션 분리 → 섹션별 병렬 LLM 청킹 + 메타 추출

개선 사항:
1. 경계 검증 + 재시도 루프 (누락/중복 줄 탐지 → DB 무결성 보장)
Expand All @@ -17,6 +17,7 @@
import os
import re
import time
from concurrent.futures import ThreadPoolExecutor, as_completed
from dataclasses import dataclass, field
from typing import Iterator

Expand All @@ -26,12 +27,9 @@


def _get_gemini_client() -> _genai.Client:
project = os.getenv("GCP_PROJECT_ID")
if project:
return _genai.Client(vertexai=True, project=project, location=os.getenv("GCP_LOCATION", "us-central1"))
api_key = os.getenv("GEMINI_API_KEY")
if not api_key:
raise ValueError("GCP_PROJECT_ID 또는 GEMINI_API_KEY 환경변수를 설정하세요.")
raise ValueError("GEMINI_API_KEY 환경변수를 설정하세요.")
return _genai.Client(api_key=api_key)


Expand Down Expand Up @@ -150,6 +148,21 @@ class _ValidationResult:
errors: list[str] = field(default_factory=list)


def _split_by_numbered_headings(raw_lines: list[str]) -> list[tuple[int, int]]:
"""1. 2. 3. 대제목 기준으로 섹션 경계 반환 (1-indexed start, end)."""
starts = [
i for i, line in enumerate(raw_lines)
if re.match(r"^\s*\d+[\.\)]\s", line)
]
if not starts:
return [(1, len(raw_lines))]
bounds = []
for idx, s in enumerate(starts):
e = starts[idx + 1] - 1 if idx + 1 < len(starts) else len(raw_lines)
bounds.append((s + 1, e)) # 1-indexed
return bounds


def _validate_coverage(sections: list[dict], total_lines: int) -> _ValidationResult:
"""섹션 경계가 전체 줄을 빠짐없이 1회 커버하는지 검증."""
covered: dict[int, str] = {} # line_no → section title
Expand All @@ -171,6 +184,24 @@ def _validate_coverage(sections: list[dict], total_lines: int) -> _ValidationRes
return _ValidationResult(ok=not errors, errors=errors)


def _validate_section_coverage(sections: list[dict], sec_start: int, sec_end: int) -> _ValidationResult:
"""섹션 구간(sec_start~sec_end)이 빠짐없이 1회 커버하는지 검증."""
covered: dict[int, str] = {}
errors: list[str] = []
for sec in sections:
title = sec.get("title", "?")
for ln in range(int(sec["start_line"]), int(sec["end_line"]) + 1):
if ln in covered:
errors.append(f"줄 {ln} 중복: '{covered[ln]}' vs '{title}'")
covered[ln] = title
missing = sorted(set(range(sec_start, sec_end + 1)) - covered.keys())
if missing:
sample = missing[:10]
suffix = f" 외 {len(missing) - 10}줄" if len(missing) > 10 else ""
errors.append(f"누락 줄: {sample}{suffix}")
return _ValidationResult(ok=not errors, errors=errors)


def _validate_categories(sections: list[dict]) -> list[str]:
"""존재하지 않는 category 값 탐지."""
errors = []
Expand All @@ -188,15 +219,16 @@ def _validate_categories(sections: list[dict]) -> list[str]:
# 프롬프트 빌더
# ═══════════════════════════════════════════════════════════════

def _build_prompt(numbered: str, total: int, format_hint: str) -> str:
def _build_prompt(numbered: str, total: int, format_hint: str, line_range: tuple[int, int] | None = None) -> str:
start, end = line_range if line_range else (1, total)
return (
"다음은 자기소개서 텍스트입니다. 각 줄 앞에 줄 번호가 붙어 있습니다.\n"
"문항(질문) 단위로 분리하고, 각 문항의 메타데이터도 함께 추출해주세요.\n\n"
f"[형식 힌트] {format_hint}\n\n"
"[분리 기준]\n"
"- 각 자소서 문항(질문)은 독립적인 섹션으로 분리\n"
"- 질문 제목과 답변을 같은 섹션에 포함\n"
f"- start_line과 end_line은 실제 줄 번호(1~{total})여야 하며 누락 없이 커버\n"
f"- start_line과 end_line은 실제 줄 번호({start}~{end})여야 하며 누락 없이 커버\n"
"- 모든 줄은 정확히 하나의 섹션에만 속해야 합니다\n\n"
"[category / sub_categories 선택 기준]\n"
"- category: 섹션 내용을 가장 잘 나타내는 주 카테고리 1개\n"
Expand Down Expand Up @@ -251,7 +283,7 @@ def _call_gemini(client: _genai.Client, prompt: str) -> list[dict]:
err = str(exc)
if attempt < _LLM_RETRIES - 1:
wait = 30 if "429" in err else 5
print(f" [WARN] LLM 호출 실패 ({err[:60]}). {wait}초 후 재시도...")
print(f" [WARN] LLM 호출 실패 ({err}). {wait}초 후 재시도...")
time.sleep(wait)
else:
raise
Expand Down Expand Up @@ -302,20 +334,24 @@ def _sections_to_chunks(
# 핵심 파이프라인
# ═══════════════════════════════════════════════════════════════

def _gemini_split(text: str, source: str) -> list[dict]:
client = _get_gemini_client()
numbered, raw_lines = _number_lines(text)
total = len(raw_lines)
format_hint = _detect_format_hint(text)
prompt = _build_prompt(numbered, total, format_hint)

sections: list[dict] = []
def _process_section(
client: _genai.Client,
raw_lines: list[str],
sec_start: int,
sec_end: int,
total: int,
format_hint: str,
) -> list[dict]:
"""단일 섹션(sec_start~sec_end)을 LLM에 투입해 청크 목록 반환."""
section_lines = raw_lines[sec_start - 1:sec_end]
numbered = "\n".join(
f"{sec_start + i:04d} | {line}" for i, line in enumerate(section_lines)
)
prompt = _build_prompt(numbered, total, format_hint, line_range=(sec_start, sec_end))

# ── 경계 검증 루프 ──────────────────────────────────────────
for v_attempt in range(_VALIDATE_RETRIES + 1):
sections = _call_gemini(client, prompt)

coverage = _validate_coverage(sections, total)
coverage = _validate_section_coverage(sections, sec_start, sec_end)
cat_errs = _validate_categories(sections)
all_errors = coverage.errors + cat_errs

Expand All @@ -324,22 +360,41 @@ def _gemini_split(text: str, source: str) -> list[dict]:

if v_attempt < _VALIDATE_RETRIES:
err_summary = "\n".join(f" - {e}" for e in all_errors[:5])
print(
f" [WARN] 검증 실패 (시도 {v_attempt + 1}/{_VALIDATE_RETRIES}):\n"
f"{err_summary}\n → 재시도..."
)
# 오류 내용을 프롬프트에 추가해 재시도
print(f" [WARN] 섹션 {sec_start}~{sec_end} 검증 실패 (시도 {v_attempt + 1}):\n{err_summary}\n → 재시도...")
prompt = (
_build_prompt(numbered, total, format_hint)
_build_prompt(numbered, total, format_hint, line_range=(sec_start, sec_end))
+ f"\n\n[이전 응답의 오류 — 반드시 수정]\n{err_summary}"
)
else:
print(
f" [ERROR] {_VALIDATE_RETRIES}회 재시도 후에도 검증 실패. "
"현재 결과로 진행합니다."
)
print(f" [ERROR] 섹션 {sec_start}~{sec_end} {_VALIDATE_RETRIES}회 재시도 후 검증 실패. 현재 결과로 진행.")

return sections

return list(_sections_to_chunks(sections, raw_lines, source))

def _gemini_split(text: str, source: str) -> list[dict]:
client = _get_gemini_client()
_, raw_lines = _number_lines(text)
total = len(raw_lines)
format_hint = _detect_format_hint(text)

# Phase 1: 대제목(1. 2. 3.) 기준 섹션 분리
bounds = _split_by_numbered_headings(raw_lines)
print(f" [청킹] {len(bounds)}개 섹션 감지 → 병렬 LLM 투입")

# Phase 2: 섹션별 병렬 LLM 호출
all_sections: list[dict] = []
if len(bounds) == 1:
all_sections = _process_section(client, raw_lines, *bounds[0], total, format_hint)
else:
with ThreadPoolExecutor(max_workers=len(bounds)) as executor:
futures = {
executor.submit(_process_section, client, raw_lines, s, e, total, format_hint): (s, e)
for s, e in bounds
}
for future in as_completed(futures):
all_sections.extend(future.result())

return list(_sections_to_chunks(all_sections, raw_lines, source))


# ═══════════════════════════════════════════════════════════════
Expand Down
18 changes: 10 additions & 8 deletions app/evaluators/cover_letter.py
Original file line number Diff line number Diff line change
Expand Up @@ -128,13 +128,10 @@ def is_competency_question(question: str) -> bool:
# ─── Gemini 클라이언트 ─────────────────────────────────────────────────────────

def _get_gemini_client() -> _genai.Client:
project = os.getenv("GCP_PROJECT_ID")
if project:
return _genai.Client(vertexai=True, project=project, location=os.getenv("GCP_LOCATION", "global"))
api_key = os.getenv("GEMINI_API_KEY")
if api_key:
return _genai.Client(api_key=api_key)
raise ValueError("GCP_PROJECT_ID 또는 GEMINI_API_KEY 환경변수를 설정하세요.")
if not api_key:
raise ValueError("GEMINI_API_KEY 환경변수를 설정하세요.")
return _genai.Client(api_key=api_key)


# ─── LLM 평가 ─────────────────────────────────────────────────────────────────
Expand Down Expand Up @@ -162,7 +159,11 @@ def llm_evaluate(text: str, char_limit: int | None = None, question: str = "") -
),
)
try:
return _EvalResult.model_validate_json(resp.text)
result = _EvalResult.model_validate_json(resp.text)
result.D.score = max(0, result.D.d1_volume + result.D.d2_quant + result.D.d3_penalty)
if result.D.score == 0 and result.D.d1_volume == 0 and attempt < 2:
continue
return result
except Exception:
if attempt == 2:
raise
Expand All @@ -172,11 +173,12 @@ def llm_evaluate(text: str, char_limit: int | None = None, question: str = "") -
# ─── 가중 합산 ─────────────────────────────────────────────────────────────────

def compute_weighted(llm: _EvalResult) -> float:
d_score = max(0, llm.D.d1_volume + llm.D.d2_quant + llm.D.d3_penalty)
total = (
llm.A.score * WEIGHTS["A"] +
llm.B.score * WEIGHTS["B"] +
llm.C.score * WEIGHTS["C"] +
llm.D.score * WEIGHTS["D"]
d_score * WEIGHTS["D"]
)
return round(total, 2)

Expand Down
17 changes: 9 additions & 8 deletions app/evaluators/portfolio.py
Original file line number Diff line number Diff line change
Expand Up @@ -116,13 +116,10 @@ class _EvalResult(BaseModel):
# ═══════════════════════════════════════════════════════════════

def _get_gemini_client() -> _genai.Client:
project = os.getenv("GCP_PROJECT_ID")
if project:
return _genai.Client(vertexai=True, project=project, location=os.getenv("GCP_LOCATION", "global"))
api_key = os.getenv("GEMINI_API_KEY")
if api_key:
return _genai.Client(api_key=api_key)
raise ValueError("GCP_PROJECT_ID 또는 GEMINI_API_KEY 환경변수를 설정하세요.")
if not api_key:
raise ValueError("GEMINI_API_KEY 환경변수를 설정하세요.")
return _genai.Client(api_key=api_key)


# ═══════════════════════════════════════════════════════════════
Expand All @@ -144,7 +141,11 @@ def llm_evaluate(text: str) -> _EvalResult:
),
)
try:
return _EvalResult.model_validate_json(resp.text)
result = _EvalResult.model_validate_json(resp.text)
result.D.score = max(0, result.D.d1_volume + result.D.d2_quant + result.D.d3_penalty)
if result.D.score == 0 and result.D.d1_volume == 0 and attempt < 2:
continue
return result
except Exception:
if attempt == 2:
raise
Expand All @@ -160,7 +161,7 @@ def compute_weighted(llm: _EvalResult) -> float:
llm.A.score * WEIGHTS["A"]
+ llm.B.score * WEIGHTS["B"]
+ llm.C.score * WEIGHTS["C"]
+ llm.D.score * WEIGHTS["D"]
+ max(0, llm.D.d1_volume + llm.D.d2_quant + llm.D.d3_penalty) * WEIGHTS["D"]
+ llm.E.score * WEIGHTS["E"]
)
return round(total, 2)
Expand Down
39 changes: 21 additions & 18 deletions app/exporters/portfolio_gen_pdf.py
Original file line number Diff line number Diff line change
Expand Up @@ -130,34 +130,37 @@ def eval_block(self, eval_result: dict | None):

llm = eval_result.get("llm", {})
for key in ("A", "B", "C", "E"):
item = llm.get(key, {})
item = llm.get(key, {})
score = item.get("score", 0)
reason = item.get("reason", "")
fix = item.get("fix", "")
self._set(8, bold=True, color=(50, 65, 130))
self.multi_cell(0, 4.5, f" [{key}] {_LABELS[key]} ({_WEIGHTS[key]}%) {score}/100",
new_x="LMARGIN", new_y="NEXT")
self._set(8, color=(70, 80, 140))
if reason:
self._set(8, color=(70, 80, 140))
self.multi_cell(0, 4.5, f" {reason}", new_x="LMARGIN", new_y="NEXT")
self.multi_cell(0, 4.5, f" 근거: {reason}", new_x="LMARGIN", new_y="NEXT")
if fix:
self.multi_cell(0, 4.5, f" 개선: {fix}", new_x="LMARGIN", new_y="NEXT")
self._gap(1)

d_info = eval_result.get("D", {})
d_score = d_info.get("score", 0)
d_detail = d_info.get("detail", {})
d_info = llm.get("D", {})
d_score = max(0, d_info.get("d1_volume", 0) + d_info.get("d2_quant", 0) + d_info.get("d3_penalty", 0))
d_reason = d_info.get("reason", "")
d_fix = d_info.get("fix", "")
d1 = d_info.get("d1_volume", 0)
d2 = d_info.get("d2_quant", 0)
d3 = d_info.get("d3_penalty", 0)
self._set(8, bold=True, color=(50, 65, 130))
self.multi_cell(0, 4.5, f" [D] {_LABELS['D']} ({_WEIGHTS['D']}%) {d_score * 10}/100",
self.multi_cell(0, 4.5, f" [D] {_LABELS['D']} ({_WEIGHTS['D']}%) {d_score}/80",
new_x="LMARGIN", new_y="NEXT")
if d_detail:
vol = d_detail.get("d1_volume", {}).get("msg", "")
qnt = d_detail.get("d2_quant", {}).get("msg", "")
pen = d_detail.get("d7_penalty", {})
self._set(8, color=(70, 80, 140))
if vol: self.multi_cell(0, 4.5, f" 분량: {vol}", new_x="LMARGIN", new_y="NEXT")
if qnt: self.multi_cell(0, 4.5, f" 수치: {qnt}", new_x="LMARGIN", new_y="NEXT")
if pen.get("penalty", 0) > 0:
self.multi_cell(0, 4.5,
f" 감점: -{pen['penalty']}점 {', '.join(pen.get('reasons', []))}",
new_x="LMARGIN", new_y="NEXT")
self._set(8, color=(70, 80, 140))
self.multi_cell(0, 4.5, f" D1 분량: {d1}/40 | D2 수치성과: {d2}/40 | D3 감점: {d3}",
new_x="LMARGIN", new_y="NEXT")
if d_reason:
self.multi_cell(0, 4.5, f" 근거: {d_reason}", new_x="LMARGIN", new_y="NEXT")
if d_fix:
self.multi_cell(0, 4.5, f" 개선: {d_fix}", new_x="LMARGIN", new_y="NEXT")
self._gap(3)

def gaps_block(self, gaps: list[dict]):
Expand Down
16 changes: 8 additions & 8 deletions app/schemas/cover_letter.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,16 +4,16 @@


class FromPortfolioRequest(BaseModel):
pdf_url: str
user_id: int | None = None
job_position: str = ""
career: str = ""
pdf_url: str | None = None
user_id: int | None = None
job_position: str = ""
career: str = ""
code_analysis_urls: list[str] = []


class FromCoverLetterRequest(BaseModel):
pdf_url: str
user_id: int | None = None
job_position: str = ""
career: str = ""
pdf_url: str | None = None
user_id: int | None = None
job_position: str = ""
career: str = ""
code_analysis_urls: list[str] = []
6 changes: 4 additions & 2 deletions app/schemas/portfolio.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@


class FromPdfRequest(BaseModel):
pdf_url: str
user_id: int | None = None
pdf_url: str | None = None
user_id: int | None = None
job_position: str = ""
career: str = ""
code_analysis_urls: list[str] = []
Loading
Loading