vignette/apps/api/app/services/live_coach.py
2026-08-28 19:55:04 +09:00

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