@@ -124,127 +124,188 @@ async def handle(self, message: AbstractIncomingMessage) -> None:
124124 )
125125 return
126126
127- req = envelope .payload
127+ try :
128+ payload = await self ._process (envelope )
129+ except Exception as exc : # noqa: BLE001
130+ await self ._publish_failed (envelope , exc )
131+ return
132+
133+ # 성공 payload 의 발행 실패는 생성 실패가 아니다 — FAILED 콜백으로 오인 발행하지
134+ # 않고 원 예외로 DLQ 에 보내 재처리 가능하게 남긴다(실패 신호 도입 전과 동일 동작).
135+ try :
136+ await self ._publish_callback (envelope , payload )
137+ except Exception :
138+ self ._idempotency .unmark (envelope .message_id )
139+ raise
128140 log .info (
129- "feedback.generate.start " ,
141+ "feedback.generate.done " ,
130142 message_id = envelope .message_id ,
131- session_id = req .session_id ,
132- msg_count = len (req .messages ),
133- ctx_count = len (req .context_document_ids ),
143+ session_id = envelope .payload .session_id ,
134144 trace_id = envelope .trace_id ,
135145 )
136146
137- await self . _emit_progress (
138- session_id = req . session_id ,
139- phase = "PREPARING" ,
140- message = "면접 기록을 정리하고 있어요." ,
141- trace_id = envelope . trace_id ,
142- )
143- transcript = _build_transcript ( req . messages )
144- score_basis = _build_score_basis ( req .messages )
145- rag_context = await self . _build_rag_context (req )
146- voice_analysis_summary = _build_voice_analysis_summary (
147- req . voice_analysis_summary
148- )
147+ async def _process (
148+ self , envelope : Envelope [ GenerateFeedbackRequest ]
149+ ) -> FeedbackCallbackPayload :
150+ req = envelope . payload
151+ log . info (
152+ "feedback.generate.start" ,
153+ message_id = envelope . message_id ,
154+ session_id = req .session_id ,
155+ msg_count = len (req . messages ),
156+ ctx_count = len ( req . context_document_ids ),
157+ trace_id = envelope . trace_id ,
158+ )
149159
150- # 세부 평가 5개가 병렬(gather)이라 순차 phase 로는 진행을 표현할 수 없다 —
151- # 각 태스크 완료 시점에 completed/total 카운터로 emit 한다.
152- scoring_total = 5
153- scoring_done = 0
160+ await self ._emit_progress (
161+ session_id = req .session_id ,
162+ phase = "PREPARING" ,
163+ message = "면접 기록을 정리하고 있어요." ,
164+ trace_id = envelope .trace_id ,
165+ )
166+ transcript = _build_transcript (req .messages )
167+ score_basis = _build_score_basis (req .messages )
168+ rag_context = await self ._build_rag_context (req )
169+ voice_analysis_summary = _build_voice_analysis_summary (
170+ req .voice_analysis_summary
171+ )
154172
155- async def _tracked (coro : Awaitable [T ]) -> T :
156- nonlocal scoring_done
157- task_result = await coro
158- scoring_done += 1
159- await self ._emit_progress (
160- session_id = req .session_id ,
161- phase = "SCORING" ,
162- message = f"세부 평가를 진행하고 있어요. ({ scoring_done } /{ scoring_total } )" ,
163- trace_id = envelope .trace_id ,
164- completed = scoring_done ,
165- total = scoring_total ,
166- )
167- return task_result
173+ # 세부 평가 5개가 병렬(gather)이라 순차 phase 로는 진행을 표현할 수 없다 —
174+ # 각 태스크 완료 시점에 completed/total 카운터로 emit 한다.
175+ scoring_total = 5
176+ scoring_done = 0
168177
178+ async def _tracked (coro : Awaitable [T ]) -> T :
179+ nonlocal scoring_done
180+ task_result = await coro
181+ scoring_done += 1
169182 await self ._emit_progress (
170183 session_id = req .session_id ,
171184 phase = "SCORING" ,
172- message = "평가위원들이 답변을 검토하고 있어요." ,
185+ message = f"세부 평가를 진행하고 있어요. ( { scoring_done } / { scoring_total } ) " ,
173186 trace_id = envelope .trace_id ,
174- completed = 0 ,
187+ completed = scoring_done ,
175188 total = scoring_total ,
176189 )
190+ return task_result
191+
192+ await self ._emit_progress (
193+ session_id = req .session_id ,
194+ phase = "SCORING" ,
195+ message = "평가위원들이 답변을 검토하고 있어요." ,
196+ trace_id = envelope .trace_id ,
197+ completed = 0 ,
198+ total = scoring_total ,
199+ )
177200
178- # 종합 피드백 + 자기소개 첫인상 + 직무 적합도(직무 맞춤 모드)를 병렬 실행.
179- # 첫인상·직무 적합도는 종합 점수(overall)에 미포함 — generator 가 모른 채 계산한 뒤 표시용으로 덧붙인다.
180- (
181- result ,
182- self_intro_item ,
183- job_fit_items ,
184- personality_item ,
185- answer_coaching ,
186- ) = await asyncio .gather (
187- _tracked (
188- self ._generate_panel (
189- job_category = req .job_category ,
190- mode = req .mode ,
191- total_question_count = req .total_question_count ,
192- end_reason = req .end_reason ,
193- transcript = transcript ,
194- score_basis = score_basis ,
195- rag_context = rag_context ,
196- voice_analysis_summary = voice_analysis_summary ,
197- domain_question_counts = req .domain_question_counts ,
198- session_id = req .session_id ,
199- )
200- ),
201- _tracked (self ._evaluate_self_intro (req , voice_analysis_summary )),
202- _tracked (self ._evaluate_job_fit (req , transcript , rag_context )),
203- _tracked (self ._evaluate_personality (req )),
204- _tracked (self ._coach_answers (req )),
205- )
206- # 빈 평가위원 항목(점수·내용 모두 없음)은 표시하지 않는다 — LLM 부분 응답이 빈 패널로 새는 것 방지.
207- extras = [self_intro_item , * job_fit_items , personality_item ]
208- result .panel_breakdown .extend (
209- e for e in extras if e is not None and _panel_has_content (e )
210- )
201+ # 종합 피드백 + 자기소개 첫인상 + 직무 적합도(직무 맞춤 모드)를 병렬 실행.
202+ # 첫인상·직무 적합도는 종합 점수(overall)에 미포함 — generator 가 모른 채 계산한 뒤 표시용으로 덧붙인다.
203+ (
204+ result ,
205+ self_intro_item ,
206+ job_fit_items ,
207+ personality_item ,
208+ answer_coaching ,
209+ ) = await asyncio .gather (
210+ _tracked (
211+ self ._generate_panel (
212+ job_category = req .job_category ,
213+ mode = req .mode ,
214+ total_question_count = req .total_question_count ,
215+ end_reason = req .end_reason ,
216+ transcript = transcript ,
217+ score_basis = score_basis ,
218+ rag_context = rag_context ,
219+ voice_analysis_summary = voice_analysis_summary ,
220+ domain_question_counts = req .domain_question_counts ,
221+ session_id = req .session_id ,
222+ )
223+ ),
224+ _tracked (self ._evaluate_self_intro (req , voice_analysis_summary )),
225+ _tracked (self ._evaluate_job_fit (req , transcript , rag_context )),
226+ _tracked (self ._evaluate_personality (req )),
227+ _tracked (self ._coach_answers (req )),
228+ )
229+ # 빈 평가위원 항목(점수·내용 모두 없음)은 표시하지 않는다 — LLM 부분 응답이 빈 패널로 새는 것 방지.
230+ extras = [self_intro_item , * job_fit_items , personality_item ]
231+ result .panel_breakdown .extend (
232+ e for e in extras if e is not None and _panel_has_content (e )
233+ )
211234
212- await self ._emit_progress (
213- session_id = req .session_id ,
214- phase = "FINALIZING" ,
215- message = "피드백 리포트를 정리하고 있어요." ,
216- trace_id = envelope .trace_id ,
217- )
218- payload = FeedbackCallbackPayload (
219- session_id = req .session_id ,
220- overall_score = result .overall_score ,
221- technical_accuracy = result .technical_accuracy ,
222- logic_score = result .logic_score ,
223- communication_score = result .communication_score ,
224- strengths_summary = result .strengths_summary ,
225- weaknesses_summary = result .weaknesses_summary ,
226- improvement_keywords = result .improvement_keywords ,
227- study_plan = result .study_plan ,
228- highlights = result .highlights ,
229- panel_breakdown = result .panel_breakdown ,
230- answer_coaching = answer_coaching ,
231- report_s3_key = None ,
232- )
235+ await self ._emit_progress (
236+ session_id = req .session_id ,
237+ phase = "FINALIZING" ,
238+ message = "피드백 리포트를 정리하고 있어요." ,
239+ trace_id = envelope .trace_id ,
240+ )
241+ payload = FeedbackCallbackPayload (
242+ session_id = req .session_id ,
243+ overall_score = result .overall_score ,
244+ technical_accuracy = result .technical_accuracy ,
245+ logic_score = result .logic_score ,
246+ communication_score = result .communication_score ,
247+ strengths_summary = result .strengths_summary ,
248+ weaknesses_summary = result .weaknesses_summary ,
249+ improvement_keywords = result .improvement_keywords ,
250+ study_plan = result .study_plan ,
251+ highlights = result .highlights ,
252+ panel_breakdown = result .panel_breakdown ,
253+ answer_coaching = answer_coaching ,
254+ report_s3_key = None ,
255+ )
233256
234- await self ._publisher .publish (
235- routing_key = self ._callback_routing_key ,
236- message_type = "callback.feedback" ,
237- payload = payload ,
238- trace_id = envelope .trace_id ,
239- correlation_id = envelope .message_id ,
240- context = envelope .context ,
241- )
242- log .info (
243- "feedback.generate.done" ,
257+ return payload
258+
259+ async def _publish_callback (
260+ self ,
261+ envelope : Envelope [GenerateFeedbackRequest ],
262+ payload : FeedbackCallbackPayload ,
263+ ) -> None :
264+ await self ._publisher .publish (
265+ routing_key = self ._callback_routing_key ,
266+ message_type = "callback.feedback" ,
267+ payload = payload ,
268+ trace_id = envelope .trace_id ,
269+ correlation_id = envelope .message_id ,
270+ context = envelope .context ,
271+ )
272+
273+ async def _publish_failed (
274+ self , envelope : Envelope [GenerateFeedbackRequest ], exc : Exception
275+ ) -> None :
276+ """생성 중 예상 못 한 예외의 실패 신호. 콜백 없이 DLQ 로만 격리되면 Core 가 실패를
277+ 모른 채 세션이 '피드백 생성 중'에 무기한 멈춘다 — 항상 FAILED 콜백을 발행하고
278+ ack 한다. 폴백 발행마저 실패하면 멱등 마킹을 되돌리고 원 예외를 다시 던져
279+ DLQ 로 보낸다(최후 안전망 — 재주입 시 duplicate skip 으로 삼켜지지 않게)."""
280+ req = envelope .payload
281+ log .exception (
282+ "feedback.generate.unexpected" ,
283+ message_id = envelope .message_id ,
284+ session_id = req .session_id ,
285+ trace_id = envelope .trace_id ,
286+ )
287+ # questions/followup consumer 와 동일 분류 — TypeError(LLM 출력 스키마 불일치)는
288+ # 같은 입력으로 재시도해도 똑같이 죽는다 → retriable=false.
289+ is_schema = isinstance (exc , TypeError )
290+ payload = FeedbackCallbackPayload (
291+ session_id = req .session_id ,
292+ status = "FAILED" ,
293+ error_code = "GENERATION_SCHEMA_INVALID" if is_schema else "UNEXPECTED" ,
294+ # str(exc) 는 LLM 응답 본문·입력 repr 까지 담길 수 있다 — 로그·와이어 크기 상한.
295+ error_message = f"{ type (exc ).__name__ } : { exc } " [:500 ],
296+ retriable = not is_schema ,
297+ )
298+ try :
299+ await self ._publish_callback (envelope , payload )
300+ except Exception : # noqa: BLE001
301+ log .exception (
302+ "feedback.failed_callback.publish_failed" ,
244303 message_id = envelope .message_id ,
245304 session_id = req .session_id ,
246305 trace_id = envelope .trace_id ,
247306 )
307+ self ._idempotency .unmark (envelope .message_id )
308+ raise exc
248309
249310 async def _emit_progress (
250311 self ,
0 commit comments