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
9 changes: 9 additions & 0 deletions ai/CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -353,6 +353,15 @@ docker run --env-file .env -p 8000:8000 stackup-ai
`LlmAnswerCoach`(Flash, `chain/prompts/answer_coaching.py`)로 **답변별 병렬** 코칭 — 모범 답안 + 내 답변
리라이트 + 한 줄 코칭. `callback.feedback.answerCoaching[{messageId,…}]` 로 보내고 Core 가 각 답변 메시지에
기록(종료 세션 조회에서만 노출). 종합 generate·첫인상·직무 적합도와 `asyncio.gather` 병렬.
- **마크다운 학습 리포트 저장 본 구현 (reportS3Key)**: `FeedbackConsumer._save_report` 가 성공 payload
조립 직전에 `chain/feedback_report.py::render_feedback_report`(LLM 미호출 결정론 렌더 — 점수 표·패널·
요약·키워드·학습 플랜·하이라이트·답변별 코칭·음성 요약을 GFM 문서로 조립)를 호출해
`feedback/{session_id}/report.md`(`FEEDBACK_REPORT_MD_KEY_TEMPLATE`, storage.md §2)에 `put_text` 저장,
키를 `callback.feedback.reportS3Key` 로 동봉한다. **실패는 전부 삼키고 None 폴백** — `_process` 의 예외는
공용 가드가 FAILED 콜백으로 승격시키므로, 부가 산출물인 리포트가 피드백 전체를 죽이면 안 된다
(`feedback.report.save_failed` warning). 사용자 답변 원문은 리포트에 싣지 않는다(사용자 입력을
마크다운으로 렌더하지 않는 규칙 — 질문·코칭·GFM 계약 필드만). 소비는 Core 프록시
`GET /api/sessions/{id}/feedback/report`.
- **직무 적합도 + 직무 이해도 평가 본 구현**: `mode=JOB_TAILORED` + JD 있을 때 `LlmJobFitEvaluator`(Pro,
`chain/prompts/job_fit_evaluation.py`)가 면접 전사·자료를 채용공고(JD)와 대조해 **두 축**을 한 번의
구조화 호출(`JobFitResult{fit, understanding}`)로 평가: `직무 적합도`(JD 요구 역량 매칭) + `직무 이해도`
Expand Down
205 changes: 205 additions & 0 deletions ai/src/ai_server/chain/feedback_report.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,205 @@
"""피드백 마크다운 리포트 렌더러 (US-24 reportS3Key).

LLM 미호출 순수 함수 — 피드백 결과·답변 코칭·요청 메타데이터를 GFM 마크다운 문서로
조립한다. 저장 키는 storage.md §2 의 `feedback/{session_id}/report.md`, 소비는 Core 프록시
경유 프론트 Markdown 뷰어.

포맷 주의: 요약·패널 detail 등은 plain text 계약 필드(마크다운 기호 없음 보장)라 골격에
그대로 삽입하고, modelAnswer/answerRewrite 는 원래 GFM 계약 필드라 원문 그대로 싣는다.
사용자 답변 원문은 싣지 않는다 — 사용자 입력 텍스트는 마크다운으로 렌더하지 않는 규칙.
"""

from __future__ import annotations

from ai_server.chain.feedback_generation_chain import _DOMAIN_KO, FeedbackResult
from ai_server.model.messages.feedback import (
AnswerCoachingItem,
FeedbackMessageItem,
GenerateFeedbackRequest,
PanelBreakdownItem,
VoiceAnalysisSummary,
)

_MODE_LABEL = {
"PERSONALITY": "인성 면접",
"TECHNICAL": "기술 면접",
"INTEGRATED": "통합 면접",
"JOB_TAILORED": "직무 맞춤 면접",
}

_END_REASON_LABEL = {
"USER_REQUEST": "사용자 종료",
"MAX_QUESTIONS_REACHED": "질문 수 도달",
"POOL_EXHAUSTED": "질문 소진",
"DURATION_EXCEEDED": "시간 초과 자동 종료",
}


def _fmt_score(value: float | None) -> str:
if value is None:
return "—"
# 80.0 → "80", 82.5 → "82.5" — 표에서 소수점 노이즈 제거.
return f"{value:g}"


def _section(lines: list[str], title: str) -> None:
lines.append("")
lines.append(f"## {title}")
lines.append("")


def _render_scores(lines: list[str], result: FeedbackResult) -> None:
_section(lines, "종합 점수")
lines.append("| 항목 | 점수 |")
lines.append("| --- | ---: |")
lines.append(f"| 종합 | {_fmt_score(result.overall_score)} |")
lines.append(f"| 기술 정확도 | {_fmt_score(result.technical_accuracy)} |")
lines.append(f"| 논리 | {_fmt_score(result.logic_score)} |")
lines.append(f"| 전달력 | {_fmt_score(result.communication_score)} |")


def _render_panel(lines: list[str], panel: list[PanelBreakdownItem]) -> None:
if not panel:
return
_section(lines, "평가위원 패널")
for item in panel:
head = f"### {item.evaluator} — {item.dimension}"
if item.score is not None:
head += f" ({_fmt_score(item.score)}점)"
lines.append(head)
lines.append("")
if item.strength:
lines.append(f"- **강점**: {item.strength}")
if item.weakness:
lines.append(f"- **약점**: {item.weakness}")
if item.strength or item.weakness:
lines.append("")
if item.detail:
lines.append(item.detail)
lines.append("")
if item.score_rationale:
lines.append(f"> 점수 근거: {item.score_rationale}")
lines.append("")


def _render_coaching(
lines: list[str],
coaching: list[AnswerCoachingItem],
messages: list[FeedbackMessageItem],
) -> None:
if not coaching:
return
_section(lines, "답변별 코칭")
by_id = {m.id: m for m in messages}
# 번호는 전사 내 질문(INTERVIEWER) 순서 기준 — 코칭 일부가 실패해 목록에 구멍이 나도
# 같은 질문은 재생성마다 같은 번호를 갖는다(enumerate 는 번호가 밀린다).
interviewers = sorted(
(m for m in messages if m.role == "INTERVIEWER"),
key=lambda m: m.sequence_number,
)
question_no = {m.id: i for i, m in enumerate(interviewers, start=1)}
for item in coaching:
question = _question_for(item.message_id, by_id)
if question is not None:
no = question_no.get(question.id)
label = f"Q{no}" if no is not None else "Q"
# 다행 질문은 한 줄로 접는다 — ATX 헤딩은 첫 개행에서 끊긴다.
lines.append(f"### {label}. {' '.join(question.content.split())}")
else:
lines.append("### Q. (질문 미확인)")
lines.append("")
if item.coaching_comment:
lines.append(f"**코칭 한 줄**: {item.coaching_comment}")
lines.append("")
if item.model_answer:
lines.append("#### 모범 답안")
lines.append("")
lines.append(item.model_answer)
lines.append("")
if item.answer_rewrite:
lines.append("#### 내 답변, 이렇게 고치면")
lines.append("")
lines.append(item.answer_rewrite)
lines.append("")


def _question_for(
message_id: int, by_id: dict[int, FeedbackMessageItem]
) -> FeedbackMessageItem | None:
answer = by_id.get(message_id)
if answer is None or answer.parent_message_id is None:
return None
question = by_id.get(answer.parent_message_id)
if question is None or question.role != "INTERVIEWER":
return None
return question


def _render_voice(lines: list[str], voice: VoiceAnalysisSummary | None) -> None:
if voice is None:
return
rows: list[str] = []
if voice.analyzed_message_count is not None:
rows.append(f"- 분석된 답변 수: {voice.analyzed_message_count}")
if voice.average_speaking_rate_wpm is not None:
rows.append(f"- 평균 말속도: {voice.average_speaking_rate_wpm:.0f} WPM")
if voice.total_silence_duration_sec is not None:
rows.append(f"- 총 무음 시간: {voice.total_silence_duration_sec:.0f}초")
fillers = [(w, c) for w, c in (voice.filler_word_counts or {}).items() if c > 0]
if fillers:
joined = " · ".join(f"{w} {c}회" for w, c in fillers)
rows.append(f"- 간투어: {joined}")
if not rows:
return
_section(lines, "음성 전달력 요약")
lines.extend(rows)


def render_feedback_report(
*,
req: GenerateFeedbackRequest,
result: FeedbackResult,
answer_coaching: list[AnswerCoachingItem],
) -> str:
"""FeedbackCallbackPayload 조립 직전 시점의 결과물로 리포트 마크다운을 만든다."""
lines: list[str] = ["# 면접 피드백 리포트", ""]

mode = _MODE_LABEL.get(req.mode, req.mode)
job = _DOMAIN_KO.get(req.job_category, req.job_category)
lines.append(f"- 세션: {req.session_id}")
lines.append(f"- 면접 유형: {mode} · {job}")
if req.total_question_count is not None:
lines.append(f"- 질문 수: {req.total_question_count}")
if req.end_reason:
lines.append(
f"- 종료 사유: {_END_REASON_LABEL.get(req.end_reason, req.end_reason)}"
)
if req.target_company_name:
# 사용자 입력 — 개행을 접어 마크다운 구조 주입('\n## …')을 차단한다.
lines.append(f"- 지원 회사: {' '.join(req.target_company_name.split())}")

_render_scores(lines, result)
_render_panel(lines, result.panel_breakdown)

if result.strengths_summary:
_section(lines, "강점 요약")
lines.append(result.strengths_summary)
if result.weaknesses_summary:
_section(lines, "개선점 요약")
lines.append(result.weaknesses_summary)
if result.improvement_keywords:
_section(lines, "개선 키워드")
lines.extend(f"- {kw}" for kw in result.improvement_keywords)
if result.study_plan:
_section(lines, "학습 플랜")
lines.extend(
f"{i}. {step}" for i, step in enumerate(result.study_plan, start=1)
)
if result.highlights:
_section(lines, "핵심 하이라이트")
lines.extend(f"> {h}" for h in result.highlights)

_render_coaching(lines, answer_coaching, req.messages)
_render_voice(lines, req.voice_analysis_summary)

return "\n".join(lines).rstrip() + "\n"
2 changes: 2 additions & 0 deletions ai/src/ai_server/config/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,8 @@ class Settings(BaseSettings):
analyzed_cover_letter_md_key_template: str = (
"analyzed/cover-letter/{resume_id}/summary.md"
)
# 피드백 마크다운 리포트 키 (reportS3Key). 소비는 Core 프록시 → 프론트 Markdown 뷰어.
feedback_report_md_key_template: str = "feedback/{session_id}/report.md"

# Core 서버 internal API (사용자별 GitHub access_token 조회 등)
core_internal_base_url: str = "http://localhost:38010"
Expand Down
57 changes: 56 additions & 1 deletion ai/src/ai_server/messaging/consumers/feedback_consumer.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@
PersonalityEvaluator,
SelfIntroEvaluator,
)
from ai_server.chain.feedback_report import render_feedback_report
from ai_server.core.client import CoreClient
from ai_server.messaging.consumers.failure_signal import (
classify_failure,
Expand All @@ -47,6 +48,7 @@
VoiceAnalysisSummary,
)
from ai_server.rag.embedder import EmbeddingProvider
from ai_server.storage.base import ObjectStorage

log = structlog.get_logger(__name__)

Expand Down Expand Up @@ -91,6 +93,10 @@ def __init__(
coaching_max_answers: int = 30,
coaching_concurrency: int = 5,
session_notifier: SessionRealtimeNotifier | None = None,
storage: ObjectStorage | None = None,
# 키 템플릿의 SSOT 는 settings — 여기 기본값을 두면 설정 변경이 테스트를 통과한 채
# 프로덕션 키만 바뀐다. storage 와 함께 주입될 때만 저장이 동작한다.
report_key_template: str | None = None,
) -> None:
self._generator = generator
self._publisher = publisher
Expand All @@ -106,6 +112,8 @@ def __init__(
self._coaching_max_answers = coaching_max_answers
self._coaching_concurrency = max(1, coaching_concurrency)
self._session_notifier = session_notifier
self._storage = storage
self._report_key_template = report_key_template

async def handle(self, message: AbstractIncomingMessage) -> None:
await consume_with_failure_signal(
Expand Down Expand Up @@ -214,6 +222,12 @@ async def _tracked(coro: Awaitable[T]) -> T:
message="피드백 리포트를 정리하고 있어요.",
trace_id=envelope.trace_id,
)
report_s3_key = await self._save_report(
req=req,
result=result,
answer_coaching=answer_coaching,
trace_id=envelope.trace_id,
)
payload = FeedbackCallbackPayload(
session_id=req.session_id,
overall_score=result.overall_score,
Expand All @@ -227,12 +241,53 @@ async def _tracked(coro: Awaitable[T]) -> T:
highlights=result.highlights,
panel_breakdown=result.panel_breakdown,
answer_coaching=answer_coaching,
report_s3_key=None,
report_s3_key=report_s3_key,
attempt_id=req.attempt_id,
)

return payload

async def _save_report(
self,
*,
req: GenerateFeedbackRequest,
result: FeedbackResult,
answer_coaching: list[AnswerCoachingItem],
trace_id: str,
) -> str | None:
"""마크다운 리포트 렌더 → 스토리지 저장 → 키 반환.

리포트는 부가 산출물 — 여기서 예외를 흘리면 공용 가드가 피드백 전체를 FAILED 로
승격시키므로 전부 삼키고 None 폴백한다 (본 피드백은 정상 전달)."""
if self._storage is None or self._report_key_template is None:
return None
# 키 조립도 try 안 — 잘못된 템플릿(placeholder 오타)의 KeyError 가 가드로 새어나가
# 피드백 전체를 FAILED 로 만들지 않게 한다.
key = self._report_key_template
try:
key = self._report_key_template.format(session_id=req.session_id)
markdown = render_feedback_report(
req=req, result=result, answer_coaching=answer_coaching
)
await self._storage.put_text(key, markdown)
except Exception as exc: # noqa: BLE001
log.warning(
"feedback.report.save_failed",
session_id=req.session_id,
key=key,
error=format_error_message(exc),
trace_id=trace_id,
)
return None
log.info(
"feedback.report.saved",
session_id=req.session_id,
key=key,
md_chars=len(markdown),
trace_id=trace_id,
)
return key

def _failed_payload(
self, req: GenerateFeedbackRequest, exc: Exception
) -> FeedbackCallbackPayload:
Expand Down
3 changes: 3 additions & 0 deletions ai/src/ai_server/messaging/runner.py
Original file line number Diff line number Diff line change
Expand Up @@ -276,6 +276,9 @@ def __init__(self, settings: Settings) -> None:
build_answer_coaching_chain(settings, core_client=core_client)
),
session_notifier=session_notifier,
# 종합 피드백의 마크다운 리포트 저장(reportS3Key). 실패해도 피드백은 정상 발행.
storage=storage,
report_key_template=settings.feedback_report_md_key_template,
)

# 음성 답변 STT + 분석 (Phase 2)
Expand Down
Loading
Loading