928 lines
37 KiB
Python
928 lines
37 KiB
Python
"""라이브 코칭 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, field_validator
|
|
|
|
from ..config import settings
|
|
from ..contracts.engine_gateway import structured_payload_from_response
|
|
from ..engine_client import EngineClient, EngineError, EngineMessage, GenerateRequest
|
|
from ..paths import repo_root, repo_path
|
|
from ..session_read_model import StageLabel, stage_label_or_none
|
|
from ..taxonomy import speaker_ko_label
|
|
from . import guardrail
|
|
|
|
if TYPE_CHECKING:
|
|
from .orchestrator import LlmAuditHook
|
|
|
|
|
|
Tone = Literal["pos", "warn", "neutral"]
|
|
CoachStatus = Literal["ready", "degraded"]
|
|
CoachCreditEventType = Literal["use", "recharge"]
|
|
CoachFocus = Literal[
|
|
"rapport",
|
|
"exploration",
|
|
"risk",
|
|
"emotion",
|
|
"cognition",
|
|
"behavior",
|
|
"interpersonal",
|
|
"goal",
|
|
"pacing",
|
|
]
|
|
SafetySignalKind = Literal["ideation", "self_harm", "urgent"]
|
|
|
|
|
|
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
|
|
persistence_source: Literal["database", "runtime"] = "database"
|
|
quota: Optional["LiveCoachQuota"] = None
|
|
credit_events: list["LiveCoachCreditEvent"] = Field(default_factory=list)
|
|
|
|
|
|
class LiveCoachQuota(BaseModel):
|
|
"""회기 중 즉시 코칭 사용 가능 횟수."""
|
|
|
|
remaining: int = Field(ge=0)
|
|
max: int = Field(ge=1)
|
|
|
|
|
|
class LiveCoachCreditEvent(BaseModel):
|
|
"""코칭 기회 사용/충전 학습 기록."""
|
|
|
|
event_id: str
|
|
session_id: str
|
|
turn_seq: int
|
|
stage: StageLabel | None = None
|
|
event_type: CoachCreditEventType
|
|
delta: int
|
|
balance: int = Field(ge=0)
|
|
reason: str
|
|
created_at: str
|
|
|
|
@field_validator("stage", mode="before")
|
|
@classmethod
|
|
def _normalize_stage(cls, value: object) -> StageLabel | None:
|
|
return stage_label_or_none(value)
|
|
|
|
|
|
class LiveCoachEvent(BaseModel):
|
|
"""회기 중 실제로 전달된 라이브 코칭 이력."""
|
|
|
|
event_id: str
|
|
session_id: str
|
|
turn_seq: int
|
|
stage: StageLabel | None = None
|
|
created_at: str
|
|
learner_text_excerpt: Optional[str] = None
|
|
client_reply_excerpt: Optional[str] = None
|
|
suggestion: LiveCoachSuggestion
|
|
|
|
@field_validator("stage", mode="before")
|
|
@classmethod
|
|
def _normalize_stage(cls, value: object) -> StageLabel | None:
|
|
return stage_label_or_none(value)
|
|
|
|
|
|
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
|
|
question: Optional[str] = None
|
|
client_reply: Optional[str] = None
|
|
recent_turns: list[dict[str, str]] = Field(default_factory=list)
|
|
evaluation: Optional[dict[str, Any]] = None
|
|
# 이번 회기 목표 단계(P1 준비 페이지 선택) — 코칭을 회기 목표에 정렬한다.
|
|
goal_stages: list[str] = Field(default_factory=list)
|
|
# 직전 코칭 요약(title/focus) — 같은 조언 반복을 막는다.
|
|
prior_coach: list[dict[str, str]] = Field(default_factory=list)
|
|
|
|
|
|
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
|
|
license_class: Literal["A", "B", "C", "D"] | None = None
|
|
external_llm_ok: bool = False
|
|
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",
|
|
}
|
|
LOCAL_SOURCE_PACK_CACHE_KEY = "repo:data/kb/live_coaching_workbook_0615.json+data/kb/live_coaching_sources/*.json"
|
|
LOCAL_SOURCE_PACK_CACHE_LIFETIME = "api-process"
|
|
|
|
|
|
def _configured_model(value: str | None) -> str | None:
|
|
model = (value or "").strip()
|
|
return model or None
|
|
|
|
|
|
_SELF_HARM_SIGNAL_CUES = ("자해", "스스로를 다치게", "스스로 다치게")
|
|
_URGENT_SAFETY_SIGNAL_CUES = (
|
|
"안전을 스스로 지키기 어렵",
|
|
"안전을 지키기 어렵",
|
|
"위험할 것 같",
|
|
)
|
|
_PERSONA_SAFETY_NOTE = (
|
|
"가상내담자 위기 신호에 대한 훈련 코칭입니다. 즉각 위험을 직접 확인하고 기관 지침에 따라 "
|
|
"보호체계 연결과 지도·지원을 이어가세요."
|
|
)
|
|
|
|
|
|
def _safety_signal_kind(item: LiveCoachInput) -> SafetySignalKind | None:
|
|
"""이번 턴의 위기 내용을 화자 역할과 분리해 코칭 우선순위로 바꾼다."""
|
|
|
|
client_text = item.client_reply or ""
|
|
client_matches = guardrail.crisis_signal_matches(client_text)
|
|
learner_crisis = guardrail.classify_crisis(item.learner_text)
|
|
if not client_matches and learner_crisis.kind == guardrail.CrisisKind.NONE:
|
|
return None
|
|
|
|
signal_text = f"{item.learner_text} {client_text}"
|
|
if any(cue in signal_text for cue in _URGENT_SAFETY_SIGNAL_CUES):
|
|
return "urgent"
|
|
if any(cue in signal_text for cue in _SELF_HARM_SIGNAL_CUES):
|
|
return "self_harm"
|
|
return "ideation"
|
|
|
|
|
|
@lru_cache(maxsize=1)
|
|
def _local_source_entries() -> tuple[tuple[str, dict[str, Any]], ...]:
|
|
"""Load repo-managed live-coach source packs.
|
|
|
|
Cache ownership: live turn generation keeps this process-local snapshot to
|
|
avoid per-turn file IO. Admin/CLI source-pack sync is the invalidation
|
|
boundary because that path explicitly compares repo files with kb.document.
|
|
"""
|
|
|
|
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 clear_local_source_pack_cache() -> None:
|
|
"""Invalidate the process-local source pack snapshot for admin/CLI refresh."""
|
|
|
|
_local_source_entries.cache_clear()
|
|
|
|
|
|
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_title": title,
|
|
"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()
|
|
safety_kind = _safety_signal_kind(item)
|
|
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 {}
|
|
license_class = str(source.get("license_class") or "").strip().upper()
|
|
if source.get("external_llm_ok") is not True or license_class not in {"A", "B"}:
|
|
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 safety_kind and source.get("source_id") == "official_suicide_risk_guidelines":
|
|
chunk_id = str(chunk.get("id") or "")
|
|
if chunk_id == "risk-ideation-plan-intent-behavior":
|
|
score += 80
|
|
if safety_kind == "self_harm" and chunk_id == "self-harm-psychosocial-assessment":
|
|
score += 120
|
|
if safety_kind == "urgent" and chunk_id == "safety-plan-not-contract":
|
|
score += 130
|
|
if safety_kind == "urgent" and chunk_id == "korea-109-emergency-connection":
|
|
score += 140
|
|
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]:
|
|
source_license_class = str(source.get("license_class") or "").strip().upper()
|
|
if source_license_class not in {"A", "B"}:
|
|
continue
|
|
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,
|
|
license_class=source_license_class,
|
|
external_llm_ok=source.get("external_llm_ok") is True,
|
|
summary=str(chunk.get("summary") or ""),
|
|
)
|
|
)
|
|
return out
|
|
|
|
|
|
def _external_safe_grounding(
|
|
grounding: list[LiveCoachGrounding],
|
|
) -> list[LiveCoachGrounding]:
|
|
"""외부 엔진 요청에 명시적으로 허용된 A/B 근거만 전달한다."""
|
|
|
|
return [
|
|
item
|
|
for item in grounding
|
|
if item.external_llm_ok is True and item.license_class in {"A", "B"}
|
|
]
|
|
|
|
|
|
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 _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 _mask_generated_text(
|
|
value: Any,
|
|
*,
|
|
client_identity: str | None,
|
|
limit: int,
|
|
) -> Optional[str]:
|
|
text = str(value or "").strip()
|
|
if not text:
|
|
return None
|
|
masked = guardrail.mask_role_identities(
|
|
text,
|
|
client_identity=client_identity,
|
|
synthetic_generated=True,
|
|
).text_masked
|
|
return _clip(masked, limit)
|
|
|
|
|
|
def _coerce_payload(
|
|
payload: dict[str, Any],
|
|
*,
|
|
sources: list[LiveCoachSource],
|
|
latency_ms: int,
|
|
client_identity: str | None = None,
|
|
) -> 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=_mask_generated_text(
|
|
payload.get("title"), client_identity=client_identity, limit=32
|
|
)
|
|
or "다음 발화 조정",
|
|
message=_mask_generated_text(
|
|
payload.get("message"), client_identity=client_identity, limit=120
|
|
)
|
|
or "지금은 내담자 말을 더 구체적으로 따라가는 편이 낫다.",
|
|
next_utterance=_mask_generated_text(
|
|
payload.get("next_utterance"),
|
|
client_identity=client_identity,
|
|
limit=140,
|
|
),
|
|
rationale=_mask_generated_text(
|
|
payload.get("rationale"), client_identity=client_identity, limit=180
|
|
),
|
|
safety_note=_mask_generated_text(
|
|
payload.get("safety_note"), client_identity=client_identity, limit=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
|
|
safety_kind = _safety_signal_kind(item)
|
|
learner_crisis = guardrail.classify_crisis(item.learner_text)
|
|
safety_note_for_signal = (
|
|
guardrail.CRISIS_RESOURCE_MESSAGE
|
|
if learner_crisis.escalate
|
|
else _PERSONA_SAFETY_NOTE
|
|
)
|
|
low_open = item.effective_openness < 0.35
|
|
tone: Tone = _evaluation_tone(item.evaluation)
|
|
focus: CoachFocus = "exploration"
|
|
title = "다음 탐색"
|
|
message = "내담자 표현을 한 번 반영한 뒤, 방금 말한 장면을 더 구체적으로 물어봐라."
|
|
safety_note: Optional[str] = None
|
|
# 단계별 기본 다음 발화 — 폴백에서도 회기 흐름에 맞는 제안을 낸다.
|
|
stage_next_lines = {
|
|
"라포": "오늘 이렇게 시간 내줘서 고마워요. 지금 마음이 어떤지 편한 만큼만 들려줄래요?",
|
|
"탐색": "방금 말한 그 장면이 언제부터 특히 힘들게 느껴졌는지 조금만 더 들려줄래요?",
|
|
"개입": "그 생각이 올라올 때 몸이나 행동은 어떻게 반응하는지 같이 한번 살펴볼까요?",
|
|
"정리": "오늘 나눈 이야기 중에 가장 마음에 남는 것 하나를 같이 정리해 볼까요?",
|
|
}
|
|
next_line = stage_next_lines.get(
|
|
str(item.stage),
|
|
"방금 말한 그 장면이 언제부터 특히 힘들게 느껴졌는지 조금만 더 들려줄래요?",
|
|
)
|
|
|
|
q = (item.question or "").strip()
|
|
if safety_kind == "urgent":
|
|
tone = "warn"
|
|
focus = "risk"
|
|
title = "긴급 안전 연결"
|
|
message = (
|
|
"일반 회기 목표를 멈추고 현재 안전을 확인해라. 혼자 두지 말고 현장 지지자와 109를 연결하며, "
|
|
"즉각 위험하면 119 또는 가까운 응급실을 우선한다."
|
|
)
|
|
next_line = "지금 혼자 계신가요? 곁에 함께 있어 줄 사람과 즉시 연결해도 될까요?"
|
|
safety_note = safety_note_for_signal
|
|
elif safety_kind == "self_harm":
|
|
tone = "warn"
|
|
focus = "risk"
|
|
title = "자해 안전 확인"
|
|
message = (
|
|
"자해를 관심 끌기나 의지 문제로 단정하지 말고 현재 안전, 심리사회적 맥락, 반복 위험과 "
|
|
"지지체계를 비판단적으로 확인해라. 구체적인 수단을 캐묻지 말고, 필요한 전문기관에 "
|
|
"연결한 뒤 안전 상태를 추후 확인할 계획을 함께 세워라."
|
|
)
|
|
next_line = "지금은 안전한지, 그리고 다시 다치지 않도록 곁에서 도와줄 사람이 있는지 확인해도 될까요?"
|
|
safety_note = safety_note_for_signal
|
|
elif safety_kind == "ideation":
|
|
tone = "warn"
|
|
focus = "risk"
|
|
title = "안전 먼저"
|
|
message = (
|
|
"위험 단서를 무시하거나 단정하지 말고 현재 사고의 최근성·빈도·강도, 계획 유무·의도·"
|
|
"위험 수단 접근 가능성을 안전 확보에 필요한 범위에서 직접 확인해라. 지지자·살아갈 이유·"
|
|
"도움 요청 가능성 같은 보호요인도 확인하되, 구체적 방법을 캐묻거나 묘사·교육하지 않는다."
|
|
)
|
|
next_line = "그 생각이 최근에 얼마나 자주, 얼마나 강하게 올라오는지 안전을 위해 같이 확인해도 될까요?"
|
|
safety_note = safety_note_for_signal
|
|
elif q and any(k in q for k in ("저항", "방어", "닫", "안 열", "거부")):
|
|
focus = "rapport"
|
|
title = "저항 수용과 안전감"
|
|
message = "내담자의 방어는 자연스러운 안전 전략입니다. 방어를 깨뜨리려 하지 말고 그 부담감을 먼저 수용해 주세요."
|
|
next_line = "지금 이 이야기를 꺼내는 것 자체가 많이 조심스럽고 부담되셨을 것 같아요."
|
|
elif q and any(k in q for k in ("공감", "라포", "위로", "마음", "감정")):
|
|
focus = "emotion"
|
|
title = "공감적 감정 명명"
|
|
message = "내담자가 겪고 있는 혼란스러운 마음에 이름을 붙여주고, 타당화하는 반영을 시도하세요."
|
|
next_line = "혼자서 그 모든 감정을 감당하느라 많이 지치고 외로우셨을 것 같습니다."
|
|
elif q and any(k in q for k in ("탐색", "질문", "원인", "호소", "사건")):
|
|
focus = "exploration"
|
|
title = "열린 초점 탐색"
|
|
message = "구체적인 촉발 사건과 그때의 신체 반응이나 생각을 묻는 열린 질문을 던져보세요."
|
|
next_line = "그런 생각이 들었을 때, 몸에서는 어떤 느낌이나 신호가 먼저 느껴졌나요?"
|
|
elif q and any(k in q for k in ("다음", "추천", "어떻게", "뭐라", "발화")):
|
|
focus = "exploration"
|
|
title = "다음 개입 추천"
|
|
message = f"현재 [{item.stage}] 단계 흐름에 맞춰 내담자의 감정을 먼저 수용하고 열린 질문으로 이어가세요."
|
|
next_line = stage_next_lines.get(str(item.stage), "그 상황에서 가장 크게 느껴졌던 마음은 어떤 것이었나요?")
|
|
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),
|
|
safety_note=safety_note,
|
|
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 _mask_prompt_text(value: object) -> str:
|
|
return guardrail.mask_pii(str(value or "")).text_masked
|
|
|
|
|
|
def _mask_prompt_value(value: Any) -> Any:
|
|
if isinstance(value, str):
|
|
return _mask_prompt_text(value)
|
|
if isinstance(value, dict):
|
|
return {
|
|
_mask_prompt_text(key): _mask_prompt_value(child)
|
|
for key, child in value.items()
|
|
}
|
|
if isinstance(value, list):
|
|
return [_mask_prompt_value(child) for child in value]
|
|
if isinstance(value, tuple):
|
|
return [_mask_prompt_value(child) for child in value]
|
|
return value
|
|
|
|
|
|
def _messages(item: LiveCoachInput, grounding: list[LiveCoachGrounding]) -> list[EngineMessage]:
|
|
learner_masked = _mask_prompt_text(item.learner_text)
|
|
client_masked = _mask_prompt_text(item.client_reply or "")
|
|
recent = "\n".join(
|
|
f"{speaker_ko_label(t.get('speaker'))}: {_mask_prompt_text(t.get('text', ''))}"
|
|
for t in item.recent_turns[-6:]
|
|
) or "(최근 맥락 없음)"
|
|
evaluation = json.dumps(_mask_prompt_value(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/지침 근거는 상담자 판단을 정렬하는 내부 참조이며, "
|
|
"학습자에게는 관찰 가능한 상담 행동과 다음 발화로만 번역한다."
|
|
)
|
|
goals = ", ".join(item.goal_stages) if item.goal_stages else "(미지정)"
|
|
prior = (
|
|
"\n".join(
|
|
f"- {_mask_generated_text(entry.get('title'), client_identity=item.persona_name, limit=80) or ''} "
|
|
f"(focus: {entry.get('focus', '')})"
|
|
for entry in item.prior_coach[-2:]
|
|
if entry.get("title")
|
|
)
|
|
or "(이번 회기 첫 코칭)"
|
|
)
|
|
question_block = (
|
|
f"[상담자의 질문/요청]\n{_mask_prompt_text(item.question)}\n(상담자의 질문에 집중하여 조언과 추천 발화를 생성한다)\n\n"
|
|
if item.question
|
|
else ""
|
|
)
|
|
user = (
|
|
f"[세션] {item.session_id} / turn {item.turn_seq}\n"
|
|
f"[내담자] [CLIENT] ({item.persona_code})\n"
|
|
f"[단계] {item.stage} / openness {item.effective_openness:.2f} / 이론 {item.theory_mode}\n"
|
|
f"[이번 회기 목표 단계] {goals} — 코칭은 목표 단계 작업에 정렬하고, 목표를 이미 이뤘다면 심화를 제안한다.\n"
|
|
f"[직전 코칭]\n{prior}\n(같은 조언을 반복하지 말고 다음 단계를 제시한다)\n\n"
|
|
f"[최근 맥락]\n{recent}\n\n"
|
|
f"{question_block}"
|
|
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,
|
|
) -> bool:
|
|
if audit_hook is None:
|
|
return True
|
|
try:
|
|
result = await audit_hook(payload)
|
|
return result is not False
|
|
except Exception:
|
|
return False
|
|
|
|
|
|
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 = _external_safe_grounding([*local_grounding, *(grounding or [])])
|
|
crisis = guardrail.classify_crisis(item.learner_text)
|
|
client_crisis = bool(guardrail.crisis_signal_matches(item.client_reply or ""))
|
|
if crisis.escalate or client_crisis:
|
|
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)
|
|
audit_ok = 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,
|
|
)
|
|
if not audit_ok:
|
|
return _fallback_suggestion(
|
|
item,
|
|
grounding=all_grounding,
|
|
reason="응답 검증 기록을 남기지 못했다",
|
|
)
|
|
payload = structured_payload_from_response(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,
|
|
client_identity=item.persona_name,
|
|
)
|
|
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",
|
|
"LiveCoachCreditEvent",
|
|
"LiveCoachQuota",
|
|
"LiveCoachSource",
|
|
"LiveCoachSuggestion",
|
|
"clear_local_source_pack_cache",
|
|
"generate_live_coaching",
|
|
]
|