vignette/apps/api/app/services/live_coach.py
Yun Chan 778e8526d4 세션 평가·라이브코치·교수자 분석 라운드 마감 + 문서 정리 + 코드품질 리팩터
- 누적 작업트리 커밋: 회기 평가 복구·durable 저장, 라이브 코치 이력/근거, 교수자 학생분석, 음성 비언어 메타, PII 마스킹, 운영 티켓/헬스 등
- 문서: 완료 기록 docs/archive/ 냉동 보관, docs/ 단일 인덱스(docs/README.md)+통합 TODO(docs/TODO.md)로 정리
- 리팩터(행위 보존): Stage enum SSOT(taxonomy 소유·state_machine re-export), store recent/masked_turns 중복 제거, speaker_ko_label 단일 헬퍼, _list_sessions N+1 제거(state/turns 배치 + 턴평가 하이드레이션 배치)
- 검증: 백엔드 pytest 352 passed, _list_sessions E2E chromium-single-run 2 passed
2026-07-02 02:50:36 +09:00

729 lines
27 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",
]
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
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_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()
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 _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/지침 근거는 상담자 판단을 정렬하는 내부 참조이며, "
"학습자에게는 관찰 가능한 상담 행동과 다음 발화로만 번역한다."
)
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",
"LiveCoachCreditEvent",
"LiveCoachQuota",
"LiveCoachSource",
"LiveCoachSuggestion",
"clear_local_source_pack_cache",
"generate_live_coaching",
]