1158 lines
40 KiB
Python
1158 lines
40 KiB
Python
"""DB-backed counseling session persistence with in-process fallback support."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import time
|
|
import uuid
|
|
from datetime import datetime, timezone
|
|
from typing import Any, Iterable
|
|
|
|
from .db import acquire, get_pool
|
|
from .deps import Principal
|
|
from .config import settings
|
|
from .persona_repository import SEED_VERSION, card_from_row, seed_fallback_persona, seed_persona_id
|
|
from .runtime_policy import require_runtime_fallback_allowed, runtime_fallback_allowed
|
|
from .services import memory, state_machine
|
|
from .services.persona import PersonaCard
|
|
from .store import DEFAULT_TURN_VISIBLE_TO, InProcSession, TurnRecord
|
|
|
|
_EVALUATION_CACHE: dict[str, dict[str, Any]] = {}
|
|
_SESSION_AUDIT_ROLES = {"teacher", "admin"}
|
|
_APPROPRIATENESS_SCORE = {
|
|
"warn": 1.0,
|
|
"neutral": 3.0,
|
|
"pos": 5.0,
|
|
}
|
|
|
|
|
|
_JOINED_CARD_COLUMNS = (
|
|
"card_persona_id",
|
|
"card_code",
|
|
"card_version",
|
|
"card_status",
|
|
"card_display_name",
|
|
"card_difficulty",
|
|
"card_theory_target",
|
|
"card_demographics",
|
|
"card_presenting",
|
|
"card_history",
|
|
"card_big5",
|
|
"card_resistance",
|
|
"card_speech_style",
|
|
"card_affect_baseline",
|
|
"card_ccd",
|
|
"card_dsm5_dimensional",
|
|
"card_source_provenance",
|
|
"card_is_synthetic",
|
|
)
|
|
|
|
|
|
def _ts(value: datetime | None) -> float | None:
|
|
if value is None:
|
|
return None
|
|
if value.tzinfo is None:
|
|
value = value.replace(tzinfo=timezone.utc)
|
|
return value.timestamp()
|
|
|
|
|
|
def _row_value(row, key: str):
|
|
try:
|
|
return row[key]
|
|
except Exception:
|
|
return None
|
|
|
|
|
|
def _card_from_joined_session_row(row) -> PersonaCard | None:
|
|
if _row_value(row, "card_persona_id") is None:
|
|
return None
|
|
card_row = {
|
|
key.removeprefix("card_"): _row_value(row, key)
|
|
for key in _JOINED_CARD_COLUMNS
|
|
}
|
|
return card_from_row(card_row)
|
|
|
|
|
|
def _stage(stage: object) -> str:
|
|
return getattr(stage, "value", str(stage))
|
|
|
|
|
|
def _clean_text(value: Any) -> str | None:
|
|
if value is None:
|
|
return None
|
|
text = str(value).strip()
|
|
return text or None
|
|
|
|
|
|
def _safe_float(value: Any) -> float | None:
|
|
if isinstance(value, (int, float)):
|
|
return float(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"
|
|
|
|
|
|
def _dict_items(value: Any) -> list[dict[str, Any]]:
|
|
if not isinstance(value, list):
|
|
return []
|
|
return [item for item in value if isinstance(item, dict)]
|
|
|
|
|
|
def _evaluation_feedback_rows(evaluation: dict[str, Any] | None) -> list[dict[str, Any]]:
|
|
"""Normalize scalar/rationale turn-evaluation fields into feedback_scores rows."""
|
|
if not isinstance(evaluation, dict):
|
|
return []
|
|
loop = _evaluation_loop(evaluation)
|
|
rows: list[dict[str, Any]] = []
|
|
|
|
def add(
|
|
dimension: str,
|
|
*,
|
|
score: float | None = None,
|
|
rationale: str | None = None,
|
|
top1_score: float | None = None,
|
|
) -> None:
|
|
dim = _clean_text(dimension)
|
|
if not dim:
|
|
return
|
|
rows.append(
|
|
{
|
|
"dimension": dim,
|
|
"score": score,
|
|
"rationale": rationale,
|
|
"top1_score": top1_score,
|
|
"loop": loop,
|
|
}
|
|
)
|
|
|
|
appropriateness = _clean_text(evaluation.get("appropriateness")) or "neutral"
|
|
if appropriateness not in _APPROPRIATENESS_SCORE:
|
|
appropriateness = "neutral"
|
|
add(
|
|
"appropriateness",
|
|
score=_APPROPRIATENESS_SCORE[appropriateness],
|
|
rationale=_clean_text(evaluation.get("appropriateness_note")),
|
|
)
|
|
|
|
rapport = _safe_float(evaluation.get("rapport_signal"))
|
|
if rapport is not None:
|
|
add("rapport_signal", score=max(-1.0, min(1.0, rapport)))
|
|
|
|
theory_mode = _clean_text(evaluation.get("theory_mode"))
|
|
if theory_mode:
|
|
add("theory_mode", rationale=theory_mode)
|
|
|
|
error = _clean_text(evaluation.get("error"))
|
|
if error:
|
|
add("error", rationale=error)
|
|
|
|
for tag in _dict_items(evaluation.get("techniques")):
|
|
code = _clean_text(tag.get("code"))
|
|
rationale = _clean_text(tag.get("rationale"))
|
|
if code and rationale:
|
|
add(f"technique:{code}", rationale=rationale)
|
|
|
|
for state in _dict_items(evaluation.get("client_state_read")):
|
|
code = _clean_text(state.get("code"))
|
|
rationale = _clean_text(state.get("rationale"))
|
|
if code and rationale:
|
|
add(f"client_state:{code}", rationale=rationale)
|
|
|
|
return rows
|
|
|
|
|
|
def _evaluation_technique_rows(evaluation: dict[str, Any] | None) -> list[dict[str, str]]:
|
|
if not isinstance(evaluation, dict):
|
|
return []
|
|
rows: list[dict[str, str]] = []
|
|
for tag in _dict_items(evaluation.get("techniques")):
|
|
code = _clean_text(tag.get("code"))
|
|
if not code:
|
|
continue
|
|
rows.append(
|
|
{
|
|
"code": code,
|
|
"label_ko": _clean_text(tag.get("label_ko")) or code,
|
|
"category": _clean_text(tag.get("category")) or "",
|
|
}
|
|
)
|
|
return rows
|
|
|
|
|
|
def _evaluation_client_state_rows(evaluation: dict[str, Any] | None) -> list[dict[str, str]]:
|
|
if not isinstance(evaluation, dict):
|
|
return []
|
|
rows: list[dict[str, str]] = []
|
|
for state in _dict_items(evaluation.get("client_state_read")):
|
|
code = _clean_text(state.get("code"))
|
|
if not code:
|
|
continue
|
|
rows.append(
|
|
{
|
|
"code": code,
|
|
"label_ko": _clean_text(state.get("label_ko")) or code,
|
|
}
|
|
)
|
|
return rows
|
|
|
|
|
|
def _evaluation_comment_rows(evaluation: dict[str, Any] | None) -> list[dict[str, Any]]:
|
|
if not isinstance(evaluation, dict):
|
|
return []
|
|
deviation = evaluation.get("intent_deviation")
|
|
if not isinstance(deviation, dict):
|
|
return []
|
|
note = _clean_text(evaluation.get("appropriateness_note")) or "의도와 다른 부분"
|
|
return [{"kind": "critique", "text": note, "intent_deviation": deviation}]
|
|
|
|
|
|
def _appropriateness_from_score(score: Any) -> str:
|
|
value = _safe_float(score)
|
|
if value is None:
|
|
return "neutral"
|
|
if value >= 4.0:
|
|
return "pos"
|
|
if value <= 2.0:
|
|
return "warn"
|
|
return "neutral"
|
|
|
|
|
|
def _base_turn_evaluation(turn_seq: int, stage: str) -> dict[str, Any]:
|
|
return {
|
|
"loop": "fast",
|
|
"turn_seq": turn_seq,
|
|
"stage": stage,
|
|
"techniques": [],
|
|
"client_state_read": [],
|
|
"appropriateness": "neutral",
|
|
}
|
|
|
|
|
|
def _rebuild_turn_evaluations(
|
|
turn_refs: list[tuple[str, int, str]],
|
|
*,
|
|
feedback_rows: Iterable[Any],
|
|
technique_rows: Iterable[Any],
|
|
client_state_rows: Iterable[Any],
|
|
comment_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}
|
|
evaluations: dict[str, dict[str, Any]] = {}
|
|
rationale_by_dimension: dict[str, dict[str, str]] = {}
|
|
|
|
def ensure(turn_id: str) -> dict[str, Any]:
|
|
if turn_id not in evaluations:
|
|
turn_seq, stage = refs[turn_id]
|
|
evaluations[turn_id] = _base_turn_evaluation(turn_seq, stage)
|
|
return evaluations[turn_id]
|
|
|
|
for row in feedback_rows:
|
|
turn_id = str(row["turn_id"])
|
|
if turn_id not in refs:
|
|
continue
|
|
ev = ensure(turn_id)
|
|
loop = _clean_text(row["loop"])
|
|
if loop in {"fast", "deep"}:
|
|
ev["loop"] = loop
|
|
dimension = _clean_text(row["dimension"]) or ""
|
|
rationale = _clean_text(row["rationale"])
|
|
if rationale:
|
|
rationale_by_dimension.setdefault(turn_id, {})[dimension] = rationale
|
|
if dimension == "appropriateness":
|
|
ev["appropriateness"] = _appropriateness_from_score(row["score"])
|
|
if rationale:
|
|
ev["appropriateness_note"] = rationale
|
|
elif dimension == "rapport_signal":
|
|
score = _safe_float(row["score"])
|
|
if score is not None:
|
|
ev["rapport_signal"] = max(-1.0, min(1.0, score))
|
|
elif dimension == "theory_mode" and rationale:
|
|
ev["theory_mode"] = rationale
|
|
elif dimension == "error" and rationale:
|
|
ev["error"] = rationale
|
|
|
|
for row in technique_rows:
|
|
turn_id = str(row["turn_id"])
|
|
if turn_id not in refs:
|
|
continue
|
|
code = _clean_text(row["code"])
|
|
if not code:
|
|
continue
|
|
item = {
|
|
"code": code,
|
|
"label_ko": _clean_text(row["label_ko"]) or code,
|
|
"category": _clean_text(row["category"]) or "",
|
|
}
|
|
rationale = rationale_by_dimension.get(turn_id, {}).get(f"technique:{code}")
|
|
if rationale:
|
|
item["rationale"] = rationale
|
|
ensure(turn_id)["techniques"].append(item)
|
|
|
|
for row in client_state_rows:
|
|
turn_id = str(row["turn_id"])
|
|
if turn_id not in refs:
|
|
continue
|
|
code = _clean_text(row["code"])
|
|
if not code:
|
|
continue
|
|
item = {
|
|
"code": code,
|
|
"label_ko": _clean_text(row["label_ko"]) or code,
|
|
}
|
|
rationale = rationale_by_dimension.get(turn_id, {}).get(f"client_state:{code}")
|
|
if rationale:
|
|
item["rationale"] = rationale
|
|
ensure(turn_id)["client_state_read"].append(item)
|
|
|
|
for row in comment_rows:
|
|
turn_id = str(row["turn_id"])
|
|
if turn_id not in refs:
|
|
continue
|
|
deviation = row["intent_deviation"]
|
|
if isinstance(deviation, dict):
|
|
ensure(turn_id)["intent_deviation"] = deviation
|
|
|
|
return evaluations
|
|
|
|
|
|
async def _record_session_read_audit(
|
|
conn: Any,
|
|
principal: Principal,
|
|
*,
|
|
target_kind: str,
|
|
target_id: str,
|
|
detail: dict[str, Any],
|
|
) -> None:
|
|
if principal.role.value not in _SESSION_AUDIT_ROLES:
|
|
return
|
|
await conn.execute(
|
|
"""
|
|
INSERT INTO audit.audit_log (
|
|
actor_uid, action, target_kind, target_id, detail
|
|
)
|
|
VALUES ($1::uuid, $2, $3, $4, $5::jsonb)
|
|
""",
|
|
principal.user_id,
|
|
"read_session",
|
|
target_kind,
|
|
target_id,
|
|
detail,
|
|
)
|
|
|
|
|
|
def _state_from_row(row, card: PersonaCard) -> state_machine.SessionState:
|
|
if row is None:
|
|
return state_machine.init_state(
|
|
params=card.openness_params(),
|
|
)
|
|
return state_machine.SessionState(
|
|
stage=state_machine.Stage(row["stage"]),
|
|
turn_seq=int(row["turn_seq"]),
|
|
effective_openness=float(row["effective_openness"]),
|
|
rapport_credit=float(row["rapport_credit"]),
|
|
resistance=float(row["resistance"]),
|
|
ideation_stage=int(row["ideation_stage"]),
|
|
turns_in_stage=int(row["turns_in_stage"] or 0),
|
|
affect_state=dict(row["affect_state"] or {}),
|
|
)
|
|
|
|
|
|
def _turn_from_row(row, evaluation: dict[str, Any] | None = None) -> TurnRecord:
|
|
created_at = _ts(row["created_at"]) or time.time()
|
|
return TurnRecord(
|
|
turn_seq=int(row["seq"]),
|
|
speaker=row["speaker"],
|
|
stage=row["stage"],
|
|
text=row["text"] or row["text_masked"] or "",
|
|
text_masked=row["text_masked"] or row["text"] or "",
|
|
turn_id=str(_row_value(row, "id")) if _row_value(row, "id") is not None else None,
|
|
created_at=created_at,
|
|
llm_provider=_row_value(row, "llm_provider"),
|
|
model=_row_value(row, "model"),
|
|
tokens_in=_row_value(row, "tokens_in"),
|
|
tokens_out=_row_value(row, "tokens_out"),
|
|
cost_usd=_row_value(row, "cost_usd"),
|
|
audio_ref=_row_value(row, "audio_ref"),
|
|
silence_ms=_row_value(row, "silence_ms"),
|
|
speech_rate=_row_value(row, "speech_rate"),
|
|
barge_in=_row_value(row, "barge_in"),
|
|
evaluation=evaluation,
|
|
visible_to=tuple(_row_value(row, "visible_to") or DEFAULT_TURN_VISIBLE_TO),
|
|
)
|
|
|
|
|
|
async def _persist_turn_evaluation(conn: Any, turn_id: str, evaluation: dict[str, Any] | None) -> None:
|
|
if not isinstance(evaluation, dict):
|
|
return
|
|
await conn.execute("SELECT set_config('app.ai_context', '1', true)")
|
|
await conn.execute("SELECT set_config('app.current_ai_view', 'evaluator', true)")
|
|
await conn.execute("SELECT set_config('app.current_sens_max', '2', true)")
|
|
|
|
for row in _evaluation_feedback_rows(evaluation):
|
|
await conn.execute(
|
|
"""
|
|
INSERT INTO app.feedback_scores (
|
|
turn_id, dimension, score, rationale, top1_score, loop
|
|
)
|
|
VALUES ($1::uuid, $2, $3, $4, $5, $6)
|
|
ON CONFLICT (turn_id, dimension) DO UPDATE SET
|
|
score = EXCLUDED.score,
|
|
rationale = EXCLUDED.rationale,
|
|
top1_score = EXCLUDED.top1_score,
|
|
loop = EXCLUDED.loop
|
|
""",
|
|
turn_id,
|
|
row["dimension"],
|
|
row["score"],
|
|
row["rationale"],
|
|
row["top1_score"],
|
|
row["loop"],
|
|
)
|
|
|
|
for row in _evaluation_technique_rows(evaluation):
|
|
label_id = await conn.fetchval(
|
|
"""
|
|
INSERT INTO app.technique_label_def (code, display_name, category)
|
|
VALUES ($1, $2, $3)
|
|
ON CONFLICT (code, version) DO UPDATE SET
|
|
display_name = EXCLUDED.display_name,
|
|
category = EXCLUDED.category,
|
|
is_active = TRUE
|
|
RETURNING label_id
|
|
""",
|
|
row["code"],
|
|
row["label_ko"],
|
|
row["category"],
|
|
)
|
|
await conn.execute(
|
|
"""
|
|
INSERT INTO app.turn_technique (turn_id, label_id)
|
|
VALUES ($1::uuid, $2)
|
|
ON CONFLICT DO NOTHING
|
|
""",
|
|
turn_id,
|
|
label_id,
|
|
)
|
|
|
|
for row in _evaluation_client_state_rows(evaluation):
|
|
label_id = await conn.fetchval(
|
|
"""
|
|
INSERT INTO app.client_state_def (code, display_name)
|
|
VALUES ($1, $2)
|
|
ON CONFLICT (code, version) DO UPDATE SET
|
|
display_name = EXCLUDED.display_name,
|
|
is_active = TRUE
|
|
RETURNING label_id
|
|
""",
|
|
row["code"],
|
|
row["label_ko"],
|
|
)
|
|
await conn.execute(
|
|
"""
|
|
INSERT INTO app.turn_client_state (turn_id, label_id)
|
|
VALUES ($1::uuid, $2)
|
|
ON CONFLICT DO NOTHING
|
|
""",
|
|
turn_id,
|
|
label_id,
|
|
)
|
|
|
|
for row in _evaluation_comment_rows(evaluation):
|
|
await conn.execute(
|
|
"""
|
|
INSERT INTO app.supervisor_comment (
|
|
turn_id, kind, text, intent_deviation
|
|
)
|
|
VALUES ($1::uuid, $2, $3, $4::jsonb)
|
|
""",
|
|
turn_id,
|
|
row["kind"],
|
|
row["text"],
|
|
row["intent_deviation"],
|
|
)
|
|
|
|
|
|
async def _load_turn_evaluations(
|
|
conn: Any,
|
|
turn_refs: list[tuple[str, int, str]],
|
|
) -> dict[str, dict[str, Any]]:
|
|
turn_ids = [turn_id for turn_id, _, _ in turn_refs]
|
|
if not turn_ids:
|
|
return {}
|
|
feedback_rows = await conn.fetch(
|
|
"""
|
|
SELECT turn_id::text AS turn_id, dimension, score, rationale, top1_score, loop
|
|
FROM app.feedback_scores
|
|
WHERE turn_id = ANY($1::uuid[])
|
|
ORDER BY created_at, dimension
|
|
""",
|
|
turn_ids,
|
|
)
|
|
technique_rows = await conn.fetch(
|
|
"""
|
|
SELECT
|
|
tt.turn_id::text AS turn_id,
|
|
d.code,
|
|
d.display_name AS label_ko,
|
|
d.category
|
|
FROM app.turn_technique tt
|
|
JOIN app.technique_label_def d ON d.label_id = tt.label_id
|
|
WHERE tt.turn_id = ANY($1::uuid[])
|
|
ORDER BY tt.turn_id, d.code
|
|
""",
|
|
turn_ids,
|
|
)
|
|
client_state_rows = await conn.fetch(
|
|
"""
|
|
SELECT
|
|
ts.turn_id::text AS turn_id,
|
|
d.code,
|
|
d.display_name AS label_ko
|
|
FROM app.turn_client_state ts
|
|
JOIN app.client_state_def d ON d.label_id = ts.label_id
|
|
WHERE ts.turn_id = ANY($1::uuid[])
|
|
ORDER BY ts.turn_id, d.code
|
|
""",
|
|
turn_ids,
|
|
)
|
|
comment_rows = await conn.fetch(
|
|
"""
|
|
SELECT turn_id::text AS turn_id, intent_deviation
|
|
FROM app.supervisor_comment
|
|
WHERE turn_id = ANY($1::uuid[])
|
|
AND intent_deviation IS NOT NULL
|
|
ORDER BY created_at
|
|
""",
|
|
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,
|
|
)
|
|
|
|
|
|
async def _hydrate_session_turn_evaluations(sess: InProcSession) -> None:
|
|
turn_refs = [
|
|
(turn.turn_id, turn.turn_seq, turn.stage)
|
|
for turn in sess.turns
|
|
if turn.turn_id is not None
|
|
]
|
|
if not turn_refs:
|
|
return
|
|
async with acquire(ai_view="evaluator") as conn:
|
|
evaluations = await _load_turn_evaluations(conn, turn_refs)
|
|
for turn in sess.turns:
|
|
if turn.turn_id and turn.turn_id in evaluations:
|
|
turn.evaluation = evaluations[turn.turn_id]
|
|
|
|
|
|
async def ensure_review_tables() -> None:
|
|
"""Create runtime review/evaluation storage when the DB role allows it."""
|
|
try:
|
|
get_pool()
|
|
async with acquire(role="admin") as conn:
|
|
await conn.execute(
|
|
"""
|
|
CREATE TABLE IF NOT EXISTS app.session_evaluation (
|
|
session_id UUID PRIMARY KEY REFERENCES app.sessions(id) ON DELETE CASCADE,
|
|
status TEXT NOT NULL CHECK (status IN ('ready','degraded','error')),
|
|
source TEXT NOT NULL,
|
|
scope TEXT NOT NULL,
|
|
stage TEXT NOT NULL,
|
|
payload JSONB NOT NULL DEFAULT '{}'::jsonb,
|
|
error TEXT,
|
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
|
)
|
|
"""
|
|
)
|
|
await conn.execute(
|
|
"""
|
|
ALTER TABLE app.session_evaluation ENABLE ROW LEVEL SECURITY;
|
|
|
|
DROP POLICY IF EXISTS p_session_evaluation_select ON app.session_evaluation;
|
|
DROP POLICY IF EXISTS p_session_evaluation_insert ON app.session_evaluation;
|
|
DROP POLICY IF EXISTS p_session_evaluation_update ON app.session_evaluation;
|
|
DROP POLICY IF EXISTS p_session_evaluation_delete ON app.session_evaluation;
|
|
|
|
CREATE POLICY p_session_evaluation_select
|
|
ON app.session_evaluation FOR SELECT USING (
|
|
app.is_ai_context()
|
|
OR app.current_role_name() IN ('admin','instructor')
|
|
OR EXISTS (
|
|
SELECT 1 FROM app.sessions s
|
|
WHERE s.id = app.session_evaluation.session_id
|
|
AND s.learner_id = app.current_uid()
|
|
)
|
|
);
|
|
CREATE POLICY p_session_evaluation_insert
|
|
ON app.session_evaluation FOR INSERT WITH CHECK (
|
|
app.is_ai_context()
|
|
OR app.current_role_name() IN ('admin','instructor')
|
|
OR EXISTS (
|
|
SELECT 1 FROM app.sessions s
|
|
WHERE s.id = app.session_evaluation.session_id
|
|
AND s.learner_id = app.current_uid()
|
|
)
|
|
);
|
|
CREATE POLICY p_session_evaluation_update
|
|
ON app.session_evaluation FOR UPDATE USING (
|
|
app.is_ai_context()
|
|
OR app.current_role_name() IN ('admin','instructor')
|
|
OR EXISTS (
|
|
SELECT 1 FROM app.sessions s
|
|
WHERE s.id = app.session_evaluation.session_id
|
|
AND s.learner_id = app.current_uid()
|
|
)
|
|
) WITH CHECK (
|
|
app.is_ai_context()
|
|
OR app.current_role_name() IN ('admin','instructor')
|
|
OR EXISTS (
|
|
SELECT 1 FROM app.sessions s
|
|
WHERE s.id = app.session_evaluation.session_id
|
|
AND s.learner_id = app.current_uid()
|
|
)
|
|
);
|
|
CREATE POLICY p_session_evaluation_delete
|
|
ON app.session_evaluation FOR DELETE USING (
|
|
app.is_ai_context()
|
|
OR app.current_role_name() IN ('admin','instructor')
|
|
OR EXISTS (
|
|
SELECT 1 FROM app.sessions s
|
|
WHERE s.id = app.session_evaluation.session_id
|
|
AND s.learner_id = app.current_uid()
|
|
)
|
|
)
|
|
"""
|
|
)
|
|
except Exception:
|
|
return
|
|
|
|
|
|
async def save_session_evaluation(
|
|
*,
|
|
session_id: str,
|
|
learner_id: str,
|
|
status: str,
|
|
source: str,
|
|
scope: str,
|
|
stage: str,
|
|
payload: dict[str, Any],
|
|
error: str | None = None,
|
|
) -> bool:
|
|
record = {
|
|
"status": status,
|
|
"source": source,
|
|
"scope": scope,
|
|
"stage": stage,
|
|
"payload": payload,
|
|
"error": error,
|
|
}
|
|
if runtime_fallback_allowed():
|
|
_EVALUATION_CACHE[session_id] = record
|
|
try:
|
|
get_pool()
|
|
async with acquire(role="learner", user_id=learner_id) as conn:
|
|
await conn.execute(
|
|
"""
|
|
INSERT INTO app.session_evaluation (
|
|
session_id, status, source, scope, stage, payload, error,
|
|
created_at, updated_at
|
|
)
|
|
VALUES ($1::uuid, $2, $3, $4, $5, $6::jsonb, $7, now(), now())
|
|
ON CONFLICT (session_id) DO UPDATE SET
|
|
status = EXCLUDED.status,
|
|
source = EXCLUDED.source,
|
|
scope = EXCLUDED.scope,
|
|
stage = EXCLUDED.stage,
|
|
payload = EXCLUDED.payload,
|
|
error = EXCLUDED.error,
|
|
updated_at = now()
|
|
""",
|
|
session_id,
|
|
status,
|
|
source,
|
|
scope,
|
|
stage,
|
|
payload,
|
|
error,
|
|
)
|
|
return True
|
|
except Exception:
|
|
require_runtime_fallback_allowed("session evaluation")
|
|
return False
|
|
|
|
|
|
async def load_session_evaluation(
|
|
session_id: str,
|
|
principal: Principal,
|
|
) -> tuple[dict[str, Any] | None, bool]:
|
|
try:
|
|
get_pool()
|
|
async with acquire(
|
|
role=principal.role.value,
|
|
user_id=principal.user_id,
|
|
cohort_ids=principal.cohort_ids,
|
|
) as conn:
|
|
row = await conn.fetchrow(
|
|
"""
|
|
SELECT status, source, scope, stage, payload, error, updated_at
|
|
FROM app.session_evaluation
|
|
WHERE session_id = $1::uuid
|
|
""",
|
|
session_id,
|
|
)
|
|
if row is None:
|
|
return (
|
|
_EVALUATION_CACHE.get(session_id) if runtime_fallback_allowed() else None
|
|
), False
|
|
return {
|
|
"status": row["status"],
|
|
"source": row["source"],
|
|
"scope": row["scope"],
|
|
"stage": row["stage"],
|
|
"payload": dict(row["payload"] or {}),
|
|
"error": row["error"],
|
|
"updated_at": _ts(row["updated_at"]),
|
|
}, True
|
|
except Exception:
|
|
require_runtime_fallback_allowed("session evaluation")
|
|
return _EVALUATION_CACHE.get(session_id), False
|
|
|
|
|
|
def _session_from_rows(row, state_row, turn_rows: Iterable) -> InProcSession | None:
|
|
card = _card_from_joined_session_row(row)
|
|
if card is None:
|
|
if not settings.allow_seed_persona_fallback:
|
|
return None
|
|
persona_code = (row["persona_code"] or "").upper()
|
|
legacy_entry = seed_fallback_persona(persona_code)
|
|
if legacy_entry is None:
|
|
return None
|
|
card = legacy_entry.card
|
|
persona_code = card.code
|
|
|
|
ended_at = _ts(row["ended_at"])
|
|
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"]),
|
|
learner_id=str(row["learner_id"]),
|
|
persona_code=persona_code,
|
|
theory_mode=row["theory_mode"] or "humanistic",
|
|
persona=card,
|
|
state=_state_from_row(state_row, card),
|
|
session_no=int(row["session_no"] or 1),
|
|
created_at=started_at,
|
|
ended_at=ended_at,
|
|
turns=[_turn_from_row(turn_row) for turn_row in turn_rows],
|
|
ended=ended_at is not None,
|
|
prev_rapport_credit=float(row["prev_rapport_credit"] or 0.0),
|
|
)
|
|
|
|
|
|
async def _upsert_state(conn, session_id: str, state: state_machine.SessionState) -> None:
|
|
await conn.execute(
|
|
"""
|
|
INSERT INTO app.session_state (
|
|
session_id, stage, turn_seq, effective_openness, rapport_credit,
|
|
resistance, ideation_stage, turns_in_stage, affect_state, updated_at
|
|
)
|
|
VALUES ($1::uuid, $2, $3, $4, $5, $6, $7, $8, $9::jsonb, now())
|
|
ON CONFLICT (session_id) DO UPDATE SET
|
|
stage = EXCLUDED.stage,
|
|
turn_seq = EXCLUDED.turn_seq,
|
|
effective_openness = EXCLUDED.effective_openness,
|
|
rapport_credit = EXCLUDED.rapport_credit,
|
|
resistance = EXCLUDED.resistance,
|
|
ideation_stage = EXCLUDED.ideation_stage,
|
|
turns_in_stage = EXCLUDED.turns_in_stage,
|
|
affect_state = EXCLUDED.affect_state,
|
|
updated_at = now()
|
|
""",
|
|
session_id,
|
|
_stage(state.stage),
|
|
state.turn_seq,
|
|
state.effective_openness,
|
|
state.rapport_credit,
|
|
state.resistance,
|
|
state.ideation_stage,
|
|
state.turns_in_stage,
|
|
state.affect_state,
|
|
)
|
|
|
|
|
|
async def create_session(
|
|
*,
|
|
learner_id: str,
|
|
card: PersonaCard,
|
|
theory_mode: str,
|
|
state: state_machine.SessionState,
|
|
session_no: int = 1,
|
|
carry_rapport: float = 0.0,
|
|
persona_id: str | None = None,
|
|
persona_version: int | None = None,
|
|
) -> InProcSession | None:
|
|
"""Create a DB-backed session, returning None when DB persistence is unavailable."""
|
|
try:
|
|
get_pool()
|
|
runtime_case_id = str(uuid.uuid4())
|
|
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
|
|
)
|
|
VALUES (
|
|
$1::uuid, $2::uuid, $3::uuid, $4,
|
|
$5, $6, $7,
|
|
$8, $9, '[]'::jsonb, $10
|
|
)
|
|
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)
|
|
return InProcSession(
|
|
session_id=str(row["id"]),
|
|
case_id=runtime_case_id,
|
|
learner_id=learner_id,
|
|
persona_code=card.code,
|
|
theory_mode=theory_mode,
|
|
persona=card,
|
|
state=state,
|
|
session_no=session_no,
|
|
created_at=_ts(row["started_at"]) or time.time(),
|
|
ended_at=None,
|
|
turns=[],
|
|
ended=False,
|
|
prev_rapport_credit=carry_rapport,
|
|
)
|
|
except Exception:
|
|
require_runtime_fallback_allowed("session creation")
|
|
return None
|
|
|
|
|
|
async def load_session(
|
|
session_id: str,
|
|
principal: Principal,
|
|
*,
|
|
allow_ended: bool = False,
|
|
include_turn_evaluation: bool = False,
|
|
) -> InProcSession | None:
|
|
try:
|
|
get_pool()
|
|
async with acquire(
|
|
role=principal.role.value,
|
|
user_id=principal.user_id,
|
|
cohort_ids=principal.cohort_ids,
|
|
) as conn:
|
|
row = await conn.fetchrow(
|
|
"""
|
|
SELECT
|
|
s.id, s.runtime_case_id, s.case_id, s.learner_id, s.persona_code,
|
|
s.session_no, s.theory_mode, s.started_at, s.ended_at,
|
|
s.prev_rapport_credit,
|
|
pc.persona_id AS card_persona_id,
|
|
pc.code AS card_code,
|
|
pc.version AS card_version,
|
|
pc.status AS card_status,
|
|
pc.display_name AS card_display_name,
|
|
pc.difficulty AS card_difficulty,
|
|
pc.theory_target AS card_theory_target,
|
|
pc.demographics AS card_demographics,
|
|
pc.presenting AS card_presenting,
|
|
pc.history AS card_history,
|
|
pc.big5 AS card_big5,
|
|
pc.resistance AS card_resistance,
|
|
pc.speech_style AS card_speech_style,
|
|
pc.affect_baseline AS card_affect_baseline,
|
|
pc.ccd AS card_ccd,
|
|
pc.dsm5_dimensional AS card_dsm5_dimensional,
|
|
pc.source_provenance AS card_source_provenance,
|
|
pc.is_synthetic AS card_is_synthetic
|
|
FROM app.sessions s
|
|
LEFT JOIN app.persona_card pc
|
|
ON pc.persona_id = s.persona_id
|
|
AND pc.version = s.persona_version
|
|
WHERE s.id = $1::uuid
|
|
""",
|
|
session_id,
|
|
)
|
|
if row is None:
|
|
return None
|
|
if row["ended_at"] is not None and not allow_ended:
|
|
return None
|
|
state_row = await conn.fetchrow(
|
|
"""
|
|
SELECT stage, turn_seq, effective_openness, rapport_credit, resistance,
|
|
ideation_stage, turns_in_stage, affect_state
|
|
FROM app.session_state
|
|
WHERE session_id = $1::uuid
|
|
""",
|
|
session_id,
|
|
)
|
|
turn_rows = await conn.fetch(
|
|
"""
|
|
SELECT id, seq, speaker, stage, text, text_masked, created_at,
|
|
llm_provider, model, tokens_in, tokens_out, cost_usd,
|
|
audio_ref, silence_ms, speech_rate, barge_in, visible_to
|
|
FROM app.turns
|
|
WHERE session_id = $1::uuid
|
|
ORDER BY seq
|
|
""",
|
|
session_id,
|
|
)
|
|
sess = _session_from_rows(row, state_row, turn_rows)
|
|
if sess is not None:
|
|
await _record_session_read_audit(
|
|
conn,
|
|
principal,
|
|
target_kind="session",
|
|
target_id=session_id,
|
|
detail={
|
|
"access": "load_session",
|
|
"role": principal.role.value,
|
|
"learner_id": sess.learner_id,
|
|
},
|
|
)
|
|
if sess is not None and include_turn_evaluation:
|
|
await _hydrate_session_turn_evaluations(sess)
|
|
return sess
|
|
except Exception:
|
|
require_runtime_fallback_allowed("session load")
|
|
return None
|
|
|
|
|
|
async def append_turn(
|
|
*,
|
|
session_id: str,
|
|
learner_id: str,
|
|
turn: TurnRecord,
|
|
) -> bool:
|
|
try:
|
|
get_pool()
|
|
async with acquire(role="learner", user_id=learner_id) as conn:
|
|
locked = await conn.fetchval(
|
|
"SELECT id FROM app.sessions WHERE id = $1::uuid FOR UPDATE",
|
|
session_id,
|
|
)
|
|
if locked is None:
|
|
return False
|
|
seq = int(
|
|
await conn.fetchval(
|
|
"SELECT COALESCE(MAX(seq), 0) + 1 FROM app.turns WHERE session_id = $1::uuid",
|
|
session_id,
|
|
)
|
|
or 1
|
|
)
|
|
inserted_turn_id = await conn.fetchval(
|
|
"""
|
|
INSERT INTO app.turns (
|
|
session_id, seq, speaker, stage, text, text_masked, actor_kind,
|
|
llm_provider, model, tokens_in, tokens_out, cost_usd,
|
|
audio_ref, silence_ms, speech_rate, barge_in, visible_to
|
|
)
|
|
VALUES (
|
|
$1::uuid, $2, $3, $4, $5, $6, $7,
|
|
$8, $9, $10, $11, $12,
|
|
$13, $14, $15, $16, $17::text[]
|
|
)
|
|
ON CONFLICT (session_id, seq) DO NOTHING
|
|
RETURNING id
|
|
""",
|
|
session_id,
|
|
seq,
|
|
turn.speaker,
|
|
turn.stage,
|
|
turn.text_masked,
|
|
turn.text_masked,
|
|
"human_learner" if turn.speaker == "counselor" else "client_ai",
|
|
turn.llm_provider,
|
|
turn.model,
|
|
turn.tokens_in,
|
|
turn.tokens_out,
|
|
turn.cost_usd,
|
|
turn.audio_ref,
|
|
turn.silence_ms,
|
|
turn.speech_rate,
|
|
turn.barge_in,
|
|
list(turn.visible_to or DEFAULT_TURN_VISIBLE_TO),
|
|
)
|
|
if inserted_turn_id is None:
|
|
return False
|
|
turn.turn_id = str(inserted_turn_id)
|
|
# 원시 평가 row는 학습자 축어록이 아니라 evaluator 전용 데이터다.
|
|
await _persist_turn_evaluation(conn, turn.turn_id, turn.evaluation)
|
|
return True
|
|
except Exception:
|
|
require_runtime_fallback_allowed("session turn append")
|
|
return False
|
|
|
|
|
|
async def update_state(
|
|
*,
|
|
session_id: str,
|
|
learner_id: str,
|
|
state: state_machine.SessionState,
|
|
) -> bool:
|
|
try:
|
|
get_pool()
|
|
async with acquire(role="learner", user_id=learner_id) as conn:
|
|
await _upsert_state(conn, session_id, state)
|
|
return True
|
|
except Exception:
|
|
require_runtime_fallback_allowed("session state update")
|
|
return False
|
|
|
|
|
|
async def end_session(sess: InProcSession, carry: memory.CarryOver) -> bool:
|
|
try:
|
|
get_pool()
|
|
digest = (
|
|
f"회기 축어록 {len(sess.turns)}개가 저장되었습니다. 정밀 리뷰는 생성 대기 중입니다."
|
|
if sess.turns
|
|
else "실제 발화가 없어 요약을 생성하지 않았습니다."
|
|
)
|
|
async with acquire(role="learner", user_id=sess.learner_id) as conn:
|
|
await conn.execute(
|
|
"""
|
|
UPDATE app.sessions
|
|
SET ended_at = COALESCE(ended_at, now())
|
|
WHERE id = $1::uuid
|
|
""",
|
|
sess.session_id,
|
|
)
|
|
await _upsert_state(conn, sess.session_id, sess.state)
|
|
await conn.execute(
|
|
"""
|
|
INSERT INTO app.session_summary (
|
|
session_id, case_id, session_no, end_state, rapport_delta,
|
|
digest, open_threads, created_at
|
|
)
|
|
VALUES ($1::uuid, $2::uuid, $3, $4::jsonb, $5, $6, $7::jsonb, now())
|
|
ON CONFLICT (session_id) DO UPDATE SET
|
|
end_state = EXCLUDED.end_state,
|
|
rapport_delta = EXCLUDED.rapport_delta,
|
|
digest = EXCLUDED.digest,
|
|
open_threads = EXCLUDED.open_threads
|
|
""",
|
|
sess.session_id,
|
|
sess.case_id,
|
|
sess.session_no,
|
|
carry.end_state,
|
|
carry.rapport_delta,
|
|
digest,
|
|
list(carry.compression_job.open_threads if carry.compression_job else []),
|
|
)
|
|
return True
|
|
except Exception:
|
|
require_runtime_fallback_allowed("session end")
|
|
return False
|
|
|
|
|
|
async def list_sessions(principal: Principal) -> tuple[list[InProcSession], bool]:
|
|
try:
|
|
get_pool()
|
|
learner_filter = "WHERE s.learner_id = $1::uuid" if principal.role.value == "learner" else ""
|
|
query_args = [principal.user_id] if principal.role.value == "learner" else []
|
|
async with acquire(
|
|
role=principal.role.value,
|
|
user_id=principal.user_id,
|
|
cohort_ids=principal.cohort_ids,
|
|
) as conn:
|
|
rows = await conn.fetch(
|
|
f"""
|
|
SELECT
|
|
s.id, s.runtime_case_id, s.case_id, s.learner_id, s.persona_code,
|
|
s.session_no, s.theory_mode, s.started_at, s.ended_at,
|
|
s.prev_rapport_credit,
|
|
pc.persona_id AS card_persona_id,
|
|
pc.code AS card_code,
|
|
pc.version AS card_version,
|
|
pc.status AS card_status,
|
|
pc.display_name AS card_display_name,
|
|
pc.difficulty AS card_difficulty,
|
|
pc.theory_target AS card_theory_target,
|
|
pc.demographics AS card_demographics,
|
|
pc.presenting AS card_presenting,
|
|
pc.history AS card_history,
|
|
pc.big5 AS card_big5,
|
|
pc.resistance AS card_resistance,
|
|
pc.speech_style AS card_speech_style,
|
|
pc.affect_baseline AS card_affect_baseline,
|
|
pc.ccd AS card_ccd,
|
|
pc.dsm5_dimensional AS card_dsm5_dimensional,
|
|
pc.source_provenance AS card_source_provenance,
|
|
pc.is_synthetic AS card_is_synthetic
|
|
FROM app.sessions s
|
|
LEFT JOIN app.persona_card pc
|
|
ON pc.persona_id = s.persona_id
|
|
AND pc.version = s.persona_version
|
|
{learner_filter}
|
|
ORDER BY s.started_at DESC
|
|
LIMIT 100
|
|
""",
|
|
*query_args,
|
|
)
|
|
sessions: list[InProcSession] = []
|
|
for row in rows:
|
|
session_id = str(row["id"])
|
|
state_row = await conn.fetchrow(
|
|
"""
|
|
SELECT stage, turn_seq, effective_openness, rapport_credit, resistance,
|
|
ideation_stage, turns_in_stage, affect_state
|
|
FROM app.session_state
|
|
WHERE session_id = $1::uuid
|
|
""",
|
|
session_id,
|
|
)
|
|
turn_rows = await conn.fetch(
|
|
"""
|
|
SELECT id, seq, speaker, stage, text, text_masked, created_at,
|
|
llm_provider, model, tokens_in, tokens_out, cost_usd,
|
|
audio_ref, silence_ms, speech_rate, barge_in, visible_to
|
|
FROM app.turns
|
|
WHERE session_id = $1::uuid
|
|
ORDER BY seq
|
|
""",
|
|
session_id,
|
|
)
|
|
sess = _session_from_rows(row, state_row, turn_rows)
|
|
if sess is not None:
|
|
sessions.append(sess)
|
|
await _record_session_read_audit(
|
|
conn,
|
|
principal,
|
|
target_kind="session_list",
|
|
target_id="sessions",
|
|
detail={
|
|
"access": "list_sessions",
|
|
"role": principal.role.value,
|
|
"result_count": len(sessions),
|
|
},
|
|
)
|
|
return sessions, True
|
|
except Exception:
|
|
require_runtime_fallback_allowed("session list")
|
|
return [], False
|