대시보드 폴드아웃/드릴다운 정리 + 페르소나 역린·misconduct 반응 + 게이트웨이 격리·RAG 비차단 수정

SSOT 대시보드:
- 한신대 기술분석 PDF(19쪽) 정합성 분석 + 이번 세션 발견 섹션 추가
- 섹션 폴드아웃(접기)·상단 목차(드릴다운)·모두 펼치기/접기 — 내용 보존, 레이아웃만 정리

페르소나 반응 강화('저항·반응 조절' 핵심 차별):
- PersonaCard.triggers(역린) 필드 + CCD 핵심상처 파생 역린 블록
- L0에 무례·모욕·조롱 시 현실적 동맹 균열 반응 지침

버그·성능 수정(라이브/E2E로 포착):
- 게이트웨이 페르소나 격리: --append-system-prompt를 --system-prompt(교체)로 + --exclude-dynamic-system-prompt-sections (내담자 캐릭터 붕괴·개발맥락 누출 차단)
- RAG: 임베더 동기 로드(약 7-13초)를 _warm_rag_caches 백그라운드 warm으로(세션 생성 블로킹 회귀 수정)
- voice TTS RMS 데드힌트 제거, init_state OpennessParams 파라미터객체화
- 한국어 PII(날짜·금액·주소) 마스킹 보강
- 레이아웃 시각 게이트: 폼 컨트롤 값 스크롤 오탐 제외(7/7)

검증: 백엔드 84/84, E2E 42(데스크톱 27·모바일 11·아바타 4), 시각 게이트 7/7
This commit is contained in:
Yun Chan 2026-06-27 02:30:46 +09:00
parent cb2aebd76c
commit 085460b5e0
327 changed files with 31226 additions and 1829 deletions

View file

@ -365,7 +365,10 @@ def _fewshot_block() -> str:
def _theory_mode(ctx: "TurnContext") -> Optional[str]:
"""페르소나 theory_target 에서 이론 모드 힌트(이론부합 평가용). 없으면 None."""
"""이론 모드(이론부합 평가용): 학습자 선택(회기 theory_mode) 우선, 없으면 페르소나 theory_target."""
sess_theory = getattr(ctx, "theory_mode", None)
if sess_theory:
return str(sess_theory)
tt = getattr(ctx.persona, "theory_target", None)
if isinstance(tt, (list, tuple)) and tt:
return ", ".join(str(x) for x in tt)
@ -634,7 +637,6 @@ async def evaluate_turn(
try:
req = GenerateRequest(
ai_role="evaluator",
tier="feedback",
messages=build_fast_messages(ctx, client_reply),
structured_schema=_fast_schema(),
max_tokens=900,
@ -691,7 +693,6 @@ async def evaluate_session(
try:
req = GenerateRequest(
ai_role="evaluator",
tier="feedback",
messages=build_deep_messages(
stage=stage,
scope=scope,

View file

@ -41,6 +41,13 @@ _PII_PATTERNS: list[tuple[str, re.Pattern[str]]] = [
("EMAIL", re.compile(r"\b[\w.+-]+@[\w-]+\.[\w.-]+\b")),
# 카드/계좌 유사 긴 숫자열 (12자리 이상)
("NUMID", re.compile(r"\b\d{12,}\b")),
# 구체적 날짜(생년월일 등): 2001.4.18 / 2001-04-18 / 2001년 4월 18일
("DATE", re.compile(r"(?:19|20)\d{2}\s?[.\-/년]\s?\d{1,2}\s?[.\-/월]\s?\d{1,2}\s?일?")),
# 금액(원): 1,200원 / 1200원 (3자리+ 또는 콤마구분) — 식별 맥락 보호
("MONEY", re.compile(r"\d{1,3}(?:,\d{3})+\s?원|\d{3,}\s?원")),
# 한국 주소 단편: ○○시/도 ○○시/군/구 ○○동/읍/면/로/길 (행정구역 연쇄)
("ADDR", re.compile(r"[가-힣]{2,}(?:시|도)\s?[가-힣]{1,4}(?:시|군|구)\s?[가-힣0-9]{1,}(?:동|읍|면|로|길)")),
# TODO(NER): 한국어 이름/기관명은 Presidio ko 모델/NER 필요(정규식 false-positive 위험).
]
# Presidio 지연 로드 캐시 (-1=미시도, None=미설치, 객체=설치됨)

View file

@ -37,8 +37,6 @@ from .state_machine import SessionState, Stage
# 평가 훅 타입: U_t(수련생 마스킹 발화) + 내담자응답 + 상태 → 평가 결과(dict)
# Features evaluator 가 이 시그니처에 맞춰 함수를 주입한다(여기선 호출만).
EvalHook = Callable[["TurnContext", str], Awaitable[Optional[dict]]]
# 로깅 훅: TurnContext + 내담자응답 → None (turns insert/임베딩은 주입측 책임)
LogHook = Callable[["TurnContext", str], Awaitable[None]]
@dataclass(slots=True)
@ -59,6 +57,8 @@ class TurnContext:
pinned_facts: list[str] = field(default_factory=list)
recent_turns: list[dict[str, str]] = field(default_factory=list)
kb_behavior_cues: list[str] = field(default_factory=list)
# 회기 이론모드(학습자 선택: humanistic|cbt|integrative). 평가 이론부합·생성 프레이밍에 사용.
theory_mode: Optional[str] = None
def to_state_context(self) -> PersonaStateContext:
st = self.state_after or self.state_before
@ -84,6 +84,11 @@ class TurnResult:
state_after: SessionState
evaluation: Optional[dict] = None
crisis_kind: str = "none"
llm_provider: Optional[str] = None
model: Optional[str] = None
tokens_in: int = 0
tokens_out: int = 0
cost_usd: float = 0.0
# ════════════════════════════════════════════════════════════════════════════
@ -100,6 +105,7 @@ def prepare_turn(
pinned_facts: Optional[list[str]] = None,
recent_turns: Optional[list[dict[str, str]]] = None,
kb_behavior_cues: Optional[list[str]] = None,
theory_mode: Optional[str] = None,
eval_rapport_signal: Optional[float] = None,
) -> TurnContext:
"""엔진 호출 전 결정론 전처리(1~3단계). 순수 — IO/LLM 없음.
@ -113,10 +119,11 @@ def prepare_turn(
persona=card,
state_before=state,
learner_text_raw=learner_text,
recall_summary=recall_summary,
pinned_facts=list(pinned_facts or []),
recent_turns=list(recent_turns or []),
recall_summary=_mask_optional_text(recall_summary),
pinned_facts=_mask_text_list(pinned_facts),
recent_turns=_mask_recent_turns(recent_turns),
kb_behavior_cues=list(kb_behavior_cues or []),
theory_mode=theory_mode,
)
# 1) 입력 가드레일 — PII 마스킹 + 위기분류
@ -130,11 +137,19 @@ def prepare_turn(
if eval_rapport_signal is not None
else state_machine.estimate_rapport_signal(ctx.learner_text_masked)
)
# 위기분류가 관측한 risk_level(>0)을 상태머신에 ideation_observed 로 전달 →
# ideation_stage 보수적 상향(절대 하향 안 함, 안전 R5). C2 위기 관측 반영.
crisis_ideation = (
ctx.crisis.risk_level
if ctx.crisis is not None and ctx.crisis.risk_level > 0
else None
)
ctx.state_after = state_machine.evolve(
state,
rapport_signal=signal,
unlock_rate=card.unlock_rate(),
decay_floor=card.decay_floor(),
ideation_observed=crisis_ideation,
)
# 3) 페르소나 컨텍스트 — L0~L6 messages 조립 (CCD 는 행동으로만, L0 가 강제)
@ -150,6 +165,25 @@ def prepare_turn(
return ctx
def _mask_optional_text(text: Optional[str]) -> Optional[str]:
if text is None:
return None
return guardrail.mask_pii(text).text_masked
def _mask_text_list(values: Optional[list[str]]) -> list[str]:
return [guardrail.mask_pii(value).text_masked for value in (values or [])]
def _mask_recent_turns(turns: Optional[list[dict[str, str]]]) -> list[dict[str, str]]:
masked: list[dict[str, str]] = []
for turn in turns or []:
item = dict(turn)
item["text"] = guardrail.mask_pii(str(item.get("text", ""))).text_masked
masked.append(item)
return masked
# ════════════════════════════════════════════════════════════════════════════
# 4~8단계 — 동기 생성 경로 (폴백/테스트)
# ════════════════════════════════════════════════════════════════════════════
@ -158,11 +192,10 @@ async def run_turn_generate(
engine: EngineClient,
*,
eval_hook: Optional[EvalHook] = None,
log_hook: Optional[LogHook] = None,
) -> TurnResult:
"""동기 턴 실행(4~8). 내담자 응답을 한 번에 받아 가드레일·평가·로깅 훅 순차 적용.
"""동기 턴 실행(4~8). 내담자 응답을 한 번에 받아 가드레일·평가 순차 적용.
eval_hook/log_hook Features 주입(없으면 생략). 엔진 장애는 EngineError 전파.
eval_hook Features 주입(없으면 생략). 엔진 장애는 EngineError 전파.
"""
assert ctx.state_after is not None
st = ctx.state_after
@ -170,7 +203,6 @@ async def run_turn_generate(
# 4) 내담자 AI 생성
req = GenerateRequest(
ai_role="client",
tier="client",
messages=ctx.messages,
session_id=ctx.session_id,
metadata={"stage": st.stage.value},
@ -193,13 +225,6 @@ async def run_turn_generate(
except Exception:
evaluation = None # 평가 실패가 상담 루프를 막지 않게(비치명적)
# 8) 로깅 훅(주입형) — turns insert + 임베딩
if log_hook is not None:
try:
await log_hook(ctx, reply)
except Exception:
pass
return TurnResult(
turn_seq=st.turn_seq,
stage=st.stage.value,
@ -209,6 +234,11 @@ async def run_turn_generate(
state_after=st,
evaluation=evaluation,
crisis_kind=ctx.crisis.kind.value if ctx.crisis else "none",
llm_provider=resp.provider,
model=resp.model,
tokens_in=resp.tokens_in,
tokens_out=resp.tokens_out,
cost_usd=resp.cost_usd,
)
@ -226,21 +256,17 @@ class StreamEvent:
async def run_turn_stream(
ctx: TurnContext,
engine: EngineClient,
*,
log_hook: Optional[LogHook] = None,
) -> AsyncIterator[StreamEvent]:
"""스트리밍 턴 실행(4~8). 게이트웨이 SSE 를 받아 token/done/safety/error 로 재방출.
출력 가드레일은 *누적 텍스트* 기준으로 수단정보를 감지(스트림 발견 safety 이벤트 +
재생성 신호). 토큰 단위 완벽 차단은 후속(현재는 누적 스캔).
로깅 훅은 done 직전 최종 텍스트로 1 호출.
"""
assert ctx.state_after is not None
st = ctx.state_after
req = StreamRequest(
ai_role="client",
tier="client",
messages=ctx.messages,
session_id=ctx.session_id,
metadata={"stage": st.stage.value},
@ -248,15 +274,34 @@ async def run_turn_stream(
accumulated = ""
flagged = False
stream_meta: dict[str, Any] = {}
if ctx.crisis is not None and ctx.crisis.escalate:
flagged = True
yield StreamEvent("safety", {"reason": "learner_real_crisis", "level": ctx.crisis.risk_level})
try:
current_event = "message"
async for raw in engine.stream(req):
# engine_client.stream 은 게이트웨이 SSE 의 *원시 라인*을 그대로 yield 한다.
# 게이트웨이 프레이밍: "event: token\ndata: {\"text\": ...}" 형식.
text_piece = _extract_sse_text(raw)
# 게이트웨이 프레이밍: "event: token|done|error" + "data: {...}".
line = raw.strip()
if line.startswith("event:"):
current_event = line[len("event:"):].strip() or "message"
continue
if not line.startswith("data:"):
continue
payload = _extract_sse_payload(line)
if current_event == "error":
detail = _payload_detail(payload, "engine stream error")
yield StreamEvent("error", {"detail": detail})
return
if current_event == "done":
if isinstance(payload, dict):
stream_meta = payload
break
text_piece = _payload_text(payload)
if text_piece is None:
continue
accumulated += text_piece
@ -272,13 +317,6 @@ async def run_turn_stream(
yield StreamEvent("token", {"text": text_piece})
# 8) 로깅 훅 — 최종 텍스트
if log_hook is not None:
try:
await log_hook(ctx, accumulated)
except Exception:
pass
yield StreamEvent(
"done",
{
@ -287,18 +325,22 @@ async def run_turn_stream(
"effective_openness": round(st.effective_openness, 4),
"turn_seq": st.turn_seq,
"safety_flagged": flagged,
"llm_provider": str(stream_meta.get("provider") or engine.engine_mode),
"model": str(stream_meta.get("model") or engine.default_model or "gateway-default"),
"tokens_in": _safe_int(stream_meta.get("tokens_in")),
"tokens_out": _safe_int(stream_meta.get("tokens_out")),
"cost_usd": _safe_float(stream_meta.get("cost_usd")),
},
)
except EngineError as e:
yield StreamEvent("error", {"detail": str(e)})
def _extract_sse_text(raw_line: str) -> Optional[str]:
"""게이트웨이 SSE 원시 라인에서 텍스트 델타를 추출.
def _extract_sse_payload(raw_line: str) -> Any:
"""게이트웨이 SSE data 라인의 JSON payload를 추출.
게이트웨이 /v1/stream 'event: token' + 'data: {"text": "..."}' 보낸다.
engine_client.stream 줄을 필터링하고 비어있지 않은 라인만 흘리므로
여기서 data: 라인의 JSON 해석한다. token 이외 이벤트(done/error) None.
token은 {"text": "..."}이고, done/error도 JSON 객체다. 구형/테스트 fixture가
plain text data를 보내면 문자열 그대로 반환한다.
"""
import json as _json
@ -309,17 +351,43 @@ def _extract_sse_text(raw_line: str) -> Optional[str]:
if not payload or payload == "[DONE]":
return None
try:
obj = _json.loads(payload)
return _json.loads(payload)
except _json.JSONDecodeError:
return None
if isinstance(obj, dict) and "text" in obj:
return obj["text"]
return payload
def _payload_text(payload: Any) -> Optional[str]:
if isinstance(payload, dict) and "text" in payload:
return str(payload["text"])
if isinstance(payload, str):
return payload
return None
def _payload_detail(payload: Any, fallback: str) -> str:
if isinstance(payload, dict) and payload.get("detail"):
return str(payload["detail"])
if isinstance(payload, str) and payload:
return payload
return fallback
def _safe_int(value: Any) -> int:
try:
return int(value or 0)
except (TypeError, ValueError):
return 0
def _safe_float(value: Any) -> float:
try:
return float(value or 0.0)
except (TypeError, ValueError):
return 0.0
__all__ = [
"EvalHook",
"LogHook",
"TurnContext",
"TurnResult",
"StreamEvent",

View file

@ -48,6 +48,9 @@ class PersonaCard:
dsm5_dimensional: dict[str, Any] # criteria_behavior_matrix (진단명 비노출)
source_provenance: str = "0615 합성변형"
is_synthetic: bool = True
# 역린/지뢰(선택) — 상담자가 건드리면 가장 강한 반응이 나오는 민감 영역·금기.
# {"sore_spots":[...], "forbidden":[...], "reaction":"..."} 형태. 비면 CCD 핵심상처에서 파생.
triggers: dict[str, Any] = field(default_factory=dict)
def base_resistance(self) -> float:
return float(self.resistance.get("base_resistance", 0.5))
@ -61,6 +64,18 @@ class PersonaCard:
def ideation_baseline(self) -> int:
return int(self.affect_baseline.get("suicide_ideation_stage", 1))
def openness_params(self) -> "OpennessParams":
"""init_state 입력용 openness 파라미터 묶음(base_resistance/unlock_rate/decay_floor/
ideation_baseline 4 일원화). state_machine은 persona를 import하지 않으므로 lazy import."""
from .state_machine import OpennessParams
return OpennessParams(
base_resistance=self.base_resistance(),
unlock_rate=self.unlock_rate(),
decay_floor=self.decay_floor(),
ideation_baseline=self.ideation_baseline(),
)
# ── L3 상태 컨텍스트 (상태머신 산출물의 페르소나 입력 표현) ──────────────
@dataclass(slots=True)
@ -93,6 +108,11 @@ L0_SAFETY = """당신은 심리상담 수련생 훈련 플랫폼의 '가상내
[연기 방향]
- 좋은 상담(공감·반영·타당화·기다림) 받으면 조금씩 마음을 연다.
- 서툰 상담(성급한 조언·평가·유도) 받으면 다시 닫히거나 방어한다.
- 무례·모욕·조롱·경멸·인신공격(: 인격 비하, 비웃음, "패배자/한심하다" 낙인) 받으면,
가상내담자로서 *현실적으로* 반응한다: 상처·위축·방어·불신이 말과 태도에 드러난다
(거리두기·말수 줄임·따지거나 항의·마음을 닫음). 정도가 심하거나 반복되면 상담을 계속할
의향이 흔들린다("이런 식이면 그만하고 싶어요", "왜 그렇게 말씀하세요"). 부당한 비난을
무조건 공손히 수용하지 않는다 , 상담자처럼 분석/조언하거나 메타발화는 여전히 금지.
- 열림의 정도는 아래 '현재 상태' effective_openness 수치를 따른다(수치 자체는 언급 금지)."""
@ -141,6 +161,29 @@ def build_persona_system_text(card: PersonaCard) -> str:
(f"저항 파라미터(언급 금지): base={card.base_resistance()}, unlock={card.unlock_rate()}, "
f"침묵확률={card.resistance.get('silence_prob')}, 회피확률={card.resistance.get('deflection_prob')}"),
]
# 역린(逆鱗) — 이 페르소나가 가장 아파하는 지점. CCD 핵심상처에서 파생하고, 명시 triggers 가
# 있으면 보강한다. 상담자가 이 영역을 조롱·낙인·확정/평가절하/강요로 건드리면 *가장 강한* 반응
# (깊은 위축·침묵·방어, 신뢰 급락, 심하면 종결의향)이 나오게 — '저항·반응 조절' 핵심 차별 기술.
ccd = card.ccd or {}
core = ccd.get("core_belief", "")
autos = ccd.get("automatic_thought", [])
tr = card.triggers or {}
parts += ["", "[역린(逆鱗) — 가장 아픈 지점. 입으로 설명 말고 '반응'으로만 드러낸다]"]
if core:
parts.append(f"핵심 상처: '{core}'" + (f" · 떠오르는 생각: {autos}" if autos else ""))
parts.append(
"상담자가 이 상처를 조롱·낙인·확정하거나, 고통을 평가절하(엄살·배부른 소리)하거나, "
"강요·당위로 밀어붙이면 — 가장 강한 반응: 깊은 위축·침묵·방어, 신뢰 급락, 심하면 상담 "
"지속 의향이 흔들린다('이럴 거면 그만…'). 이 지점에선 쉽게 열리지 않는다."
)
if tr.get("sore_spots"):
parts.append("특히 민감한 영역: " + ", ".join(tr["sore_spots"]))
if tr.get("forbidden"):
parts.append("상담자가 절대 하면 안 되는 것(하면 강한 단절): " + ", ".join(tr["forbidden"]))
if tr.get("reaction"):
parts.append("반응 양상: " + str(tr["reaction"]))
return "\n".join(parts)

View file

@ -21,6 +21,8 @@
from __future__ import annotations
import asyncio
import json
import time
from dataclasses import dataclass, field
from enum import Enum
@ -467,7 +469,7 @@ async def search_kb(
sens_max = min(sens_max, fs) # 더 엄격하게만
# (2) 질의 임베딩(dense+sparse). 모델 미가용 → NotConfigured 전파.
eq = embed_query(query)
eq = await asyncio.to_thread(embed_query, query) # CPU 인코딩 → 스레드풀(이벤트루프 비차단)
q_dense_lit = _vector_literal(eq.dense)
# (3) 하이브리드 SQL 실행. vector 확장 미설치/컬럼 부재면 asyncpg 가 예외 → NotConfigured 변환.
@ -494,7 +496,9 @@ async def search_kb(
for r in rows:
if src_filter and r["source_id"] not in src_filter:
continue
meta = dict(r["meta"] or {})
# asyncpg는 jsonb를 str(JSON text)로 반환 → 파싱. 코덱 등록 시 dict 그대로도 수용.
_meta_raw = r["meta"]
meta = json.loads(_meta_raw) if isinstance(_meta_raw, str) else dict(_meta_raw or {})
body = r["chunk_text"] if policy.expose_body else None
cue = None
if not policy.expose_body:
@ -580,7 +584,7 @@ async def retrieve_persona_memory(
Raises: NotConfigured 임베딩 모델/DB 미가용.
"""
t0 = time.perf_counter()
eq = embed_query(query)
eq = await asyncio.to_thread(embed_query, query) # CPU 인코딩 → 스레드풀(이벤트루프 비차단)
q_dense_lit = _vector_literal(eq.dense)
try:
rows = await conn.fetch(
@ -765,13 +769,13 @@ async def index_document(
continue
context_prefix = c.get("context_prefix")
emb_lit: Optional[str] = None
sparse_json: Optional[dict] = None
sparse_json: Optional[str] = None # jsonb 바인딩용 직렬화 문자열(asyncpg는 dict 자동인코딩 안 함)
if embedder is not None:
# Contextual Retrieval: prefix+body 결합본을 *색인 대상* 으로 임베딩(주입 본문은 body 만).
index_text = apply_contextual_prefix(chunk_text, context_prefix)
eq = embed_query(index_text)
eq = await asyncio.to_thread(embed_query, index_text) # CPU 인코딩 → 스레드풀
emb_lit = _vector_literal(eq.dense)
sparse_json = eq.sparse
sparse_json = json.dumps(eq.sparse)
await conn.execute(
"""
INSERT INTO kb.chunk
@ -794,7 +798,7 @@ async def index_document(
c.get("visible_to"),
c.get("sensitivity"),
c.get("label_id"),
c.get("meta"),
json.dumps(c.get("meta")) if c.get("meta") is not None else None,
c.get("token_count"),
)
indexed += 1

View file

@ -231,12 +231,22 @@ def evolve(
return advanced
@dataclass(frozen=True, slots=True)
class OpennessParams:
"""페르소나 파생 openness 곡선 파라미터 묶음(init_state 입력).
base_resistance/unlock_rate/decay_floor/ideation_baseline 4종을 객체로 호출부의
4-인자 분해(card.base_resistance() ) PersonaCard.openness_params() 일원화한다.
"""
base_resistance: float
unlock_rate: float
decay_floor: float
ideation_baseline: int = 1
def init_state(
*,
base_resistance: float,
unlock_rate: float,
decay_floor: float,
ideation_baseline: int = 1,
params: OpennessParams,
carry: Optional[dict] = None,
) -> SessionState:
"""회기 시작 상태 초기화 (memory.carry_over 결과 주입 가능).
@ -245,23 +255,25 @@ def init_state(
stage='라포' 재시작, rapport_credit ×0.7 이월, resistance drift, ideation 보수적 유지.
"""
stage = Stage.RAPPORT
resistance = base_resistance
resistance = params.base_resistance
rapport_credit = 0.0
ideation_stage = ideation_baseline
ideation_stage = params.ideation_baseline
if carry:
rapport_credit = float(carry.get("rapport_credit", 0.0)) * 0.7 # P2 이월
# inter-session drift: 라포가 쌓였으면 저항 소폭 완화된 채로 재시작
prev_resist = float(carry.get("resistance", base_resistance))
resistance = _clamp01((prev_resist + base_resistance) / 2.0)
ideation_stage = max(int(carry.get("ideation_stage", ideation_baseline)), ideation_baseline)
prev_resist = float(carry.get("resistance", params.base_resistance))
resistance = _clamp01((prev_resist + params.base_resistance) / 2.0)
ideation_stage = max(
int(carry.get("ideation_stage", params.ideation_baseline)), params.ideation_baseline
)
eff = compute_effective_openness(
stage=stage,
rapport_credit=rapport_credit,
resistance=resistance,
unlock_rate=unlock_rate,
decay_floor=decay_floor,
unlock_rate=params.unlock_rate,
decay_floor=params.decay_floor,
)
return SessionState(
stage=stage,
@ -280,6 +292,7 @@ __all__ = [
"STAGE_BASE_OPENNESS",
"STAGE_ORDER",
"SessionState",
"OpennessParams",
"estimate_rapport_signal",
"compute_effective_openness",
"next_stage",

View file

@ -18,8 +18,8 @@ PRESET_TO_OPENAI_VOICE 테이블이 흡수. 새 preset 추가는 이 테이블
from __future__ import annotations
import math
from dataclasses import dataclass, field
import re
from dataclasses import dataclass
from typing import AsyncIterator, Optional
import httpx
@ -46,6 +46,9 @@ STT_LANGUAGE = "ko"
# TTS 출력 포맷: 브라우저 MediaSource/<audio> 친화. 스트리밍은 mp3/opus 청크.
TTS_RESPONSE_FORMAT = "mp3"
# End-of-turn readiness default for cascaded STT providers.
EOT_SILENCE_THRESHOLD_MS = 1200
# OpenAI 공식 voice 풀(2026 기준): alloy, ash, ballad, coral, echo, fable,
# nova, onyx, sage, shimmer, verse. 페르소나 톤별로 골라 매핑한다.
_OPENAI_VOICES = {
@ -109,13 +112,23 @@ class TranscriptResult:
duration: Optional[float] = None
@dataclass(frozen=True, slots=True)
class EndOfTurnDecision:
"""Provider-neutral readiness signal for a completed learner utterance."""
ready: bool
transcript_ready: bool
silence_ready: bool
silence_ms: int
threshold_ms: int
reason: str
@dataclass(slots=True)
class TTSChunk:
"""TTS 스트림 1청크 + 립싱크 힌트(설계 §4.3 RMS 1채널)."""
"""TTS 스트림 1청크(오디오 바이트). 립싱크는 프론트 Web Audio AnalyserNode가 자체 산출."""
audio: bytes
rms: float = 0.0 # 0~1, 입 열림(scaleY) 매핑용 근사 진폭
seq: int = 0
# ════════════════════════════════════════════════════════════════════════════
@ -150,33 +163,75 @@ def resolve_voice(
)
# ════════════════════════════════════════════════════════════════════════════
# 립싱크 RMS 근사 (설계 §4.3 — 정밀 viseme 안 함, 진폭 1채널)
# ════════════════════════════════════════════════════════════════════════════
def estimate_chunk_rms(chunk: bytes) -> float:
"""오디오 청크 바이트 에너지로 RMS(0~1) 근사.
# 비언어 지문 패턴: (…)·(…)·[…]·【…】. 내담자 발화의 무대지시(고개 끄덕/한숨/침묵 등).
_STAGE_DIRECTION_RE = re.compile(r"[\(\[【][^\)\]】]*[\)\]】]")
압축 포맷(mp3) 바이트를 PCM 디코딩 없이 근사한다(의존성 0). 평균 바이트 편차를
0~1 정규화 프론트가 데드존(0.04)·지수평활(τ180ms) 적용해 열림에 매핑.
NOTE: 정밀 진폭이 필요하면 프론트 Web Audio AnalyserNode 재계산(설계 §4.3 권장).
힌트는 서버측 보조(네트워크 끊김/저사양 폴백).
def speakable_text(text: str) -> str:
"""TTS로 읽을 텍스트만 남긴다 — 비언어 지문((고개 살짝 끄덕)·(한숨)·[침묵])을 제거.
지문은 자막/회기리뷰에 남고 아바타 애니메이션이 표현하며, 음성으로는 읽지 않는다.
지문만으로 이뤄진 발화(: "(침묵)") 문자열을 반환 합성 생략.
"""
if not chunk:
return 0.0
# 128 중심 편차의 RMS(8bit 가정 근사). mp3 프레임이라 정밀치 아님(상대값).
n = len(chunk)
acc = 0
# 과샘플 비용 회피 — 최대 2048 바이트만 샘플링
step = max(1, n // 2048)
cnt = 0
for i in range(0, n, step):
d = chunk[i] - 128
acc += d * d
cnt += 1
if cnt == 0:
return 0.0
rms = math.sqrt(acc / cnt) / 128.0
return max(0.0, min(1.0, rms))
if not text:
return ""
stripped = _STAGE_DIRECTION_RE.sub(" ", text)
# 말줄임표/중복 공백 정리 + 고아 구두점 앞 공백 제거
stripped = re.sub(r"\s+", " ", stripped)
stripped = re.sub(r"\s+([,.!?…」』】)])", r"\1", stripped)
return stripped.strip()
def build_tts_payload(
text: str,
voice: VoicePreset,
*,
model: str = TTS_MODEL,
response_format: str = TTS_RESPONSE_FORMAT,
) -> dict[str, object]:
"""Build the deterministic OpenAI TTS payload for a resolved voice preset."""
payload: dict[str, object] = {
"model": model,
"voice": voice.openai_voice,
"input": text,
"response_format": response_format,
"speed": _clamp_speed(voice.rate),
}
if voice.instructions and model.startswith("gpt-4o"):
payload["instructions"] = voice.instructions
return payload
def assess_end_of_turn(
*,
transcript_text: Optional[str],
transcript_final: bool,
silence_ms: Optional[int],
silence_threshold_ms: int = EOT_SILENCE_THRESHOLD_MS,
) -> EndOfTurnDecision:
"""Return whether final STT text plus observed silence is enough to run a turn."""
observed_silence = _nonnegative_int(silence_ms)
threshold = max(0, _nonnegative_int(silence_threshold_ms))
has_text = bool((transcript_text or "").strip())
transcript_ready = bool(transcript_final and has_text)
silence_ready = observed_silence >= threshold
ready = transcript_ready and silence_ready
if ready:
reason = "ready"
elif not has_text:
reason = "empty_transcript"
elif not transcript_final:
reason = "final_transcript_pending"
else:
reason = "silence_threshold_pending"
return EndOfTurnDecision(
ready=ready,
transcript_ready=transcript_ready,
silence_ready=silence_ready,
silence_ms=observed_silence,
threshold_ms=threshold,
reason=reason,
)
# ════════════════════════════════════════════════════════════════════════════
@ -281,20 +336,18 @@ class VoiceService:
설계 §5.2 'speaking' 상태: 오디오 청크를 흘리며 진폭 힌트(립싱크) 같이 보낸다.
없으면 VoiceUnavailable. OpenAI 오류는 RuntimeError 전파.
"""
if not text or not text.strip():
# 비언어 지문((고개 끄덕)·(한숨)·[침묵])은 음성으로 읽지 않는다. 자막엔 남고
# 아바타 애니메이션이 표현한다. 지문만 있는 발화는 합성 생략(빈 오디오).
text = speakable_text(text)
if not text:
return
payload: dict[str, object] = {
"model": model,
"voice": voice.openai_voice,
"input": text,
"response_format": response_format,
"speed": _clamp_speed(voice.rate),
}
# gpt-4o-mini-tts 계열은 instructions(표현 지시) 지원. tts-1 은 무시됨.
if voice.instructions and model.startswith("gpt-4o"):
payload["instructions"] = voice.instructions
payload = build_tts_payload(
text,
voice,
model=model,
response_format=response_format,
)
seq = 0
try:
async with self._http.stream("POST", TTS_ENDPOINT, json=payload) as r:
if r.status_code == 404 and model != TTS_MODEL_FALLBACK:
@ -309,8 +362,7 @@ class VoiceService:
async for chunk in r.aiter_bytes(chunk_size=4096):
if not chunk:
continue
yield TTSChunk(audio=chunk, rms=estimate_chunk_rms(chunk), seq=seq)
seq += 1
yield TTSChunk(audio=chunk)
except VoiceUnavailable:
raise
except httpx.HTTPStatusError as e:
@ -333,11 +385,9 @@ class VoiceService:
except httpx.HTTPError as e:
raise RuntimeError(f"TTS(fallback) transport error: {e}") from e
data = r.content
seq = 0
for i in range(0, len(data), 4096):
chunk = data[i : i + 4096]
yield TTSChunk(audio=chunk, rms=estimate_chunk_rms(chunk), seq=seq)
seq += 1
yield TTSChunk(audio=chunk)
def _clamp_speed(rate: float) -> float:
@ -348,6 +398,13 @@ def _clamp_speed(rate: float) -> float:
return 1.0
def _nonnegative_int(value: object) -> int:
try:
return max(0, int(value)) # type: ignore[arg-type]
except (TypeError, ValueError):
return 0
# 앱 전역 싱글톤 (main lifespan 이 startup/shutdown — Foundation 이 관리하거나
# 라우트가 lazy 사용). engine_client 패턴과 동일.
voice_service = VoiceService()
@ -357,11 +414,14 @@ __all__ = [
"VoiceUnavailable",
"VoicePreset",
"TranscriptResult",
"EndOfTurnDecision",
"TTSChunk",
"VoiceService",
"voice_service",
"resolve_voice",
"estimate_chunk_rms",
"build_tts_payload",
"assess_end_of_turn",
"EOT_SILENCE_THRESHOLD_MS",
"PRESET_TO_OPENAI_VOICE",
"PERSONA_CODE_TO_PRESET",
"DEFAULT_OPENAI_VOICE",