diff --git a/ai/CLAUDE.md b/ai/CLAUDE.md index e6d6afbf..a527f714 100644 --- a/ai/CLAUDE.md +++ b/ai/CLAUDE.md @@ -345,6 +345,7 @@ docker run --env-file .env -p 8000:8000 stackup-ai (직무가 무엇을 하는 자리인지 이해·지원동기). 두 축을 `panelBreakdown` 의 `evaluator="직무 적합도"`· `evaluator="직무 이해도"` 항목으로 append(첫인상과 같은 병렬·미집계 메커니즘). 그 외 모드/빈 JD/실패는 건너뜀. - **꼬리질문 토큰 스트리밍 본 구현**: followup 출력을 `{json}` 구분자 포맷으로 바꾸고(`chain/prompts/followup_generation.py`), `StreamingFollowupGenerator`(`astream`)가 `` 토큰만 `SessionRealtimeNotifier`(`messaging/session_notify.py`)로 `SESSION_MESSAGE_DELTA` 발행(`stackup.realtime`/`realtime.session.notify`, Core 우회). `DONT_KNOW` 면 델타 미발행. 종료 후 `parse_followup_result` 로 검증해 기존 `callback.questions(FOLLOWUP, followupMessageId)` 발행. 와이어링은 `messaging/runner.py`(분석 진행 publisher 재사용). +- **짧은 확인 질문/답변 커버 본 구현**: followup 프롬프트(`chain/prompts/followup_generation.py`)가 매 턴 깊이 파기 대신 "그럼 OO 하신 건가요?" 같은 **짧은 확인형 질문**도 던지도록 안내하고, 직전 답변이 확인형 단답('네 맞습니다'·'아뇨 그건 아닙니다')이면 specificity/logic/correctness=null·structure=NONE 으로 두어 짧다는 이유로 감점하지 않도록 지시. 피드백 단계에선 `feedback_consumer._is_short_confirmation`(확인/부정 표현으로 시작 + 짧은 길이) 으로 그런 단답을 **질문별 복기(코칭) 대상에서 제외** — '네 맞습니다'에 모범답안/리라이트가 붙지 않게 한다(결정론적). 출력 포맷·콜백 스키마 무변경. - **문장 단위 TTS 본 구현 (Part B)**: followup consumer 스트림 루프가 `chain/sentence_split.next_sentences` 로 문장 경계를 잡아, 문장마다 `TtsProvider` 인라인 합성(`asyncio.create_task` 백그라운드, 텍스트 델타 비차단)→S3 `interview/tts/{sid}/{mid}/seg-{seq}.{ext}` PUT→`SessionRealtimeNotifier.emit_audio`(`SESSION_MESSAGE_AUDIO`). 콜백 전 `gather` 로 수거. 라이브 세그먼트는 휘발성(DB 미기록). - **임베딩 본 구현** (`rag/`): `MarkdownChunker` + `GeminiEmbeddingProvider` (1536d, `gemini-embedding-001`). 운영/개발 default 는 gemini, 테스트는 `MockEmbeddingProvider`. diff --git a/ai/src/ai_server/chain/prompts/followup_generation.py b/ai/src/ai_server/chain/prompts/followup_generation.py index 96a34f4f..66ed35ac 100644 --- a/ai/src/ai_server/chain/prompts/followup_generation.py +++ b/ai/src/ai_server/chain/prompts/followup_generation.py @@ -21,6 +21,9 @@ "correctness 의심→자료와의 불일치 확인).\n" "- 꼬리질문은 **한 문장으로 간결하게**(대략 60자 이내). 장황한 배경 설명 없이 " "핵심만 묻고, 면접관이 입으로 말할 길이여야 합니다. CLARIFICATION 재설명도 짧고 쉽게.\n" + "- 매번 깊이 파고들 필요는 없습니다. 지원자가 특정 사실/선택을 말했을 때는 " + "'그럼 OO 하신 건가요?' 같은 **짧은 확인형(예/아니오) 질문**으로 사실관계를 짚는 것도 자연스럽습니다. " + "다만 매 턴 확인형만 반복하지 말고 대체로는 깊이 있는 질문을 유지하세요.\n" "- '이미 나눈 대화'에서 다룬 내용을 그대로 반복하지 말고 새로운 각도로 파고드세요.\n" "- answer_intent 로 답변 의도를 분류하세요:\n" " - DONT_KNOW: '모르겠습니다/잘 모릅니다/패스' 등 사실상 답을 못 한 경우.\n" @@ -28,6 +31,9 @@ "이때 followup_question 에는 새 꼬리질문이 아니라 **직전 질문을 더 쉽고 구체적으로 다시 설명한 문장**을 담으세요.\n" " - NORMAL: 그 외 정상 답변. followup_question 은 평소처럼 가장 약한 축을 파는 꼬리질문.\n" " - DONT_KNOW/CLARIFICATION 이면 채점(specificity/logic/correctness)은 보수적으로(낮게/null) 둡니다.\n" + "- 직전 답변이 확인형 질문에 대한 **짧은 단답·정정**(예: '네 맞습니다', '아뇨 그건 아닙니다')이라 " + "평가할 내용이 거의 없으면 specificity/logic/correctness 를 null, structure 를 NONE 으로 두고 " + "**짧다는 이유만으로 감점하지 마세요**(확인형 질문에 대한 단답은 정상입니다).\n" "- 출력은 정확히 다음 3개 블록을 이 순서로만 작성합니다(설명·코드펜스 금지):\n" " NORMAL 또는 DONT_KNOW 또는 CLARIFICATION\n" " 지원자에게 보여줄 한국어 질문 1개(또는 CLARIFICATION 시 재설명 문장)\n" diff --git a/ai/src/ai_server/messaging/consumers/feedback_consumer.py b/ai/src/ai_server/messaging/consumers/feedback_consumer.py index b7464306..67c02972 100644 --- a/ai/src/ai_server/messaging/consumers/feedback_consumer.py +++ b/ai/src/ai_server/messaging/consumers/feedback_consumer.py @@ -1,6 +1,7 @@ from __future__ import annotations import asyncio +import re import structlog from aio_pika.abc import AbstractIncomingMessage @@ -337,10 +338,27 @@ def _collect_coachable_pairs( continue if (question.category or "") == _SELF_INTRO_CATEGORY: continue # 자기소개는 첫인상 평가가 커버 + if _is_short_confirmation(m.content): + continue # 확인형 질문에 대한 단답('네 맞습니다' 등) — 모범답안 코칭 무의미 pairs.append((question, m)) return pairs +# 확인형 질문에 대한 단답(예/아니오 + 짧은 정정)으로 시작하는지. 짧고(어절 수·길이) 확인/부정 +# 표현으로 시작할 때만 True — 부연이 충분히 길면 일반 답변으로 보고 코칭 대상에 남긴다. +_CONFIRMATION_PREFIX_RE = re.compile( + r"^(네|예|응|맞(습니다|아요|네요)|그렇(습니다|죠|네요)|" + r"아(뇨|니요|닙니다|니에요)|아니(요|에요)?|모르(겠습니다|겠어요))" +) + + +def _is_short_confirmation(text: str | None) -> bool: + s = (text or "").strip() + if not s or len(s) > 25 or len(s.split()) > 6: + return False + return bool(_CONFIRMATION_PREFIX_RE.match(s)) + + def _coaching_target_role(req: GenerateFeedbackRequest) -> str: """직무 맞춤 모드일 때만 코칭 프롬프트에 실을 회사/JD 발췌. 그 외 빈 문자열.""" if (req.mode or "") != _JOB_TAILORED_MODE: diff --git a/ai/tests/test_short_confirmation.py b/ai/tests/test_short_confirmation.py new file mode 100644 index 00000000..07180e18 --- /dev/null +++ b/ai/tests/test_short_confirmation.py @@ -0,0 +1,56 @@ +from __future__ import annotations + +import pytest + +from ai_server.messaging.consumers.feedback_consumer import ( + _collect_coachable_pairs, + _is_short_confirmation, +) +from ai_server.model.messages.feedback import FeedbackMessageItem + + +@pytest.mark.parametrize( + "text", + [ + "네 맞습니다.", + "네, 맞습니다", + "아뇨 그건 아닙니다.", + "아니요.", + "예 그렇습니다.", + "맞습니다.", + ], +) +def test_detects_short_confirmation(text: str) -> None: + assert _is_short_confirmation(text) is True + + +@pytest.mark.parametrize( + "text", + [ + # 충분히 긴 정정/부연은 일반 답변 — 코칭 대상으로 남긴다. + "아뇨, 그 부분은 캐시 무효화 전략이 달라서 조인 대신 임시 테이블을 썼습니다.", + # 짧지만 확인형 단답이 아닌 실질 답변. + "시간 복잡도는 O(n log n) 입니다.", + "인덱스를 풀스캔하지 않도록 복합 인덱스를 새로 만들었습니다.", + "", + ], +) +def test_keeps_substantive_answers(text: str) -> None: + assert _is_short_confirmation(text) is False + + +def test_coaching_pairs_skip_short_confirmation() -> None: + msgs = [ + FeedbackMessageItem(id=1, sequence_number=1, role="INTERVIEWER", + content="그럼 인덱스를 직접 만드셨나요?", category="TECH_CHOICE"), + FeedbackMessageItem(id=2, sequence_number=2, role="INTERVIEWEE", + content="네 맞습니다.", parent_message_id=1), + FeedbackMessageItem(id=3, sequence_number=3, role="INTERVIEWER", + content="어떤 컬럼으로 복합 인덱스를 구성했나요?", category="TECH_CHOICE"), + FeedbackMessageItem(id=4, sequence_number=4, role="INTERVIEWEE", + content="조회 조건인 user_id 와 created_at 으로 복합 인덱스를 잡았습니다.", + parent_message_id=3), + ] + pairs = _collect_coachable_pairs(msgs) + answer_ids = [a.id for _, a in pairs] + assert answer_ids == [4] # 단답(2)은 제외, 실질 답변(4)만 코칭