현재 작업 전체 반영

This commit is contained in:
Yun Chan 2026-06-27 16:08:41 +09:00
parent 5560638e54
commit c0dddab594
85 changed files with 11322 additions and 539 deletions

View file

@ -4,6 +4,7 @@ from __future__ import annotations
import time
import uuid
from dataclasses import dataclass
from datetime import datetime, timezone
from typing import Any, Iterable
@ -25,6 +26,12 @@ _APPROPRIATENESS_SCORE = {
}
@dataclass(slots=True)
class CaseContext:
case_id: str
last_session_no: int
_JOINED_CARD_COLUMNS = (
"card_persona_id",
"card_code",
@ -89,6 +96,16 @@ def _safe_float(value: Any) -> float | None:
return None
def _safe_int(value: Any) -> int | None:
if isinstance(value, bool):
return int(value)
if isinstance(value, int):
return value
if isinstance(value, float):
return int(value)
return None
def _evaluation_loop(evaluation: dict[str, Any]) -> str:
loop = _clean_text(evaluation.get("loop")) or "fast"
return loop if loop in {"fast", "deep"} else "fast"
@ -208,6 +225,25 @@ def _evaluation_comment_rows(evaluation: dict[str, Any] | None) -> list[dict[str
return [{"kind": "critique", "text": note, "intent_deviation": deviation}]
def _evaluation_alternative_rows(evaluation: dict[str, Any] | None) -> list[dict[str, str | None]]:
if not isinstance(evaluation, dict):
return []
alternatives = evaluation.get("alternative_utterances")
if not isinstance(alternatives, list):
return []
rows: list[dict[str, str | None]] = []
for item in alternatives:
if isinstance(item, dict):
suggestion = _clean_text(item.get("suggestion") or item.get("text"))
rationale = _clean_text(item.get("rationale"))
else:
suggestion = _clean_text(item)
rationale = None
if suggestion:
rows.append({"suggestion": suggestion, "rationale": rationale})
return rows
def _appropriateness_from_score(score: Any) -> str:
value = _safe_float(score)
if value is None:
@ -237,6 +273,7 @@ def _rebuild_turn_evaluations(
technique_rows: Iterable[Any],
client_state_rows: Iterable[Any],
comment_rows: Iterable[Any],
alternative_rows: Iterable[Any] = (),
) -> dict[str, dict[str, Any]]:
"""Rehydrate normalized DB rows back into the TurnRecord.evaluation shape."""
refs = {turn_id: (turn_seq, stage) for turn_id, turn_seq, stage in turn_refs}
@ -315,6 +352,14 @@ def _rebuild_turn_evaluations(
if isinstance(deviation, dict):
ensure(turn_id)["intent_deviation"] = deviation
for row in alternative_rows:
turn_id = str(row["turn_id"])
if turn_id not in refs:
continue
suggestion = _clean_text(row["suggestion"])
if suggestion:
ensure(turn_id).setdefault("alternative_utterances", []).append(suggestion)
return evaluations
@ -343,6 +388,47 @@ async def _record_session_read_audit(
)
async def record_llm_call_audit(payload: dict[str, Any]) -> bool:
"""Append provider/token/cost metadata for an external LLM call.
The audit table intentionally stores no prompt or completion text. A DB outage
must not block the counseling loop, so failures are reported as False.
"""
try:
get_pool()
except Exception:
return False
try:
async with acquire(ai_context=True, ai_view="evaluator") as conn:
await conn.execute(
"""
INSERT INTO audit.llm_call_log (
session_id, turn_id, provider, model,
tokens_in, tokens_out, cost_usd,
inference_geo, latency_ms
)
VALUES (
$1::uuid, $2::uuid, $3, $4,
$5, $6, $7,
$8, $9
)
""",
_clean_text(payload.get("session_id")),
_clean_text(payload.get("turn_id")),
_clean_text(payload.get("provider")),
_clean_text(payload.get("model")),
_safe_int(payload.get("tokens_in")),
_safe_int(payload.get("tokens_out")),
_safe_float(payload.get("cost_usd")),
_clean_text(payload.get("inference_geo")),
_safe_int(payload.get("latency_ms")),
)
return True
except Exception:
return False
def _state_from_row(row, card: PersonaCard) -> state_machine.SessionState:
if row is None:
return state_machine.init_state(
@ -474,6 +560,20 @@ async def _persist_turn_evaluation(conn: Any, turn_id: str, evaluation: dict[str
row["intent_deviation"],
)
await conn.execute("DELETE FROM app.alternative_utterance WHERE turn_id = $1::uuid", turn_id)
for row in _evaluation_alternative_rows(evaluation):
await conn.execute(
"""
INSERT INTO app.alternative_utterance (
turn_id, suggestion, rationale
)
VALUES ($1::uuid, $2, $3)
""",
turn_id,
row["suggestion"],
row["rationale"],
)
async def _load_turn_evaluations(
conn: Any,
@ -528,12 +628,22 @@ async def _load_turn_evaluations(
""",
turn_ids,
)
alternative_rows = await conn.fetch(
"""
SELECT turn_id::text AS turn_id, suggestion, rationale
FROM app.alternative_utterance
WHERE turn_id = ANY($1::uuid[])
ORDER BY turn_id, created_at, id
""",
turn_ids,
)
return _rebuild_turn_evaluations(
turn_refs,
feedback_rows=feedback_rows,
technique_rows=technique_rows,
client_state_rows=client_state_rows,
comment_rows=comment_rows,
alternative_rows=alternative_rows,
)
@ -742,7 +852,7 @@ def _session_from_rows(row, state_row, turn_rows: Iterable) -> InProcSession | N
started_at = _ts(row["started_at"]) or time.time()
return InProcSession(
session_id=str(row["id"]),
case_id=str(row["runtime_case_id"] or row["case_id"] or row["id"]),
case_id=str(row["case_id"] or row["runtime_case_id"] or row["id"]),
learner_id=str(row["learner_id"]),
persona_code=persona_code,
theory_mode=row["theory_mode"] or "humanistic",
@ -788,6 +898,34 @@ async def _upsert_state(conn, session_id: str, state: state_machine.SessionState
)
async def get_case_context(
*,
learner_id: str,
persona_id: str,
) -> CaseContext | None:
"""Return the stable learner-persona case row, creating it when possible."""
try:
get_pool()
async with acquire(role="learner", user_id=learner_id) as conn:
row = await conn.fetchrow(
"""
INSERT INTO app.case_profile (persona_id, learner_id)
VALUES ($1::uuid, $2::uuid)
ON CONFLICT (persona_id, learner_id) DO UPDATE SET
updated_at = app.case_profile.updated_at
RETURNING case_id, last_session_no
""",
persona_id,
learner_id,
)
return CaseContext(
case_id=str(row["case_id"]),
last_session_no=int(row["last_session_no"] or 0),
)
except Exception:
return None
async def create_session(
*,
learner_id: str,
@ -798,6 +936,7 @@ async def create_session(
carry_rapport: float = 0.0,
persona_id: str | None = None,
persona_version: int | None = None,
case_id: str | None = None,
) -> InProcSession | None:
"""Create a DB-backed session, returning None when DB persistence is unavailable."""
try:
@ -806,42 +945,74 @@ async def create_session(
pinned_persona_id = persona_id or seed_persona_id(card.code)
pinned_persona_version = persona_version or SEED_VERSION
async with acquire(role="learner", user_id=learner_id) as conn:
row = await conn.fetchrow(
"""
INSERT INTO app.sessions (
runtime_case_id, learner_id, persona_id, persona_version,
persona_code, persona_display_name, persona_difficulty,
session_no, theory_mode, stage_path, prev_rapport_credit
async with conn.transaction():
stable_case_id = case_id
if stable_case_id is None:
case_row = await conn.fetchrow(
"""
INSERT INTO app.case_profile (persona_id, learner_id)
VALUES ($1::uuid, $2::uuid)
ON CONFLICT (persona_id, learner_id) DO UPDATE SET
updated_at = app.case_profile.updated_at
RETURNING case_id, last_session_no
""",
pinned_persona_id,
learner_id,
)
stable_case_id = str(case_row["case_id"])
session_no = int(case_row["last_session_no"] or 0) + 1
counter_row = await conn.fetchrow(
"""
UPDATE app.case_profile
SET last_session_no = GREATEST(last_session_no + 1, $2),
updated_at = now()
WHERE case_id = $1::uuid
AND learner_id = $3::uuid
RETURNING last_session_no
""",
stable_case_id,
session_no,
learner_id,
)
VALUES (
$1::uuid, $2::uuid, $3::uuid, $4,
$5, $6, $7,
$8, $9, '[]'::jsonb, $10
if counter_row is not None:
session_no = int(counter_row["last_session_no"] or session_no)
row = await conn.fetchrow(
"""
INSERT INTO app.sessions (
runtime_case_id, case_id, learner_id, persona_id, persona_version,
persona_code, persona_display_name, persona_difficulty,
session_no, theory_mode, stage_path, prev_rapport_credit
)
VALUES (
$1::uuid, $2::uuid, $3::uuid, $4::uuid, $5,
$6, $7, $8,
$9, $10, '[]'::jsonb, $11
)
RETURNING id, runtime_case_id, case_id, learner_id, persona_code,
session_no, theory_mode, started_at, ended_at, prev_rapport_credit
""",
runtime_case_id,
stable_case_id,
learner_id,
pinned_persona_id,
pinned_persona_version,
card.code,
card.display_name,
card.difficulty,
session_no,
theory_mode,
carry_rapport,
)
RETURNING id, runtime_case_id, case_id, learner_id, persona_code,
session_no, theory_mode, started_at, ended_at, prev_rapport_credit
""",
runtime_case_id,
learner_id,
pinned_persona_id,
pinned_persona_version,
card.code,
card.display_name,
card.difficulty,
session_no,
theory_mode,
carry_rapport,
)
await _upsert_state(conn, str(row["id"]), state)
await _upsert_state(conn, str(row["id"]), state)
return InProcSession(
session_id=str(row["id"]),
case_id=runtime_case_id,
case_id=str(row["case_id"] or row["runtime_case_id"] or row["id"]),
learner_id=learner_id,
persona_code=card.code,
theory_mode=theory_mode,
persona=card,
state=state,
session_no=session_no,
session_no=int(row["session_no"] or session_no),
created_at=_ts(row["started_at"]) or time.time(),
ended_at=None,
turns=[],
@ -1071,7 +1242,11 @@ async def end_session(sess: InProcSession, carry: memory.CarryOver) -> bool:
return False
async def list_sessions(principal: Principal) -> tuple[list[InProcSession], bool]:
async def list_sessions(
principal: Principal,
*,
include_turn_evaluation: bool = False,
) -> tuple[list[InProcSession], bool]:
try:
get_pool()
learner_filter = "WHERE s.learner_id = $1::uuid" if principal.role.value == "learner" else ""
@ -1140,6 +1315,8 @@ async def list_sessions(principal: Principal) -> tuple[list[InProcSession], bool
)
sess = _session_from_rows(row, state_row, turn_rows)
if sess is not None:
if include_turn_evaluation:
await _hydrate_session_turn_evaluations(sess)
sessions.append(sess)
await _record_session_read_audit(
conn,
@ -1156,3 +1333,62 @@ async def list_sessions(principal: Principal) -> tuple[list[InProcSession], bool
except Exception:
require_runtime_fallback_allowed("session list")
return [], False
async def list_safety_alerts(
principal: Principal,
*,
limit: int = 20,
) -> tuple[list[dict[str, Any]], bool]:
"""Teacher/admin-visible crisis alerts from app.safety_events."""
try:
get_pool()
async with acquire(
role=principal.role.value,
user_id=principal.user_id,
cohort_ids=principal.cohort_ids,
) as conn:
rows = await conn.fetch(
"""
SELECT
se.id, se.session_id, se.trigger_type, se.ko_risk_level,
se.escalated, se.detail, se.created_at,
s.learner_id, s.persona_code, s.session_no
FROM app.safety_events se
LEFT JOIN app.sessions s ON s.id = se.session_id
WHERE se.escalated = TRUE
ORDER BY se.created_at DESC
LIMIT $1
""",
limit,
)
return [
{
"id": str(row["id"]),
"session_id": str(row["session_id"]),
"learner_id": str(row["learner_id"] or ""),
"learner_label": _learner_label_from_id(str(row["learner_id"] or "")),
"persona_code": str(row["persona_code"] or ""),
"session_no": int(row["session_no"] or 0),
"trigger_type": str(row["trigger_type"] or "crisis"),
"ko_risk_level": int(row["ko_risk_level"] or 0),
"escalated": bool(row["escalated"]),
"detail": dict(row["detail"] or {}),
"created_at": _iso_dt(row["created_at"]),
}
for row in rows
], True
except Exception:
require_runtime_fallback_allowed("safety alert list")
return [], False
def _learner_label_from_id(learner_id: str) -> str:
suffix = learner_id[-6:] if len(learner_id) > 6 else learner_id
return f"학습자 {suffix}" if suffix else "학습자"
def _iso_dt(value: datetime | None) -> str:
if value is None:
return ""
return value.astimezone(timezone.utc).isoformat()