Skip to content

Commit 5bd20da

Browse files
authored
Merge pull request #112 from Team-StackUp/feature/multi-agent-feedback-panel
feat(ai): 멀티 면접관 패널 피드백 평가 (Phase 1 — AI 단독, 출력 호환)
2 parents 3faab4a + 66e19e7 commit 5bd20da

4 files changed

Lines changed: 353 additions & 5 deletions

File tree

ai/src/ai_server/chain/feedback_generation_chain.py

Lines changed: 224 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,23 @@
11
from __future__ import annotations
22

3+
import asyncio
4+
from dataclasses import dataclass
35
from typing import Protocol
46

7+
import structlog
58
from langchain_core.output_parsers import PydanticOutputParser
69
from langchain_core.prompts import ChatPromptTemplate
710
from langchain_core.runnables import Runnable
811
from pydantic import BaseModel, Field
912

1013
from ai_server.chain.prompts.feedback_generation import HUMAN_PROMPT, SYSTEM_PROMPT
14+
from ai_server.chain.prompts import feedback_panel
1115
from ai_server.config.settings import Settings
1216
from ai_server.core.client import CoreClient
1317
from ai_server.observability.llm_logging_callback import CoreAiLogCallback
1418

19+
log = structlog.get_logger(__name__)
20+
1521

1622
class FeedbackResult(BaseModel):
1723
overall_score: float | None = Field(None, description="0~100")
@@ -105,3 +111,221 @@ def build_feedback_generation_chain(
105111
callbacks=callbacks,
106112
)
107113
return prompt | llm | parser
114+
115+
116+
# ── 멀티 면접관 패널 ──────────────────────────────────────────────────────────
117+
# 단일 평가자 대신 직군·논리·커뮤니케이션 평가위원이 각자 한 축을 채점(병렬) →
118+
# 가중평균으로 종합. A=평가만 / B=직군별(+단일직군도 다관점) / C=가중평균 / D=프롬프트 멀티콜.
119+
120+
121+
class EvaluatorResult(BaseModel):
122+
score: float | None = Field(None, description="0~100, 산정 불가 시 null")
123+
strength: str | None = None
124+
weakness: str | None = None
125+
keywords: list[str] = Field(default_factory=list)
126+
127+
128+
@dataclass(frozen=True)
129+
class _EvaluatorSpec:
130+
key: str # 'technical' | 'logic' | 'communication'
131+
label: str # 요약 표기용 ('기술'/'인성'/'논리'/'전달')
132+
persona: str
133+
dimension_name: str
134+
dimension_guide: str
135+
136+
137+
def _domain_spec(job_category: str, mode: str) -> _EvaluatorSpec:
138+
# PERSONALITY 모드는 기술 평가자를 인성·협업 평가자로 교체(사용자 결정).
139+
if (mode or "").upper() == "PERSONALITY":
140+
return _EvaluatorSpec(
141+
key="technical",
142+
label="인성",
143+
persona="인성·협업 중심 면접관",
144+
dimension_name="인성·협업 역량",
145+
dimension_guide=(
146+
"- 협업/갈등 해결, 성장 경험, 태도, 자기주도성을 봅니다. "
147+
"기술 정확도는 평가하지 않습니다."
148+
),
149+
)
150+
return _EvaluatorSpec(
151+
key="technical",
152+
label="기술",
153+
persona=f"{job_category} 직군 시니어 기술 면접관",
154+
dimension_name="기술 정확도·깊이",
155+
dimension_guide=(
156+
"- 기술 정확성, 깊이, trade-off, 근거를 봅니다. 질문의 '기대 신호'를 "
157+
"답변이 얼마나 짚었는지를 핵심 근거로 삼습니다."
158+
),
159+
)
160+
161+
162+
_LOGIC_SPEC = _EvaluatorSpec(
163+
key="logic",
164+
label="논리",
165+
persona="논리·문제해결 평가위원",
166+
dimension_name="논리·인과관계 명확성",
167+
dimension_guide=(
168+
"- 주장→근거→결론의 인과, trade-off 설명의 일관성, 문제 구조화를 봅니다."
169+
),
170+
)
171+
172+
_COMM_SPEC = _EvaluatorSpec(
173+
key="communication",
174+
label="전달",
175+
persona="커뮤니케이션·전달력 평가위원",
176+
dimension_name="명료성·구조화·전달력",
177+
dimension_guide=(
178+
"- 답변의 구조(STAR 등)·간결성·명료성을 보고, 음성 분석(WPM·무음·간투어)이 "
179+
"있으면 전달력 판단에 적극 활용합니다."
180+
),
181+
)
182+
183+
184+
def build_panel_evaluator_chain(
185+
settings: Settings, core_client: CoreClient | None = None
186+
) -> Runnable:
187+
"""패널 평가위원 1명용 체인. persona/dimension 을 invoke 변수로 받아 N회 재사용."""
188+
from langchain_openai import ChatOpenAI
189+
190+
parser = PydanticOutputParser(pydantic_object=EvaluatorResult)
191+
prompt = ChatPromptTemplate.from_messages(
192+
[
193+
("system", feedback_panel.SYSTEM_PROMPT),
194+
("human", feedback_panel.HUMAN_PROMPT),
195+
]
196+
).partial(format_instructions=parser.get_format_instructions())
197+
198+
callbacks = []
199+
if core_client is not None:
200+
callbacks.append(
201+
CoreAiLogCallback(
202+
core_client=core_client,
203+
request_type="generate.feedback.panel",
204+
default_model=settings.llm_pro_model,
205+
)
206+
)
207+
208+
llm = ChatOpenAI(
209+
model=settings.llm_pro_model,
210+
temperature=settings.llm_pro_temperature,
211+
api_key=settings.llm_api_key or None,
212+
base_url=settings.llm_base_url,
213+
callbacks=callbacks,
214+
)
215+
return prompt | llm | parser
216+
217+
218+
def _weighted_overall(pairs: list[tuple[float | None, float]]) -> float | None:
219+
"""(score, weight) 중 score 가 있는 것만 가중평균. 전부 None 이면 None."""
220+
present = [(s, w) for s, w in pairs if s is not None and w > 0]
221+
if not present:
222+
return None
223+
total_w = sum(w for _, w in present)
224+
return round(sum(s * w for s, w in present) / total_w)
225+
226+
227+
def _merge_notes(items: list[tuple[str, str | None]]) -> str | None:
228+
parts = [f"[{label}] {note.strip()}" for label, note in items if note and note.strip()]
229+
return " ".join(parts) if parts else None
230+
231+
232+
def _dedup_keywords(keywords: list[str], cap: int = 8) -> list[str]:
233+
seen: set[str] = set()
234+
out: list[str] = []
235+
for kw in keywords:
236+
k = (kw or "").strip()
237+
if k and k not in seen:
238+
seen.add(k)
239+
out.append(k)
240+
if len(out) >= cap:
241+
break
242+
return out
243+
244+
245+
class PanelFeedbackGenerator:
246+
"""직군·논리·커뮤니케이션 평가위원을 병렬 호출 → 가중평균 종합. FeedbackGenerator 호환."""
247+
248+
def __init__(self, chain: Runnable, *, weights: tuple[float, float, float] = (0.5, 0.25, 0.25)) -> None:
249+
self._chain = chain
250+
self._w_tech, self._w_logic, self._w_comm = weights
251+
252+
async def generate(
253+
self,
254+
*,
255+
job_category: str,
256+
mode: str,
257+
total_question_count: int | None,
258+
end_reason: str | None,
259+
transcript: str,
260+
rag_context: str,
261+
voice_analysis_summary: str = "",
262+
score_basis: str = "(없음)",
263+
) -> FeedbackResult:
264+
specs = [_domain_spec(job_category, mode), _LOGIC_SPEC, _COMM_SPEC]
265+
shared = {
266+
"job_category": job_category,
267+
"mode": mode,
268+
"total_question_count": total_question_count or 0,
269+
"end_reason": end_reason or "USER_REQUEST",
270+
"transcript": transcript,
271+
"score_basis": score_basis or "(없음)",
272+
"rag_context": rag_context or "(none)",
273+
"voice_analysis_summary": voice_analysis_summary
274+
or "No voice analysis summary was provided.",
275+
}
276+
raw = await asyncio.gather(
277+
*(
278+
self._chain.ainvoke(
279+
{
280+
**shared,
281+
"persona": s.persona,
282+
"dimension_name": s.dimension_name,
283+
"dimension_guide": s.dimension_guide,
284+
}
285+
)
286+
for s in specs
287+
),
288+
return_exceptions=True,
289+
)
290+
291+
results: dict[str, EvaluatorResult] = {}
292+
for spec, r in zip(specs, raw):
293+
if isinstance(r, EvaluatorResult):
294+
results[spec.key] = r
295+
else:
296+
log.warning(
297+
"feedback.panel.evaluator_failed",
298+
evaluator=spec.key,
299+
error=str(r),
300+
)
301+
results[spec.key] = EvaluatorResult()
302+
303+
tech = results["technical"]
304+
logic = results["logic"]
305+
comm = results["communication"]
306+
domain_label = specs[0].label
307+
308+
overall = _weighted_overall(
309+
[
310+
(tech.score, self._w_tech),
311+
(logic.score, self._w_logic),
312+
(comm.score, self._w_comm),
313+
]
314+
)
315+
strengths = _merge_notes(
316+
[(domain_label, tech.strength), ("논리", logic.strength), ("전달", comm.strength)]
317+
)
318+
weaknesses = _merge_notes(
319+
[(domain_label, tech.weakness), ("논리", logic.weakness), ("전달", comm.weakness)]
320+
)
321+
keywords = _dedup_keywords(tech.keywords + logic.keywords + comm.keywords)
322+
323+
return FeedbackResult(
324+
overall_score=overall,
325+
technical_accuracy=tech.score,
326+
logic_score=logic.score,
327+
communication_score=comm.score,
328+
strengths_summary=strengths,
329+
weaknesses_summary=weaknesses,
330+
improvement_keywords=keywords,
331+
)
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
# 멀티 면접관 패널 — 단일 평가위원 프롬프트.
2+
# persona/평가축(dimension)을 변수로 주입해 같은 체인을 N회(직군·논리·커뮤) 호출한다.
3+
4+
SYSTEM_PROMPT = (
5+
"당신은 IT 직군 면접 평가 패널의 한 평가위원입니다.\n"
6+
"역할: {persona}\n"
7+
"당신은 **오직 '{dimension_name}' 한 축만** 평가합니다. 다른 축은 평가하지 마세요.\n"
8+
"{dimension_guide}\n"
9+
"- 점수는 0~100 정수, 산정 불가(짧거나 빈 답변 등) 시 null.\n"
10+
"- 점수 앵커: 90~100 정확·구체적이며 근거·trade-off까지 깊이 있음 / "
11+
"70~89 대체로 정확하나 일부 깊이·근거 부족 / 50~69 방향은 맞으나 추상적 / "
12+
"30~49 부분적으로만 타당하고 핵심 누락 多 / 0~29 부정확하거나 거의 무응답.\n"
13+
"- '점수 기준값(score_basis)'에 해당 축 기준값이 있으면 그 값에서 ±15점 이내로 산정한다.\n"
14+
"- 점수를 매기기 전에 강점/약점 근거를 먼저 정리한 뒤 산정한다(즉흥 점수 금지).\n"
15+
"- strength/weakness 는 각각 한 줄(한국어, 구체적으로). keywords 는 이 축에서 보완할 "
16+
"개선 키워드 0~4개(짧은 명사구).\n"
17+
"- 응답은 반드시 지정된 JSON 스키마를 따른다."
18+
)
19+
20+
HUMAN_PROMPT = (
21+
"직군: {job_category} / 면접 모드: {mode} / 질문 수: {total_question_count} / "
22+
"종료 사유: {end_reason}\n\n"
23+
"=== 면접 전사 ===\n{transcript}\n\n"
24+
"=== 점수 기준값 (per-answer 평가 집계) ===\n{score_basis}\n\n"
25+
"=== 참고 문서 컨텍스트(RAG) ===\n{rag_context}\n\n"
26+
"=== 음성 분석 ===\n{voice_analysis_summary}\n\n"
27+
"{format_instructions}"
28+
)

ai/src/ai_server/messaging/runner.py

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -20,8 +20,8 @@
2020
build_streaming_followup_generator,
2121
)
2222
from ai_server.chain.feedback_generation_chain import (
23-
LlmFeedbackGenerator,
24-
build_feedback_generation_chain,
23+
PanelFeedbackGenerator,
24+
build_panel_evaluator_chain,
2525
)
2626
from ai_server.chain.question_generation_chain import (
2727
LlmQuestionGenerator,
@@ -210,9 +210,9 @@ def __init__(self, settings: Settings) -> None:
210210
rag_timeout_sec=settings.followup_rag_timeout_sec,
211211
)
212212

213-
# 종합 피드백 생성 (US-24)
214-
feedback_generator = LlmFeedbackGenerator(
215-
build_feedback_generation_chain(settings, core_client=core_client)
213+
# 종합 피드백 생성 (US-24) — 멀티 면접관 패널(직군·논리·커뮤 평가위원 병렬 → 가중평균)
214+
feedback_generator = PanelFeedbackGenerator(
215+
build_panel_evaluator_chain(settings, core_client=core_client)
216216
)
217217
self._feedback_consumer = FeedbackConsumer(
218218
generator=feedback_generator,

ai/tests/test_feedback_panel.py

Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
import pytest
2+
3+
from ai_server.chain.feedback_generation_chain import (
4+
EvaluatorResult,
5+
PanelFeedbackGenerator,
6+
)
7+
8+
# 평가축(dimension_name) 으로 라우팅하는 가짜 체인.
9+
TECH = "기술 정확도·깊이"
10+
PERSONALITY = "인성·협업 역량"
11+
LOGIC = "논리·인과관계 명확성"
12+
COMM = "명료성·구조화·전달력"
13+
14+
15+
class _FakeChain:
16+
def __init__(self, by_dim: dict[str, EvaluatorResult]):
17+
self._by_dim = by_dim
18+
self.calls: list[str] = []
19+
20+
async def ainvoke(self, variables):
21+
dim = variables["dimension_name"]
22+
self.calls.append(dim)
23+
return self._by_dim[dim]
24+
25+
26+
async def _run(by_dim, **kw):
27+
gen = PanelFeedbackGenerator(_FakeChain(by_dim))
28+
return await gen.generate(
29+
job_category=kw.get("job_category", "BACKEND"),
30+
mode=kw.get("mode", "TECHNICAL"),
31+
total_question_count=5,
32+
end_reason="MAX_QUESTIONS_REACHED",
33+
transcript="t",
34+
rag_context="(none)",
35+
voice_analysis_summary="",
36+
score_basis="(없음)",
37+
)
38+
39+
40+
@pytest.mark.asyncio
41+
async def test_weighted_overall_and_dimension_mapping():
42+
r = await _run(
43+
{
44+
TECH: EvaluatorResult(score=80, strength="설계 깊이", keywords=["JPA"]),
45+
LOGIC: EvaluatorResult(score=60, strength="인과 명확", keywords=["trade-off"]),
46+
COMM: EvaluatorResult(score=40, strength="간결", keywords=["STAR"]),
47+
}
48+
)
49+
assert r.technical_accuracy == 80
50+
assert r.logic_score == 60
51+
assert r.communication_score == 40
52+
# 0.5*80 + 0.25*60 + 0.25*40 = 65
53+
assert r.overall_score == 65
54+
assert "[기술]" in r.strengths_summary and "[논리]" in r.strengths_summary
55+
assert set(r.improvement_keywords) == {"JPA", "trade-off", "STAR"}
56+
57+
58+
@pytest.mark.asyncio
59+
async def test_overall_reweights_when_a_dimension_is_null():
60+
r = await _run(
61+
{
62+
TECH: EvaluatorResult(score=80),
63+
LOGIC: EvaluatorResult(score=None),
64+
COMM: EvaluatorResult(score=40),
65+
}
66+
)
67+
# logic None → (80*0.5 + 40*0.25) / 0.75 = 66.67 → 67
68+
assert r.logic_score is None
69+
assert r.overall_score == 67
70+
71+
72+
@pytest.mark.asyncio
73+
async def test_personality_mode_swaps_domain_to_behavioral():
74+
r = await _run(
75+
{
76+
PERSONALITY: EvaluatorResult(score=70, strength="협업 태도 우수"),
77+
LOGIC: EvaluatorResult(score=50),
78+
COMM: EvaluatorResult(score=60),
79+
},
80+
mode="PERSONALITY",
81+
)
82+
# 기술 평가자 자리가 인성·협업 평가자로 교체됨 → technical_accuracy 슬롯에 인성 점수
83+
assert r.technical_accuracy == 70
84+
assert "[인성]" in r.strengths_summary
85+
86+
87+
@pytest.mark.asyncio
88+
async def test_keyword_dedup():
89+
r = await _run(
90+
{
91+
TECH: EvaluatorResult(score=70, keywords=["동시성", "트랜잭션"]),
92+
LOGIC: EvaluatorResult(score=70, keywords=["트랜잭션"]),
93+
COMM: EvaluatorResult(score=70, keywords=["두괄식"]),
94+
}
95+
)
96+
assert r.improvement_keywords == ["동시성", "트랜잭션", "두괄식"]

0 commit comments

Comments
 (0)