From 1d317083dcfff61fd4f74dcb0d32d58f9b39700d Mon Sep 17 00:00:00 2001 From: Jaeho Date: Sun, 23 Aug 2026 00:02:59 +0900 Subject: [PATCH 1/3] =?UTF-8?q?feat(ai):=20=ED=94=BC=EB=93=9C=EB=B0=B1=20?= =?UTF-8?q?=EC=83=9D=EC=84=B1=20=EC=8B=A4=ED=8C=A8=20=EC=8B=9C=20FAILED=20?= =?UTF-8?q?=EC=BD=9C=EB=B0=B1=20=EB=B0=9C=ED=96=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit feedback_consumer 의 예상 못 한 예외가 DLQ 로만 격리돼 콜백 없이 세션이 '피드백 생성 중'에 무기한 멈추던 gap 수정. handle 본문을 _process 로 추출해 생성 구간 실패 시 status=FAILED 콜백(TypeError→GENERATION_SCHEMA_INVALID/ retriable=false, 그 외 UNEXPECTED/retriable=true) 발행 후 ack. 성공 콜백 발행 실패는 FAILED 오인 없이 원 예외로 DLQ 유지, 콜백 0건 DLQ 경로는 멱등 unmark 로 재주입 재처리 보장. errorMessage 500자 상한, GenerationStatus Literal 재사용. --- ai/CLAUDE.md | 14 + .../messaging/consumers/feedback_consumer.py | 261 +++++++++++------- ai/src/ai_server/messaging/idempotency.py | 9 + ai/src/ai_server/model/messages/feedback.py | 6 + ai/tests/test_feedback_consumer.py | 114 ++++++++ 5 files changed, 304 insertions(+), 100 deletions(-) diff --git a/ai/CLAUDE.md b/ai/CLAUDE.md index fcdc88a4..81392f87 100644 --- a/ai/CLAUDE.md +++ b/ai/CLAUDE.md @@ -397,6 +397,20 @@ docker run --env-file .env -p 8000:8000 stackup-ai 하드 타임아웃을 추가하고, 모든 `ChatOpenAI` 호출에 `llm_pro_timeout_sec`(30s)/`llm_flash_timeout_sec` (10s) 요청 타임아웃을 명시했다(이전엔 미설정 — SDK 기본값까지 무기한 대기 가능). +- **피드백 생성 실패 신호 본 구현**: `feedback_consumer` 만 위 리팩터에서 빠져 있던 gap 을 닫았다 — + 패널·부가 평가는 내부 폴백(빈 결과/생략)으로 흡수되지만, 그 방어망 밖(트랜스크립트/RAG 컨텍스트 빌드, + payload 조립, 발행 등)의 예상 못 한 예외는 그대로 새서 DLQ 로만 격리되고 Core 는 아무 신호도 못 받아 + 세션이 "피드백 생성 중"에 무기한 멈췄다. `handle()` 본문을 payload 를 **반환**하는 `_process()` 로 + 추출하고 envelope 파싱·멱등 체크 이후의 생성 전 구간을 try/except 로 감싸, 실패 시 + `FeedbackCallbackPayload`(`status=FAILED`, `errorCode`, `errorMessage`(상한 500자), `retriable`)를 + 발행하고 ack 한다(`_publish_failed`). 분류는 questions/followup 과 동일 — `TypeError` 는 + `GENERATION_SCHEMA_INVALID`/`retriable=false`, 그 외 `UNEXPECTED`/`retriable=true`. **성공 콜백 + 발행 실패는 생성 실패가 아니다** — FAILED 오인 발행 없이 원 예외로 DLQ(변경 전과 동일, 재처리 + 가능). 콜백을 하나도 못 낸 채 DLQ 로 가는 경로(폴백 발행 실패 포함)는 `LruIdempotencyStore.unmark` + 로 마킹을 되돌려 재주입 시 duplicate skip 으로 삼켜지지 않게 한다. `status` 는 `GenerationStatus` + Literal 재사용, 기본값 `OK` 라 성공 콜백·구버전 소비자와 하위호환. + Core 쪽 처리는 [`backend/CLAUDE.md`](../backend/CLAUDE.md) 참고. + - **질문 풀·피드백 생성 진행 이벤트 본 구현 (B2)**: 스트리밍이 없는 두 블로킹 생성 경로(질문 풀 Pro ≤30s, 피드백 병렬 gather ≈2분 예산)가 진행 중 무통보였던 것을 고쳤다. `SessionRealtimeNotifier.emit_progress` (`messaging/session_notify.py`)가 `realtime.session.notify` 로 `QUESTION_POOL_PROGRESS`/`FEEDBACK_PROGRESS` diff --git a/ai/src/ai_server/messaging/consumers/feedback_consumer.py b/ai/src/ai_server/messaging/consumers/feedback_consumer.py index a6b91cb6..8f5f1a2a 100644 --- a/ai/src/ai_server/messaging/consumers/feedback_consumer.py +++ b/ai/src/ai_server/messaging/consumers/feedback_consumer.py @@ -124,127 +124,188 @@ async def handle(self, message: AbstractIncomingMessage) -> None: ) return - req = envelope.payload + try: + payload = await self._process(envelope) + except Exception as exc: # noqa: BLE001 + await self._publish_failed(envelope, exc) + return + + # 성공 payload 의 발행 실패는 생성 실패가 아니다 — FAILED 콜백으로 오인 발행하지 + # 않고 원 예외로 DLQ 에 보내 재처리 가능하게 남긴다(실패 신호 도입 전과 동일 동작). + try: + await self._publish_callback(envelope, payload) + except Exception: + self._idempotency.unmark(envelope.message_id) + raise log.info( - "feedback.generate.start", + "feedback.generate.done", message_id=envelope.message_id, - session_id=req.session_id, - msg_count=len(req.messages), - ctx_count=len(req.context_document_ids), + session_id=envelope.payload.session_id, trace_id=envelope.trace_id, ) - await self._emit_progress( - session_id=req.session_id, - phase="PREPARING", - message="면접 기록을 정리하고 있어요.", - trace_id=envelope.trace_id, - ) - transcript = _build_transcript(req.messages) - score_basis = _build_score_basis(req.messages) - rag_context = await self._build_rag_context(req) - voice_analysis_summary = _build_voice_analysis_summary( - req.voice_analysis_summary - ) + async def _process( + self, envelope: Envelope[GenerateFeedbackRequest] + ) -> FeedbackCallbackPayload: + req = envelope.payload + log.info( + "feedback.generate.start", + message_id=envelope.message_id, + session_id=req.session_id, + msg_count=len(req.messages), + ctx_count=len(req.context_document_ids), + trace_id=envelope.trace_id, + ) - # 세부 평가 5개가 병렬(gather)이라 순차 phase 로는 진행을 표현할 수 없다 — - # 각 태스크 완료 시점에 completed/total 카운터로 emit 한다. - scoring_total = 5 - scoring_done = 0 + await self._emit_progress( + session_id=req.session_id, + phase="PREPARING", + message="면접 기록을 정리하고 있어요.", + trace_id=envelope.trace_id, + ) + transcript = _build_transcript(req.messages) + score_basis = _build_score_basis(req.messages) + rag_context = await self._build_rag_context(req) + voice_analysis_summary = _build_voice_analysis_summary( + req.voice_analysis_summary + ) - async def _tracked(coro: Awaitable[T]) -> T: - nonlocal scoring_done - task_result = await coro - scoring_done += 1 - await self._emit_progress( - session_id=req.session_id, - phase="SCORING", - message=f"세부 평가를 진행하고 있어요. ({scoring_done}/{scoring_total})", - trace_id=envelope.trace_id, - completed=scoring_done, - total=scoring_total, - ) - return task_result + # 세부 평가 5개가 병렬(gather)이라 순차 phase 로는 진행을 표현할 수 없다 — + # 각 태스크 완료 시점에 completed/total 카운터로 emit 한다. + scoring_total = 5 + scoring_done = 0 + async def _tracked(coro: Awaitable[T]) -> T: + nonlocal scoring_done + task_result = await coro + scoring_done += 1 await self._emit_progress( session_id=req.session_id, phase="SCORING", - message="평가위원들이 답변을 검토하고 있어요.", + message=f"세부 평가를 진행하고 있어요. ({scoring_done}/{scoring_total})", trace_id=envelope.trace_id, - completed=0, + completed=scoring_done, total=scoring_total, ) + return task_result + + await self._emit_progress( + session_id=req.session_id, + phase="SCORING", + message="평가위원들이 답변을 검토하고 있어요.", + trace_id=envelope.trace_id, + completed=0, + total=scoring_total, + ) - # 종합 피드백 + 자기소개 첫인상 + 직무 적합도(직무 맞춤 모드)를 병렬 실행. - # 첫인상·직무 적합도는 종합 점수(overall)에 미포함 — generator 가 모른 채 계산한 뒤 표시용으로 덧붙인다. - ( - result, - self_intro_item, - job_fit_items, - personality_item, - answer_coaching, - ) = await asyncio.gather( - _tracked( - self._generate_panel( - job_category=req.job_category, - mode=req.mode, - total_question_count=req.total_question_count, - end_reason=req.end_reason, - transcript=transcript, - score_basis=score_basis, - rag_context=rag_context, - voice_analysis_summary=voice_analysis_summary, - domain_question_counts=req.domain_question_counts, - session_id=req.session_id, - ) - ), - _tracked(self._evaluate_self_intro(req, voice_analysis_summary)), - _tracked(self._evaluate_job_fit(req, transcript, rag_context)), - _tracked(self._evaluate_personality(req)), - _tracked(self._coach_answers(req)), - ) - # 빈 평가위원 항목(점수·내용 모두 없음)은 표시하지 않는다 — LLM 부분 응답이 빈 패널로 새는 것 방지. - extras = [self_intro_item, *job_fit_items, personality_item] - result.panel_breakdown.extend( - e for e in extras if e is not None and _panel_has_content(e) - ) + # 종합 피드백 + 자기소개 첫인상 + 직무 적합도(직무 맞춤 모드)를 병렬 실행. + # 첫인상·직무 적합도는 종합 점수(overall)에 미포함 — generator 가 모른 채 계산한 뒤 표시용으로 덧붙인다. + ( + result, + self_intro_item, + job_fit_items, + personality_item, + answer_coaching, + ) = await asyncio.gather( + _tracked( + self._generate_panel( + job_category=req.job_category, + mode=req.mode, + total_question_count=req.total_question_count, + end_reason=req.end_reason, + transcript=transcript, + score_basis=score_basis, + rag_context=rag_context, + voice_analysis_summary=voice_analysis_summary, + domain_question_counts=req.domain_question_counts, + session_id=req.session_id, + ) + ), + _tracked(self._evaluate_self_intro(req, voice_analysis_summary)), + _tracked(self._evaluate_job_fit(req, transcript, rag_context)), + _tracked(self._evaluate_personality(req)), + _tracked(self._coach_answers(req)), + ) + # 빈 평가위원 항목(점수·내용 모두 없음)은 표시하지 않는다 — LLM 부분 응답이 빈 패널로 새는 것 방지. + extras = [self_intro_item, *job_fit_items, personality_item] + result.panel_breakdown.extend( + e for e in extras if e is not None and _panel_has_content(e) + ) - await self._emit_progress( - session_id=req.session_id, - phase="FINALIZING", - message="피드백 리포트를 정리하고 있어요.", - trace_id=envelope.trace_id, - ) - payload = FeedbackCallbackPayload( - session_id=req.session_id, - overall_score=result.overall_score, - technical_accuracy=result.technical_accuracy, - logic_score=result.logic_score, - communication_score=result.communication_score, - strengths_summary=result.strengths_summary, - weaknesses_summary=result.weaknesses_summary, - improvement_keywords=result.improvement_keywords, - study_plan=result.study_plan, - highlights=result.highlights, - panel_breakdown=result.panel_breakdown, - answer_coaching=answer_coaching, - report_s3_key=None, - ) + await self._emit_progress( + session_id=req.session_id, + phase="FINALIZING", + message="피드백 리포트를 정리하고 있어요.", + trace_id=envelope.trace_id, + ) + payload = FeedbackCallbackPayload( + session_id=req.session_id, + overall_score=result.overall_score, + technical_accuracy=result.technical_accuracy, + logic_score=result.logic_score, + communication_score=result.communication_score, + strengths_summary=result.strengths_summary, + weaknesses_summary=result.weaknesses_summary, + improvement_keywords=result.improvement_keywords, + study_plan=result.study_plan, + highlights=result.highlights, + panel_breakdown=result.panel_breakdown, + answer_coaching=answer_coaching, + report_s3_key=None, + ) - await self._publisher.publish( - routing_key=self._callback_routing_key, - message_type="callback.feedback", - payload=payload, - trace_id=envelope.trace_id, - correlation_id=envelope.message_id, - context=envelope.context, - ) - log.info( - "feedback.generate.done", + return payload + + async def _publish_callback( + self, + envelope: Envelope[GenerateFeedbackRequest], + payload: FeedbackCallbackPayload, + ) -> None: + await self._publisher.publish( + routing_key=self._callback_routing_key, + message_type="callback.feedback", + payload=payload, + trace_id=envelope.trace_id, + correlation_id=envelope.message_id, + context=envelope.context, + ) + + async def _publish_failed( + self, envelope: Envelope[GenerateFeedbackRequest], exc: Exception + ) -> None: + """생성 중 예상 못 한 예외의 실패 신호. 콜백 없이 DLQ 로만 격리되면 Core 가 실패를 + 모른 채 세션이 '피드백 생성 중'에 무기한 멈춘다 — 항상 FAILED 콜백을 발행하고 + ack 한다. 폴백 발행마저 실패하면 멱등 마킹을 되돌리고 원 예외를 다시 던져 + DLQ 로 보낸다(최후 안전망 — 재주입 시 duplicate skip 으로 삼켜지지 않게).""" + req = envelope.payload + log.exception( + "feedback.generate.unexpected", + message_id=envelope.message_id, + session_id=req.session_id, + trace_id=envelope.trace_id, + ) + # questions/followup consumer 와 동일 분류 — TypeError(LLM 출력 스키마 불일치)는 + # 같은 입력으로 재시도해도 똑같이 죽는다 → retriable=false. + is_schema = isinstance(exc, TypeError) + payload = FeedbackCallbackPayload( + session_id=req.session_id, + status="FAILED", + error_code="GENERATION_SCHEMA_INVALID" if is_schema else "UNEXPECTED", + # str(exc) 는 LLM 응답 본문·입력 repr 까지 담길 수 있다 — 로그·와이어 크기 상한. + error_message=f"{type(exc).__name__}: {exc}"[:500], + retriable=not is_schema, + ) + try: + await self._publish_callback(envelope, payload) + except Exception: # noqa: BLE001 + log.exception( + "feedback.failed_callback.publish_failed", message_id=envelope.message_id, session_id=req.session_id, trace_id=envelope.trace_id, ) + self._idempotency.unmark(envelope.message_id) + raise exc async def _emit_progress( self, diff --git a/ai/src/ai_server/messaging/idempotency.py b/ai/src/ai_server/messaging/idempotency.py index a7308dcc..2f943f08 100644 --- a/ai/src/ai_server/messaging/idempotency.py +++ b/ai/src/ai_server/messaging/idempotency.py @@ -25,3 +25,12 @@ def is_seen_then_mark(self, key: str) -> bool: if len(self._store) > self._max_size: self._store.popitem(last=False) return False + + def unmark(self, key: str) -> None: + """처리 실패로 콜백을 하나도 발행하지 못한 메시지의 마킹 해제. + + 마킹은 처리 전에 이뤄지므로, 그대로 두면 DLQ 로 간 메시지를 같은 프로세스에 + 재주입했을 때 duplicate 로 skip 돼 콜백 없이 ack 된다 — 재처리 가능하게 되돌린다. + """ + with self._lock: + self._store.pop(key, None) diff --git a/ai/src/ai_server/model/messages/feedback.py b/ai/src/ai_server/model/messages/feedback.py index 20f01ab0..bb2fbbe9 100644 --- a/ai/src/ai_server/model/messages/feedback.py +++ b/ai/src/ai_server/model/messages/feedback.py @@ -3,6 +3,7 @@ from pydantic import BaseModel, Field from ai_server.model._config import camel_config +from ai_server.model.messages.questions import GenerationStatus InterviewMode = Literal["PERSONALITY", "TECHNICAL", "INTEGRATED", "JOB_TAILORED"] @@ -120,3 +121,8 @@ class FeedbackCallbackPayload(BaseModel): # 질문별 복기(답변 메시지별 모범 답안·리라이트·코칭). 비면 복기 없음. answer_coaching: list[AnswerCoachingItem] = Field(default_factory=list) report_s3_key: str | None = None + # 실패 신호 (questions/analysis 콜백과 동일 규약). status 미명시(구버전)는 OK 취급. + status: GenerationStatus = "OK" + error_code: str | None = None + error_message: str | None = None + retriable: bool | None = None diff --git a/ai/tests/test_feedback_consumer.py b/ai/tests/test_feedback_consumer.py index c66d8aae..dff9c62c 100644 --- a/ai/tests/test_feedback_consumer.py +++ b/ai/tests/test_feedback_consumer.py @@ -135,6 +135,7 @@ async def test_consumer_generates_feedback_and_publishes_callback(): payload: FeedbackCallbackPayload = publisher.publish.await_args.kwargs["payload"] assert payload.session_id == 50 assert payload.overall_score == 85.0 + assert payload.status == "OK" # 실패 신호 도입 후에도 성공 콜백은 OK(기본값) assert publisher.publish.await_args.kwargs["message_type"] == "callback.feedback" @@ -167,6 +168,119 @@ async def test_consumer_publishes_degraded_feedback_when_panel_generation_fails( assert payload.panel_breakdown == [] +@pytest.mark.asyncio +async def test_consumer_publishes_failed_callback_on_unexpected_error(): + """보호 구간(패널·부가 평가) 밖의 예상 못 한 예외는 예전엔 reject→DLQ 로만 가서 + Core 가 실패를 모른 채 세션이 '피드백 생성 중'에 무기한 멈췄다 — 이제 FAILED + 콜백을 발행하고 ack 해야 한다.""" + generator = _generator() + publisher = MagicMock() + publisher.publish = AsyncMock() + core = MagicMock() + + consumer = FeedbackConsumer( + generator=generator, + publisher=publisher, + idempotency=LruIdempotencyStore(max_size=10), + callback_routing_key="callback.feedback", + core_client=core, + embedder=None, + ) + consumer._build_rag_context = AsyncMock(side_effect=RuntimeError("rag down")) + + await consumer.handle(_StubMessage(_envelope())) # raise 없이 ack 경로 + + publisher.publish.assert_awaited_once() + payload: FeedbackCallbackPayload = publisher.publish.await_args.kwargs["payload"] + assert payload.session_id == 50 + assert payload.status == "FAILED" + assert payload.error_code == "UNEXPECTED" + assert payload.error_message == "RuntimeError: rag down" + assert payload.retriable is True + assert payload.overall_score is None + assert publisher.publish.await_args.kwargs["message_type"] == "callback.feedback" + + +@pytest.mark.asyncio +async def test_consumer_marks_schema_error_not_retriable(): + """TypeError(LLM 출력 스키마 불일치)는 재시도해도 똑같이 죽는다 — + questions/followup consumer 와 동일하게 retriable=false 로 분류.""" + 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, + ) + consumer._build_rag_context = AsyncMock(side_effect=TypeError("bad schema")) + + await consumer.handle(_StubMessage(_envelope())) + + payload: FeedbackCallbackPayload = publisher.publish.await_args.kwargs["payload"] + assert payload.status == "FAILED" + assert payload.error_code == "GENERATION_SCHEMA_INVALID" + assert payload.retriable is False + + +@pytest.mark.asyncio +async def test_consumer_does_not_send_failed_when_success_publish_fails(): + """생성이 성공했는데 OK 콜백 발행만 실패한 경우 — FAILED 로 오인 발행하지 않고 + 원 예외로 DLQ 에 보내 재처리 가능하게 남긴다(멱등 마킹도 되돌림).""" + generator = _generator() + publisher = MagicMock() + publisher.publish = AsyncMock(side_effect=ConnectionError("mq down")) + store = LruIdempotencyStore(max_size=10) + + consumer = FeedbackConsumer( + generator=generator, + publisher=publisher, + idempotency=store, + callback_routing_key="callback.feedback", + core_client=MagicMock(), + embedder=None, + ) + + with pytest.raises(ConnectionError, match="mq down"): + await consumer.handle(_StubMessage(_envelope())) + + publisher.publish.assert_awaited_once() # FAILED 재발행 시도 없음 + payload: FeedbackCallbackPayload = publisher.publish.await_args.kwargs["payload"] + assert payload.status == "OK" + # unmark 됐으므로 재주입 시 duplicate skip 되지 않는다. + assert store.is_seen_then_mark("fb-1") is False + + +@pytest.mark.asyncio +async def test_consumer_reraises_when_failed_callback_publish_also_fails(): + """폴백(FAILED 콜백) 발행마저 실패하면 원 예외를 다시 던져 DLQ 로 보낸다(최후 안전망).""" + generator = _generator() + publisher = MagicMock() + publisher.publish = AsyncMock(side_effect=ConnectionError("mq down")) + core = MagicMock() + store = LruIdempotencyStore(max_size=10) + + consumer = FeedbackConsumer( + generator=generator, + publisher=publisher, + idempotency=store, + callback_routing_key="callback.feedback", + core_client=core, + embedder=None, + ) + consumer._build_rag_context = AsyncMock(side_effect=RuntimeError("rag down")) + + with pytest.raises(RuntimeError, match="rag down"): + await consumer.handle(_StubMessage(_envelope())) + + # unmark 됐으므로 DLQ 재주입 시 duplicate skip 으로 삼켜지지 않는다. + assert store.is_seen_then_mark("fb-1") is False + + @pytest.mark.asyncio async def test_consumer_accepts_pool_exhausted_end_reason(): # 회귀: Core 가 POOL_EXHAUSTED 로 종료해도 파싱 실패(DLQ) 없이 피드백 생성돼야 한다. From bfca33cdb13b8dd0af002dd723c874587f89e917 Mon Sep 17 00:00:00 2001 From: Jaeho Date: Sun, 23 Aug 2026 00:03:07 +0900 Subject: [PATCH 2/3] =?UTF-8?q?feat(backend):=20=ED=94=BC=EB=93=9C?= =?UTF-8?q?=EB=B0=B1=20FAILED=20=EC=BD=9C=EB=B0=B1=20=EC=88=98=EC=8B=A0=20?= =?UTF-8?q?=EC=B2=98=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit FeedbackCallbackPayload 에 status/errorCode/errorMessage/retriable 추가 (구버전 13-arg 생성자는 status=OK 위임). FeedbackCallbackService.apply 가 저장 전 isFailed() 분기 — 저장 없이 SseEventType.ERROR(SessionErrorNotice, scope=FEEDBACK)를 세션/유저 채널에 발행하고 멱등 마킹. AI errorMessage 원문은 서버 로그에만 남기고 클라이언트에는 화이트리스트 문구만 전송. --- backend/CLAUDE.md | 12 +++- .../application/FeedbackCallbackService.java | 21 +++++++ .../dto/FeedbackCallbackPayload.java | 22 +++++++- .../FeedbackCallbackServiceTest.java | 55 +++++++++++++++++++ 4 files changed, 107 insertions(+), 3 deletions(-) diff --git a/backend/CLAUDE.md b/backend/CLAUDE.md index 84150e3d..ff03323c 100644 --- a/backend/CLAUDE.md +++ b/backend/CLAUDE.md @@ -450,11 +450,19 @@ docker compose up -d "생성 중"에 무기한 멈추던 문제를 고쳤다. `QuestionsCallbackPayload`에 `status`(`OK`|`FAILED`)· `errorCode`·`errorMessage`·`retriable` 필드 추가(구버전 9-arg 생성자는 `status=OK` 로 위임하는 오버로드로 하위호환). `QuestionsCallbackService.apply` 가 kind 분기 전에 `isFailed()` 를 먼저 확인: - POOL 실패는 저장할 게 없어 `SseEventType.ERROR`(`SessionErrorNotice`)로 세션/유저 채널에만 알리고 - 세션 상태는 그대로 둔다(재시도 트리거는 후속 과제). FOLLOWUP 실패는 placeholder 를 삭제하지 않고 + POOL 실패는 저장할 게 없고 재시도를 트리거하는 곳도 없어 세션을 바로 정상 종료시켜 피드백 흐름을 + 태운다(`endSessionOnPoolFailure` — "질문 준비 중" 무기한 대기 방지). FOLLOWUP 실패는 placeholder 를 삭제하지 않고 `InterviewMessage.failFollowup()`(content=`FOLLOWUP_GENERATION_FAILED_TEXT`, status=`FAILED`)로 확정한 뒤 `SESSION_MESSAGE`(`FOLLOWUP_FAILED`) 발행 + DONT_KNOW 와 동일하게 `advanceToNextGeneral` 로 다음 일반질문으로 진행 — 턴이 사라진 것처럼 보이지 않으면서 면접은 멈추지 않는다. +- **피드백 생성 실패 신호 본 구현**: AI `feedback_consumer` 의 예상 못 한 예외가 DLQ 로만 격리돼 + 세션이 "피드백 생성 중"에 무기한 멈추던 gap 을 닫았다. `FeedbackCallbackPayload` 에 `status` + (`OK`|`FAILED`)·`errorCode`·`errorMessage`·`retriable` 추가(구버전 13-arg 생성자는 `status=OK` + 위임 오버로드로 하위호환). `FeedbackCallbackService.apply` 가 저장 전에 `isFailed()` 를 확인: + 실패면 저장 없이 `SseEventType.ERROR`(`SessionErrorNotice`, scope=`FEEDBACK`, + code=`FEEDBACK_GENERATION_FAILED`)를 세션/유저 채널에 발행하고 멱등 마킹만 한다. AI 의 + `errorMessage` 원문은 서버 로그에만 남기고 클라이언트에는 화이트리스트 문구만 보낸다 + (QuestionsCallbackService 와 동일 원칙). AI 쪽 발행은 [`ai/CLAUDE.md`](../ai/CLAUDE.md) 참고. - **문장 단위 TTS 세그먼트 프록시 본 구현 (Part B)**: `InterviewMessageService.streamAudioSegment` + `GET /api/sessions/{sid}/messages/{mid}/audio/segments/{seq}?ext=`. AI 가 휘발성으로 쓴 라이브 세그먼트를 규칙(`interview/tts/{sid}/{mid}/seg-{seq}.{ext}`)으로 재구성해 프록시(DB 미기록). 소유권+ext 화이트리스트+seq>=0 검증으로 임의 키 노출 차단. - AI 호출 로깅 (US-30) 본 구현: `/api/internal/ai-logs` + `ai_request_logs` INSERT - **웹 이력서(URL) 본 구현 (US-09)**: `POST /api/resumes/web { url }`. AI 서버에 웹 분석이 이미 diff --git a/backend/src/main/java/com/stackup/stackup/session/application/FeedbackCallbackService.java b/backend/src/main/java/com/stackup/stackup/session/application/FeedbackCallbackService.java index 851396a1..077dc59f 100644 --- a/backend/src/main/java/com/stackup/stackup/session/application/FeedbackCallbackService.java +++ b/backend/src/main/java/com/stackup/stackup/session/application/FeedbackCallbackService.java @@ -70,6 +70,11 @@ public void apply(FeedbackCallbackEnvelope envelope) { markProcessed(envelope.messageId()); return; } + if (payload.isFailed()) { + applyFeedbackFailed(session, payload); + markProcessed(envelope.messageId()); + return; + } SessionFeedback feedback = SessionFeedback.of( session, @@ -128,6 +133,22 @@ private void applyAnswerCoaching(Long sessionId, java.util.List keywords) { if (keywords == null) { return null; diff --git a/backend/src/main/java/com/stackup/stackup/session/application/dto/FeedbackCallbackPayload.java b/backend/src/main/java/com/stackup/stackup/session/application/dto/FeedbackCallbackPayload.java index 67893c59..0c866e73 100644 --- a/backend/src/main/java/com/stackup/stackup/session/application/dto/FeedbackCallbackPayload.java +++ b/backend/src/main/java/com/stackup/stackup/session/application/dto/FeedbackCallbackPayload.java @@ -20,6 +20,26 @@ public record FeedbackCallbackPayload( List panelBreakdown, // 질문별 복기 (답변 메시지별 모범 답안·리라이트·코칭). 비면 복기 없음. List answerCoaching, - String reportS3Key + String reportS3Key, + String status, // OK(기본) | FAILED. null 이면 구버전 취급(OK). + String errorCode, + String errorMessage, + Boolean retriable ) { + // 구버전(실패 신호 없던 시절) 호출부·테스트 호환용 — status=OK 로 위임. + public FeedbackCallbackPayload( + Long sessionId, Double overallScore, Double technicalAccuracy, Double logicScore, + Double communicationScore, String strengthsSummary, String weaknessesSummary, + List improvementKeywords, List studyPlan, List highlights, + List panelBreakdown, List answerCoaching, + String reportS3Key + ) { + this(sessionId, overallScore, technicalAccuracy, logicScore, communicationScore, + strengthsSummary, weaknessesSummary, improvementKeywords, studyPlan, highlights, + panelBreakdown, answerCoaching, reportS3Key, "OK", null, null, null); + } + + public boolean isFailed() { + return "FAILED".equals(status); + } } diff --git a/backend/src/test/java/com/stackup/stackup/session/application/FeedbackCallbackServiceTest.java b/backend/src/test/java/com/stackup/stackup/session/application/FeedbackCallbackServiceTest.java index 578922f8..729930a8 100644 --- a/backend/src/test/java/com/stackup/stackup/session/application/FeedbackCallbackServiceTest.java +++ b/backend/src/test/java/com/stackup/stackup/session/application/FeedbackCallbackServiceTest.java @@ -136,6 +136,61 @@ void apply_skipsWhenFeedbackAlreadyExists() { verify(feedbackRepository, never()).save(any(SessionFeedback.class)); } + @Test + void apply_failedCallbackSkipsSaveAndPushesErrorSse() { + // AI 의 예상 못 한 예외로 생성 자체가 실패한 콜백 — 저장 없이 SSE ERROR 로만 알리고 멱등 마킹. + InterviewSession session = sessionFixture(50L); + FeedbackCallbackEnvelope env = envelope(50L, "fb-fail", + new FeedbackCallbackPayload(50L, null, null, null, null, null, null, + List.of(), List.of(), List.of(), List.of(), List.of(), null, + "FAILED", "UNEXPECTED", "boom: internal detail", true)); + when(processedMessageRepository.existsById("fb-fail")).thenReturn(false); + when(sessionRepository.findById(50L)).thenReturn(Optional.of(session)); + when(feedbackRepository.existsBySession_Id(50L)).thenReturn(false); + + service.apply(env); + + verify(feedbackRepository, never()).save(any(SessionFeedback.class)); + ArgumentCaptor evCap = ArgumentCaptor.forClass(Object.class); + verify(events, atLeastOnce()).publishEvent(evCap.capture()); + List errors = evCap.getAllValues().stream() + .filter(RealtimeNotifyEvent.class::isInstance) + .map(RealtimeNotifyEvent.class::cast) + .filter(e -> e.type() == SseEventType.ERROR) + .toList(); + assertThat(errors).hasSize(2); // session + user 채널 + assertThat(errors).extracting(RealtimeNotifyEvent::channel) + .containsExactlyInAnyOrder(RealtimeNotifyEvent.Channel.SESSION, RealtimeNotifyEvent.Channel.USER); + assertThat(errors).allSatisfy(e -> { + QuestionsCallbackService.SessionErrorNotice notice = + (QuestionsCallbackService.SessionErrorNotice) e.payload(); + assertThat(notice.scope()).isEqualTo("FEEDBACK"); + assertThat(notice.errorCode()).isEqualTo("FEEDBACK_GENERATION_FAILED"); + // AI errorMessage 원문(내부 상세)은 클라이언트로 새지 않는다. + assertThat(notice.message()).doesNotContain("internal detail"); + assertThat(notice.retriable()).isTrue(); + }); + verify(processedMessageRepository).save(any()); + } + + @Test + void apply_nullStatusTreatedAsOk() { + // 구버전 콜백(status 미명시)은 OK 로 취급 — 기존 저장 경로 회귀 방지. + InterviewSession session = sessionFixture(50L); + FeedbackCallbackEnvelope env = envelope(50L, "fb-legacy", + new FeedbackCallbackPayload(50L, 80.0, null, null, null, null, null, + List.of(), List.of(), List.of(), List.of(), List.of(), null, + null, null, null, null)); + when(processedMessageRepository.existsById("fb-legacy")).thenReturn(false); + when(sessionRepository.findById(50L)).thenReturn(Optional.of(session)); + when(feedbackRepository.existsBySession_Id(50L)).thenReturn(false); + when(feedbackRepository.save(any(SessionFeedback.class))).thenAnswer(inv -> inv.getArgument(0)); + + service.apply(env); + + verify(feedbackRepository).save(any(SessionFeedback.class)); + } + private FeedbackCallbackEnvelope envelope(Long sessionId, String messageId, FeedbackCallbackPayload payload) { return new FeedbackCallbackEnvelope(messageId, "callback.feedback", "1", "t", null, "ai", payload, null); } From 9a6937492ebc7e7504d6ceaa77928de0f987c854 Mon Sep 17 00:00:00 2001 From: Jaeho Date: Sun, 23 Aug 2026 00:03:07 +0900 Subject: [PATCH 3/3] =?UTF-8?q?docs:=20=ED=94=BC=EB=93=9C=EB=B0=B1=20?= =?UTF-8?q?=EC=8B=A4=ED=8C=A8=20=EC=BD=9C=EB=B0=B1=20=EC=8A=A4=ED=8E=99=20?= =?UTF-8?q?=EB=B0=98=EC=98=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit messaging.md §5.11 실패 케이스 블록·§6 정책 행 추가, AI 서버 절에 feedback_consumer 실패 신호 기록. event-stream.md §3.6 에 SessionErrorNotice 형태(scope=FOLLOWUP|FEEDBACK)와 사용자 문구 원칙 문서화. --- docs/event-stream.md | 17 +++++++++++++++++ docs/messaging.md | 28 ++++++++++++++++++++++++++++ 2 files changed, 45 insertions(+) diff --git a/docs/event-stream.md b/docs/event-stream.md index 79ecc007..3bc9a0e4 100644 --- a/docs/event-stream.md +++ b/docs/event-stream.md @@ -191,6 +191,23 @@ WS(RT1)는 같은 내용을 JSON 한 줄 프레임으로: `{ "id": , "e } ``` +세션 도메인 실패(꼬리질문·피드백 생성)는 `SessionErrorNotice` 형태로 세션·유저 채널에 +발행된다 — `scope` 로 실패 지점을 구분한다 (`FOLLOWUP` | `FEEDBACK`; 질문 풀 실패는 SSE ERROR +없이 세션을 정상 종료시킨다): +```json +{ + "type": "ERROR", + "payload": { + "sessionId": 99, + "scope": "FEEDBACK", + "errorCode": "FEEDBACK_GENERATION_FAILED", + "message": "피드백 생성에 실패했습니다. 잠시 후 다시 시도해 주세요.", + "retriable": true + } +} +``` +`message` 는 서버가 정의한 사용자 문구다 — AI 의 `errorMessage` 원문(내부 상세)은 SSE 로 나가지 않는다. + --- ## 4. 재연결 정책 diff --git a/docs/messaging.md b/docs/messaging.md index ec3c54d0..b9731515 100644 --- a/docs/messaging.md +++ b/docs/messaging.md @@ -504,6 +504,30 @@ } ``` +**실패 시** (§5.5/§5.7 과 동일 규약 — `status` 미명시(구버전)는 OK 로 취급): + +```json +{ + "messageType": "callback.feedback", + "payload": { + "sessionId": 99, + "status": "FAILED", + "errorCode": "UNEXPECTED", + "errorMessage": "...", + "retriable": true + } +} +``` + +- 피드백 생성의 부분 실패(패널·부가 평가위원)는 AI 서버 내부에서 폴백(빈 결과/생략)으로 흡수되어 + 성공 콜백으로 나간다 — FAILED 는 그 방어망 밖의 **예상 못 한 예외** 전용이다. `errorCode` 는 + questions/followup 과 동일 분류: `TypeError`(LLM 출력 스키마 불일치)면 `GENERATION_SCHEMA_INVALID` + + `retriable: false`, 그 외는 `UNEXPECTED` + `retriable: true`. +- 생성이 성공했는데 **성공 콜백 발행만** 실패한 경우는 FAILED 로 오인 발행하지 않는다 — 원 예외로 + DLQ 에 보내 재처리 가능하게 남긴다(멱등 마킹도 되돌림). +- Core 는 FAILED 수신 시 피드백을 저장하지 않고 SSE `ERROR`(scope=FEEDBACK) 로 세션·유저 채널에 + 알린다. `errorMessage` 원문은 서버 로그에만 남긴다(클라이언트 미노출). + ### 5.12 `realtime.session.notify` ```json { @@ -602,6 +626,7 @@ placeholder 를 `FAILED` 로 확정해 클라이언트의 턴이 잠기지 않 | 멱등 충돌 (이미 처리된 messageId) | ACK + 처리 skip (`processed_messages`) | | 영구 분석 실패 (PDF 손상 등) | ACK + 실패 callback 발행 (`status: FAILED`, `retriable: false`) — DLQ 미사용 | | 질문 풀/꼬리질문 생성 실패 (LLM 게이트웨이 장애, 스키마 위반 등) | ACK + 실패 callback 발행 (`status: FAILED`) — 세션이 "생성 중"에 무기한 멈추지 않게 항상 콜백을 보낸다. DLQ 미사용 | +| 피드백 생성 중 예상 못 한 예외 | ACK + 실패 callback 발행 (`status: FAILED`, `errorCode: UNEXPECTED`\|`GENERATION_SCHEMA_INVALID`) — 세션이 "피드백 생성 중"에 무기한 멈추지 않게 항상 콜백을 보낸다. 폴백 발행마저 실패하면 멱등 마킹 해제 후 원 예외로 DLQ (최후 안전망). 성공 콜백 발행 실패는 FAILED 오인 없이 DLQ (재처리 가능) | ### Core (Spring AMQP) - `RabbitMqConfig#rabbitListenerContainerFactory` 가 stateless retry interceptor (`RetryInterceptorBuilder.stateless()`) 를 attach. @@ -612,6 +637,9 @@ placeholder 를 `FAILED` 로 확정해 클라이언트의 턴이 잠기지 않 - `questions_consumer`/`followup_consumer` 도 동일 패턴 — 생성 호출을 catch 해 항상 콜백을 발행한다 (`QuestionPoolCallbackPayload`/`FollowupCallbackPayload` 의 `status: FAILED`). Core 가 이미 선INSERT 한 꼬리질문 placeholder 가 영원히 "생성 중"으로 남는 것을 방지. +- `feedback_consumer` 는 envelope 파싱·멱등 체크 이후 전 구간을 catch 해 예상 못 한 예외 시 + `FeedbackCallbackPayload` 의 `status: FAILED` 콜백을 발행하고 ACK 한다. 폴백 발행마저 실패하면 + 원 예외를 re-raise → DLQ (최후 안전망). - 그 외 예외는 re-raise → nack(requeue=false) → DLX 로 routing. - 일시 장애의 in-process 재시도는 미구현 (Phase 2 — 아래 Quorum Queue 도입과 함께).