Skip to content

Commit 49bf458

Browse files
authored
Merge pull request #116 from Team-StackUp/feature/detailed-feedback
feat: 피드백 상세화 — 면접관별 상세평가·점수근거 + 종합 서술형 + 학습 방향
2 parents bb06485 + 7cdbc5b commit 49bf458

18 files changed

Lines changed: 248 additions & 23 deletions

File tree

ai/src/ai_server/chain/feedback_generation_chain.py

Lines changed: 96 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@
1111
from pydantic import BaseModel, Field
1212

1313
from 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
1515
from ai_server.config.settings import Settings
1616
from ai_server.core.client import CoreClient
1717
from 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)
133143
class _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+
256300
def _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]:
283327
class 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
)

ai/src/ai_server/chain/prompts/feedback_panel.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,9 @@
1414
"- 점수를 매기기 전에 강점/약점 근거를 먼저 정리한 뒤 산정한다(즉흥 점수 금지).\n"
1515
"- strength/weakness 는 각각 한 줄(한국어, 구체적으로). keywords 는 이 축에서 보완할 "
1616
"개선 키워드 0~4개(짧은 명사구).\n"
17+
"- detail: 이 축에 대한 2~4문장 상세 평가. **답변의 구체적 부분을 인용/지목**하고 무엇이 "
18+
"왜 좋았는지/아쉬웠는지 근거를 들어 서술한다(추상적 총평 금지).\n"
19+
"- score_rationale: 그 점수를 준 핵심 근거(가점/감점 요인)를 한두 문장으로.\n"
1720
"- 응답은 반드시 지정된 JSON 스키마를 따른다."
1821
)
1922

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
# 멀티 면접관 패널 종합 — 평가위원들의 결과를 통합해 서술형 종합평 + 학습 방향 생성.
2+
3+
SYSTEM_PROMPT = (
4+
"당신은 면접 평가 패널을 총괄하는 수석 면접관입니다. 여러 평가위원(직군 기술·논리·"
5+
"전달 등)의 평가를 받아 지원자에게 줄 **통합 종합 피드백**을 작성하세요.\n"
6+
"- 평가위원들의 관점을 단순 나열하지 말고 **하나의 일관된 서술**로 엮습니다.\n"
7+
"- strengths_summary: 지원자의 강점을 3~5문장 한국어 문단으로. 어느 영역에서 어떻게 "
8+
"드러났는지 구체적으로.\n"
9+
"- weaknesses_summary: 보완점을 3~5문장 한국어 문단으로. 무엇이 왜 부족했고 어떤 방향으로 "
10+
"개선해야 하는지.\n"
11+
"- improvement_keywords: 다음 면접에서 보완할 키워드 5~10개(짧은 명사구).\n"
12+
"- study_plan: 구체적 학습 방향/다음 단계 액션 아이템 3~6개. 각 항목은 '무엇을 어떻게'가 "
13+
"드러나는 실행 문장(예: 'Redis 분산 락의 SETNX·TTL 옵션을 직접 구현해보며 원자성 보장 원리 정리').\n"
14+
"- 평가위원 점수·근거에 모순되지 않게, 과장 없이 사실에 기반해 작성합니다.\n"
15+
"- 응답은 반드시 지정된 JSON 스키마를 따릅니다."
16+
)
17+
18+
HUMAN_PROMPT = (
19+
"직군: {job_category} / 면접 모드: {mode}\n\n"
20+
"=== 평가위원별 평가 결과 ===\n{panel_summary}\n\n"
21+
"=== 면접 전사(참고) ===\n{transcript}\n\n"
22+
"{format_instructions}"
23+
)

ai/src/ai_server/messaging/consumers/feedback_consumer.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -109,6 +109,7 @@ async def handle(self, message: AbstractIncomingMessage) -> None:
109109
strengths_summary=result.strengths_summary,
110110
weaknesses_summary=result.weaknesses_summary,
111111
improvement_keywords=result.improvement_keywords,
112+
study_plan=result.study_plan,
112113
panel_breakdown=result.panel_breakdown,
113114
report_s3_key=None,
114115
)

ai/src/ai_server/messaging/runner.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@
2121
)
2222
from ai_server.chain.feedback_generation_chain import (
2323
PanelFeedbackGenerator,
24+
build_feedback_synthesis_chain,
2425
build_panel_evaluator_chain,
2526
)
2627
from ai_server.chain.question_generation_chain import (
@@ -211,8 +212,10 @@ def __init__(self, settings: Settings) -> None:
211212
)
212213

213214
# 종합 피드백 생성 (US-24) — 멀티 면접관 패널(직군·논리·커뮤 평가위원 병렬 → 가중평균)
215+
# + 종합 서술형 평·학습 방향 synthesis.
214216
feedback_generator = PanelFeedbackGenerator(
215-
build_panel_evaluator_chain(settings, core_client=core_client)
217+
build_panel_evaluator_chain(settings, core_client=core_client),
218+
synthesis_chain=build_feedback_synthesis_chain(settings, core_client=core_client),
216219
)
217220
self._feedback_consumer = FeedbackConsumer(
218221
generator=feedback_generator,

ai/src/ai_server/model/messages/feedback.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,10 @@ class PanelBreakdownItem(BaseModel):
7474
score: float | None = None
7575
strength: str | None = None
7676
weakness: str | None = None
77+
# 구체적 근거·예시(답변 인용)를 담은 2~4문장 상세 평가.
78+
detail: str | None = None
79+
# 이 점수를 준 근거(감점/가점 요인) 간단 설명.
80+
score_rationale: str | None = None
7781

7882

7983
class FeedbackCallbackPayload(BaseModel):
@@ -89,6 +93,8 @@ class FeedbackCallbackPayload(BaseModel):
8993
strengths_summary: str | None = None
9094
weaknesses_summary: str | None = None
9195
improvement_keywords: list[str] = Field(default_factory=list)
96+
# 패널을 통합한 학습 방향/다음 단계 액션 아이템.
97+
study_plan: list[str] = Field(default_factory=list)
9298
# 평가위원별 분해(패널). 비어 있으면 단일/레거시 경로.
9399
panel_breakdown: list[PanelBreakdownItem] = Field(default_factory=list)
94100
report_s3_key: str | None = None

ai/tests/test_feedback_panel.py

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,18 @@
33
from ai_server.chain.feedback_generation_chain import (
44
EvaluatorResult,
55
PanelFeedbackGenerator,
6+
SynthesisResult,
67
)
78

9+
10+
class _FakeSynthesis:
11+
def __init__(self, result: SynthesisResult):
12+
self._result = result
13+
14+
async def ainvoke(self, _v):
15+
return self._result
16+
17+
818
# 평가축(dimension_name) 으로 라우팅하는 가짜 체인.
919
TECH = "기술 정확도·깊이"
1020
PERSONALITY = "인성·협업 역량"
@@ -123,6 +133,44 @@ async def test_multi_domain_weighted_by_question_counts():
123133
assert r.overall_score == 62
124134

125135

136+
@pytest.mark.asyncio
137+
async def test_synthesis_narrative_study_plan_and_breakdown_detail():
138+
syn = SynthesisResult(
139+
strengths_summary="통합 강점 서술",
140+
weaknesses_summary="통합 약점 서술",
141+
improvement_keywords=["동시성"],
142+
study_plan=["Redis 분산 락 SETNX/TTL 직접 구현"],
143+
)
144+
gen = PanelFeedbackGenerator(
145+
_FakeChain({
146+
TECH: EvaluatorResult(score=80, strength="s", detail="상세 평가", score_rationale="근거"),
147+
LOGIC: EvaluatorResult(score=60),
148+
COMM: EvaluatorResult(score=50),
149+
}),
150+
synthesis_chain=_FakeSynthesis(syn),
151+
)
152+
r = await _run_gen(gen)
153+
# 종합 서술형(synthesis 결과로 대체)
154+
assert r.strengths_summary == "통합 강점 서술"
155+
assert r.weaknesses_summary == "통합 약점 서술"
156+
assert r.improvement_keywords == ["동시성"]
157+
assert r.study_plan == ["Redis 분산 락 SETNX/TTL 직접 구현"]
158+
# 평가위원 분해에 detail/score_rationale 포함
159+
assert r.panel_breakdown[0].detail == "상세 평가"
160+
assert r.panel_breakdown[0].score_rationale == "근거"
161+
162+
163+
async def _run_gen(gen):
164+
return await gen.generate(
165+
job_category="BACKEND",
166+
mode="TECHNICAL",
167+
total_question_count=3,
168+
end_reason="x",
169+
transcript="t",
170+
rag_context="(none)",
171+
)
172+
173+
126174
@pytest.mark.asyncio
127175
async def test_keyword_dedup():
128176
r = await _run(

backend/openapi.json

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3348,6 +3348,13 @@
33483348
"$ref" : "#/components/schemas/PanelBreakdownItem"
33493349
}
33503350
},
3351+
"studyPlan" : {
3352+
"type" : "array",
3353+
"description" : "Study plan / next-step action items synthesized from the panel.",
3354+
"items" : {
3355+
"type" : "string"
3356+
}
3357+
},
33513358
"reportFilePath" : {
33523359
"type" : "string",
33533360
"description" : "Stored report path when AI generates a detailed learning guide/report."
@@ -3376,6 +3383,12 @@
33763383
},
33773384
"weakness" : {
33783385
"type" : "string"
3386+
},
3387+
"detail" : {
3388+
"type" : "string"
3389+
},
3390+
"scoreRationale" : {
3391+
"type" : "string"
33793392
}
33803393
}
33813394
},

backend/src/main/java/com/stackup/stackup/session/application/FeedbackCallbackService.java

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,7 @@ public void apply(FeedbackCallbackEnvelope envelope) {
7676
payload.weaknessesSummary(),
7777
keywordsToJson(payload.improvementKeywords()),
7878
breakdownToJson(payload.panelBreakdown()),
79+
keywordsToJson(payload.studyPlan()),
7980
payload.reportS3Key()
8081
);
8182
try {

backend/src/main/java/com/stackup/stackup/session/application/dto/FeedbackCallbackPayload.java

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ public record FeedbackCallbackPayload(
1414
String strengthsSummary,
1515
String weaknessesSummary,
1616
List<String> improvementKeywords,
17+
List<String> studyPlan,
1718
List<PanelBreakdownItem> panelBreakdown,
1819
String reportS3Key
1920
) {

0 commit comments

Comments
 (0)