Skip to content

Commit e7160e5

Browse files
i3monthsclaude
andcommitted
feat(ai): 인성·자소서 평가위원 추가 (회의적 리뷰 D)
임원면접 품질 감사에서 3/3로 확인된 갭: PERSONALITY/INTEGRATED 면접의 인성·자소서 답변이 기술 축(기술/논리/전달)으로만 채점되어 평가에서 빠짐. - chain/prompts/personality_evaluation.py(신규): 자소서 경험의 소유·진정성, 지원동기· 가치관·성장 서사 일관성, 표준 인성 주제 답변의 구체성·STAR를 평가. - feedback_generation_chain: PERSONALITY_EVALUATOR_LABEL="인성·자소서", PersonalityEvaluator Protocol + LlmPersonalityEvaluator + build 체인(첫인상 평가위원 미러). - feedback_consumer: _evaluate_personality(req) — mode∈{PERSONALITY,INTEGRATED} + 실질 경험형 답변 있을 때만. 첫인상·직무적합도와 동일하게 panel_breakdown 에 가산(비집계) → 종합 점수(overall/기술/논리/전달) 불변(저위험). BEHAVIORAL 우선, 없으면 폴백/스킵. - runner: LlmPersonalityEvaluator 주입. - 테스트 3건(패널 추가/미해당 스킵/overall_score 불변). 전체 308 passed, flake8 clean. 프론트는 비-SPECIAL 라벨을 '면접관 패널 평가'로 렌더하므로 자동 노출(무변경). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent d777f45 commit e7160e5

5 files changed

Lines changed: 301 additions & 18 deletions

File tree

ai/src/ai_server/chain/feedback_generation_chain.py

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
feedback_panel,
1717
feedback_synthesis,
1818
job_fit_evaluation,
19+
personality_evaluation,
1920
self_intro_evaluation,
2021
)
2122
from ai_server.config.settings import Settings
@@ -351,6 +352,79 @@ def build_self_intro_evaluation_chain(
351352
return prompt | llm | parser
352353

353354

355+
PERSONALITY_EVALUATOR_LABEL = "인성·자소서"
356+
PERSONALITY_DIMENSION = "자소서 소유·인성 답변 구체성/STAR"
357+
358+
359+
def build_personality_evaluation_chain(
360+
settings: Settings, core_client: CoreClient | None = None
361+
) -> Runnable:
362+
"""인성·자소서 답변(경험형·BEHAVIORAL)을 기술 축과 별개로 평가하는 경량 체인(Flash)."""
363+
from langchain_openai import ChatOpenAI
364+
365+
parser = PydanticOutputParser(pydantic_object=EvaluatorResult)
366+
prompt = ChatPromptTemplate.from_messages(
367+
[
368+
("system", personality_evaluation.SYSTEM_PROMPT),
369+
("human", personality_evaluation.HUMAN_PROMPT),
370+
]
371+
).partial(format_instructions=parser.get_format_instructions())
372+
373+
callbacks = []
374+
if core_client is not None:
375+
callbacks.append(
376+
CoreAiLogCallback(
377+
core_client=core_client,
378+
request_type="generate.feedback.personality",
379+
default_model=settings.llm_flash_model,
380+
)
381+
)
382+
383+
llm = ChatOpenAI(
384+
model=settings.llm_flash_model,
385+
temperature=settings.llm_flash_temperature,
386+
api_key=settings.llm_api_key or None,
387+
base_url=settings.llm_base_url,
388+
callbacks=callbacks,
389+
)
390+
return prompt | llm | parser
391+
392+
393+
class PersonalityEvaluator(Protocol):
394+
async def evaluate(
395+
self,
396+
*,
397+
job_category: str,
398+
mode: str,
399+
transcript: str,
400+
) -> EvaluatorResult: ...
401+
402+
403+
class LlmPersonalityEvaluator:
404+
def __init__(self, chain: Runnable) -> None:
405+
self._chain = chain
406+
407+
async def evaluate(
408+
self,
409+
*,
410+
job_category: str,
411+
mode: str,
412+
transcript: str,
413+
) -> EvaluatorResult:
414+
result = await self._chain.ainvoke(
415+
{
416+
"job_category": job_category,
417+
"mode": mode,
418+
"transcript": transcript or "(빈 답변)",
419+
}
420+
)
421+
if not isinstance(result, EvaluatorResult):
422+
raise TypeError(
423+
f"chain returned {type(result).__name__}, expected EvaluatorResult"
424+
)
425+
return result
426+
427+
354428
class SelfIntroEvaluator(Protocol):
355429
async def evaluate(
356430
self,
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
# 인성·자소서 평가 프롬프트 (PERSONALITY/INTEGRATED 모드).
2+
# 임원면접식 인성/자소서 답변을 기술 축과 별개로 평가한다.
3+
# 결과는 패널의 '인성·자소서' 평가위원 한 명으로 표시되며, 종합 점수 집계에는 포함되지 않는다.
4+
5+
SYSTEM_PROMPT = (
6+
"당신은 IT 직군 임원면접의 **인성·자소서 평가위원**입니다. 지원자의 인성·경험형(자소서 기반) "
7+
"질문과 답변만 보고, 기술 정확성이 아니라 **인성·태도·자소서 소유도**를 평가합니다.\n"
8+
"- 평가 관점(오직 아래만 봅니다. 기술 정확성 평가 금지):\n"
9+
" · 자소서 소유·진정성: 자소서에 쓴 경험을 본인이 진짜 겪고 이해하는가(구체적 상황·본인 역할·"
10+
"결과가 일관되게 드러나는가, 빌린 서사·모호한 총평이 아닌가).\n"
11+
" · 서사 일관성: 지원동기·가치관·성장 스토리가 서로 모순 없이 연결되는가.\n"
12+
" · 표준 인성 주제(리더십·갈등해결·성공/실패·장단점·지원동기·비전 등) 답변의 **구체성**과 "
13+
"**STAR(상황·과제·행동·결과)** 충실도 — 추상적 다짐이 아니라 구체 사례·본인 행동·결과가 있는가.\n"
14+
" · 협업·태도: 갈등·실패·관계 상황에서 드러나는 태도가 성숙한가.\n"
15+
"- 점수는 0~100 정수. 인성/자소서 답변이 없거나 너무 부실해 판단 불가하면 null.\n"
16+
"- 점수 앵커: 90~100 경험 소유·구체성·일관성 모두 뛰어남 / 70~89 무난하나 일부 추상적 / "
17+
"50~69 답은 하나 구체 사례·본인 행동이 약함 / 30~49 상투적이거나 모순 / 0~29 거의 무응답.\n"
18+
"- strength/weakness 는 각각 한 줄(한국어, 구체적으로). keywords 는 보완 키워드 0~3개(짧은 명사구).\n"
19+
"- detail: 답변의 구체적 부분을 인용/지목하며 2~4문장으로 서술(추상적 총평 금지).\n"
20+
"- score_rationale: 그 점수를 준 핵심 근거를 한두 문장으로.\n"
21+
"- 응답은 반드시 지정된 JSON 스키마를 따른다."
22+
)
23+
24+
HUMAN_PROMPT = (
25+
"지원 직군: {job_category} / 면접 모드: {mode}\n\n"
26+
"=== 인성·자소서 관련 질문과 답변 ===\n{transcript}\n\n"
27+
"{format_instructions}"
28+
)

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

Lines changed: 80 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,8 @@
99
from ai_server.chain.feedback_generation_chain import (
1010
JOB_FIT_DIMENSION,
1111
JOB_FIT_EVALUATOR_LABEL,
12+
PERSONALITY_DIMENSION,
13+
PERSONALITY_EVALUATOR_LABEL,
1214
ROLE_UNDERSTANDING_DIMENSION,
1315
ROLE_UNDERSTANDING_LABEL,
1416
SELF_INTRO_DIMENSION,
@@ -17,6 +19,7 @@
1719
EvaluatorResult,
1820
FeedbackGenerator,
1921
JobFitEvaluator,
22+
PersonalityEvaluator,
2023
SelfIntroEvaluator,
2124
)
2225
from ai_server.core.client import CoreClient
@@ -37,6 +40,9 @@
3740

3841
_SELF_INTRO_CATEGORY = "SELF_INTRODUCTION"
3942
_JOB_TAILORED_MODE = "JOB_TAILORED"
43+
_BEHAVIORAL_CATEGORY = "BEHAVIORAL"
44+
# 인성·자소서 평가위원이 도는 모드. 이 모드에서만 인성/경험형 답변을 별도 축으로 평가한다.
45+
_PERSONALITY_MODES = {"PERSONALITY", "INTEGRATED"}
4046
# 답변별 코칭 RAG 는 해당 화제에 국한된 소수 청크만. 세션 공용 top_k 보다 작게.
4147
_COACHING_RAG_TOP_K = 3
4248
# 세션 RAG 질의 상한(문자). 답변 이어붙임이 임베딩 입력 한도를 넘지 않게.
@@ -66,6 +72,7 @@ def __init__(
6672
rag_top_k: int = 5,
6773
self_intro_evaluator: SelfIntroEvaluator | None = None,
6874
job_fit_evaluator: JobFitEvaluator | None = None,
75+
personality_evaluator: PersonalityEvaluator | None = None,
6976
answer_coach: AnswerCoach | None = None,
7077
coaching_max_answers: int = 30,
7178
coaching_concurrency: int = 5,
@@ -79,6 +86,7 @@ def __init__(
7986
self._rag_top_k = rag_top_k
8087
self._self_intro_evaluator = self_intro_evaluator
8188
self._job_fit_evaluator = job_fit_evaluator
89+
self._personality_evaluator = personality_evaluator
8290
self._answer_coach = answer_coach
8391
self._coaching_max_answers = coaching_max_answers
8492
self._coaching_concurrency = max(1, coaching_concurrency)
@@ -124,26 +132,31 @@ async def handle(self, message: AbstractIncomingMessage) -> None:
124132

125133
# 종합 피드백 + 자기소개 첫인상 + 직무 적합도(직무 맞춤 모드)를 병렬 실행.
126134
# 첫인상·직무 적합도는 종합 점수(overall)에 미포함 — generator 가 모른 채 계산한 뒤 표시용으로 덧붙인다.
127-
result, self_intro_item, job_fit_items, answer_coaching = (
128-
await asyncio.gather(
129-
self._generator.generate(
130-
job_category=req.job_category,
131-
mode=req.mode,
132-
total_question_count=req.total_question_count,
133-
end_reason=req.end_reason,
134-
transcript=transcript,
135-
score_basis=score_basis,
136-
rag_context=rag_context,
137-
voice_analysis_summary=voice_analysis_summary,
138-
domain_question_counts=req.domain_question_counts,
139-
),
140-
self._evaluate_self_intro(req, voice_analysis_summary),
141-
self._evaluate_job_fit(req, transcript, rag_context),
142-
self._coach_answers(req),
143-
)
135+
(
136+
result,
137+
self_intro_item,
138+
job_fit_items,
139+
personality_item,
140+
answer_coaching,
141+
) = await asyncio.gather(
142+
self._generator.generate(
143+
job_category=req.job_category,
144+
mode=req.mode,
145+
total_question_count=req.total_question_count,
146+
end_reason=req.end_reason,
147+
transcript=transcript,
148+
score_basis=score_basis,
149+
rag_context=rag_context,
150+
voice_analysis_summary=voice_analysis_summary,
151+
domain_question_counts=req.domain_question_counts,
152+
),
153+
self._evaluate_self_intro(req, voice_analysis_summary),
154+
self._evaluate_job_fit(req, transcript, rag_context),
155+
self._evaluate_personality(req),
156+
self._coach_answers(req),
144157
)
145158
# 빈 평가위원 항목(점수·내용 모두 없음)은 표시하지 않는다 — LLM 부분 응답이 빈 패널로 새는 것 방지.
146-
extras = [self_intro_item, *job_fit_items]
159+
extras = [self_intro_item, *job_fit_items, personality_item]
147160
result.panel_breakdown.extend(
148161
e for e in extras if e is not None and _panel_has_content(e)
149162
)
@@ -253,6 +266,42 @@ async def _evaluate_job_fit(
253266
),
254267
]
255268

269+
async def _evaluate_personality(
270+
self, req: GenerateFeedbackRequest
271+
) -> PanelBreakdownItem | None:
272+
"""PERSONALITY·INTEGRATED 모드에서 인성·자소서(경험형) 답변을 기술 축과 별개로 평가.
273+
그 외 모드/대상 없음/실패는 None(패널 미표시). 종합 점수엔 미포함."""
274+
if self._personality_evaluator is None:
275+
return None
276+
if (req.mode or "") not in _PERSONALITY_MODES:
277+
return None
278+
pairs = _collect_coachable_pairs(req.messages) # 자기소개·빈·짧은확인 제외
279+
behavioral = [
280+
(q, a) for (q, a) in pairs if (q.category or "") == _BEHAVIORAL_CATEGORY
281+
]
282+
# PERSONALITY 인데 카테고리 태깅이 없으면 비자기소개 답변 전체로 폴백. INTEGRATED 는
283+
# BEHAVIORAL 이 하나도 없으면 평가할 인성 답변이 없는 것으로 보고 건너뛴다.
284+
selected = behavioral or (
285+
pairs if (req.mode or "") == "PERSONALITY" else []
286+
)
287+
if not selected:
288+
return None
289+
transcript = _qa_transcript(selected)
290+
try:
291+
ev = await self._personality_evaluator.evaluate(
292+
job_category=req.job_category,
293+
mode=req.mode,
294+
transcript=transcript,
295+
)
296+
except Exception as exc: # noqa: BLE001
297+
log.warning(
298+
"feedback.personality.failed",
299+
error=str(exc),
300+
session_id=req.session_id,
301+
)
302+
return None
303+
return _to_panel_item(PERSONALITY_EVALUATOR_LABEL, PERSONALITY_DIMENSION, ev)
304+
256305
async def _coach_answers(
257306
self, req: GenerateFeedbackRequest
258307
) -> list[AnswerCoachingItem]:
@@ -427,6 +476,19 @@ def _panel_has_content(item: PanelBreakdownItem) -> bool:
427476
)
428477

429478

479+
def _qa_transcript(
480+
pairs: list[tuple[FeedbackMessageItem, FeedbackMessageItem]],
481+
) -> str:
482+
"""(질문, 답변) 쌍 목록을 인성 평가위원 입력용 전사 문자열로."""
483+
lines: list[str] = []
484+
for q, a in pairs:
485+
lines.append(f"면접관: {q.content}")
486+
if q.expected_signal:
487+
lines.append(f" └ 기대 신호: {q.expected_signal}")
488+
lines.append(f"지원자: {a.content}")
489+
return "\n".join(lines)
490+
491+
430492
def _to_panel_item(
431493
label: str, dimension: str, ev: EvaluatorResult
432494
) -> PanelBreakdownItem:

ai/src/ai_server/messaging/runner.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,12 +23,14 @@
2323
from ai_server.chain.feedback_generation_chain import (
2424
LlmAnswerCoach,
2525
LlmJobFitEvaluator,
26+
LlmPersonalityEvaluator,
2627
LlmSelfIntroEvaluator,
2728
PanelFeedbackGenerator,
2829
build_answer_coaching_chain,
2930
build_feedback_synthesis_chain,
3031
build_job_fit_evaluation_chain,
3132
build_panel_evaluator_chain,
33+
build_personality_evaluation_chain,
3234
build_self_intro_evaluation_chain,
3335
)
3436
from ai_server.chain.question_generation_chain import (
@@ -261,6 +263,10 @@ def __init__(self, settings: Settings) -> None:
261263
job_fit_evaluator=LlmJobFitEvaluator(
262264
build_job_fit_evaluation_chain(settings, core_client=core_client)
263265
),
266+
# 인성·자소서 평가(Flash). PERSONALITY·INTEGRATED 에서만 동작, 종합 점수엔 미포함.
267+
personality_evaluator=LlmPersonalityEvaluator(
268+
build_personality_evaluation_chain(settings, core_client=core_client)
269+
),
264270
# 질문별 복기(Flash, 답변 수만큼 병렬). 모범 답안+리라이트+한 줄 코칭.
265271
answer_coach=LlmAnswerCoach(
266272
build_answer_coaching_chain(settings, core_client=core_client)

0 commit comments

Comments
 (0)