1111from pydantic import BaseModel , Field
1212
1313from ai_server .chain .prompts .feedback_generation import HUMAN_PROMPT , SYSTEM_PROMPT
14- from ai_server .chain .prompts import feedback_panel
14+ from ai_server .chain .prompts import feedback_panel , feedback_synthesis
1515from ai_server .config .settings import Settings
1616from ai_server .core .client import CoreClient
1717from ai_server .model .messages .feedback import PanelBreakdownItem
@@ -28,6 +28,7 @@ class FeedbackResult(BaseModel):
2828 strengths_summary : str | None = Field (None )
2929 weaknesses_summary : str | None = Field (None )
3030 improvement_keywords : list [str ] = Field (default_factory = list )
31+ study_plan : list [str ] = Field (default_factory = list )
3132 panel_breakdown : list [PanelBreakdownItem ] = Field (default_factory = list )
3233
3334
@@ -126,9 +127,18 @@ class EvaluatorResult(BaseModel):
126127 score : float | None = Field (None , description = "0~100, 산정 불가 시 null" )
127128 strength : str | None = None
128129 weakness : str | None = None
130+ detail : str | None = Field (None , description = "근거·예시 포함 2~4문장 상세 평가" )
131+ score_rationale : str | None = Field (None , description = "점수 근거 한두 문장" )
129132 keywords : list [str ] = Field (default_factory = list )
130133
131134
135+ class SynthesisResult (BaseModel ):
136+ strengths_summary : str | None = None
137+ weaknesses_summary : str | None = None
138+ improvement_keywords : list [str ] = Field (default_factory = list )
139+ study_plan : list [str ] = Field (default_factory = list )
140+
141+
132142@dataclass (frozen = True )
133143class _EvaluatorSpec :
134144 key : str # 'technical' | 'logic' | 'communication'
@@ -253,6 +263,40 @@ def build_panel_evaluator_chain(
253263 return prompt | llm | parser
254264
255265
266+ def build_feedback_synthesis_chain (
267+ settings : Settings , core_client : CoreClient | None = None
268+ ) -> Runnable :
269+ """패널 결과를 통합한 종합 서술형 평 + 학습 방향 생성 체인."""
270+ from langchain_openai import ChatOpenAI
271+
272+ parser = PydanticOutputParser (pydantic_object = SynthesisResult )
273+ prompt = ChatPromptTemplate .from_messages (
274+ [
275+ ("system" , feedback_synthesis .SYSTEM_PROMPT ),
276+ ("human" , feedback_synthesis .HUMAN_PROMPT ),
277+ ]
278+ ).partial (format_instructions = parser .get_format_instructions ())
279+
280+ callbacks = []
281+ if core_client is not None :
282+ callbacks .append (
283+ CoreAiLogCallback (
284+ core_client = core_client ,
285+ request_type = "generate.feedback.synthesis" ,
286+ default_model = settings .llm_pro_model ,
287+ )
288+ )
289+
290+ llm = ChatOpenAI (
291+ model = settings .llm_pro_model ,
292+ temperature = settings .llm_pro_temperature ,
293+ api_key = settings .llm_api_key or None ,
294+ base_url = settings .llm_base_url ,
295+ callbacks = callbacks ,
296+ )
297+ return prompt | llm | parser
298+
299+
256300def _weighted_overall (pairs : list [tuple [float | None , float ]]) -> float | None :
257301 """(score, weight) 중 score 가 있는 것만 가중평균. 전부 None 이면 None."""
258302 present = [(s , w ) for s , w in pairs if s is not None and w > 0 ]
@@ -283,8 +327,15 @@ def _dedup_keywords(keywords: list[str], cap: int = 8) -> list[str]:
283327class PanelFeedbackGenerator :
284328 """직군·논리·커뮤니케이션 평가위원을 병렬 호출 → 가중평균 종합. FeedbackGenerator 호환."""
285329
286- def __init__ (self , chain : Runnable , * , weights : tuple [float , float , float ] = (0.5 , 0.25 , 0.25 )) -> None :
330+ def __init__ (
331+ self ,
332+ chain : Runnable ,
333+ * ,
334+ synthesis_chain : Runnable | None = None ,
335+ weights : tuple [float , float , float ] = (0.5 , 0.25 , 0.25 ),
336+ ) -> None :
287337 self ._chain = chain
338+ self ._synthesis = synthesis_chain
288339 self ._w_tech , self ._w_logic , self ._w_comm = weights
289340
290341 async def generate (
@@ -353,18 +404,18 @@ async def generate(
353404 ]
354405 )
355406
356- note_items = [( spec . label , r . strength ) for spec , _ , r in domain_results ]
357- note_items + = [("논리" , logic .strength ), ( "전달" , comm . strength ) ]
358- strengths = _merge_notes ( note_items )
359- weak_items = [( spec . label , r . weakness ) for spec , _ , r in domain_results ]
360- weak_items + = [("논리" , logic .weakness ), ( "전달" , comm . weakness ) ]
361- weaknesses = _merge_notes ( weak_items )
362-
363- all_keywords : list [ str ] = []
364- for _ , _ , r in domain_results :
365- all_keywords += r .keywords
366- all_keywords += logic . keywords + comm .keywords
367- keywords = _dedup_keywords ( all_keywords )
407+ # 기계적 병합(synthesis 미설정/실패 시 폴백용).
408+ fb_strength_items = [(spec . label , r .strength ) for spec , _ , r in domain_results ]
409+ fb_strength_items += [( "논리" , logic . strength ), ( "전달" , comm . strength )]
410+ fb_strengths = _merge_notes ( fb_strength_items )
411+ fb_weak_items = [(spec . label , r .weakness ) for spec , _ , r in domain_results ]
412+ fb_weak_items += [( "논리" , logic . weakness ), ( "전달" , comm . weakness )]
413+ fb_weaknesses = _merge_notes ( fb_weak_items )
414+ fb_keywords = _dedup_keywords (
415+ [ k for _ , _ , r in domain_results for k in r . keywords ]
416+ + logic .keywords
417+ + comm .keywords
418+ )
368419
369420 breakdown = [
370421 PanelBreakdownItem (
@@ -373,6 +424,8 @@ async def generate(
373424 score = r .score ,
374425 strength = r .strength ,
375426 weakness = r .weakness ,
427+ detail = r .detail ,
428+ score_rationale = r .score_rationale ,
376429 )
377430 for spec , _ , r in domain_results
378431 ]
@@ -384,9 +437,37 @@ async def generate(
384437 score = r .score ,
385438 strength = r .strength ,
386439 weakness = r .weakness ,
440+ detail = r .detail ,
441+ score_rationale = r .score_rationale ,
387442 )
388443 )
389444
445+ # 종합 서술형 + 학습 방향(synthesis). 미설정/실패 시 기계적 병합으로 폴백.
446+ strengths , weaknesses , keywords , study_plan = fb_strengths , fb_weaknesses , fb_keywords , []
447+ if self ._synthesis is not None :
448+ panel_summary = "\n " .join (
449+ f"- { b .evaluator } ({ b .dimension } ) "
450+ f"{ ('%g점' % b .score ) if b .score is not None else '점수없음' } : "
451+ f"{ (b .detail or '' ).strip () or (b .strength or '' )} "
452+ for b in breakdown
453+ )
454+ try :
455+ syn = await self ._synthesis .ainvoke (
456+ {
457+ "job_category" : job_category ,
458+ "mode" : mode ,
459+ "panel_summary" : panel_summary ,
460+ "transcript" : transcript ,
461+ }
462+ )
463+ if isinstance (syn , SynthesisResult ):
464+ strengths = syn .strengths_summary or fb_strengths
465+ weaknesses = syn .weaknesses_summary or fb_weaknesses
466+ keywords = _dedup_keywords (syn .improvement_keywords ) or fb_keywords
467+ study_plan = syn .study_plan or []
468+ except Exception as exc : # noqa: BLE001
469+ log .warning ("feedback.synthesis.failed" , error = str (exc ))
470+
390471 return FeedbackResult (
391472 overall_score = overall ,
392473 technical_accuracy = technical_accuracy ,
@@ -395,5 +476,6 @@ async def generate(
395476 strengths_summary = strengths ,
396477 weaknesses_summary = weaknesses ,
397478 improvement_keywords = keywords ,
479+ study_plan = study_plan ,
398480 panel_breakdown = breakdown ,
399481 )
0 commit comments