Skip to content

Commit 026efa4

Browse files
i3monthsclaude
andcommitted
feat(ai): 피드백/코칭 RAG 재질의 (L8) — 마지막답변 편향·코칭 날조 완화
면접 품질 감사 L8. 기존엔 _build_rag_context 가 '마지막 INTERVIEWEE 답변' 하나만 검색해 그 컨텍스트를 세션 전체 채점(종합·패널·직무적합도) + 모든 답변 코칭에 공유 → 마지막 화제로 correctness 편향, 코칭 모범답안이 무관 청크로 경험 날조. - 검색 로직을 _retrieve_context(query, docIds, top_k)로 공용화. - 세션 채점: _session_rag_query(모든 실질 답변, 짧은 확인 제외, 2000자 상한)로 질의 → 마지막 답변 편향 제거(전부 확인형이면 마지막 답변 폴백). - 코칭: _coach_answers 가 (질문+답변) 쌍마다 개별 RAG(top_k=3)로 검색 → 모범답안이 해당 화제 근거에만 걸림. 동시성 세마포어는 그대로. - 실패/무결과/임베더 없음은 기존과 동일하게 '(none)' 폴백. - _session_rag_query 단위 테스트 5건 추가. 전체 305 passed, flake8 clean. 주: 채점/코칭 근거 분포가 바뀌는 변경 — 실제 품질은 라이브 before/after 로 확인 권장. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 50af35c commit 026efa4

2 files changed

Lines changed: 112 additions & 16 deletions

File tree

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

Lines changed: 55 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,10 @@
3737

3838
_SELF_INTRO_CATEGORY = "SELF_INTRODUCTION"
3939
_JOB_TAILORED_MODE = "JOB_TAILORED"
40+
# 답변별 코칭 RAG 는 해당 화제에 국한된 소수 청크만. 세션 공용 top_k 보다 작게.
41+
_COACHING_RAG_TOP_K = 3
42+
# 세션 RAG 질의 상한(문자). 답변 이어붙임이 임베딩 입력 한도를 넘지 않게.
43+
_SESSION_RAG_QUERY_MAX_CHARS = 2000
4044

4145

4246
class FeedbackConsumer:
@@ -135,7 +139,7 @@ async def handle(self, message: AbstractIncomingMessage) -> None:
135139
),
136140
self._evaluate_self_intro(req, voice_analysis_summary),
137141
self._evaluate_job_fit(req, transcript, rag_context),
138-
self._coach_answers(req, rag_context),
142+
self._coach_answers(req),
139143
)
140144
)
141145
# 빈 평가위원 항목(점수·내용 모두 없음)은 표시하지 않는다 — LLM 부분 응답이 빈 패널로 새는 것 방지.
@@ -250,9 +254,13 @@ async def _evaluate_job_fit(
250254
]
251255

252256
async def _coach_answers(
253-
self, req: GenerateFeedbackRequest, rag_context: str
257+
self, req: GenerateFeedbackRequest
254258
) -> list[AnswerCoachingItem]:
255-
"""자기소개 제외 답변마다 모범 답안·리라이트·코칭을 병렬 생성 → 메시지별 복기 리스트."""
259+
"""자기소개 제외 답변마다 모범 답안·리라이트·코칭을 병렬 생성 → 메시지별 복기 리스트.
260+
261+
코칭 근거(RAG)는 **답변별로 개별 검색**한다 — 세션 공용 컨텍스트를 쓰면 마지막 답변
262+
화제의 청크로 다른 답변의 모범답안이 오염(경험 날조)되므로, (질문+답변) 쌍으로 질의한다.
263+
"""
256264
if self._answer_coach is None:
257265
return []
258266
pairs = _collect_coachable_pairs(req.messages)
@@ -275,14 +283,21 @@ async def _one(
275283
) -> AnswerCoachingItem | None:
276284
try:
277285
async with sem:
286+
# 이 (질문,답변)에 국한된 근거만 검색해 모범답안이 다른 화제로 새지 않게.
287+
pair_context = await self._retrieve_context(
288+
f"{question.content}\n{answer.content}",
289+
req.context_document_ids,
290+
_COACHING_RAG_TOP_K,
291+
req.session_id,
292+
)
278293
res = await self._answer_coach.coach(
279294
job_category=req.job_category,
280295
mode=req.mode,
281296
target_role=target_role,
282297
question=question.content,
283298
expected_signal=question.expected_signal or "",
284299
answer=answer.content,
285-
rag_context=rag_context,
300+
rag_context=pair_context,
286301
)
287302
except Exception as exc: # noqa: BLE001
288303
log.warning(
@@ -303,26 +318,31 @@ async def _one(
303318
return [it for it in items if it is not None]
304319

305320
async def _build_rag_context(self, req: GenerateFeedbackRequest) -> str:
306-
if not self._embedder or not req.context_document_ids:
307-
return "(none)"
308-
last_answer = next(
309-
(m.content for m in reversed(req.messages) if m.role == "INTERVIEWEE"),
310-
None,
321+
# 세션 전체 채점(종합·패널·직무 적합도)용 컨텍스트. 마지막 답변 하나만 쓰면
322+
# 그 화제로 근거가 편향되므로, 세션의 모든 실질 답변(짧은 확인 제외)을 질의로 삼는다.
323+
query = _session_rag_query(req.messages)
324+
return await self._retrieve_context(
325+
query, req.context_document_ids, self._rag_top_k, req.session_id
311326
)
312-
if not last_answer:
327+
328+
async def _retrieve_context(
329+
self, query_text: str, document_ids: list[int], top_k: int, session_id: int
330+
) -> str:
331+
"""query_text 로 pgvector 검색 → 청크를 컨텍스트 문자열로. 실패/무결과는 '(none)'."""
332+
if not self._embedder or not document_ids or not query_text.strip():
313333
return "(none)"
314334
try:
315335
query_vec = (
316-
await self._embedder.embed([last_answer], task_type="RETRIEVAL_QUERY")
336+
await self._embedder.embed([query_text], task_type="RETRIEVAL_QUERY")
317337
)[0]
318338
hits = await self._core.search_embeddings(
319339
query_embedding=query_vec,
320-
query_text=last_answer,
321-
document_ids=req.context_document_ids,
322-
top_k=self._rag_top_k,
340+
query_text=query_text,
341+
document_ids=document_ids,
342+
top_k=top_k,
323343
)
324-
except Exception as exc:
325-
log.warn("feedback.rag.failed", error=str(exc), session_id=req.session_id)
344+
except Exception as exc: # noqa: BLE001
345+
log.warn("feedback.rag.failed", error=str(exc), session_id=session_id)
326346
return "(none)"
327347
if not hits:
328348
return "(none)"
@@ -331,6 +351,25 @@ async def _build_rag_context(self, req: GenerateFeedbackRequest) -> str:
331351
)
332352

333353

354+
def _session_rag_query(messages: list[FeedbackMessageItem]) -> str:
355+
"""세션 채점 RAG 검색 질의 — 실질 INTERVIEWEE 답변(짧은 확인 제외)을 이어붙여 상한 절단.
356+
마지막 답변 하나만 쓰던 편향을 없앤다. 전부 비면(레거시/전부 확인형) 마지막 답변 폴백."""
357+
answers = [
358+
(m.content or "").strip()
359+
for m in messages
360+
if m.role == "INTERVIEWEE"
361+
and (m.content or "").strip()
362+
and not _is_short_confirmation(m.content)
363+
]
364+
joined = "\n".join(answers)
365+
if not joined:
366+
last = next(
367+
(m.content for m in reversed(messages) if m.role == "INTERVIEWEE"), None
368+
)
369+
joined = (last or "").strip()
370+
return joined[:_SESSION_RAG_QUERY_MAX_CHARS]
371+
372+
334373
def _collect_coachable_pairs(
335374
messages: list[FeedbackMessageItem],
336375
) -> list[tuple[FeedbackMessageItem, FeedbackMessageItem]]:
Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
from __future__ import annotations
2+
3+
from ai_server.messaging.consumers.feedback_consumer import _session_rag_query
4+
from ai_server.model.messages.feedback import FeedbackMessageItem
5+
6+
7+
def _msg(mid: int, role: str, content: str, seq: int | None = None) -> FeedbackMessageItem:
8+
return FeedbackMessageItem(id=mid, sequence_number=seq or mid, role=role, content=content)
9+
10+
11+
def test_joins_substantive_answers_excludes_short_confirmations() -> None:
12+
msgs = [
13+
_msg(1, "INTERVIEWER", "인덱스를 어떻게 튜닝했나요?"),
14+
_msg(2, "INTERVIEWEE", "복합 인덱스를 user_id, created_at 으로 잡아 풀스캔을 제거했습니다."),
15+
_msg(3, "INTERVIEWER", "그럼 커버링 인덱스였나요?"),
16+
_msg(4, "INTERVIEWEE", "네 맞습니다."), # 짧은 확인 → 제외
17+
_msg(5, "INTERVIEWER", "캐시는요?"),
18+
_msg(6, "INTERVIEWEE", "Redis 로 조회 캐시를 붙여 p95 를 40ms 로 낮췄습니다."),
19+
]
20+
q = _session_rag_query(msgs)
21+
assert "복합 인덱스" in q
22+
assert "Redis" in q
23+
assert "네 맞습니다" not in q # 짧은 확인 답변은 질의에서 빠진다
24+
25+
26+
def test_not_only_last_answer() -> None:
27+
# 마지막 답변 하나만 쓰던 편향 제거 — 앞선 실질 답변도 질의에 포함.
28+
msgs = [
29+
_msg(1, "INTERVIEWER", "Q1"),
30+
_msg(2, "INTERVIEWEE", "쿼리 튜닝으로 75% 단축했습니다."),
31+
_msg(3, "INTERVIEWER", "Q2"),
32+
_msg(4, "INTERVIEWEE", "마지막으로 모니터링을 붙였습니다."),
33+
]
34+
q = _session_rag_query(msgs)
35+
assert "쿼리 튜닝" in q and "모니터링" in q
36+
37+
38+
def test_all_short_falls_back_to_last_answer() -> None:
39+
msgs = [
40+
_msg(1, "INTERVIEWER", "Q"),
41+
_msg(2, "INTERVIEWEE", "네 맞습니다."),
42+
_msg(3, "INTERVIEWER", "Q2"),
43+
_msg(4, "INTERVIEWEE", "아니요."),
44+
]
45+
q = _session_rag_query(msgs)
46+
assert q == "아니요." # 전부 확인형이면 마지막 답변 폴백
47+
48+
49+
def test_no_interviewee_is_empty() -> None:
50+
msgs = [_msg(1, "INTERVIEWER", "Q"), _msg(2, "SYSTEM", "S")]
51+
assert _session_rag_query(msgs) == ""
52+
53+
54+
def test_caps_length() -> None:
55+
long = "가" * 5000
56+
msgs = [_msg(1, "INTERVIEWEE", long)]
57+
assert len(_session_rag_query(msgs)) == 2000

0 commit comments

Comments
 (0)