"""라이브 코칭 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 . 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: 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 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", } 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 @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_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 _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_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) 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", "clear_local_source_pack_cache", "generate_live_coaching", ]