대시보드 폴드아웃/드릴다운 정리 + 페르소나 역린·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

@ -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",