Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
110 changes: 96 additions & 14 deletions ai/src/ai_server/chain/feedback_generation_chain.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@
from pydantic import BaseModel, Field

from ai_server.chain.prompts.feedback_generation import HUMAN_PROMPT, SYSTEM_PROMPT
from ai_server.chain.prompts import feedback_panel
from ai_server.chain.prompts import feedback_panel, feedback_synthesis
from ai_server.config.settings import Settings
from ai_server.core.client import CoreClient
from ai_server.model.messages.feedback import PanelBreakdownItem
Expand All @@ -28,6 +28,7 @@ class FeedbackResult(BaseModel):
strengths_summary: str | None = Field(None)
weaknesses_summary: str | None = Field(None)
improvement_keywords: list[str] = Field(default_factory=list)
study_plan: list[str] = Field(default_factory=list)
panel_breakdown: list[PanelBreakdownItem] = Field(default_factory=list)


Expand Down Expand Up @@ -126,9 +127,18 @@ class EvaluatorResult(BaseModel):
score: float | None = Field(None, description="0~100, 산정 불가 시 null")
strength: str | None = None
weakness: str | None = None
detail: str | None = Field(None, description="근거·예시 포함 2~4문장 상세 평가")
score_rationale: str | None = Field(None, description="점수 근거 한두 문장")
keywords: list[str] = Field(default_factory=list)


class SynthesisResult(BaseModel):
strengths_summary: str | None = None
weaknesses_summary: str | None = None
improvement_keywords: list[str] = Field(default_factory=list)
study_plan: list[str] = Field(default_factory=list)


@dataclass(frozen=True)
class _EvaluatorSpec:
key: str # 'technical' | 'logic' | 'communication'
Expand Down Expand Up @@ -253,6 +263,40 @@ def build_panel_evaluator_chain(
return prompt | llm | parser


def build_feedback_synthesis_chain(
settings: Settings, core_client: CoreClient | None = None
) -> Runnable:
"""패널 결과를 통합한 종합 서술형 평 + 학습 방향 생성 체인."""
from langchain_openai import ChatOpenAI

parser = PydanticOutputParser(pydantic_object=SynthesisResult)
prompt = ChatPromptTemplate.from_messages(
[
("system", feedback_synthesis.SYSTEM_PROMPT),
("human", feedback_synthesis.HUMAN_PROMPT),
]
).partial(format_instructions=parser.get_format_instructions())

callbacks = []
if core_client is not None:
callbacks.append(
CoreAiLogCallback(
core_client=core_client,
request_type="generate.feedback.synthesis",
default_model=settings.llm_pro_model,
)
)

llm = ChatOpenAI(
model=settings.llm_pro_model,
temperature=settings.llm_pro_temperature,
api_key=settings.llm_api_key or None,
base_url=settings.llm_base_url,
callbacks=callbacks,
)
return prompt | llm | parser


def _weighted_overall(pairs: list[tuple[float | None, float]]) -> float | None:
"""(score, weight) 중 score 가 있는 것만 가중평균. 전부 None 이면 None."""
present = [(s, w) for s, w in pairs if s is not None and w > 0]
Expand Down Expand Up @@ -283,8 +327,15 @@ def _dedup_keywords(keywords: list[str], cap: int = 8) -> list[str]:
class PanelFeedbackGenerator:
"""직군·논리·커뮤니케이션 평가위원을 병렬 호출 → 가중평균 종합. FeedbackGenerator 호환."""

def __init__(self, chain: Runnable, *, weights: tuple[float, float, float] = (0.5, 0.25, 0.25)) -> None:
def __init__(
self,
chain: Runnable,
*,
synthesis_chain: Runnable | None = None,
weights: tuple[float, float, float] = (0.5, 0.25, 0.25),
) -> None:
self._chain = chain
self._synthesis = synthesis_chain
self._w_tech, self._w_logic, self._w_comm = weights

async def generate(
Expand Down Expand Up @@ -353,18 +404,18 @@ async def generate(
]
)

note_items = [(spec.label, r.strength) for spec, _, r in domain_results]
note_items += [("논리", logic.strength), ("전달", comm.strength)]
strengths = _merge_notes(note_items)
weak_items = [(spec.label, r.weakness) for spec, _, r in domain_results]
weak_items += [("논리", logic.weakness), ("전달", comm.weakness)]
weaknesses = _merge_notes(weak_items)

all_keywords: list[str] = []
for _, _, r in domain_results:
all_keywords += r.keywords
all_keywords += logic.keywords + comm.keywords
keywords = _dedup_keywords(all_keywords)
# 기계적 병합(synthesis 미설정/실패 시 폴백용).
fb_strength_items = [(spec.label, r.strength) for spec, _, r in domain_results]
fb_strength_items += [("논리", logic.strength), ("전달", comm.strength)]
fb_strengths = _merge_notes(fb_strength_items)
fb_weak_items = [(spec.label, r.weakness) for spec, _, r in domain_results]
fb_weak_items += [("논리", logic.weakness), ("전달", comm.weakness)]
fb_weaknesses = _merge_notes(fb_weak_items)
fb_keywords = _dedup_keywords(
[k for _, _, r in domain_results for k in r.keywords]
+ logic.keywords
+ comm.keywords
)

breakdown = [
PanelBreakdownItem(
Expand All @@ -373,6 +424,8 @@ async def generate(
score=r.score,
strength=r.strength,
weakness=r.weakness,
detail=r.detail,
score_rationale=r.score_rationale,
)
for spec, _, r in domain_results
]
Expand All @@ -384,9 +437,37 @@ async def generate(
score=r.score,
strength=r.strength,
weakness=r.weakness,
detail=r.detail,
score_rationale=r.score_rationale,
)
)

# 종합 서술형 + 학습 방향(synthesis). 미설정/실패 시 기계적 병합으로 폴백.
strengths, weaknesses, keywords, study_plan = fb_strengths, fb_weaknesses, fb_keywords, []
if self._synthesis is not None:
panel_summary = "\n".join(
f"- {b.evaluator}({b.dimension}) "
f"{('%g점' % b.score) if b.score is not None else '점수없음'}: "
f"{(b.detail or '').strip() or (b.strength or '')}"
for b in breakdown
)
try:
syn = await self._synthesis.ainvoke(
{
"job_category": job_category,
"mode": mode,
"panel_summary": panel_summary,
"transcript": transcript,
}
)
if isinstance(syn, SynthesisResult):
strengths = syn.strengths_summary or fb_strengths
weaknesses = syn.weaknesses_summary or fb_weaknesses
keywords = _dedup_keywords(syn.improvement_keywords) or fb_keywords
study_plan = syn.study_plan or []
except Exception as exc: # noqa: BLE001
log.warning("feedback.synthesis.failed", error=str(exc))

return FeedbackResult(
overall_score=overall,
technical_accuracy=technical_accuracy,
Expand All @@ -395,5 +476,6 @@ async def generate(
strengths_summary=strengths,
weaknesses_summary=weaknesses,
improvement_keywords=keywords,
study_plan=study_plan,
panel_breakdown=breakdown,
)
3 changes: 3 additions & 0 deletions ai/src/ai_server/chain/prompts/feedback_panel.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,9 @@
"- 점수를 매기기 전에 강점/약점 근거를 먼저 정리한 뒤 산정한다(즉흥 점수 금지).\n"
"- strength/weakness 는 각각 한 줄(한국어, 구체적으로). keywords 는 이 축에서 보완할 "
"개선 키워드 0~4개(짧은 명사구).\n"
"- detail: 이 축에 대한 2~4문장 상세 평가. **답변의 구체적 부분을 인용/지목**하고 무엇이 "
"왜 좋았는지/아쉬웠는지 근거를 들어 서술한다(추상적 총평 금지).\n"
"- score_rationale: 그 점수를 준 핵심 근거(가점/감점 요인)를 한두 문장으로.\n"
"- 응답은 반드시 지정된 JSON 스키마를 따른다."
)

Expand Down
23 changes: 23 additions & 0 deletions ai/src/ai_server/chain/prompts/feedback_synthesis.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
# 멀티 면접관 패널 종합 — 평가위원들의 결과를 통합해 서술형 종합평 + 학습 방향 생성.

SYSTEM_PROMPT = (
"당신은 면접 평가 패널을 총괄하는 수석 면접관입니다. 여러 평가위원(직군 기술·논리·"
"전달 등)의 평가를 받아 지원자에게 줄 **통합 종합 피드백**을 작성하세요.\n"
"- 평가위원들의 관점을 단순 나열하지 말고 **하나의 일관된 서술**로 엮습니다.\n"
"- strengths_summary: 지원자의 강점을 3~5문장 한국어 문단으로. 어느 영역에서 어떻게 "
"드러났는지 구체적으로.\n"
"- weaknesses_summary: 보완점을 3~5문장 한국어 문단으로. 무엇이 왜 부족했고 어떤 방향으로 "
"개선해야 하는지.\n"
"- improvement_keywords: 다음 면접에서 보완할 키워드 5~10개(짧은 명사구).\n"
"- study_plan: 구체적 학습 방향/다음 단계 액션 아이템 3~6개. 각 항목은 '무엇을 어떻게'가 "
"드러나는 실행 문장(예: 'Redis 분산 락의 SETNX·TTL 옵션을 직접 구현해보며 원자성 보장 원리 정리').\n"
"- 평가위원 점수·근거에 모순되지 않게, 과장 없이 사실에 기반해 작성합니다.\n"
"- 응답은 반드시 지정된 JSON 스키마를 따릅니다."
)

HUMAN_PROMPT = (
"직군: {job_category} / 면접 모드: {mode}\n\n"
"=== 평가위원별 평가 결과 ===\n{panel_summary}\n\n"
"=== 면접 전사(참고) ===\n{transcript}\n\n"
"{format_instructions}"
)
1 change: 1 addition & 0 deletions ai/src/ai_server/messaging/consumers/feedback_consumer.py
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,7 @@ async def handle(self, message: AbstractIncomingMessage) -> None:
strengths_summary=result.strengths_summary,
weaknesses_summary=result.weaknesses_summary,
improvement_keywords=result.improvement_keywords,
study_plan=result.study_plan,
panel_breakdown=result.panel_breakdown,
report_s3_key=None,
)
Expand Down
5 changes: 4 additions & 1 deletion ai/src/ai_server/messaging/runner.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
)
from ai_server.chain.feedback_generation_chain import (
PanelFeedbackGenerator,
build_feedback_synthesis_chain,
build_panel_evaluator_chain,
)
from ai_server.chain.question_generation_chain import (
Expand Down Expand Up @@ -211,8 +212,10 @@ def __init__(self, settings: Settings) -> None:
)

# 종합 피드백 생성 (US-24) — 멀티 면접관 패널(직군·논리·커뮤 평가위원 병렬 → 가중평균)
# + 종합 서술형 평·학습 방향 synthesis.
feedback_generator = PanelFeedbackGenerator(
build_panel_evaluator_chain(settings, core_client=core_client)
build_panel_evaluator_chain(settings, core_client=core_client),
synthesis_chain=build_feedback_synthesis_chain(settings, core_client=core_client),
)
self._feedback_consumer = FeedbackConsumer(
generator=feedback_generator,
Expand Down
6 changes: 6 additions & 0 deletions ai/src/ai_server/model/messages/feedback.py
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,10 @@ class PanelBreakdownItem(BaseModel):
score: float | None = None
strength: str | None = None
weakness: str | None = None
# 구체적 근거·예시(답변 인용)를 담은 2~4문장 상세 평가.
detail: str | None = None
# 이 점수를 준 근거(감점/가점 요인) 간단 설명.
score_rationale: str | None = None


class FeedbackCallbackPayload(BaseModel):
Expand All @@ -89,6 +93,8 @@ class FeedbackCallbackPayload(BaseModel):
strengths_summary: str | None = None
weaknesses_summary: str | None = None
improvement_keywords: list[str] = Field(default_factory=list)
# 패널을 통합한 학습 방향/다음 단계 액션 아이템.
study_plan: list[str] = Field(default_factory=list)
# 평가위원별 분해(패널). 비어 있으면 단일/레거시 경로.
panel_breakdown: list[PanelBreakdownItem] = Field(default_factory=list)
report_s3_key: str | None = None
48 changes: 48 additions & 0 deletions ai/tests/test_feedback_panel.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,18 @@
from ai_server.chain.feedback_generation_chain import (
EvaluatorResult,
PanelFeedbackGenerator,
SynthesisResult,
)


class _FakeSynthesis:
def __init__(self, result: SynthesisResult):
self._result = result

async def ainvoke(self, _v):
return self._result


# 평가축(dimension_name) 으로 라우팅하는 가짜 체인.
TECH = "기술 정확도·깊이"
PERSONALITY = "인성·협업 역량"
Expand Down Expand Up @@ -123,6 +133,44 @@ async def test_multi_domain_weighted_by_question_counts():
assert r.overall_score == 62


@pytest.mark.asyncio
async def test_synthesis_narrative_study_plan_and_breakdown_detail():
syn = SynthesisResult(
strengths_summary="통합 강점 서술",
weaknesses_summary="통합 약점 서술",
improvement_keywords=["동시성"],
study_plan=["Redis 분산 락 SETNX/TTL 직접 구현"],
)
gen = PanelFeedbackGenerator(
_FakeChain({
TECH: EvaluatorResult(score=80, strength="s", detail="상세 평가", score_rationale="근거"),
LOGIC: EvaluatorResult(score=60),
COMM: EvaluatorResult(score=50),
}),
synthesis_chain=_FakeSynthesis(syn),
)
r = await _run_gen(gen)
# 종합 서술형(synthesis 결과로 대체)
assert r.strengths_summary == "통합 강점 서술"
assert r.weaknesses_summary == "통합 약점 서술"
assert r.improvement_keywords == ["동시성"]
assert r.study_plan == ["Redis 분산 락 SETNX/TTL 직접 구현"]
# 평가위원 분해에 detail/score_rationale 포함
assert r.panel_breakdown[0].detail == "상세 평가"
assert r.panel_breakdown[0].score_rationale == "근거"


async def _run_gen(gen):
return await gen.generate(
job_category="BACKEND",
mode="TECHNICAL",
total_question_count=3,
end_reason="x",
transcript="t",
rag_context="(none)",
)


@pytest.mark.asyncio
async def test_keyword_dedup():
r = await _run(
Expand Down
13 changes: 13 additions & 0 deletions backend/openapi.json
Original file line number Diff line number Diff line change
Expand Up @@ -3348,6 +3348,13 @@
"$ref" : "#/components/schemas/PanelBreakdownItem"
}
},
"studyPlan" : {
"type" : "array",
"description" : "Study plan / next-step action items synthesized from the panel.",
"items" : {
"type" : "string"
}
},
"reportFilePath" : {
"type" : "string",
"description" : "Stored report path when AI generates a detailed learning guide/report."
Expand Down Expand Up @@ -3376,6 +3383,12 @@
},
"weakness" : {
"type" : "string"
},
"detail" : {
"type" : "string"
},
"scoreRationale" : {
"type" : "string"
}
}
},
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,7 @@ public void apply(FeedbackCallbackEnvelope envelope) {
payload.weaknessesSummary(),
keywordsToJson(payload.improvementKeywords()),
breakdownToJson(payload.panelBreakdown()),
keywordsToJson(payload.studyPlan()),
payload.reportS3Key()
);
try {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ public record FeedbackCallbackPayload(
String strengthsSummary,
String weaknessesSummary,
List<String> improvementKeywords,
List<String> studyPlan,
List<PanelBreakdownItem> panelBreakdown,
String reportS3Key
) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ public record FeedbackResult(
String weaknessesSummary,
List<String> improvementKeywords,
List<PanelBreakdownItem> panelBreakdown,
List<String> studyPlan,
String reportFilePath,
Instant createdAt
) {
Expand All @@ -37,6 +38,7 @@ public static FeedbackResult of(SessionFeedback f) {
f.getWeaknessesSummary(),
parseKeywords(f.getImprovementKeywords()),
parseBreakdown(f.getPanelBreakdown()),
parseKeywords(f.getStudyPlan()),
f.getReportFilePath(),
f.getCreatedAt()
);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ public record PanelBreakdownItem(
String dimension,
Double score,
String strength,
String weakness
String weakness,
String detail, // 근거·예시 포함 상세 평가
String scoreRationale // 점수 근거
) {
}
Loading