대시보드 폴드아웃/드릴다운 정리 + 페르소나 역린·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
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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=미설치, 객체=설치됨)
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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