Skip to content

Commit c4440af

Browse files
authored
Merge pull request #95 from Team-StackUp/feature/interview-streaming
feat: 면접 꼬리질문 토큰 스트리밍 + 문장 단위 TTS (체감 반응 속도 개선)
2 parents ce3def1 + b1c4c24 commit c4440af

45 files changed

Lines changed: 1599 additions & 112 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

ai/CLAUDE.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -323,6 +323,8 @@ docker run --env-file .env -p 8000:8000 stackup-ai
323323
- 질문 풀 생성 (Pro 모델, `chain/question_generation_chain.py`)
324324
- 꼬리질문 + 답변 평가 (Flash 모델, `chain/followup_generation_chain.py`)
325325
- 콜백: `callback.questions` (`kind=POOL|FOLLOWUP`)
326+
- **꼬리질문 토큰 스트리밍 본 구현**: 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 재사용).
327+
- **문장 단위 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 미기록).
326328
- **임베딩 본 구현** (`rag/`): `MarkdownChunker` + `GeminiEmbeddingProvider` (1536d, `gemini-embedding-001`).
327329
운영/개발 default 는 gemini, 테스트는 `MockEmbeddingProvider`.
328330
- 청크를 `EMBEDDING_BATCH_SIZE`(기본 32) 단위로 쪼개 순차 호출 — 한 요청에 몰면 분당 토큰 한도(429 `RESOURCE_EXHAUSTED`)에 걸린다.

ai/src/ai_server/chain/followup_generation_chain.py

Lines changed: 154 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
from __future__ import annotations
22

3-
from typing import Literal, Protocol
3+
import re
4+
from collections.abc import Awaitable, Callable
5+
from typing import Any, Literal, Protocol
46

57
from langchain_core.output_parsers import PydanticOutputParser
68
from langchain_core.prompts import ChatPromptTemplate
@@ -16,11 +18,123 @@
1618

1719
class FollowupResult(BaseModel):
1820
followup_question: str = Field(..., description="한국어 꼬리질문 1개")
19-
answer_evaluation: AnswerEvaluation
21+
answer_evaluation: AnswerEvaluation | None = None
2022
# 답변 의도. NORMAL=정상답변, DONT_KNOW=모름/포기, CLARIFICATION=질문 재설명 요청.
2123
answer_intent: Literal["NORMAL", "DONT_KNOW", "CLARIFICATION"] = "NORMAL"
2224

2325

26+
_INTENT_RE = re.compile(r"<intent>\s*(.*?)\s*</intent>", re.DOTALL)
27+
_QUESTION_RE = re.compile(r"<question>\s*(.*?)\s*</question>", re.DOTALL)
28+
_META_RE = re.compile(r"<meta>\s*(\{.*?\})\s*</meta>", re.DOTALL)
29+
_VALID_INTENTS = {"NORMAL", "DONT_KNOW", "CLARIFICATION"}
30+
31+
32+
def extract_question_span(text: str) -> str:
33+
"""완성 또는 진행 중 텍스트에서 <question> 안쪽만 추출(닫는 태그 없어도 가능)."""
34+
closed = _QUESTION_RE.search(text)
35+
if closed:
36+
return closed.group(1).strip()
37+
open_idx = text.find("<question>")
38+
if open_idx == -1:
39+
return ""
40+
tail = text[open_idx + len("<question>") :]
41+
tail = tail.split("<meta>", 1)[0]
42+
# 닫는/메타 태그가 스트림 청크 경계로 잘려 들어와도(예: "질문?</quest")
43+
# 미완성 '<...' 꼬리를 잘라낸다 — 질문 본문에는 '<' 가 없다고 가정.
44+
lt = tail.rfind("<")
45+
if lt != -1 and ">" not in tail[lt:]:
46+
tail = tail[:lt]
47+
return tail.strip()
48+
49+
50+
def parse_followup_result(text: str) -> FollowupResult:
51+
"""종료된 누적 텍스트를 FollowupResult 로. 태그 누락 시 전체를 질문으로 폴백."""
52+
intent_m = _INTENT_RE.search(text)
53+
intent = intent_m.group(1).strip().upper() if intent_m else "NORMAL"
54+
if intent not in _VALID_INTENTS:
55+
intent = "NORMAL"
56+
57+
question = extract_question_span(text)
58+
if not question:
59+
question = text.strip()
60+
61+
evaluation = None
62+
meta_m = _META_RE.search(text)
63+
if meta_m:
64+
try:
65+
evaluation = AnswerEvaluation.model_validate_json(meta_m.group(1))
66+
except Exception:
67+
evaluation = None
68+
69+
return FollowupResult(
70+
followup_question=question,
71+
answer_evaluation=evaluation,
72+
answer_intent=intent,
73+
)
74+
75+
76+
class StreamingFollowupGenerator:
77+
"""단일 LLM 콜을 astream 으로 흘리며 <question> 토큰만 콜백으로 내보낸다.
78+
79+
intent 가 DONT_KNOW 면 질문 델타를 보내지 않는다(Core 가 폐기하므로).
80+
종료 후 누적 텍스트를 parse_followup_result 로 검증해 반환.
81+
"""
82+
83+
def __init__(self, *, prompt: ChatPromptTemplate, llm: Any) -> None:
84+
self._prompt = prompt
85+
self._llm = llm
86+
87+
async def stream(
88+
self,
89+
*,
90+
on_question_token: Callable[[str], Awaitable[None] | None],
91+
job_category: str,
92+
mode: str,
93+
previous_question: str,
94+
answer_text: str,
95+
context: str,
96+
parent_category: str,
97+
expected_signal: str,
98+
history: str,
99+
) -> FollowupResult:
100+
all_vars = {
101+
"job_category": job_category,
102+
"mode": mode,
103+
"previous_question": previous_question,
104+
"answer_text": answer_text,
105+
"context": context,
106+
"parent_category": parent_category,
107+
"expected_signal": expected_signal,
108+
"history": history,
109+
}
110+
# 프롬프트가 선언한 변수만 전달(템플릿마다 변수 집합이 다를 수 있음).
111+
wanted = set(self._prompt.input_variables)
112+
prompt_vars = {k: v for k, v in all_vars.items() if k in wanted}
113+
messages = self._prompt.format_messages(**prompt_vars)
114+
115+
acc = ""
116+
emitted_q = ""
117+
async for chunk in self._llm.astream(messages):
118+
piece = getattr(chunk, "content", "") or ""
119+
if not piece:
120+
continue
121+
acc += piece
122+
intent_known = _INTENT_RE.search(acc)
123+
if not intent_known:
124+
continue
125+
if intent_known.group(1).strip().upper() == "DONT_KNOW":
126+
continue
127+
q_now = extract_question_span(acc)
128+
if len(q_now) > len(emitted_q):
129+
delta = q_now[len(emitted_q) :]
130+
emitted_q = q_now
131+
res = on_question_token(delta)
132+
if res is not None and hasattr(res, "__await__"):
133+
await res
134+
135+
return parse_followup_result(acc)
136+
137+
24138
class FollowupGenerator(Protocol):
25139
async def generate(
26140
self,
@@ -103,3 +217,41 @@ def build_followup_generation_chain(
103217
callbacks=callbacks,
104218
)
105219
return prompt | llm | parser
220+
221+
222+
def build_streaming_followup_generator(
223+
settings: Settings, core_client: CoreClient | None = None
224+
) -> StreamingFollowupGenerator:
225+
"""스트리밍 꼬리질문 생성기 빌더.
226+
227+
format_instructions 없는 프롬프트 + Flash LLM 을 조합해
228+
StreamingFollowupGenerator 를 반환한다.
229+
"""
230+
from langchain_openai import ChatOpenAI
231+
232+
prompt = ChatPromptTemplate.from_messages(
233+
[
234+
("system", SYSTEM_PROMPT),
235+
("human", HUMAN_PROMPT),
236+
]
237+
)
238+
239+
callbacks = []
240+
if core_client is not None:
241+
callbacks.append(
242+
CoreAiLogCallback(
243+
core_client=core_client,
244+
request_type="generate.followup.stream",
245+
default_model=settings.llm_flash_model,
246+
)
247+
)
248+
249+
llm = ChatOpenAI(
250+
model=settings.llm_flash_model,
251+
temperature=settings.llm_flash_temperature,
252+
api_key=settings.llm_api_key or None,
253+
base_url=settings.llm_base_url,
254+
max_tokens=settings.llm_flash_max_tokens,
255+
callbacks=callbacks,
256+
)
257+
return StreamingFollowupGenerator(prompt=prompt, llm=llm)

ai/src/ai_server/chain/prompts/followup_generation.py

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,13 @@
2626
"이때 followup_question 에는 새 꼬리질문이 아니라 **직전 질문을 더 쉽고 구체적으로 다시 설명한 문장**을 담으세요.\n"
2727
" - NORMAL: 그 외 정상 답변. followup_question 은 평소처럼 가장 약한 축을 파는 꼬리질문.\n"
2828
" - DONT_KNOW/CLARIFICATION 이면 채점(specificity/logic/correctness)은 보수적으로(낮게/null) 둡니다.\n"
29-
"- 응답은 반드시 지정된 JSON 스키마를 따릅니다."
29+
"- 출력은 정확히 다음 3개 블록을 이 순서로만 작성합니다(설명·코드펜스 금지):\n"
30+
" <intent>NORMAL 또는 DONT_KNOW 또는 CLARIFICATION</intent>\n"
31+
" <question>지원자에게 보여줄 한국어 질문 1개(또는 CLARIFICATION 시 재설명 문장)</question>\n"
32+
" <meta>{{\"specificity\": <0~5>, \"logic\": <0~5>, "
33+
"\"structure\": \"FULL_STAR|PARTIAL_STAR|NONE\", \"correctness\": <0~5 또는 null>}}</meta>\n"
34+
"- <intent> 를 가장 먼저 확정해 출력합니다. DONT_KNOW 여도 <question> 블록은 비우지 말고 한 문장 넣되, "
35+
"표시는 시스템이 결정합니다."
3036
)
3137

3238
HUMAN_PROMPT = (
@@ -41,6 +47,5 @@
4147
"직전 질문:\n{previous_question}\n\n"
4248
"직전 질문의 기대 신호(평가 기준):\n{expected_signal}\n\n"
4349
"지원자 답변:\n{answer_text}\n\n"
44-
"검색 문서 컨텍스트:\n---\n{context}\n---\n\n"
45-
"{format_instructions}"
50+
"검색 문서 컨텍스트:\n---\n{context}\n---\n"
4651
)
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
from __future__ import annotations
2+
3+
import re
4+
5+
# 종결: 문장부호(. ! ? …) 뒤에 공백/끝, 또는 줄바꿈.
6+
_SENTENCE_END = re.compile(r".*?(?:[.!?…](?=[ \t]|$)|\n)", re.DOTALL)
7+
8+
9+
def next_sentences(text: str, consumed: int) -> tuple[list[str], int]:
10+
"""text[consumed:] 에서 '완성된' 문장들만 떼어 반환하고 새 consumed 를 돌려준다.
11+
12+
미완성(종결부호 없는) 꼬리는 남겨 다음 호출에서 이어 처리한다.
13+
14+
반환 정책:
15+
- 완성 문장이 없으면 ([], consumed) 반환.
16+
- 완성 문장이 1개이거나 마지막 완성 문장이 '\\n' 으로 끝나면 전부 반환.
17+
- 그 외(마지막 완성 문장이 공백 앞 구두점으로만 끝나고 2개 이상 존재)에는
18+
마지막 문장을 버퍼에 남기고 나머지만 반환한다. 이는 스트리밍 중 아직
19+
다음 토큰이 올 수 있는 경계를 보수적으로 처리하기 위함이다.
20+
"""
21+
rest = text[consumed:]
22+
23+
# 각 완성 문장의 (stripped 텍스트, 줄바꿈 종결 여부, 매치 끝 위치) 수집
24+
segments: list[tuple[str, bool, int]] = []
25+
pos = 0
26+
for m in _SENTENCE_END.finditer(rest):
27+
chunk = rest[pos : m.end()].strip()
28+
is_newline = m.group().endswith("\n")
29+
if chunk:
30+
segments.append((chunk, is_newline, m.end()))
31+
pos = m.end()
32+
33+
if not segments:
34+
return [], consumed
35+
36+
# 마지막 문장이 줄바꿈 종결이거나 완성 문장이 1개뿐이면 전부 방출
37+
last_is_newline = segments[-1][1]
38+
if last_is_newline or len(segments) == 1:
39+
out = [s for s, _, _ in segments]
40+
new_consumed = consumed + segments[-1][2]
41+
else:
42+
# 마지막 space-terminated 문장은 버퍼로 남김
43+
to_emit = segments[:-1]
44+
out = [s for s, _, _ in to_emit]
45+
new_consumed = consumed + to_emit[-1][2]
46+
47+
return out, new_consumed

0 commit comments

Comments
 (0)