diff --git a/ai/CLAUDE.md b/ai/CLAUDE.md index b710103a..fa1c18d2 100644 --- a/ai/CLAUDE.md +++ b/ai/CLAUDE.md @@ -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 요구 역량 매칭) + `직무 이해도` diff --git a/ai/src/ai_server/chain/feedback_report.py b/ai/src/ai_server/chain/feedback_report.py new file mode 100644 index 00000000..632386cb --- /dev/null +++ b/ai/src/ai_server/chain/feedback_report.py @@ -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" diff --git a/ai/src/ai_server/config/settings.py b/ai/src/ai_server/config/settings.py index e7dfb9ad..6f134355 100644 --- a/ai/src/ai_server/config/settings.py +++ b/ai/src/ai_server/config/settings.py @@ -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" diff --git a/ai/src/ai_server/messaging/consumers/feedback_consumer.py b/ai/src/ai_server/messaging/consumers/feedback_consumer.py index 60b96938..3c7b534b 100644 --- a/ai/src/ai_server/messaging/consumers/feedback_consumer.py +++ b/ai/src/ai_server/messaging/consumers/feedback_consumer.py @@ -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, @@ -47,6 +48,7 @@ VoiceAnalysisSummary, ) from ai_server.rag.embedder import EmbeddingProvider +from ai_server.storage.base import ObjectStorage log = structlog.get_logger(__name__) @@ -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 @@ -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( @@ -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, @@ -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: diff --git a/ai/src/ai_server/messaging/runner.py b/ai/src/ai_server/messaging/runner.py index 4f7b2587..36d2f9e2 100644 --- a/ai/src/ai_server/messaging/runner.py +++ b/ai/src/ai_server/messaging/runner.py @@ -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) diff --git a/ai/tests/test_feedback_consumer.py b/ai/tests/test_feedback_consumer.py index 0098e0bb..f50ef76a 100644 --- a/ai/tests/test_feedback_consumer.py +++ b/ai/tests/test_feedback_consumer.py @@ -1171,3 +1171,96 @@ async def test_consumer_without_session_notifier_still_publishes_callback(): ) await consumer.handle(_StubMessage(_envelope())) publisher.publish.assert_awaited_once() + + +# --------------------------------------------------------------------------- +# 마크다운 리포트 저장 (reportS3Key) + + +class _FakeStorage: + """put_text 호출을 (key, text, content_type) 로 축적. fail=True 면 저장 실패 재현.""" + + def __init__(self, fail: bool = False): + self.puts: list[tuple[str, str, str | None]] = [] + self._fail = fail + + async def put_text( + self, key, text, *, content_type: str | None = "text/markdown; charset=utf-8" + ): + if self._fail: + raise RuntimeError("minio down") + self.puts.append((key, text, content_type)) + + +@pytest.mark.asyncio +async def test_consumer_saves_markdown_report_and_sets_key(): + generator = _generator() + publisher = MagicMock() + publisher.publish = AsyncMock() + storage = _FakeStorage() + + consumer = FeedbackConsumer( + generator=generator, + publisher=publisher, + idempotency=LruIdempotencyStore(max_size=10), + callback_routing_key="callback.feedback", + core_client=MagicMock(), + embedder=None, + storage=storage, + report_key_template="feedback/{session_id}/report.md", + ) + await consumer.handle(_StubMessage(_envelope())) + + payload: FeedbackCallbackPayload = publisher.publish.await_args.kwargs["payload"] + assert payload.status == "OK" + assert payload.report_s3_key == "feedback/50/report.md" + assert len(storage.puts) == 1 + key, text, content_type = storage.puts[0] + assert key == "feedback/50/report.md" + assert text.startswith("# 면접 피드백 리포트") + assert "ACID 4요소를 명확히 답변." in text # 결과 요약이 실제로 담긴다 + + +@pytest.mark.asyncio +async def test_report_save_failure_falls_back_to_none_and_keeps_feedback_ok(): + generator = _generator() + publisher = MagicMock() + publisher.publish = AsyncMock() + + consumer = FeedbackConsumer( + generator=generator, + publisher=publisher, + idempotency=LruIdempotencyStore(max_size=10), + callback_routing_key="callback.feedback", + core_client=MagicMock(), + embedder=None, + storage=_FakeStorage(fail=True), + report_key_template="feedback/{session_id}/report.md", + ) + await consumer.handle(_StubMessage(_envelope())) + + # 리포트는 부가 산출물 — 저장 실패가 피드백을 FAILED 로 승격시키면 안 된다. + payload: FeedbackCallbackPayload = publisher.publish.await_args.kwargs["payload"] + assert payload.status == "OK" + assert payload.overall_score == 85.0 + assert payload.report_s3_key is None + + +@pytest.mark.asyncio +async def test_no_storage_keeps_report_key_none(): + generator = _generator() + publisher = MagicMock() + publisher.publish = AsyncMock() + + consumer = FeedbackConsumer( + generator=generator, + publisher=publisher, + idempotency=LruIdempotencyStore(max_size=10), + callback_routing_key="callback.feedback", + core_client=MagicMock(), + embedder=None, + ) + await consumer.handle(_StubMessage(_envelope())) + + payload: FeedbackCallbackPayload = publisher.publish.await_args.kwargs["payload"] + assert payload.report_s3_key is None diff --git a/ai/tests/test_feedback_report.py b/ai/tests/test_feedback_report.py new file mode 100644 index 00000000..59375a1e --- /dev/null +++ b/ai/tests/test_feedback_report.py @@ -0,0 +1,136 @@ +"""feedback_report 렌더러 단위 테스트 — LLM 미호출 순수 함수.""" + +from ai_server.chain.feedback_generation_chain import FeedbackResult +from ai_server.chain.feedback_report import render_feedback_report +from ai_server.model.messages.feedback import ( + AnswerCoachingItem, + GenerateFeedbackRequest, + PanelBreakdownItem, +) + + +def _request(**overrides) -> GenerateFeedbackRequest: + base = { + "session_id": 50, + "mode": "TECHNICAL", + "job_category": "BACKEND", + "total_question_count": 2, + "end_reason": "MAX_QUESTIONS_REACHED", + "messages": [ + { + "id": 100, + "sequence_number": 1, + "role": "INTERVIEWER", + "content": "트랜잭션 ACID 를 설명해 주세요.", + }, + { + "id": 101, + "sequence_number": 2, + "role": "INTERVIEWEE", + "content": "**사용자답변원문** — 원자성 일관성", + "parent_message_id": 100, + }, + ], + } + base.update(overrides) + return GenerateFeedbackRequest.model_validate(base) + + +def _result(**overrides) -> FeedbackResult: + base = dict( + overall_score=85.0, + technical_accuracy=82.5, + logic_score=88.0, + communication_score=None, + strengths_summary="ACID 4요소를 명확히 답변.", + weaknesses_summary="구체적 사례 부족.", + improvement_keywords=["MVCC"], + study_plan=["격리 수준 4단계 정리"], + highlights=["명확히 답변"], + panel_breakdown=[ + PanelBreakdownItem( + evaluator="기술", + dimension="기술 정확도", + score=82.0, + strength="개념 정확", + weakness="사례 부족", + detail="정의는 정확했으나 실무 예시가 없었다.", + score_rationale="예시 부재 감점", + ) + ], + ) + base.update(overrides) + return FeedbackResult(**base) + + +def test_renders_full_report_sections(): + md = render_feedback_report( + req=_request( + voice_analysis_summary={ + "analyzed_message_count": 2, + "average_speaking_rate_wpm": 132.5, + "total_silence_duration_sec": 4.2, + "filler_word_counts": {"음": 3, "like": 0}, + } + ), + result=_result(), + answer_coaching=[ + AnswerCoachingItem( + message_id=101, + model_answer="- **원자성**: 전부 반영되거나 전부 취소", + answer_rewrite="ACID 각 요소를 예시와 함께 설명합니다.", + coaching_comment="정의에 실무 예시를 더하세요.", + ) + ], + ) + + assert md.startswith("# 면접 피드백 리포트\n") + assert "- 면접 유형: 기술 면접 · 백엔드" in md + assert "- 종료 사유: 질문 수 도달" in md + # 점수 표: 85.0 → "85", 82.5 는 유지, None 은 —. + assert "| 종합 | 85 |" in md + assert "| 기술 정확도 | 82.5 |" in md + assert "| 전달력 | — |" in md + # 패널. + assert "### 기술 — 기술 정확도 (82점)" in md + assert "> 점수 근거: 예시 부재 감점" in md + # 요약·키워드·플랜·하이라이트. + assert "## 강점 요약" in md and "ACID 4요소를 명확히 답변." in md + assert "- MVCC" in md + assert "1. 격리 수준 4단계 정리" in md + assert "> 명확히 답변" in md + # 코칭: 질문 텍스트는 싣고(GFM 계약 필드 원문 유지), 사용자 답변 원문은 싣지 않는다. + assert "### Q1. 트랜잭션 ACID 를 설명해 주세요." in md + assert "- **원자성**: 전부 반영되거나 전부 취소" in md + assert "사용자답변원문" not in md + # 음성 요약: 0회 간투어는 제외. + assert "- 평균 말속도: 132 WPM" in md + assert "- 간투어: 음 3회" in md + assert "like" not in md + + +def test_minimal_result_omits_empty_sections(): + md = render_feedback_report( + req=_request(total_question_count=None, end_reason=None, messages=[]), + result=FeedbackResult(), + answer_coaching=[], + ) + + assert "# 면접 피드백 리포트" in md + assert "| 종합 | — |" in md # 점수 표는 항상 노출(— 표기) + assert "## 평가위원 패널" not in md + assert "## 답변별 코칭" not in md + assert "## 음성 전달력 요약" not in md + assert "- 질문 수:" not in md + + +def test_coaching_question_fallback_when_message_unknown(): + md = render_feedback_report( + req=_request(), + result=_result(), + answer_coaching=[ + AnswerCoachingItem(message_id=999, coaching_comment="한 줄 코칭") + ], + ) + assert "### Q. (질문 미확인)" in md + assert "**코칭 한 줄**: 한 줄 코칭" in md diff --git a/backend/CLAUDE.md b/backend/CLAUDE.md index c304ea0f..3f84fb98 100644 --- a/backend/CLAUDE.md +++ b/backend/CLAUDE.md @@ -455,6 +455,13 @@ docker compose up -d `InterviewMessage.failFollowup()`(content=`FOLLOWUP_GENERATION_FAILED_TEXT`, status=`FAILED`)로 확정한 뒤 `SESSION_MESSAGE`(`FOLLOWUP_FAILED`) 발행 + DONT_KNOW 와 동일하게 `advanceToNextGeneral` 로 다음 일반질문으로 진행 — 턴이 사라진 것처럼 보이지 않으면서 면접은 멈추지 않는다. +- **AI 학습 리포트 프록시 본 구현 (reportS3Key)**: AI 가 피드백 생성 시 저장하는 마크다운 리포트 + (`feedback/{sessionId}/report.md`)를 `SessionFeedbackQueryService.getReportContent` → + `GET /api/sessions/{id}/feedback/report`(text/markdown, private 캐시 10분)로 중계한다 — 분석 원문 + `/documents/{id}/content` 프록시와 동일 패턴(presigned 는 내부 MinIO 호스트라 브라우저 접근 불가). + 키 부재(AI 저장 실패 폴백·구버전 피드백)는 422 `FEEDBACK_REPORT_NOT_AVAILABLE` 로 "생성 중"과 구분. + **공개 공유 응답(`FeedbackResponse.fromPublic`)에서는 `reportFilePath` 를 제거** — 내부 스토리지 키 + 구조를 비인증 응답에 노출하지 않는다(shareToken 제거와 같은 원칙, 프록시도 소유자 전용). - **피드백 생성 실패 신호 본 구현**: AI `feedback_consumer` 의 예상 못 한 예외가 DLQ 로만 격리돼 세션이 "피드백 생성 중"에 무기한 멈추던 gap 을 닫았다. `FeedbackCallbackPayload` 에 `status` (`OK`|`FAILED`)·`errorCode`·`errorMessage`·`retriable` 추가(구버전 13-arg 생성자는 `status=OK` diff --git a/backend/openapi.json b/backend/openapi.json index ce3b3587..ee5af73a 100644 --- a/backend/openapi.json +++ b/backend/openapi.json @@ -2440,6 +2440,69 @@ } } }, + "/api/sessions/{sessionId}/feedback/report" : { + "get" : { + "tags" : [ "Session Feedback" ], + "summary" : "AI 학습 리포트(마크다운) 프록시", + "description" : "AI 가 피드백 생성 시 저장한 마크다운 리포트를 중계한다. presigned URL 은 내부(MinIO) 호스트라 브라우저가 직접 접근할 수 없다 (분석 원문 /content 프록시와 동일 패턴). 소유자 전용 — 공유(비인증) 응답에는 키 자체를 싣지 않는다.", + "operationId" : "getSessionFeedbackReport", + "parameters" : [ { + "name" : "sessionId", + "in" : "path", + "required" : true, + "schema" : { + "type" : "integer", + "format" : "int64" + } + } ], + "responses" : { + "200" : { + "description" : "리포트 (text/markdown)", + "content" : { + "*/*" : { + "schema" : { + "type" : "string", + "format" : "binary" + } + } + } + }, + "401" : { + "description" : "인증 실패", + "content" : { + "*/*" : { + "schema" : { + "type" : "string", + "format" : "binary" + } + } + } + }, + "404" : { + "description" : "세션 또는 피드백 없음", + "content" : { + "*/*" : { + "schema" : { + "type" : "string", + "format" : "binary" + } + } + } + }, + "422" : { + "description" : "리포트 파일 없음 (저장 실패 폴백·구버전 피드백)", + "content" : { + "*/*" : { + "schema" : { + "type" : "string", + "format" : "binary" + } + } + } + } + } + } + }, "/api/resumes/{resumeId}" : { "get" : { "tags" : [ "Resumes" ], diff --git a/backend/src/main/java/com/stackup/stackup/common/exception/ApiErrorCode.java b/backend/src/main/java/com/stackup/stackup/common/exception/ApiErrorCode.java index 01c7f726..5c46d5e4 100644 --- a/backend/src/main/java/com/stackup/stackup/common/exception/ApiErrorCode.java +++ b/backend/src/main/java/com/stackup/stackup/common/exception/ApiErrorCode.java @@ -48,6 +48,8 @@ public enum ApiErrorCode { FEEDBACK_GENERATION_FAILED(HttpStatus.NOT_FOUND, "피드백 생성에 실패했습니다. 다시 생성을 요청해 주세요."), FEEDBACK_NOT_FOUND(HttpStatus.NOT_FOUND, "공유된 피드백을 찾을 수 없습니다."), FEEDBACK_ALREADY_EXISTS(HttpStatus.CONFLICT, "피드백이 이미 생성되어 있습니다."), + // reportS3Key(=report_file_path) 미보유 — AI 리포트 저장 실패 폴백 또는 구버전 피드백. + FEEDBACK_REPORT_NOT_AVAILABLE(HttpStatus.UNPROCESSABLE_CONTENT, "생성된 피드백 리포트 파일이 없습니다."), VOICE_EMPTY_FILE(HttpStatus.BAD_REQUEST, "음성 파일을 업로드할 수 없습니다."), VOICE_FILE_TOO_LARGE(HttpStatus.BAD_REQUEST, "음성 파일 크기가 너무 큽니다."), VOICE_INVALID_CONTENT_TYPE(HttpStatus.BAD_REQUEST, "지원하지 않는 음성 형식입니다."), diff --git a/backend/src/main/java/com/stackup/stackup/common/storage/S3ObjectStorageClient.java b/backend/src/main/java/com/stackup/stackup/common/storage/S3ObjectStorageClient.java index 40ce59d1..47770cff 100644 --- a/backend/src/main/java/com/stackup/stackup/common/storage/S3ObjectStorageClient.java +++ b/backend/src/main/java/com/stackup/stackup/common/storage/S3ObjectStorageClient.java @@ -18,6 +18,7 @@ import software.amazon.awssdk.services.s3.S3Configuration; import software.amazon.awssdk.services.s3.model.DeleteObjectRequest; import software.amazon.awssdk.services.s3.model.GetObjectRequest; +import software.amazon.awssdk.services.s3.model.NoSuchKeyException; import software.amazon.awssdk.services.s3.model.GetObjectResponse; import software.amazon.awssdk.services.s3.model.PutObjectRequest; import software.amazon.awssdk.services.s3.presigner.model.GetObjectPresignRequest; @@ -95,6 +96,9 @@ public InputStream get(String key) { .build() ); return response; + } catch (NoSuchKeyException e) { + // 데이터 정합 문제(키는 DB에 있는데 객체가 없음)를 인프라 장애로 오인하지 않도록 분리. + throw new StorageException(StorageErrorType.OBJECT_NOT_FOUND, "Object not found in S3", e); } catch (SdkException e) { throw new StorageException(StorageErrorType.DOWNLOAD_FAILED, "Failed to download object from S3", e); } diff --git a/backend/src/main/java/com/stackup/stackup/common/storage/StorageErrorType.java b/backend/src/main/java/com/stackup/stackup/common/storage/StorageErrorType.java index 002c4655..25d6ad9c 100644 --- a/backend/src/main/java/com/stackup/stackup/common/storage/StorageErrorType.java +++ b/backend/src/main/java/com/stackup/stackup/common/storage/StorageErrorType.java @@ -2,6 +2,8 @@ public enum StorageErrorType { INVALID_OBJECT_KEY, + // 키는 유효하나 객체가 없음(NoSuchKey) — 호출부가 인프라 장애(503)와 구분해 처리할 수 있게 분리. + OBJECT_NOT_FOUND, UPLOAD_FAILED, DOWNLOAD_FAILED, DELETE_FAILED, diff --git a/backend/src/main/java/com/stackup/stackup/session/application/SessionFeedbackQueryService.java b/backend/src/main/java/com/stackup/stackup/session/application/SessionFeedbackQueryService.java index a1814b22..c57912ab 100644 --- a/backend/src/main/java/com/stackup/stackup/session/application/SessionFeedbackQueryService.java +++ b/backend/src/main/java/com/stackup/stackup/session/application/SessionFeedbackQueryService.java @@ -2,6 +2,9 @@ import com.stackup.stackup.common.exception.ApiErrorCode; import com.stackup.stackup.common.exception.DomainException; +import com.stackup.stackup.common.storage.ObjectStorageClient; +import com.stackup.stackup.common.storage.StorageErrorType; +import com.stackup.stackup.common.storage.StorageException; import com.stackup.stackup.session.application.event.FeedbackRegenerateRequestedEvent; import com.stackup.stackup.session.application.dto.FeedbackResult; import com.stackup.stackup.session.domain.InterviewSession; @@ -9,6 +12,7 @@ import com.stackup.stackup.session.domain.SessionFeedback; import com.stackup.stackup.session.domain.SessionFeedbackRepository; import com.stackup.stackup.session.domain.SessionStatus; +import java.io.InputStream; import java.util.Map; import java.util.UUID; import lombok.RequiredArgsConstructor; @@ -24,6 +28,7 @@ public class SessionFeedbackQueryService { private final InterviewSessionRepository sessionRepository; private final SessionFeedbackRepository feedbackRepository; private final ApplicationEventPublisher events; + private final ObjectStorageClient storage; public FeedbackResult get(Long userId, Long sessionId) { InterviewSession session = sessionRepository.findByIdAndUser_IdAndDeletedFalse(sessionId, userId) @@ -45,6 +50,27 @@ private DomainException notReadyOrFailed(InterviewSession session) { return new DomainException(ApiErrorCode.FEEDBACK_NOT_READY); } + // AI 마크다운 리포트 프록시: presigned URL 은 내부(MinIO) 호스트라 브라우저가 직접 + // 접근할 수 없어 Core 가 바이트를 중계한다 (분석 원문 /content 프록시와 동일 패턴). + public InputStream getReportContent(Long userId, Long sessionId) { + SessionFeedback feedback = ownedFeedback(userId, sessionId); + String path = feedback.getReportFilePath(); + if (path == null || path.isBlank()) { + // 리포트는 부가 산출물 — AI 저장 실패 폴백(None)이나 구버전 피드백은 키가 없다. + throw new DomainException(ApiErrorCode.FEEDBACK_REPORT_NOT_AVAILABLE); + } + try { + return storage.get(path); + } catch (StorageException e) { + if (e.getType() == StorageErrorType.OBJECT_NOT_FOUND) { + // 키만 남고 객체가 없는 정합 붕괴(볼륨 리셋 등) — 재시도해도 안 되는 503 대신 + // 키 부재와 같은 422 로: 클라이언트 처리 경로가 하나로 수렴한다. + throw new DomainException(ApiErrorCode.FEEDBACK_REPORT_NOT_AVAILABLE); + } + throw e; + } + } + // 공유 활성화: 소유자 검증 후 토큰 보장(없으면 발급). 멱등 — 현재 토큰 반환. @Transactional public String enableShare(Long userId, Long sessionId) { diff --git a/backend/src/main/java/com/stackup/stackup/session/presentation/SessionFeedbackController.java b/backend/src/main/java/com/stackup/stackup/session/presentation/SessionFeedbackController.java index 2ec96a6c..9204ef51 100644 --- a/backend/src/main/java/com/stackup/stackup/session/presentation/SessionFeedbackController.java +++ b/backend/src/main/java/com/stackup/stackup/session/presentation/SessionFeedbackController.java @@ -7,7 +7,13 @@ import io.swagger.v3.oas.annotations.responses.ApiResponse; import io.swagger.v3.oas.annotations.responses.ApiResponses; import io.swagger.v3.oas.annotations.tags.Tag; +import java.time.Duration; import lombok.RequiredArgsConstructor; +import org.springframework.core.io.InputStreamResource; +import org.springframework.core.io.Resource; +import org.springframework.http.CacheControl; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; import org.springframework.http.HttpStatus; import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.web.bind.annotation.DeleteMapping; @@ -40,6 +46,30 @@ public FeedbackResponse get( return FeedbackResponse.from(queryService.get(principal.userId(), sessionId)); } + @Operation( + operationId = "getSessionFeedbackReport", + summary = "AI 학습 리포트(마크다운) 프록시", + description = "AI 가 피드백 생성 시 저장한 마크다운 리포트를 중계한다. " + + "presigned URL 은 내부(MinIO) 호스트라 브라우저가 직접 접근할 수 없다 " + + "(분석 원문 /content 프록시와 동일 패턴). 소유자 전용 — 공유(비인증) 응답에는 키 자체를 싣지 않는다." + ) + @ApiResponses({ + @ApiResponse(responseCode = "200", description = "리포트 (text/markdown)"), + @ApiResponse(responseCode = "401", description = "인증 실패"), + @ApiResponse(responseCode = "404", description = "세션 또는 피드백 없음"), + @ApiResponse(responseCode = "422", description = "리포트 파일 없음 (저장 실패 폴백·구버전 피드백)") + }) + @GetMapping("/report") + public ResponseEntity report( + @AuthenticationPrincipal UserPrincipal principal, + @PathVariable Long sessionId + ) { + return ResponseEntity.ok() + .contentType(MediaType.parseMediaType("text/markdown; charset=utf-8")) + .cacheControl(CacheControl.maxAge(Duration.ofMinutes(10)).cachePrivate()) + .body(new InputStreamResource(queryService.getReportContent(principal.userId(), sessionId))); + } + @Operation(operationId = "shareSessionFeedback", summary = "피드백 공유 토큰 발급(멱등)") @ApiResponses({ @ApiResponse(responseCode = "200", description = "공유 토큰"), diff --git a/backend/src/main/java/com/stackup/stackup/session/presentation/dto/FeedbackResponse.java b/backend/src/main/java/com/stackup/stackup/session/presentation/dto/FeedbackResponse.java index 0bdad5c2..2e1fe094 100644 --- a/backend/src/main/java/com/stackup/stackup/session/presentation/dto/FeedbackResponse.java +++ b/backend/src/main/java/com/stackup/stackup/session/presentation/dto/FeedbackResponse.java @@ -31,22 +31,23 @@ public record FeedbackResponse( ) { public static FeedbackResponse from(FeedbackResult r) { - return build(r, r.shareToken()); + return build(r, r.reportFilePath(), r.shareToken()); } // 공개(비인증) 응답: 호출자가 이미 토큰을 알고 있더라도 응답 본문에는 싣지 않는다 — - // 캐시·로그·스크린샷 경유 재유출 면을 줄인다. + // 캐시·로그·스크린샷 경유 재유출 면을 줄인다. reportFilePath(내부 S3 키)도 같은 이유로 + // 제외 — 리포트 프록시는 소유자 전용이라 공개 화면에선 쓸 수도 없다. public static FeedbackResponse fromPublic(FeedbackResult r) { - return build(r, null); + return build(r, null, null); } - private static FeedbackResponse build(FeedbackResult r, String shareToken) { + private static FeedbackResponse build(FeedbackResult r, String reportFilePath, String shareToken) { return new FeedbackResponse( r.id(), r.sessionId(), r.overallScore(), r.technicalAccuracy(), r.logicScore(), r.communicationScore(), r.strengthsSummary(), r.weaknessesSummary(), r.improvementKeywords(), r.panelBreakdown(), r.studyPlan(), r.highlights(), - r.reportFilePath(), shareToken, r.createdAt() + reportFilePath, shareToken, r.createdAt() ); } } diff --git a/backend/src/test/java/com/stackup/stackup/session/application/SessionFeedbackQueryServiceTest.java b/backend/src/test/java/com/stackup/stackup/session/application/SessionFeedbackQueryServiceTest.java index e8d25f27..9081c9cb 100644 --- a/backend/src/test/java/com/stackup/stackup/session/application/SessionFeedbackQueryServiceTest.java +++ b/backend/src/test/java/com/stackup/stackup/session/application/SessionFeedbackQueryServiceTest.java @@ -8,6 +8,9 @@ import com.stackup.stackup.common.exception.ApiErrorCode; import com.stackup.stackup.common.exception.DomainException; +import com.stackup.stackup.common.storage.ObjectStorageClient; +import com.stackup.stackup.common.storage.StorageErrorType; +import com.stackup.stackup.common.storage.StorageException; import com.stackup.stackup.session.application.event.FeedbackRegenerateRequestedEvent; import com.stackup.stackup.session.domain.InterviewSession; import com.stackup.stackup.session.domain.InterviewSessionRepository; @@ -16,6 +19,8 @@ import com.stackup.stackup.session.domain.SessionFeedbackRepository; import com.stackup.stackup.session.domain.SessionMode; import com.stackup.stackup.user.domain.User; +import java.io.ByteArrayInputStream; +import java.io.InputStream; import java.util.List; import java.util.Optional; import org.junit.jupiter.api.Test; @@ -32,6 +37,7 @@ class SessionFeedbackQueryServiceTest { @Mock InterviewSessionRepository sessionRepository; @Mock SessionFeedbackRepository feedbackRepository; @Mock ApplicationEventPublisher events; + @Mock ObjectStorageClient storage; @InjectMocks SessionFeedbackQueryService service; @Test @@ -161,6 +167,53 @@ private InterviewSession sessionFixture(Long id) { return s; } + @Test + void getReportContent_streamsStoredReport() { + InterviewSession session = sessionFixture(50L); + SessionFeedback feedback = SessionFeedback.of(session, 80.0, 80.0, 80.0, 80.0, + "s", "w", null, null, null, null, "feedback/50/report.md"); + when(sessionRepository.findByIdAndUser_IdAndDeletedFalse(50L, 1L)) + .thenReturn(Optional.of(session)); + when(feedbackRepository.findBySession_Id(50L)).thenReturn(Optional.of(feedback)); + InputStream stored = new ByteArrayInputStream("# report".getBytes()); + when(storage.get("feedback/50/report.md")).thenReturn(stored); + + assertThat(service.getReportContent(1L, 50L)).isSameAs(stored); + } + + @Test + void getReportContent_throwsWhenReportMissing() { + // AI 리포트 저장 실패 폴백(reportS3Key=None)·구버전 피드백 — 프록시는 422 로 구분한다. + InterviewSession session = sessionFixture(50L); + SessionFeedback feedback = feedbackFixture(session); + when(sessionRepository.findByIdAndUser_IdAndDeletedFalse(50L, 1L)) + .thenReturn(Optional.of(session)); + when(feedbackRepository.findBySession_Id(50L)).thenReturn(Optional.of(feedback)); + + assertThatThrownBy(() -> service.getReportContent(1L, 50L)) + .isInstanceOfSatisfying(DomainException.class, + e -> assertThat(e.getErrorCode()) + .isEqualTo(ApiErrorCode.FEEDBACK_REPORT_NOT_AVAILABLE)); + } + + @Test + void getReportContent_treatsDanglingKeyAsNotAvailable() { + // DB 에 키는 남았는데 객체가 사라진 정합 붕괴 — 503(인프라 장애)이 아니라 422 로. + InterviewSession session = sessionFixture(50L); + SessionFeedback feedback = SessionFeedback.of(session, 80.0, 80.0, 80.0, 80.0, + "s", "w", null, null, null, null, "feedback/50/report.md"); + when(sessionRepository.findByIdAndUser_IdAndDeletedFalse(50L, 1L)) + .thenReturn(Optional.of(session)); + when(feedbackRepository.findBySession_Id(50L)).thenReturn(Optional.of(feedback)); + when(storage.get("feedback/50/report.md")) + .thenThrow(new StorageException(StorageErrorType.OBJECT_NOT_FOUND, "gone")); + + assertThatThrownBy(() -> service.getReportContent(1L, 50L)) + .isInstanceOfSatisfying(DomainException.class, + e -> assertThat(e.getErrorCode()) + .isEqualTo(ApiErrorCode.FEEDBACK_REPORT_NOT_AVAILABLE)); + } + private SessionFeedback feedbackFixture(InterviewSession session) { return SessionFeedback.of(session, 80.0, 80.0, 80.0, 80.0, "s", "w", null, null, null, null, null); diff --git a/backend/src/test/java/com/stackup/stackup/session/presentation/dto/FeedbackResponseTest.java b/backend/src/test/java/com/stackup/stackup/session/presentation/dto/FeedbackResponseTest.java new file mode 100644 index 00000000..4427385f --- /dev/null +++ b/backend/src/test/java/com/stackup/stackup/session/presentation/dto/FeedbackResponseTest.java @@ -0,0 +1,36 @@ +package com.stackup.stackup.session.presentation.dto; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.stackup.stackup.session.application.dto.FeedbackResult; +import java.time.Instant; +import java.util.List; +import org.junit.jupiter.api.Test; + +class FeedbackResponseTest { + + private FeedbackResult result() { + return new FeedbackResult(1L, 50L, 80.0, 80.0, 80.0, 80.0, + "s", "w", List.of(), List.of(), List.of(), List.of(), + "feedback/50/report.md", "tok-1", Instant.EPOCH); + } + + @Test + void from_exposesReportFilePathAndShareTokenToOwner() { + FeedbackResponse res = FeedbackResponse.from(result()); + + assertThat(res.reportFilePath()).isEqualTo("feedback/50/report.md"); + assertThat(res.shareToken()).isEqualTo("tok-1"); + } + + @Test + void fromPublic_hidesInternalStorageKeyAndShareToken() { + // 공개(비인증) 응답 — 내부 S3 키·공유 토큰 재유출 면을 응답 본문에서 제거한다. + // 리포트 프록시는 소유자 전용이라 공개 화면에선 키가 있어도 쓸 수 없다. + FeedbackResponse res = FeedbackResponse.fromPublic(result()); + + assertThat(res.reportFilePath()).isNull(); + assertThat(res.shareToken()).isNull(); + assertThat(res.overallScore()).isEqualTo(80.0); + } +} diff --git a/docs/api-conventions.md b/docs/api-conventions.md index 3edb827e..bc086c53 100644 --- a/docs/api-conventions.md +++ b/docs/api-conventions.md @@ -84,6 +84,7 @@ GET /api/sessions/{id}/messages 메시지 목록 POST /api/sessions/{id}/messages 메시지 추가 (RealTime → Core 전용) GET /api/sessions/{id}/feedback 피드백 조회 +GET /api/sessions/{id}/feedback/report AI 학습 리포트(마크다운) 프록시 — 소유자 전용, /documents/{id}/content 와 동일 패턴 ``` ### 2.5 시스템 @@ -251,6 +252,7 @@ FEEDBACK_NOT_READY 404 아직 생성 중 (폴링/SSE 대기 지속) FEEDBACK_GENERATION_FAILED 404 생성 실패 마커 존재 — 대기 중단, 재생성 유도. details.retriable(boolean) 동봉 FEEDBACK_NOT_FOUND 404 공유 토큰 무효 FEEDBACK_ALREADY_EXISTS 409 재생성 요청 시 이미 존재 (재조회 신호) +FEEDBACK_REPORT_NOT_AVAILABLE 422 리포트 파일 없음 — AI 저장 실패 폴백(reportS3Key=null)·구버전 피드백 # 시스템 (SYS_*) SYS_RATE_LIMITED 429 diff --git a/docs/environment.md b/docs/environment.md index 5b4ab8c9..47f14d93 100644 --- a/docs/environment.md +++ b/docs/environment.md @@ -155,6 +155,7 @@ EMBEDDING_RETRY_BASE_DELAY_SEC=2.0 # delay = base*2^attempt + jitter (상한 30 ANALYZED_RESUME_MD_KEY_TEMPLATE=analyzed/resume/{resume_id}/summary.md ANALYZED_REPOSITORY_MD_KEY_TEMPLATE=analyzed/repository/{repository_id}/summary.md ANALYZED_WEB_RESUME_MD_KEY_TEMPLATE=analyzed/web-resume/{resume_id}/summary.md +FEEDBACK_REPORT_MD_KEY_TEMPLATE=feedback/{session_id}/report.md # ===== STT/TTS (Phase 2) ===== STT_PROVIDER=whisper-api # whisper-api | whisper-self-hosted diff --git a/docs/frontend-types.md b/docs/frontend-types.md index d427dce0..a7bb4eca 100644 --- a/docs/frontend-types.md +++ b/docs/frontend-types.md @@ -119,7 +119,7 @@ class ErrorBoundary extends Component { | 포맷 | 필드 | 렌더 | |---|---|---| -| **GFM 마크다운** | 분석 산출물(`documentPath` 문서), `answerCoaching[].modelAnswer`/`answerRewrite` | `shared/ui/Markdown` (lazy + sanitize) | +| **GFM 마크다운** | 분석 산출물(`documentPath` 문서), `answerCoaching[].modelAnswer`/`answerRewrite`, AI 학습 리포트(`reportFilePath` — `GET /feedback/report` 프록시 본문) | `shared/ui/Markdown` (lazy + sanitize) | | **일반 텍스트** | 피드백 요약·`highlights[]`·`studyPlan[]`·패널 `detail`·질문 텍스트·`coachingComment` 등 나머지 전부 | plain (`whitespace-pre-wrap`) — `HighlightedText` 부분 문자열 매칭·델타 스트리밍과의 충돌을 막기 위한 계약 | 사용자 입력(내 답변 등)은 어떤 경우에도 마크다운으로 렌더하지 않는다. diff --git a/docs/messaging.md b/docs/messaging.md index b4ea8d9f..f3b59057 100644 --- a/docs/messaging.md +++ b/docs/messaging.md @@ -485,6 +485,12 @@ > generator 가 모른 채 overall 계산 후 표시용으로 append). > `highlights[]` 는 강점·개선 본문에서 발췌한 핵심 구절 — 프론트가 부분 문자열 매칭으로 리포트에 > 하이라이트 표시한다(`HighlightedText`). 빈 리스트 허용. +> `reportS3Key` 는 AI 가 발행 직전에 저장한 **마크다운(GFM) 학습 리포트**의 스토리지 키 +> (`feedback/{session_id}/report.md`, [storage.md §2](./storage.md)). LLM 미호출 결정론 렌더 — +> 위 payload 의 점수·패널·요약·코칭을 문서로 조립한 것이다. **null 허용**: 렌더·업로드 실패는 +> 피드백 전체를 FAILED 로 만들지 않고 None 폴백한다(리포트는 부가 산출물). FAILED 콜백에서는 +> 항상 null. Core 는 `session_feedbacks.report_file_path` 에 저장하고 소유자 전용 프록시 +> `GET /api/sessions/{id}/feedback/report` 로 중계한다(공개 공유 응답에는 키 미노출). ```json { diff --git a/docs/storage.md b/docs/storage.md index ee7fd10c..332a535b 100644 --- a/docs/storage.md +++ b/docs/storage.md @@ -26,7 +26,7 @@ session/{session_id}/audio/{message_id}.webm session/{session_id}/audio/{message_id}.transcript.txt interview/tts/{session_id}/{message_id}.{ext} # 질문 whole-message TTS (영속, ttsAudioPath) interview/tts/{session_id}/{message_id}/seg-{seq}.{ext} # 꼬리질문 문장 단위 TTS 세그먼트 (휘발성, DB 미기록) -feedback/{session_id}/report.md +feedback/{session_id}/report.md # AI 학습 리포트 (GFM, reportS3Key — Core 프록시로 열람) feedback/{session_id}/charts/{name}.png ``` diff --git a/frontend/src/features/feedback/model/useFeedbackReport.ts b/frontend/src/features/feedback/model/useFeedbackReport.ts new file mode 100644 index 00000000..dafbf8ec --- /dev/null +++ b/frontend/src/features/feedback/model/useFeedbackReport.ts @@ -0,0 +1,24 @@ +import { useQuery } from '@tanstack/react-query' +import { apiClient } from '@/shared/api' +import { feedbackKeys } from './useFeedback' + +// AI 학습 리포트(마크다운) 로드 — AI 가 피드백 생성 시 저장한 reportS3Key 파일을 +// Core 프록시(`GET /api/sessions/{id}/feedback/report`)로 받는다. presigned 는 내부(MinIO) +// 호스트라 브라우저가 접근할 수 없다(분석 원문 /content 프록시와 동일 이유). +// 소유자 전용 — 공개 공유 응답에는 reportFilePath 자체가 실리지 않아 진입점이 없다. +export function useFeedbackReport(sessionId: number, enabled: boolean) { + return useQuery({ + // detail 키의 하위로 둔다 — 재생성(useRegenerateFeedback)의 resetQueries(prefix 매칭)가 + // 리포트 캐시도 함께 비워, 재생성 후 이전 리포트가 staleTime 동안 보이는 일을 막는다. + queryKey: [...feedbackKeys.detail(sessionId), 'report'], + enabled, + staleTime: 5 * 60_000, + queryFn: async ({ signal }) => { + const res = await apiClient.get(`/api/sessions/${sessionId}/feedback/report`, { + signal, + responseType: 'text', + }) + return res.data + }, + }) +} diff --git a/frontend/src/features/feedback/ui/FeedbackAiReport.test.tsx b/frontend/src/features/feedback/ui/FeedbackAiReport.test.tsx new file mode 100644 index 00000000..38790709 --- /dev/null +++ b/frontend/src/features/feedback/ui/FeedbackAiReport.test.tsx @@ -0,0 +1,69 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import type { ReactNode } from 'react' +import { render, screen, waitFor } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import { apiClient } from '@/shared/api' +import { FeedbackAiReport } from './FeedbackAiReport' + +vi.mock('@/shared/api', async (importOriginal) => { + const mod = await importOriginal() + return { ...mod, apiClient: { get: vi.fn() } } +}) + +function renderWithQuery(ui: ReactNode) { + const qc = new QueryClient({ + defaultOptions: { queries: { retry: false, refetchOnWindowFocus: false } }, + }) + return render({ui}) +} + +beforeEach(() => { + vi.mocked(apiClient.get).mockReset() +}) + +describe('FeedbackAiReport', () => { + it('reportFilePath 가 없으면(저장 실패 폴백·구버전·공개 응답) 아무것도 렌더하지 않는다', () => { + const { container } = renderWithQuery( + , + ) + expect(container).toBeEmptyDOMElement() + expect(apiClient.get).not.toHaveBeenCalled() + }) + + it('보기 클릭 시에만 프록시를 호출하고 마크다운으로 렌더한다', async () => { + vi.mocked(apiClient.get).mockResolvedValue({ + data: '# 면접 피드백 리포트\n\n**핵심** 정리', + } as never) + renderWithQuery( + , + ) + + // 열람 전에는 fetch 하지 않는다. + expect(apiClient.get).not.toHaveBeenCalled() + + await userEvent.click(screen.getByRole('button', { name: 'AI 학습 리포트 보기' })) + expect(apiClient.get).toHaveBeenCalledWith( + '/api/sessions/50/feedback/report', + expect.objectContaining({ responseType: 'text' }), + ) + // 마크다운 렌더러(lazy)가 풀리면 원문 기호 없이 실제 엘리먼트로 보인다. + const strong = await screen.findByText('핵심') + expect(strong.tagName).toBe('STRONG') + expect(screen.getByRole('button', { name: '.md 다운로드' })).toBeInTheDocument() + }) + + it('프록시 실패 시 재시도 가능한 에러 상태를 보여준다', async () => { + vi.mocked(apiClient.get).mockRejectedValue(new Error('boom')) + renderWithQuery( + , + ) + + await userEvent.click(screen.getByRole('button', { name: 'AI 학습 리포트 보기' })) + await waitFor(() => + expect( + screen.getByText('AI 학습 리포트를 불러오지 못했습니다.'), + ).toBeInTheDocument(), + ) + }) +}) diff --git a/frontend/src/features/feedback/ui/FeedbackAiReport.tsx b/frontend/src/features/feedback/ui/FeedbackAiReport.tsx new file mode 100644 index 00000000..a82a5cd9 --- /dev/null +++ b/frontend/src/features/feedback/ui/FeedbackAiReport.tsx @@ -0,0 +1,73 @@ +import { memo, useState } from 'react' +import { Markdown, QueryError } from '@/shared/ui' +import { useFeedbackReport } from '../model/useFeedbackReport' + +// AI 학습 리포트(마크다운) 열람/다운로드. reportFilePath 가 있을 때만 렌더 — +// 저장 실패 폴백(None)·구버전 피드백·공개 공유 응답(키 미노출)에서는 진입점 자체가 없다. +// PDF 캡처(reportRef) 바깥에 두는 것이 의도 — 리포트 파일은 자체 산출물이라 중복 캡처하지 않는다. +// memo: 부모(FeedbackReport)의 잦은 상태 변화(복사 토글·공유 pending·PDF 생성)마다 +// react-markdown 이 전체 리포트를 재파싱하지 않게 한다 — props 는 원시값 2개뿐이다. +export const FeedbackAiReport = memo(function FeedbackAiReport({ + sessionId, + reportFilePath, +}: { + sessionId: number + reportFilePath?: string | null +}) { + // 열람 요청 시에만 로드 — 리포트 화면 진입만으로 원문 fetch 를 낭비하지 않는다(분석 원문 보기와 동일). + const [open, setOpen] = useState(false) + const report = useFeedbackReport(sessionId, open) + + if (!reportFilePath) return null + + const handleDownloadMd = () => { + if (!report.data) return + const blob = new Blob([report.data], { type: 'text/markdown;charset=utf-8' }) + const url = URL.createObjectURL(blob) + const a = document.createElement('a') + a.href = url + a.download = '면접피드백리포트.md' + // Firefox 등은 DOM 에 없는 앵커의 click 다운로드를 무시할 수 있다. + document.body.appendChild(a) + a.click() + a.remove() + URL.revokeObjectURL(url) + } + + return ( +
+
+ + {open && report.data && ( + + )} +
+ {open && + (report.isPending ? ( +

리포트를 불러오는 중…

+ ) : report.isError ? ( + report.refetch()} + /> + ) : ( +
+ {report.data ?? ''} +
+ ))} +
+ ) +}) diff --git a/frontend/src/features/feedback/ui/FeedbackReport.tsx b/frontend/src/features/feedback/ui/FeedbackReport.tsx index 1e2d03bb..65fa2b18 100644 --- a/frontend/src/features/feedback/ui/FeedbackReport.tsx +++ b/frontend/src/features/feedback/ui/FeedbackReport.tsx @@ -7,6 +7,7 @@ import { useCopyToClipboard } from '@/shared/hooks' import type { Feedback } from '../api/feedbackApi' import { downloadElementAsPdf } from '../lib/downloadPdf' import { useShareFeedback, useUnshareFeedback } from '../model/useFeedback' +import { FeedbackAiReport } from './FeedbackAiReport' import { HighlightedText } from './HighlightedText' // AI 가 별도 정성 평가를 패널 항목으로 실어 보낼 때 쓰는 라벨(모두 종합 점수엔 미포함). @@ -277,6 +278,13 @@ export function FeedbackReport({ )} + + {typeof feedback.sessionId === 'number' && ( + + )} ) } diff --git a/frontend/src/shared/api/generated.ts b/frontend/src/shared/api/generated.ts index 7153e2e0..6dbf1cef 100644 --- a/frontend/src/shared/api/generated.ts +++ b/frontend/src/shared/api/generated.ts @@ -748,6 +748,26 @@ export interface paths { patch?: never; trace?: never; }; + "/api/sessions/{sessionId}/feedback/report": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * AI 학습 리포트(마크다운) 프록시 + * @description AI 가 피드백 생성 시 저장한 마크다운 리포트를 중계한다. presigned URL 은 내부(MinIO) 호스트라 브라우저가 직접 접근할 수 없다 (분석 원문 /content 프록시와 동일 패턴). 소유자 전용 — 공유(비인증) 응답에는 키 자체를 싣지 않는다. + */ + get: operations["getSessionFeedbackReport"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/api/resumes/{resumeId}": { parameters: { query?: never; @@ -3591,6 +3611,55 @@ export interface operations { }; }; }; + getSessionFeedbackReport: { + parameters: { + query?: never; + header?: never; + path: { + sessionId: number; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description 리포트 (text/markdown) */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": string; + }; + }; + /** @description 인증 실패 */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": string; + }; + }; + /** @description 세션 또는 피드백 없음 */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": string; + }; + }; + /** @description 리포트 파일 없음 (저장 실패 폴백·구버전 피드백) */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": string; + }; + }; + }; + }; getResume: { parameters: { query?: never;