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
1 change: 1 addition & 0 deletions ai/CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -345,6 +345,7 @@ docker run --env-file .env -p 8000:8000 stackup-ai
(직무가 무엇을 하는 자리인지 이해·지원동기). 두 축을 `panelBreakdown` 의 `evaluator="직무 적합도"`·
`evaluator="직무 이해도"` 항목으로 append(첫인상과 같은 병렬·미집계 메커니즘). 그 외 모드/빈 JD/실패는 건너뜀.
- **꼬리질문 토큰 스트리밍 본 구현**: followup 출력을 `<intent>…</intent><question>…</question><meta>{json}</meta>` 구분자 포맷으로 바꾸고(`chain/prompts/followup_generation.py`), `StreamingFollowupGenerator`(`astream`)가 `<question>` 토큰만 `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`.
Expand Down
6 changes: 6 additions & 0 deletions ai/src/ai_server/chain/prompts/followup_generation.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,13 +21,19 @@
"correctness 의심→자료와의 불일치 확인).\n"
"- 꼬리질문은 **한 문장으로 간결하게**(대략 60자 이내). 장황한 배경 설명 없이 "
"핵심만 묻고, 면접관이 입으로 말할 길이여야 합니다. CLARIFICATION 재설명도 짧고 쉽게.\n"
"- 매번 깊이 파고들 필요는 없습니다. 지원자가 특정 사실/선택을 말했을 때는 "
"'그럼 OO 하신 건가요?' 같은 **짧은 확인형(예/아니오) 질문**으로 사실관계를 짚는 것도 자연스럽습니다. "
"다만 매 턴 확인형만 반복하지 말고 대체로는 깊이 있는 질문을 유지하세요.\n"
"- '이미 나눈 대화'에서 다룬 내용을 그대로 반복하지 말고 새로운 각도로 파고드세요.\n"
"- answer_intent 로 답변 의도를 분류하세요:\n"
" - DONT_KNOW: '모르겠습니다/잘 모릅니다/패스' 등 사실상 답을 못 한 경우.\n"
" - CLARIFICATION: 답변 대신 '질문을 다시/쉽게 설명해 달라'고 요청한 경우. "
"이때 followup_question 에는 새 꼬리질문이 아니라 **직전 질문을 더 쉽고 구체적으로 다시 설명한 문장**을 담으세요.\n"
" - NORMAL: 그 외 정상 답변. followup_question 은 평소처럼 가장 약한 축을 파는 꼬리질문.\n"
" - DONT_KNOW/CLARIFICATION 이면 채점(specificity/logic/correctness)은 보수적으로(낮게/null) 둡니다.\n"
"- 직전 답변이 확인형 질문에 대한 **짧은 단답·정정**(예: '네 맞습니다', '아뇨 그건 아닙니다')이라 "
"평가할 내용이 거의 없으면 specificity/logic/correctness 를 null, structure 를 NONE 으로 두고 "
"**짧다는 이유만으로 감점하지 마세요**(확인형 질문에 대한 단답은 정상입니다).\n"
"- 출력은 정확히 다음 3개 블록을 이 순서로만 작성합니다(설명·코드펜스 금지):\n"
" <intent>NORMAL 또는 DONT_KNOW 또는 CLARIFICATION</intent>\n"
" <question>지원자에게 보여줄 한국어 질문 1개(또는 CLARIFICATION 시 재설명 문장)</question>\n"
Expand Down
18 changes: 18 additions & 0 deletions ai/src/ai_server/messaging/consumers/feedback_consumer.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
from __future__ import annotations

import asyncio
import re

import structlog
from aio_pika.abc import AbstractIncomingMessage
Expand Down Expand Up @@ -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:
Expand Down
56 changes: 56 additions & 0 deletions ai/tests/test_short_confirmation.py
Original file line number Diff line number Diff line change
@@ -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)만 코칭