음성 재생과 운영 배포 정리
This commit is contained in:
parent
8ed185ce6c
commit
ac7db95542
1020 changed files with 46863 additions and 2175 deletions
677
apps/api/app/services/live_coach.py
Normal file
677
apps/api/app/services/live_coach.py
Normal file
|
|
@ -0,0 +1,677 @@
|
|||
"""라이브 코칭 AI — 턴 직후 짧은 슈퍼비전 힌트.
|
||||
|
||||
내담자 생성 루프와 분리된 별도 경로다. 상담 응답 스트리밍은 막지 않고,
|
||||
턴이 저장된 뒤 학습자 UI가 이 서비스를 호출해 다음 한 문장 중심의 코칭을 받는다.
|
||||
|
||||
원칙:
|
||||
- 상담 루프 비차단: 엔진/RAG 실패 시 규칙 기반 코칭으로 degrade.
|
||||
- PII 마스킹 후 외부 LLM 전송.
|
||||
- 허가된 DSM/공식 지침/논문 요약 KB를 근거로 사용하되, 진단 확정·처방·장문 원문 재현은 금지.
|
||||
- 학습자에게 페르소나 내부 정답(CCD/상태 수치)을 노출하지 않는다.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import time
|
||||
import hashlib
|
||||
from functools import lru_cache
|
||||
from typing import TYPE_CHECKING, Any, Literal, Optional
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from ..config import settings
|
||||
from ..engine_client import EngineClient, EngineError, EngineMessage, GenerateRequest, GenerateResponse
|
||||
from ..paths import repo_root, repo_path
|
||||
from . import guardrail
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .orchestrator import LlmAuditHook
|
||||
|
||||
|
||||
Tone = Literal["pos", "warn", "neutral"]
|
||||
CoachStatus = Literal["ready", "degraded"]
|
||||
CoachFocus = Literal[
|
||||
"rapport",
|
||||
"exploration",
|
||||
"risk",
|
||||
"emotion",
|
||||
"cognition",
|
||||
"behavior",
|
||||
"interpersonal",
|
||||
"goal",
|
||||
"pacing",
|
||||
]
|
||||
|
||||
|
||||
class LiveCoachSource(BaseModel):
|
||||
"""라이브 코칭 근거 출처. 원문 장문이 아니라 출처 식별자와 위치만 노출한다."""
|
||||
|
||||
source_id: str
|
||||
title: str
|
||||
locator: Optional[str] = None
|
||||
kb_kind: str = "template"
|
||||
source_type: Optional[str] = None
|
||||
version: Optional[str] = None
|
||||
citation: Optional[str] = None
|
||||
|
||||
|
||||
class LiveCoachSuggestion(BaseModel):
|
||||
"""프론트가 그대로 표시하는 턴 직후 코칭 카드."""
|
||||
|
||||
status: CoachStatus = "ready"
|
||||
tone: Tone = "neutral"
|
||||
focus: CoachFocus = "exploration"
|
||||
title: str
|
||||
message: str
|
||||
next_utterance: Optional[str] = None
|
||||
rationale: Optional[str] = None
|
||||
sources: list[LiveCoachSource] = Field(default_factory=list)
|
||||
safety_note: Optional[str] = None
|
||||
latency_ms: int = 0
|
||||
|
||||
|
||||
class LiveCoachEvent(BaseModel):
|
||||
"""회기 중 실제로 전달된 라이브 코칭 이력."""
|
||||
|
||||
event_id: str
|
||||
session_id: str
|
||||
turn_seq: int
|
||||
stage: str
|
||||
created_at: str
|
||||
learner_text_excerpt: Optional[str] = None
|
||||
client_reply_excerpt: Optional[str] = None
|
||||
suggestion: LiveCoachSuggestion
|
||||
|
||||
|
||||
class LiveCoachInput(BaseModel):
|
||||
"""라이브 코칭 입력. raw text는 서비스 내부에서 마스킹 후 프롬프트에 쓴다."""
|
||||
|
||||
session_id: str
|
||||
turn_seq: int
|
||||
stage: str
|
||||
effective_openness: float
|
||||
theory_mode: str
|
||||
persona_code: str
|
||||
persona_name: str
|
||||
learner_text: str
|
||||
client_reply: Optional[str] = None
|
||||
recent_turns: list[dict[str, str]] = Field(default_factory=list)
|
||||
evaluation: Optional[dict[str, Any]] = None
|
||||
|
||||
|
||||
class LiveCoachGrounding(BaseModel):
|
||||
"""LLM에 넣는 짧은 근거 요약. 원문을 길게 복사하지 않는다."""
|
||||
|
||||
source_id: str
|
||||
title: str
|
||||
locator: Optional[str] = None
|
||||
kb_kind: str = "template"
|
||||
source_type: Optional[str] = None
|
||||
version: Optional[str] = None
|
||||
citation: Optional[str] = None
|
||||
summary: str
|
||||
|
||||
|
||||
_REPO_ROOT = repo_root()
|
||||
_WORKBOOK_PATH = repo_path("data", "kb", "live_coaching_workbook_0615.json")
|
||||
_SOURCE_DIR = repo_path("data", "kb", "live_coaching_sources")
|
||||
_ALLOWED_KB_KINDS = {
|
||||
"diagnostic",
|
||||
"theory",
|
||||
"technique",
|
||||
"taxonomy",
|
||||
"supervisor_pattern",
|
||||
"template",
|
||||
"ko_context",
|
||||
"microskill",
|
||||
}
|
||||
|
||||
|
||||
def _configured_model(value: str | None) -> str | None:
|
||||
model = (value or "").strip()
|
||||
return model or None
|
||||
|
||||
|
||||
@lru_cache(maxsize=1)
|
||||
def _local_source_entries() -> tuple[tuple[str, dict[str, Any]], ...]:
|
||||
entries: list[tuple[str, dict[str, Any]]] = []
|
||||
paths = [_WORKBOOK_PATH]
|
||||
if _SOURCE_DIR.exists():
|
||||
paths.extend(sorted(_SOURCE_DIR.glob("*.json")))
|
||||
for path in paths:
|
||||
try:
|
||||
payload = json.loads(path.read_text(encoding="utf-8"))
|
||||
except Exception:
|
||||
continue
|
||||
if isinstance(payload, dict):
|
||||
entries.append((str(path.relative_to(_REPO_ROOT)).replace("\\", "/"), payload))
|
||||
if entries:
|
||||
return tuple(entries)
|
||||
return (
|
||||
(
|
||||
str(_WORKBOOK_PATH.relative_to(_REPO_ROOT)).replace("\\", "/"),
|
||||
{
|
||||
"source": {
|
||||
"source_id": "workbook_0615_case_conceptualization",
|
||||
"title": "0615 사례개념화 워크북",
|
||||
"external_llm_ok": True,
|
||||
"kb_kind": "template",
|
||||
},
|
||||
"chunks": [],
|
||||
},
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def _local_source_payloads() -> list[dict[str, Any]]:
|
||||
return [payload for _, payload in _local_source_entries()]
|
||||
|
||||
|
||||
def iter_local_source_packs() -> list[dict[str, Any]]:
|
||||
"""허가된 라이브 코칭 source pack 목록을 반환한다."""
|
||||
|
||||
return _local_source_payloads()
|
||||
|
||||
|
||||
def _source_id(source: dict[str, Any]) -> str:
|
||||
return str(source.get("source_id") or "").strip()
|
||||
|
||||
|
||||
def _source_kb_kind(source: dict[str, Any], chunks: list[dict[str, Any]]) -> str:
|
||||
value = str(source.get("kb_kind") or "").strip()
|
||||
if value in _ALLOWED_KB_KINDS:
|
||||
return value
|
||||
for chunk in chunks:
|
||||
value = str(chunk.get("kb_kind") or "").strip()
|
||||
if value in _ALLOWED_KB_KINDS:
|
||||
return value
|
||||
return "supervisor_pattern"
|
||||
|
||||
|
||||
def _license_class(source: dict[str, Any]) -> str:
|
||||
value = str(source.get("license_class") or "B").strip().upper()
|
||||
return value if value in {"A", "B", "C", "D"} else "B"
|
||||
|
||||
|
||||
def _rag_visible_to(source: dict[str, Any], chunk: dict[str, Any]) -> list[str]:
|
||||
configured = chunk.get("visible_to") or source.get("visible_to")
|
||||
if isinstance(configured, list):
|
||||
values = [str(item).strip() for item in configured if str(item).strip()]
|
||||
if values:
|
||||
return values
|
||||
return ["evaluator"]
|
||||
|
||||
|
||||
def _rag_sensitivity(source: dict[str, Any], chunk: dict[str, Any], kb_kind: str) -> int:
|
||||
configured = chunk.get("sensitivity", source.get("sensitivity"))
|
||||
if configured is not None:
|
||||
try:
|
||||
return max(0, min(3, int(configured)))
|
||||
except (TypeError, ValueError):
|
||||
pass
|
||||
if _license_class(source) in {"C", "D"} or kb_kind in {"diagnostic", "taxonomy"}:
|
||||
return 2
|
||||
return 1
|
||||
|
||||
|
||||
def _canonical_hash(payload: dict[str, Any]) -> str:
|
||||
raw = json.dumps(payload, ensure_ascii=False, sort_keys=True, separators=(",", ":"))
|
||||
return hashlib.sha256(raw.encode("utf-8")).hexdigest()
|
||||
|
||||
|
||||
def _rag_version(source: dict[str, Any]) -> int:
|
||||
value = source.get("rag_version", source.get("index_version", 1))
|
||||
try:
|
||||
return max(1, int(value))
|
||||
except (TypeError, ValueError):
|
||||
return 1
|
||||
|
||||
|
||||
def build_rag_source_rows() -> list[dict[str, Any]]:
|
||||
"""라이브 코칭 source pack을 kb.source upsert row로 변환한다."""
|
||||
|
||||
rows: list[dict[str, Any]] = []
|
||||
for origin_path, payload in _local_source_entries():
|
||||
source = payload.get("source") or {}
|
||||
chunks = [chunk for chunk in (payload.get("chunks") or []) if isinstance(chunk, dict)]
|
||||
source_id = _source_id(source)
|
||||
if not source_id:
|
||||
continue
|
||||
rows.append(
|
||||
{
|
||||
"source_id": source_id,
|
||||
"title": str(source.get("title") or source_id),
|
||||
"kb_kind": _source_kb_kind(source, chunks),
|
||||
"license_class": _license_class(source),
|
||||
"origin_path": origin_path,
|
||||
"citation": str(source.get("citation") or ""),
|
||||
"external_llm_ok": bool(source.get("external_llm_ok", True)),
|
||||
}
|
||||
)
|
||||
return rows
|
||||
|
||||
|
||||
def build_rag_index_payloads() -> list[dict[str, Any]]:
|
||||
"""라이브 코칭 source pack을 /kb/index 요청 payload로 변환한다."""
|
||||
|
||||
payloads: list[dict[str, Any]] = []
|
||||
for origin_path, payload in _local_source_entries():
|
||||
source = payload.get("source") or {}
|
||||
source_id = _source_id(source)
|
||||
if not source_id:
|
||||
continue
|
||||
title = str(source.get("title") or source_id)
|
||||
citation = str(source.get("citation") or "")
|
||||
version_label = str(source.get("version") or "")
|
||||
chunks_in = [chunk for chunk in (payload.get("chunks") or []) if isinstance(chunk, dict)]
|
||||
chunks: list[dict[str, Any]] = []
|
||||
for seq, chunk in enumerate(chunks_in):
|
||||
summary = str(chunk.get("summary") or "").strip()
|
||||
if not summary:
|
||||
continue
|
||||
kb_kind = str(chunk.get("kb_kind") or _source_kb_kind(source, chunks_in))
|
||||
if kb_kind not in _ALLOWED_KB_KINDS:
|
||||
kb_kind = "supervisor_pattern"
|
||||
heading = str(chunk.get("heading") or chunk.get("id") or f"chunk-{seq}")
|
||||
chunk_citation = str(chunk.get("citation") or citation)
|
||||
context = f"{title} / {heading}"
|
||||
if version_label:
|
||||
context += f" / {version_label}"
|
||||
if chunk_citation:
|
||||
context += f" / {chunk_citation}"
|
||||
chunks.append(
|
||||
{
|
||||
"seq": seq,
|
||||
"chunk_text": summary,
|
||||
"heading_path": heading,
|
||||
"context_prefix": context,
|
||||
"kb_kind": kb_kind,
|
||||
"visible_to": _rag_visible_to(source, chunk),
|
||||
"sensitivity": _rag_sensitivity(source, chunk, kb_kind),
|
||||
"meta": {
|
||||
"live_coaching_source": True,
|
||||
"source_type": str(chunk.get("source_type") or source.get("source_type") or ""),
|
||||
"source_version": version_label,
|
||||
"citation": chunk_citation,
|
||||
"license_class": _license_class(source),
|
||||
"external_llm_ok": bool(source.get("external_llm_ok", True)),
|
||||
"keywords": chunk.get("keywords") or [],
|
||||
},
|
||||
"token_count": max(1, len(summary) // 4),
|
||||
}
|
||||
)
|
||||
if not chunks:
|
||||
continue
|
||||
payloads.append(
|
||||
{
|
||||
"source_id": source_id,
|
||||
"doc_uri": f"live-coaching/{origin_path}",
|
||||
"version": _rag_version(source),
|
||||
"content_hash": _canonical_hash({"source": source, "chunks": chunks_in}),
|
||||
"chunks": chunks,
|
||||
}
|
||||
)
|
||||
return payloads
|
||||
|
||||
|
||||
def _local_reference_grounding(item: LiveCoachInput, *, limit: int = 5) -> list[LiveCoachGrounding]:
|
||||
query = " ".join(
|
||||
[
|
||||
item.stage,
|
||||
item.theory_mode,
|
||||
item.learner_text,
|
||||
item.client_reply or "",
|
||||
" ".join(str((item.evaluation or {}).get(k, "")) for k in ("appropriateness", "appropriateness_note")),
|
||||
]
|
||||
).lower()
|
||||
scored: list[tuple[int, int, dict[str, Any], dict[str, Any]]] = []
|
||||
fallback: list[tuple[int, dict[str, Any], dict[str, Any]]] = []
|
||||
for source_index, payload in enumerate(_local_source_payloads()):
|
||||
source = payload.get("source") or {}
|
||||
if source.get("external_llm_ok") is False:
|
||||
continue
|
||||
for chunk_index, chunk in enumerate(payload.get("chunks") or []):
|
||||
if not isinstance(chunk, dict):
|
||||
continue
|
||||
fallback.append((source_index * 1000 + chunk_index, source, chunk))
|
||||
keywords = [str(k).lower() for k in (chunk.get("keywords") or [])]
|
||||
score = sum(1 for kw in keywords if kw and kw in query)
|
||||
if score > 0:
|
||||
priority = int(source.get("priority") or 50)
|
||||
scored.append((score, priority, source, chunk))
|
||||
if not scored:
|
||||
scored = [(1, -order, source, chunk) for order, source, chunk in fallback[:limit]]
|
||||
scored.sort(key=lambda pair: (pair[0], pair[1]), reverse=True)
|
||||
out: list[LiveCoachGrounding] = []
|
||||
for _, _, source, chunk in scored[:limit]:
|
||||
out.append(
|
||||
LiveCoachGrounding(
|
||||
source_id=str(chunk.get("source_id") or source.get("source_id") or "live_coaching_source"),
|
||||
title=str(chunk.get("title") or source.get("title") or "라이브 코칭 KB"),
|
||||
locator=str(chunk.get("heading") or chunk.get("id") or "") or None,
|
||||
kb_kind=str(chunk.get("kb_kind") or "template"),
|
||||
source_type=str(chunk.get("source_type") or source.get("source_type") or "") or None,
|
||||
version=str(chunk.get("version") or source.get("version") or "") or None,
|
||||
citation=str(chunk.get("citation") or source.get("citation") or "") or None,
|
||||
summary=str(chunk.get("summary") or ""),
|
||||
)
|
||||
)
|
||||
return out
|
||||
|
||||
|
||||
def _source_refs(grounding: list[LiveCoachGrounding]) -> list[LiveCoachSource]:
|
||||
seen: set[tuple[str, str | None]] = set()
|
||||
refs: list[LiveCoachSource] = []
|
||||
for g in grounding:
|
||||
key = (g.source_id, g.locator)
|
||||
if key in seen:
|
||||
continue
|
||||
seen.add(key)
|
||||
refs.append(
|
||||
LiveCoachSource(
|
||||
source_id=g.source_id,
|
||||
title=g.title,
|
||||
locator=g.locator,
|
||||
kb_kind=g.kb_kind,
|
||||
source_type=g.source_type,
|
||||
version=g.version,
|
||||
citation=g.citation,
|
||||
)
|
||||
)
|
||||
return refs[:4]
|
||||
|
||||
|
||||
def _schema() -> dict[str, Any]:
|
||||
return {
|
||||
"type": "object",
|
||||
"additionalProperties": False,
|
||||
"properties": {
|
||||
"tone": {"type": "string", "enum": ["pos", "warn", "neutral"]},
|
||||
"focus": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"rapport",
|
||||
"exploration",
|
||||
"risk",
|
||||
"emotion",
|
||||
"cognition",
|
||||
"behavior",
|
||||
"interpersonal",
|
||||
"goal",
|
||||
"pacing",
|
||||
],
|
||||
},
|
||||
"title": {"type": "string"},
|
||||
"message": {"type": "string"},
|
||||
"next_utterance": {"type": ["string", "null"]},
|
||||
"rationale": {"type": ["string", "null"]},
|
||||
"safety_note": {"type": ["string", "null"]},
|
||||
},
|
||||
"required": ["tone", "focus", "title", "message"],
|
||||
}
|
||||
|
||||
|
||||
def _structured_payload(resp: GenerateResponse) -> Optional[dict[str, Any]]:
|
||||
if isinstance(resp.structured, dict):
|
||||
return resp.structured
|
||||
raw = (resp.text or "").strip()
|
||||
if not raw:
|
||||
return None
|
||||
if raw.startswith("```"):
|
||||
raw = raw.split("```", 2)[1] if raw.count("```") >= 2 else raw.strip("`")
|
||||
if raw.lstrip().lower().startswith("json"):
|
||||
raw = raw.lstrip()[4:]
|
||||
try:
|
||||
data = json.loads(raw)
|
||||
return data if isinstance(data, dict) else None
|
||||
except (json.JSONDecodeError, ValueError):
|
||||
start, end = raw.find("{"), raw.rfind("}")
|
||||
if 0 <= start < end:
|
||||
try:
|
||||
data = json.loads(raw[start : end + 1])
|
||||
return data if isinstance(data, dict) else None
|
||||
except (json.JSONDecodeError, ValueError):
|
||||
return None
|
||||
return None
|
||||
|
||||
|
||||
def _clip(value: Any, limit: int) -> Optional[str]:
|
||||
text = str(value or "").strip()
|
||||
if not text:
|
||||
return None
|
||||
return text if len(text) <= limit else text[: limit - 1].rstrip() + "…"
|
||||
|
||||
|
||||
def _coerce_payload(payload: dict[str, Any], *, sources: list[LiveCoachSource], latency_ms: int) -> LiveCoachSuggestion:
|
||||
tone = str(payload.get("tone") or "neutral")
|
||||
if tone not in ("pos", "warn", "neutral"):
|
||||
tone = "neutral"
|
||||
focus = str(payload.get("focus") or "exploration")
|
||||
allowed_focus = {
|
||||
"rapport",
|
||||
"exploration",
|
||||
"risk",
|
||||
"emotion",
|
||||
"cognition",
|
||||
"behavior",
|
||||
"interpersonal",
|
||||
"goal",
|
||||
"pacing",
|
||||
}
|
||||
if focus not in allowed_focus:
|
||||
focus = "exploration"
|
||||
return LiveCoachSuggestion(
|
||||
status="ready",
|
||||
tone=tone, # type: ignore[arg-type]
|
||||
focus=focus, # type: ignore[arg-type]
|
||||
title=_clip(payload.get("title"), 32) or "다음 발화 조정",
|
||||
message=_clip(payload.get("message"), 120) or "지금은 내담자 말을 더 구체적으로 따라가는 편이 낫다.",
|
||||
next_utterance=_clip(payload.get("next_utterance"), 140),
|
||||
rationale=_clip(payload.get("rationale"), 180),
|
||||
safety_note=_clip(payload.get("safety_note"), 120),
|
||||
sources=sources,
|
||||
latency_ms=latency_ms,
|
||||
)
|
||||
|
||||
|
||||
def _evaluation_tone(evaluation: Optional[dict[str, Any]]) -> Tone:
|
||||
if not evaluation:
|
||||
return "neutral"
|
||||
value = str(evaluation.get("appropriateness") or "neutral")
|
||||
if value == "pos":
|
||||
return "pos"
|
||||
if value == "warn":
|
||||
return "warn"
|
||||
return "neutral"
|
||||
|
||||
|
||||
def _fallback_suggestion(
|
||||
item: LiveCoachInput,
|
||||
*,
|
||||
grounding: list[LiveCoachGrounding],
|
||||
status: CoachStatus = "degraded",
|
||||
reason: Optional[str] = None,
|
||||
) -> LiveCoachSuggestion:
|
||||
text = item.learner_text
|
||||
low_open = item.effective_openness < 0.35
|
||||
tone: Tone = _evaluation_tone(item.evaluation)
|
||||
focus: CoachFocus = "exploration"
|
||||
title = "다음 탐색"
|
||||
message = "내담자 표현을 한 번 반영한 뒤, 방금 말한 장면을 더 구체적으로 물어봐라."
|
||||
next_line = "방금 말한 그 장면이 언제부터 특히 힘들게 느껴졌는지 조금만 더 들려줄래요?"
|
||||
|
||||
crisis = guardrail.classify_crisis(text)
|
||||
if crisis.kind != guardrail.CrisisKind.NONE:
|
||||
tone = "warn"
|
||||
focus = "risk"
|
||||
title = "안전 먼저"
|
||||
message = "위험 단서가 나온 턴이다. 방법을 캐묻지 말고 최근성, 강도, 보호요인을 차분히 확인해라."
|
||||
next_line = "그 생각이 최근에 얼마나 자주, 얼마나 강하게 올라오는지 안전을 위해 같이 확인해도 될까요?"
|
||||
elif any(word in text for word in ("해야", "해봐", "괜찮아", "그냥", "왜 안")):
|
||||
tone = "warn"
|
||||
focus = "rapport"
|
||||
title = "조언 속도 낮추기"
|
||||
message = "지금은 해결책보다 감정과 욕구 반영이 먼저다. 설득처럼 들릴 수 있는 표현을 줄여라."
|
||||
next_line = "그만큼 답답하고 막막해서 쉽게 움직이기 어려운 마음이 있는 것 같아요."
|
||||
elif any(word in text for word in ("느꼈", "마음", "감정", "속상", "힘들")):
|
||||
tone = "pos" if tone != "warn" else tone
|
||||
focus = "emotion"
|
||||
title = "감정 반영 유지"
|
||||
message = "감정으로 잘 들어갔다. 다음에는 그 감정 밑의 욕구나 구체 사건을 한 단계만 더 확인해라."
|
||||
next_line = "그 마음이 가장 크게 올라왔던 순간이 언제였는지 떠오르는 장면이 있을까요?"
|
||||
elif low_open:
|
||||
focus = "pacing"
|
||||
title = "짧게, 선택권 있게"
|
||||
message = "아직 개방도가 낮다. 질문을 좁히고, 내담자가 답하지 않을 권리도 함께 줘라."
|
||||
next_line = "대답하기 불편하면 넘어가도 괜찮아요. 그래도 지금 제일 덜 부담되는 얘기부터 해볼까요?"
|
||||
|
||||
if reason:
|
||||
rationale = f"AI 코칭 엔진은 {reason}. 현재 코칭은 워크북 루브릭과 규칙 기반 신호로 생성했다."
|
||||
else:
|
||||
rationale = "워크북의 첫 회기 사례개념화 틀과 현재 턴 신호를 기준으로 한 비차단 코칭이다."
|
||||
|
||||
return LiveCoachSuggestion(
|
||||
status=status,
|
||||
tone=tone,
|
||||
focus=focus,
|
||||
title=title,
|
||||
message=message,
|
||||
next_utterance=next_line,
|
||||
rationale=rationale,
|
||||
sources=_source_refs(grounding),
|
||||
latency_ms=0,
|
||||
)
|
||||
|
||||
|
||||
def _grounding_block(grounding: list[LiveCoachGrounding]) -> str:
|
||||
if not grounding:
|
||||
return "(근거 없음)"
|
||||
lines: list[str] = []
|
||||
for index, item in enumerate(grounding[:6], start=1):
|
||||
locator = f" / {item.locator}" if item.locator else ""
|
||||
version = f" / {item.version}" if item.version else ""
|
||||
citation = f"\n- 출처: {item.citation[:220]}" if item.citation else ""
|
||||
lines.append(
|
||||
f"[{index}] {item.source_id}{locator}{version} ({item.kb_kind})\n"
|
||||
f"- {item.summary[:360]}{citation}"
|
||||
)
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def _messages(item: LiveCoachInput, grounding: list[LiveCoachGrounding]) -> list[EngineMessage]:
|
||||
learner_masked = guardrail.mask_pii(item.learner_text).text_masked
|
||||
client_masked = guardrail.mask_pii(item.client_reply or "").text_masked
|
||||
recent = "\n".join(
|
||||
f"{'상담자' if t.get('speaker') == 'counselor' else '내담자'}: {t.get('text', '')}"
|
||||
for t in item.recent_turns[-6:]
|
||||
) or "(최근 맥락 없음)"
|
||||
evaluation = json.dumps(item.evaluation or {}, ensure_ascii=False)[:1200]
|
||||
system = (
|
||||
"당신은 심리상담 수련생에게 회기 중 즉시 피드백을 주는 라이브 코치다.\n"
|
||||
"목표는 지금 흐름을 끊지 않고 다음 상담자 발화 하나를 더 낫게 만드는 것이다.\n\n"
|
||||
"[절대 규칙]\n"
|
||||
"- 점수, 등급, 정답 공개, 페르소나 내부 설정(CCD/DSM 차원/상태 수치) 노출 금지.\n"
|
||||
"- 허가된 DSM/공식 지침/논문 요약 KB는 근거로 사용할 수 있다.\n"
|
||||
"- 그래도 진단 확정, 처방, 공식 문항·DSM 원문 장문 재현은 금지한다. 근거는 짧게 요약하고 출처 식별자만 남긴다.\n"
|
||||
"- 위기 단서가 있으면 코칭보다 안전 확인, 보호요인, 109/기관 연결 방향을 우선한다.\n"
|
||||
"- 메시지는 한국어 반말이 아니라 학습자 UI 문장체로 간결하게 쓴다.\n"
|
||||
"- next_utterance는 상담자가 바로 말할 수 있는 한 문장만 제시한다.\n\n"
|
||||
"[근거 기반 코칭 프레임]\n"
|
||||
"첫 회기에서는 내담자 언어의 호소를 신체/생리, 인지, 정서, 대처행동, 대인관계로 나누고, "
|
||||
"촉발사건과 가족/학교/또래 상호작용을 단정 없이 탐색한다. 감정은 먼저 타당화하고, "
|
||||
"위험 단서는 방법을 캐묻지 않은 채 안전 확인으로 다룬다. 목표와 전략은 생물/심리/사회 "
|
||||
"영역의 구체 행동으로 연결한다. DSM/지침 근거는 상담자 판단을 정렬하는 내부 참조이며, "
|
||||
"학습자에게는 관찰 가능한 상담 행동과 다음 발화로만 번역한다."
|
||||
)
|
||||
user = (
|
||||
f"[세션] {item.session_id} / turn {item.turn_seq}\n"
|
||||
f"[내담자] {item.persona_name} ({item.persona_code})\n"
|
||||
f"[단계] {item.stage} / openness {item.effective_openness:.2f} / 이론 {item.theory_mode}\n\n"
|
||||
f"[최근 맥락]\n{recent}\n\n"
|
||||
f"[이번 상담자 발화]\n{learner_masked}\n\n"
|
||||
f"[이어진 내담자 응답]\n{client_masked or '(아직 없음)'}\n\n"
|
||||
f"[fast-loop 평가 신호]\n{evaluation}\n\n"
|
||||
f"[검색/워크북 근거]\n{_grounding_block(grounding)}\n\n"
|
||||
"출력은 structured schema에 맞춰라. title은 16자 안팎, message는 120자 이내, "
|
||||
"next_utterance는 한 문장으로."
|
||||
)
|
||||
return [
|
||||
EngineMessage(role="system", content=system, cache=True),
|
||||
EngineMessage(role="user", content=user, cache=False),
|
||||
]
|
||||
|
||||
|
||||
async def _record_llm_audit(
|
||||
audit_hook: Optional["LlmAuditHook"],
|
||||
**payload: Any,
|
||||
) -> None:
|
||||
if audit_hook is None:
|
||||
return
|
||||
try:
|
||||
await audit_hook(payload)
|
||||
except Exception:
|
||||
return
|
||||
|
||||
|
||||
async def generate_live_coaching(
|
||||
item: LiveCoachInput,
|
||||
*,
|
||||
engine: EngineClient,
|
||||
grounding: Optional[list[LiveCoachGrounding]] = None,
|
||||
audit_hook: Optional["LlmAuditHook"] = None,
|
||||
) -> LiveCoachSuggestion:
|
||||
"""턴 직후 라이브 코칭을 생성한다. 실패해도 규칙 기반 제안으로 반환한다."""
|
||||
local_grounding = _local_reference_grounding(item)
|
||||
all_grounding = [*local_grounding, *(grounding or [])]
|
||||
crisis = guardrail.classify_crisis(item.learner_text)
|
||||
if crisis.escalate:
|
||||
return _fallback_suggestion(item, grounding=all_grounding, status="ready")
|
||||
|
||||
started = time.perf_counter()
|
||||
try:
|
||||
req = GenerateRequest(
|
||||
ai_role="evaluator",
|
||||
messages=_messages(item, all_grounding),
|
||||
structured_schema=_schema(),
|
||||
model=_configured_model(settings.evaluator_fast_model),
|
||||
max_tokens=700,
|
||||
temperature=0.2,
|
||||
session_id=item.session_id,
|
||||
metadata={"loop": "live_coach", "turn_seq": item.turn_seq, "stage": item.stage},
|
||||
)
|
||||
resp = await engine.generate(req)
|
||||
latency_ms = int((time.perf_counter() - started) * 1000)
|
||||
await _record_llm_audit(
|
||||
audit_hook,
|
||||
session_id=item.session_id,
|
||||
provider=resp.provider,
|
||||
model=resp.model,
|
||||
tokens_in=resp.tokens_in,
|
||||
tokens_out=resp.tokens_out,
|
||||
cost_usd=resp.cost_usd,
|
||||
inference_geo=resp.inference_geo,
|
||||
latency_ms=latency_ms,
|
||||
)
|
||||
payload = _structured_payload(resp)
|
||||
if payload is None:
|
||||
return _fallback_suggestion(
|
||||
item,
|
||||
grounding=all_grounding,
|
||||
reason="구조화 출력을 반환하지 않았다",
|
||||
)
|
||||
return _coerce_payload(payload, sources=_source_refs(all_grounding), latency_ms=latency_ms)
|
||||
except EngineError as exc:
|
||||
return _fallback_suggestion(item, grounding=all_grounding, reason=str(exc))
|
||||
except Exception as exc:
|
||||
return _fallback_suggestion(item, grounding=all_grounding, reason=str(exc))
|
||||
|
||||
|
||||
__all__ = [
|
||||
"LiveCoachEvent",
|
||||
"LiveCoachGrounding",
|
||||
"LiveCoachInput",
|
||||
"LiveCoachSource",
|
||||
"LiveCoachSuggestion",
|
||||
"generate_live_coaching",
|
||||
]
|
||||
|
|
@ -252,6 +252,7 @@ def _vector_literal(vec: Sequence[float]) -> str:
|
|||
# $7 = w_sparse(real)
|
||||
# $8 = pre_k (int — dense/sparse 각 후보 수, 보통 50)
|
||||
# $9 = k (int — 융합 후 반환 수)
|
||||
# $10 = source_ids(text[] — 빈 배열이면 전체 허용)
|
||||
#
|
||||
# 정보비대칭 강제: 두 CTE 모두 `$4 = ANY(visible_to) AND sensitivity <= $5` 사전필터.
|
||||
# kinds 빈 배열 처리: cardinality($3)=0 이면 kb_kind 조건을 통과(전체).
|
||||
|
|
@ -267,6 +268,7 @@ dense AS (
|
|||
FROM kb.chunk c, params p
|
||||
WHERE c.embedding IS NOT NULL
|
||||
AND (cardinality($3::text[]) = 0 OR c.kb_kind = ANY($3::text[]))
|
||||
AND (cardinality($10::text[]) = 0 OR c.source_id = ANY($10::text[]))
|
||||
AND $4 = ANY(c.visible_to)
|
||||
AND c.sensitivity <= $5
|
||||
ORDER BY c.embedding <=> p.q_dense
|
||||
|
|
@ -279,6 +281,7 @@ sparse AS (
|
|||
WHERE p.q_ts IS NOT NULL
|
||||
AND to_tsvector('simple', c.chunk_text) @@ p.q_ts
|
||||
AND (cardinality($3::text[]) = 0 OR c.kb_kind = ANY($3::text[]))
|
||||
AND (cardinality($10::text[]) = 0 OR c.source_id = ANY($10::text[]))
|
||||
AND $4 = ANY(c.visible_to)
|
||||
AND c.sensitivity <= $5
|
||||
ORDER BY s_sparse DESC
|
||||
|
|
@ -473,6 +476,7 @@ async def search_kb(
|
|||
fs = filters.get("sensitivity_max")
|
||||
if isinstance(fs, int):
|
||||
sens_max = min(sens_max, fs) # 더 엄격하게만
|
||||
source_filter = [str(item) for item in (filters.get("source_id", []) if filters else [])]
|
||||
|
||||
# (2) 질의 임베딩(dense+sparse). 모델 미가용 → NotConfigured 전파.
|
||||
eq = await asyncio.to_thread(embed_query, query) # CPU 인코딩 → 스레드풀(이벤트루프 비차단)
|
||||
|
|
@ -491,17 +495,13 @@ async def search_kb(
|
|||
policy.w_sparse, # $7
|
||||
pre_k, # $8 pre_k
|
||||
max(k * 4, k), # $9 융합 후 1차 컷(리랭킹 입력 여유분)
|
||||
source_filter, # $10 source_id 좁힘
|
||||
)
|
||||
except Exception as e: # UndefinedFunction(vector 미설치) / UndefinedColumn 등
|
||||
raise NotConfigured(f"KB hybrid query failed (DB/pgvector not ready): {e}") from e
|
||||
|
||||
# source_id 추가 좁힘(SQL 후처리 — 화이트리스트 보존, 코드 단순화)
|
||||
src_filter = set(filters.get("source_id", [])) if filters else set()
|
||||
|
||||
chunks: list[RetrievedChunk] = []
|
||||
for r in rows:
|
||||
if src_filter and r["source_id"] not in src_filter:
|
||||
continue
|
||||
# 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 {})
|
||||
|
|
@ -635,6 +635,7 @@ async def retrieve_eval_grounding(
|
|||
query: str,
|
||||
k: int = 5,
|
||||
kinds: Optional[Sequence[str]] = None,
|
||||
source_ids: Optional[Sequence[str]] = None,
|
||||
rerank: bool = True,
|
||||
) -> RetrievalResult:
|
||||
"""평가 AI 채점 근거 회수 — DSM/이론/taxonomy 정답라벨 + 논평.
|
||||
|
|
@ -644,13 +645,17 @@ async def retrieve_eval_grounding(
|
|||
|
||||
kinds: 평가 차원에 따라 좁히기(예: 기법 채점 → ['technique','supervisor_pattern']).
|
||||
"""
|
||||
filters = {"kb_kind": list(kinds)} if kinds else None
|
||||
filters: dict[str, Any] = {}
|
||||
if kinds:
|
||||
filters["kb_kind"] = list(kinds)
|
||||
if source_ids:
|
||||
filters["source_id"] = list(source_ids)
|
||||
return await search_kb(
|
||||
conn,
|
||||
query=query,
|
||||
role=AIRole.EVALUATOR,
|
||||
k=k,
|
||||
filters=filters,
|
||||
filters=filters or None,
|
||||
rerank=rerank,
|
||||
)
|
||||
|
||||
|
|
|
|||
247
apps/api/app/services/session_metrics.py
Normal file
247
apps/api/app/services/session_metrics.py
Normal file
|
|
@ -0,0 +1,247 @@
|
|||
"""Session-level learning metrics shared by learner and teacher dashboards."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime
|
||||
from typing import Any, Callable
|
||||
|
||||
from ..store import InProcSession
|
||||
|
||||
|
||||
_APPROPRIATENESS_SCORE = {
|
||||
"neg": 0.0,
|
||||
"warn": 0.25,
|
||||
"neutral": 0.5,
|
||||
"pos": 1.0,
|
||||
}
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class SessionGrowthPoint:
|
||||
session_id: str
|
||||
session_no: int
|
||||
persona_code: str
|
||||
stage: str
|
||||
started_at: str
|
||||
ended_at: str | None
|
||||
score: float | None = None
|
||||
rapport: float | None = None
|
||||
technique_count: int = 0
|
||||
watch_count: int = 0
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class LearnerGrowthMetrics:
|
||||
learner_id: str
|
||||
learner_label: str
|
||||
sessions: int
|
||||
ended_sessions: int
|
||||
latest_at: str
|
||||
first_score: float | None = None
|
||||
latest_score: float | None = None
|
||||
score_delta: float | None = None
|
||||
avg_score: float | None = None
|
||||
avg_rapport: float | None = None
|
||||
trend: str = "insufficient"
|
||||
top_techniques: list[str] = field(default_factory=list)
|
||||
points: list[SessionGrowthPoint] = field(default_factory=list)
|
||||
|
||||
|
||||
def iso_datetime(ts: float | None) -> str | None:
|
||||
if ts is None:
|
||||
return None
|
||||
return datetime.fromtimestamp(ts).isoformat(timespec="seconds")
|
||||
|
||||
|
||||
def session_activity_time(sess: InProcSession) -> float:
|
||||
return sess.ended_at or sess.created_at
|
||||
|
||||
|
||||
def safe_float(value: object) -> float | None:
|
||||
try:
|
||||
return float(value) # type: ignore[arg-type]
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
def avg(values: list[float]) -> float | None:
|
||||
if not values:
|
||||
return None
|
||||
return round(sum(values) / len(values), 3)
|
||||
|
||||
|
||||
def turn_eval(turn: Any) -> dict[str, Any] | None:
|
||||
ev = getattr(turn, "evaluation", None)
|
||||
return ev if isinstance(ev, dict) else None
|
||||
|
||||
|
||||
def turn_score(ev: dict[str, Any]) -> float | None:
|
||||
raw = str(ev.get("appropriateness") or "").strip().lower()
|
||||
return _APPROPRIATENESS_SCORE.get(raw)
|
||||
|
||||
|
||||
def turn_rapport(ev: dict[str, Any]) -> float | None:
|
||||
value = safe_float(ev.get("rapport_signal"))
|
||||
if value is None:
|
||||
return None
|
||||
return max(-1.0, min(1.0, value))
|
||||
|
||||
|
||||
def turn_techniques(ev: dict[str, Any]) -> list[str]:
|
||||
raw = ev.get("techniques")
|
||||
if not isinstance(raw, list):
|
||||
return []
|
||||
labels: list[str] = []
|
||||
for item in raw:
|
||||
if isinstance(item, dict):
|
||||
label = item.get("label") or item.get("name") or item.get("id") or item.get("code")
|
||||
else:
|
||||
label = item
|
||||
if label:
|
||||
labels.append(str(label))
|
||||
return labels
|
||||
|
||||
|
||||
def turn_feedback_note(ev: dict[str, Any]) -> str | None:
|
||||
raw = ev.get("appropriateness_note")
|
||||
if raw is None:
|
||||
return None
|
||||
text = str(raw).strip()
|
||||
return text or None
|
||||
|
||||
|
||||
def session_growth_point(sess: InProcSession) -> SessionGrowthPoint:
|
||||
scores: list[float] = []
|
||||
rapports: list[float] = []
|
||||
technique_count = 0
|
||||
watch_count = 0
|
||||
for turn in sess.turns:
|
||||
if turn.speaker != "counselor":
|
||||
continue
|
||||
ev = turn_eval(turn)
|
||||
if ev is None:
|
||||
continue
|
||||
score = turn_score(ev)
|
||||
if score is not None:
|
||||
scores.append(score)
|
||||
if score < 1.0:
|
||||
watch_count += 1
|
||||
rapport = turn_rapport(ev)
|
||||
if rapport is not None:
|
||||
rapports.append(rapport)
|
||||
technique_count += len(turn_techniques(ev))
|
||||
return SessionGrowthPoint(
|
||||
session_id=sess.session_id,
|
||||
session_no=sess.session_no,
|
||||
persona_code=sess.persona_code,
|
||||
stage=sess.state.stage.value,
|
||||
started_at=iso_datetime(sess.created_at) or "",
|
||||
ended_at=iso_datetime(sess.ended_at),
|
||||
score=avg(scores),
|
||||
rapport=avg(rapports),
|
||||
technique_count=technique_count,
|
||||
watch_count=watch_count,
|
||||
)
|
||||
|
||||
|
||||
def build_learner_growth(
|
||||
sessions: list[InProcSession],
|
||||
*,
|
||||
learner_label: Callable[[str], str],
|
||||
limit: int | None = None,
|
||||
) -> list[LearnerGrowthMetrics]:
|
||||
grouped: dict[str, list[InProcSession]] = {}
|
||||
for sess in sessions:
|
||||
grouped.setdefault(sess.learner_id, []).append(sess)
|
||||
|
||||
result: list[LearnerGrowthMetrics] = []
|
||||
for learner_id, learner_sessions in grouped.items():
|
||||
ordered = sorted(learner_sessions, key=lambda sess: sess.created_at)
|
||||
points = [session_growth_point(sess) for sess in ordered]
|
||||
scored = [point for point in points if point.score is not None]
|
||||
rapport_values = [point.rapport for point in points if point.rapport is not None]
|
||||
technique_counts: dict[str, int] = {}
|
||||
for sess in ordered:
|
||||
for turn in sess.turns:
|
||||
if turn.speaker != "counselor":
|
||||
continue
|
||||
ev = turn_eval(turn)
|
||||
if ev is None:
|
||||
continue
|
||||
for label in turn_techniques(ev):
|
||||
technique_counts[label] = technique_counts.get(label, 0) + 1
|
||||
|
||||
first_score = scored[0].score if scored else None
|
||||
latest_score = scored[-1].score if scored else None
|
||||
score_delta: float | None = None
|
||||
trend = "insufficient"
|
||||
if first_score is not None and latest_score is not None:
|
||||
score_delta = round(latest_score - first_score, 3)
|
||||
if len(scored) >= 2:
|
||||
if score_delta >= 0.1:
|
||||
trend = "up"
|
||||
elif score_delta <= -0.1:
|
||||
trend = "down"
|
||||
else:
|
||||
trend = "flat"
|
||||
|
||||
latest_session = ordered[-1]
|
||||
top_techniques = [
|
||||
label
|
||||
for label, _count in sorted(
|
||||
technique_counts.items(),
|
||||
key=lambda item: (-item[1], item[0]),
|
||||
)[:3]
|
||||
]
|
||||
result.append(
|
||||
LearnerGrowthMetrics(
|
||||
learner_id=learner_id,
|
||||
learner_label=learner_label(learner_id),
|
||||
sessions=len(ordered),
|
||||
ended_sessions=sum(1 for sess in ordered if sess.ended),
|
||||
latest_at=iso_datetime(session_activity_time(latest_session)) or "",
|
||||
first_score=first_score,
|
||||
latest_score=latest_score,
|
||||
score_delta=score_delta,
|
||||
avg_score=avg([point.score for point in scored if point.score is not None]),
|
||||
avg_rapport=avg([value for value in rapport_values if value is not None]),
|
||||
trend=trend,
|
||||
top_techniques=top_techniques,
|
||||
points=points[-6:],
|
||||
)
|
||||
)
|
||||
sorted_result = sorted(result, key=lambda item: item.latest_at, reverse=True)
|
||||
return sorted_result if limit is None else sorted_result[:limit]
|
||||
|
||||
|
||||
def recent_feedback_notes(sessions: list[InProcSession], *, limit: int = 5) -> list[dict[str, object]]:
|
||||
notes: list[dict[str, object]] = []
|
||||
for sess in sorted(sessions, key=session_activity_time, reverse=True):
|
||||
for turn in reversed(sess.turns):
|
||||
if turn.speaker != "counselor":
|
||||
continue
|
||||
ev = turn_eval(turn)
|
||||
if ev is None:
|
||||
continue
|
||||
note = turn_feedback_note(ev)
|
||||
if note is None:
|
||||
continue
|
||||
notes.append(
|
||||
{
|
||||
"session_id": sess.session_id,
|
||||
"persona_code": sess.persona_code,
|
||||
"persona_name": sess.persona.display_name,
|
||||
"session_no": sess.session_no,
|
||||
"stage": turn.stage,
|
||||
"turn_seq": turn.turn_seq,
|
||||
"created_at": iso_datetime(turn.created_at) or iso_datetime(session_activity_time(sess)) or "",
|
||||
"score": turn_score(ev),
|
||||
"rapport": turn_rapport(ev),
|
||||
"note": note,
|
||||
"techniques": turn_techniques(ev)[:3],
|
||||
}
|
||||
)
|
||||
if len(notes) >= limit:
|
||||
return notes
|
||||
return notes
|
||||
|
|
@ -21,11 +21,12 @@ from __future__ import annotations
|
|||
import re
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import AsyncIterator, Optional
|
||||
from typing import Any, AsyncIterator, Mapping, Optional
|
||||
|
||||
import httpx
|
||||
|
||||
from ..config import settings
|
||||
from ..paths import repo_root, repo_path
|
||||
|
||||
# ════════════════════════════════════════════════════════════════════════════
|
||||
# OpenAI 음성 엔드포인트/모델 상수
|
||||
|
|
@ -50,10 +51,9 @@ TTS_RESPONSE_FORMAT = "mp3"
|
|||
# End-of-turn readiness default for cascaded STT providers.
|
||||
EOT_SILENCE_THRESHOLD_MS = 1200
|
||||
|
||||
_REPO_ROOT = Path(__file__).resolve().parents[4]
|
||||
POC_SAMPLE_TTS_PRESET = "soft-young-fem"
|
||||
POC_SAMPLE_TTS_DEFAULT_DIR = (
|
||||
_REPO_ROOT / "docs" / "voice-art" / "p1-seoyeon-higgs-v3-20260627"
|
||||
repo_path("docs", "voice-art", "p1-seoyeon-higgs-v3-20260627")
|
||||
)
|
||||
POC_SAMPLE_TTS_CHUNK_SIZE = 4096
|
||||
_POC_SAMPLE_TTS_DEFAULT_SAMPLE = "p1_seoyeon_01_depressed_slow"
|
||||
|
|
@ -190,6 +190,51 @@ def resolve_voice(
|
|||
)
|
||||
|
||||
|
||||
def resolve_voice_from_map(
|
||||
*,
|
||||
provider: str,
|
||||
voice_id: str,
|
||||
base_params: Mapping[str, Any] | None,
|
||||
persona_code: Optional[str] = None,
|
||||
) -> VoicePreset | None:
|
||||
"""DB persona_voice_map row -> live OpenAI VoicePreset.
|
||||
|
||||
provider-agnostic rows are allowed in the catalog, but this service only
|
||||
knows how to send OpenAI TTS. Unsupported providers return None so callers
|
||||
can fall back to the existing preset resolver.
|
||||
"""
|
||||
if provider.strip().lower() != "openai":
|
||||
return None
|
||||
|
||||
fallback = resolve_voice(persona_code=persona_code)
|
||||
params = dict(base_params or {})
|
||||
preset = _clean_optional_text(params.get("preset")) or fallback.preset
|
||||
mapped_voice = _clean_optional_text(params.get("openai_voice"))
|
||||
voice_id_value = _clean_optional_text(voice_id)
|
||||
if not mapped_voice and voice_id_value in _OPENAI_VOICES:
|
||||
mapped_voice = voice_id_value
|
||||
if not mapped_voice:
|
||||
mapped_voice = PRESET_TO_OPENAI_VOICE.get(preset, fallback.openai_voice)
|
||||
if mapped_voice not in _OPENAI_VOICES:
|
||||
mapped_voice = fallback.openai_voice
|
||||
if mapped_voice not in _OPENAI_VOICES:
|
||||
mapped_voice = DEFAULT_OPENAI_VOICE
|
||||
|
||||
rate = PRESET_RATE.get(preset, fallback.rate)
|
||||
if "rate" in params:
|
||||
try:
|
||||
rate = float(params["rate"])
|
||||
except (TypeError, ValueError):
|
||||
rate = fallback.rate
|
||||
|
||||
return VoicePreset(
|
||||
preset=preset,
|
||||
openai_voice=mapped_voice,
|
||||
rate=rate,
|
||||
instructions=_clean_optional_text(params.get("instructions")),
|
||||
)
|
||||
|
||||
|
||||
# 비언어 지문 패턴: (…)·(…)·[…]·【…】. 내담자 발화의 무대지시(고개 끄덕/한숨/침묵 등).
|
||||
_STAGE_DIRECTION_RE = re.compile(r"[\((\[【][^\))\]】]*[\))\]】]")
|
||||
|
||||
|
|
@ -291,7 +336,7 @@ class VoiceService:
|
|||
)
|
||||
sample_dir = Path(sample_dir_value)
|
||||
if not sample_dir.is_absolute():
|
||||
sample_dir = _REPO_ROOT / sample_dir
|
||||
sample_dir = repo_root() / sample_dir
|
||||
self._poc_sample_tts_dir = sample_dir
|
||||
self._client: Optional[httpx.AsyncClient] = None
|
||||
|
||||
|
|
@ -507,6 +552,13 @@ def _nonnegative_int(value: object) -> int:
|
|||
return 0
|
||||
|
||||
|
||||
def _clean_optional_text(value: object) -> str | None:
|
||||
if value is None:
|
||||
return None
|
||||
text = str(value).strip()
|
||||
return text or None
|
||||
|
||||
|
||||
# 앱 전역 싱글톤 (main lifespan 이 startup/shutdown — Foundation 이 관리하거나
|
||||
# 라우트가 lazy 사용). engine_client 패턴과 동일.
|
||||
voice_service = VoiceService()
|
||||
|
|
@ -521,6 +573,7 @@ __all__ = [
|
|||
"VoiceService",
|
||||
"voice_service",
|
||||
"resolve_voice",
|
||||
"resolve_voice_from_map",
|
||||
"build_tts_payload",
|
||||
"assess_end_of_turn",
|
||||
"EOT_SILENCE_THRESHOLD_MS",
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue