676 lines
26 KiB
Python
676 lines
26 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 . 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",
|
|
]
|