|
1 | 1 | from __future__ import annotations |
2 | 2 |
|
| 3 | +import asyncio |
| 4 | +from dataclasses import dataclass |
3 | 5 | from typing import Protocol |
4 | 6 |
|
| 7 | +import structlog |
5 | 8 | from langchain_core.output_parsers import PydanticOutputParser |
6 | 9 | from langchain_core.prompts import ChatPromptTemplate |
7 | 10 | from langchain_core.runnables import Runnable |
8 | 11 | from pydantic import BaseModel, Field |
9 | 12 |
|
10 | 13 | from ai_server.chain.prompts.feedback_generation import HUMAN_PROMPT, SYSTEM_PROMPT |
| 14 | +from ai_server.chain.prompts import feedback_panel |
11 | 15 | from ai_server.config.settings import Settings |
12 | 16 | from ai_server.core.client import CoreClient |
13 | 17 | from ai_server.observability.llm_logging_callback import CoreAiLogCallback |
14 | 18 |
|
| 19 | +log = structlog.get_logger(__name__) |
| 20 | + |
15 | 21 |
|
16 | 22 | class FeedbackResult(BaseModel): |
17 | 23 | overall_score: float | None = Field(None, description="0~100") |
@@ -105,3 +111,221 @@ def build_feedback_generation_chain( |
105 | 111 | callbacks=callbacks, |
106 | 112 | ) |
107 | 113 | 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 | + ) |
0 commit comments