음성 재생과 운영 배포 정리

This commit is contained in:
Yun Chan 2026-06-28 12:18:20 +09:00
parent 8ed185ce6c
commit ac7db95542
1020 changed files with 46863 additions and 2175 deletions

View 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",
]

View file

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

View 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

View file

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