대시보드 폴드아웃/드릴다운 정리 + 페르소나 역린·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:
parent
cb2aebd76c
commit
085460b5e0
327 changed files with 31226 additions and 1829 deletions
|
|
@ -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",
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue