현재 작업 전체 반영
This commit is contained in:
parent
5560638e54
commit
c0dddab594
85 changed files with 11322 additions and 539 deletions
|
|
@ -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()
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue