음성 재생과 운영 배포 정리

This commit is contained in:
Yun Chan 2026-06-28 12:18:20 +09:00
parent 8ed185ce6c
commit ac7db95542
1020 changed files with 46863 additions and 2175 deletions

View file

@ -4,6 +4,7 @@ from __future__ import annotations
import time
import uuid
import hashlib
from dataclasses import dataclass
from datetime import datetime, timezone
from typing import Any, Iterable
@ -13,12 +14,17 @@ 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 import guardrail, memory, state_machine
from .services.persona import PersonaCard
from .store import DEFAULT_TURN_VISIBLE_TO, InProcSession, TurnRecord
_EVALUATION_CACHE: dict[str, dict[str, Any]] = {}
_CASE_WORKSHEET_CACHE: dict[str, dict[str, Any]] = {}
_SESSION_REVIEW_STATUS_CACHE: dict[str, dict[str, Any]] = {}
_SESSION_SHARE_CACHE: dict[str, dict[str, Any]] = {}
_SESSION_SHARE_TOKEN_INDEX: dict[str, str] = {}
_LIVE_COACH_EVENT_CACHE: dict[str, list[dict[str, Any]]] = {}
_SESSION_ARCHIVE_CACHE: dict[str, dict[str, Any]] = {}
_SESSION_AUDIT_ROLES = {"teacher", "admin"}
_APPROPRIATENESS_SCORE = {
"warn": 1.0,
@ -50,6 +56,7 @@ _JOINED_CARD_COLUMNS = (
"card_affect_baseline",
"card_ccd",
"card_dsm5_dimensional",
"card_triggers",
"card_source_provenance",
"card_is_synthetic",
)
@ -63,6 +70,84 @@ def _ts(value: datetime | None) -> float | None:
return value.timestamp()
def share_token_hash(token: str) -> str:
return hashlib.sha256(token.encode("utf-8")).hexdigest()
def _share_record_from_row(row) -> dict[str, Any]:
return {
"session_id": str(row["session_id"]),
"payload": dict(row["payload"] or {}),
"created_at": _ts(row["created_at"]),
"updated_at": _ts(row["updated_at"]),
"revoked_at": _ts(row["revoked_at"]),
}
def _archive_record_from_row(row) -> dict[str, Any]:
return {
"session_id": str(row["session_id"]),
"learner_id": str(row["learner_id"]),
"archived_at": _ts(row["archived_at"]),
"updated_at": _ts(row["updated_at"]),
}
def _model_payload(value: Any) -> dict[str, Any]:
if hasattr(value, "model_dump"):
return value.model_dump(mode="json")
if isinstance(value, dict):
return dict(value)
return {}
def _masked_excerpt(value: str | None, *, limit: int = 220) -> str | None:
text = (value or "").strip()
if not text:
return None
result = guardrail.mask_pii(text)
masked = str(getattr(result, "text_masked", text)).strip()
compact = " ".join(masked.split())
if len(compact) <= limit:
return compact
return f"{compact[: max(0, limit - 1)].rstrip()}..."
def _live_coach_event_from_row(row) -> dict[str, Any]:
return {
"event_id": str(row["id"]),
"session_id": str(row["session_id"]),
"turn_seq": int(row["turn_seq"] or 1),
"stage": str(row["stage"] or ""),
"created_at": _iso_dt(row["created_at"]),
"learner_text_excerpt": row["learner_text_excerpt"],
"client_reply_excerpt": row["client_reply_excerpt"],
"suggestion": dict(row["payload"] or {}),
}
def _live_coach_cache_record(
*,
session_id: str,
turn_seq: int,
stage: str,
learner_text: str,
client_reply: str | None,
suggestion: Any,
) -> dict[str, Any]:
now = datetime.now(timezone.utc)
return {
"event_id": str(uuid.uuid4()),
"session_id": session_id,
"turn_seq": int(turn_seq),
"stage": stage,
"created_at": now.isoformat().replace("+00:00", "Z"),
"learner_text_excerpt": _masked_excerpt(learner_text),
"client_reply_excerpt": _masked_excerpt(client_reply),
"suggestion": _model_payload(suggestion),
}
def _row_value(row, key: str):
try:
return row[key]
@ -802,6 +887,209 @@ async def ensure_review_tables() -> None:
)
"""
)
await conn.execute(
"""
CREATE TABLE IF NOT EXISTS app.live_coach_events (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
session_id UUID NOT NULL REFERENCES app.sessions(id) ON DELETE CASCADE,
turn_seq INT NOT NULL CHECK (turn_seq >= 1),
stage TEXT NOT NULL,
learner_text_excerpt TEXT,
client_reply_excerpt TEXT,
payload JSONB NOT NULL DEFAULT '{}'::jsonb,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS idx_live_coach_events_session_turn
ON app.live_coach_events(session_id, turn_seq, created_at);
ALTER TABLE app.live_coach_events ENABLE ROW LEVEL SECURITY;
DROP POLICY IF EXISTS p_live_coach_events_select ON app.live_coach_events;
DROP POLICY IF EXISTS p_live_coach_events_insert ON app.live_coach_events;
CREATE POLICY p_live_coach_events_select
ON app.live_coach_events FOR SELECT USING (
app.current_role_name() IN ('admin','instructor')
OR EXISTS (
SELECT 1 FROM app.sessions s
WHERE s.id = app.live_coach_events.session_id
AND s.learner_id = app.current_uid()
)
);
CREATE POLICY p_live_coach_events_insert
ON app.live_coach_events FOR INSERT WITH CHECK (
EXISTS (
SELECT 1 FROM app.sessions s
WHERE s.id = app.live_coach_events.session_id
AND s.learner_id = app.current_uid()
)
)
"""
)
await conn.execute(
"""
CREATE TABLE IF NOT EXISTS app.session_review_status (
session_id UUID PRIMARY KEY REFERENCES app.sessions(id) ON DELETE CASCADE,
reviewer_id UUID REFERENCES app.app_user(user_id),
status TEXT NOT NULL DEFAULT 'pending'
CHECK (status IN ('pending','viewed','closed')),
note TEXT NOT NULL DEFAULT '',
reviewed_at TIMESTAMPTZ,
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
)
"""
)
await conn.execute(
"""
ALTER TABLE app.session_review_status ENABLE ROW LEVEL SECURITY;
DROP POLICY IF EXISTS p_session_review_status_select ON app.session_review_status;
DROP POLICY IF EXISTS p_session_review_status_insert ON app.session_review_status;
DROP POLICY IF EXISTS p_session_review_status_update ON app.session_review_status;
DROP POLICY IF EXISTS p_session_review_status_delete ON app.session_review_status;
CREATE POLICY p_session_review_status_select
ON app.session_review_status FOR SELECT USING (
app.current_role_name() IN ('admin','instructor')
);
CREATE POLICY p_session_review_status_insert
ON app.session_review_status FOR INSERT WITH CHECK (
app.current_role_name() IN ('admin','instructor')
);
CREATE POLICY p_session_review_status_update
ON app.session_review_status FOR UPDATE USING (
app.current_role_name() IN ('admin','instructor')
) WITH CHECK (
app.current_role_name() IN ('admin','instructor')
);
CREATE POLICY p_session_review_status_delete
ON app.session_review_status FOR DELETE USING (
app.current_role_name() IN ('admin','instructor')
)
"""
)
await conn.execute(
"""
CREATE TABLE IF NOT EXISTS app.session_share_link (
session_id UUID PRIMARY KEY REFERENCES app.sessions(id) ON DELETE CASCADE,
created_by UUID NOT NULL REFERENCES app.app_user(user_id),
token_hash TEXT NOT NULL UNIQUE,
payload JSONB NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
revoked_at TIMESTAMPTZ
);
CREATE INDEX IF NOT EXISTS idx_session_share_token_active
ON app.session_share_link(token_hash)
WHERE revoked_at IS NULL;
ALTER TABLE app.session_share_link ENABLE ROW LEVEL SECURITY;
DROP POLICY IF EXISTS p_session_share_select ON app.session_share_link;
DROP POLICY IF EXISTS p_session_share_insert ON app.session_share_link;
DROP POLICY IF EXISTS p_session_share_update ON app.session_share_link;
DROP POLICY IF EXISTS p_session_share_delete ON app.session_share_link;
CREATE POLICY p_session_share_select
ON app.session_share_link 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_share_link.session_id
AND s.learner_id = app.current_uid()
)
);
CREATE POLICY p_session_share_insert
ON app.session_share_link FOR INSERT WITH CHECK (
app.is_ai_context()
OR (
created_by = app.current_uid()
AND EXISTS (
SELECT 1 FROM app.sessions s
WHERE s.id = app.session_share_link.session_id
AND s.learner_id = app.current_uid()
)
)
);
CREATE POLICY p_session_share_update
ON app.session_share_link FOR UPDATE USING (
app.is_ai_context()
OR EXISTS (
SELECT 1 FROM app.sessions s
WHERE s.id = app.session_share_link.session_id
AND s.learner_id = app.current_uid()
)
) WITH CHECK (
app.is_ai_context()
OR (
created_by = app.current_uid()
AND EXISTS (
SELECT 1 FROM app.sessions s
WHERE s.id = app.session_share_link.session_id
AND s.learner_id = app.current_uid()
)
)
);
CREATE POLICY p_session_share_delete
ON app.session_share_link FOR DELETE USING (
app.is_ai_context()
OR EXISTS (
SELECT 1 FROM app.sessions s
WHERE s.id = app.session_share_link.session_id
AND s.learner_id = app.current_uid()
)
);
"""
)
await conn.execute(
"""
CREATE TABLE IF NOT EXISTS app.session_archive_state (
session_id UUID PRIMARY KEY REFERENCES app.sessions(id) ON DELETE CASCADE,
learner_id UUID NOT NULL REFERENCES app.app_user(user_id),
archived_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS idx_session_archive_learner
ON app.session_archive_state(learner_id, archived_at DESC);
ALTER TABLE app.session_archive_state ENABLE ROW LEVEL SECURITY;
DROP POLICY IF EXISTS p_session_archive_select ON app.session_archive_state;
DROP POLICY IF EXISTS p_session_archive_insert ON app.session_archive_state;
DROP POLICY IF EXISTS p_session_archive_update ON app.session_archive_state;
DROP POLICY IF EXISTS p_session_archive_delete ON app.session_archive_state;
CREATE POLICY p_session_archive_select
ON app.session_archive_state FOR SELECT USING (
app.current_role_name() IN ('admin','instructor')
OR learner_id = app.current_uid()
);
CREATE POLICY p_session_archive_insert
ON app.session_archive_state FOR INSERT WITH CHECK (
learner_id = app.current_uid()
AND EXISTS (
SELECT 1 FROM app.sessions s
WHERE s.id = app.session_archive_state.session_id
AND s.learner_id = app.current_uid()
)
);
CREATE POLICY p_session_archive_update
ON app.session_archive_state FOR UPDATE USING (
learner_id = app.current_uid()
) WITH CHECK (
learner_id = app.current_uid()
AND EXISTS (
SELECT 1 FROM app.sessions s
WHERE s.id = app.session_archive_state.session_id
AND s.learner_id = app.current_uid()
)
);
CREATE POLICY p_session_archive_delete
ON app.session_archive_state FOR DELETE USING (
learner_id = app.current_uid()
)
"""
)
except Exception:
return
@ -956,6 +1244,400 @@ async def load_case_worksheet(
return _CASE_WORKSHEET_CACHE.get(session_id), False
async def save_live_coach_event(
*,
session_id: str,
learner_id: str,
turn_seq: int,
stage: str,
learner_text: str,
client_reply: str | None,
suggestion: Any,
) -> tuple[dict[str, Any] | None, bool]:
record = _live_coach_cache_record(
session_id=session_id,
turn_seq=turn_seq,
stage=stage,
learner_text=learner_text,
client_reply=client_reply,
suggestion=suggestion,
)
if runtime_fallback_allowed():
_LIVE_COACH_EVENT_CACHE.setdefault(session_id, []).append(record)
try:
get_pool()
async with acquire(role="learner", user_id=learner_id) as conn:
row = await conn.fetchrow(
"""
INSERT INTO app.live_coach_events (
session_id, turn_seq, stage, learner_text_excerpt,
client_reply_excerpt, payload, created_at
)
VALUES ($1::uuid, $2, $3, $4, $5, $6::jsonb, now())
RETURNING id, session_id, turn_seq, stage, learner_text_excerpt,
client_reply_excerpt, payload, created_at
""",
session_id,
int(turn_seq),
stage,
record.get("learner_text_excerpt"),
record.get("client_reply_excerpt"),
_model_payload(suggestion),
)
return (_live_coach_event_from_row(row) if row else None), True
except Exception:
require_runtime_fallback_allowed("live coach event save")
return record, False
async def list_live_coach_events(
session_id: str,
principal: Principal,
) -> tuple[list[dict[str, Any]], bool]:
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 id, session_id, turn_seq, stage, learner_text_excerpt,
client_reply_excerpt, payload, created_at
FROM app.live_coach_events
WHERE session_id = $1::uuid
ORDER BY created_at ASC, turn_seq ASC
""",
session_id,
)
return [_live_coach_event_from_row(row) for row in rows], True
except Exception:
require_runtime_fallback_allowed("live coach event list")
return [dict(item) for item in _LIVE_COACH_EVENT_CACHE.get(session_id, [])], False
def _review_status_from_row(row: Any) -> dict[str, Any]:
return {
"session_id": str(row["session_id"]),
"reviewer_id": str(row["reviewer_id"] or ""),
"status": str(row["status"] or "pending"),
"note": str(row["note"] or ""),
"reviewed_at": _iso_dt(row["reviewed_at"]),
"updated_at": _iso_dt(row["updated_at"]),
}
def _review_status_cache_record(
*,
session_id: str,
reviewer_id: str,
status: str,
note: str,
) -> dict[str, Any]:
now = datetime.now(timezone.utc)
previous = _SESSION_REVIEW_STATUS_CACHE.get(session_id) or {}
reviewed_at = previous.get("reviewed_at")
if status == "closed" and not reviewed_at:
reviewed_at = now.isoformat().replace("+00:00", "Z")
if status != "closed":
reviewed_at = None
return {
"session_id": session_id,
"reviewer_id": reviewer_id,
"status": status,
"note": note,
"reviewed_at": reviewed_at,
"updated_at": now.isoformat().replace("+00:00", "Z"),
}
async def list_session_review_statuses(
session_ids: list[str],
principal: Principal,
) -> tuple[dict[str, dict[str, Any]], bool]:
if not session_ids:
return {}, True
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 session_id, reviewer_id, status, note, reviewed_at, updated_at
FROM app.session_review_status
WHERE session_id = ANY($1::uuid[])
""",
session_ids,
)
return {
str(row["session_id"]): _review_status_from_row(row)
for row in rows
}, True
except Exception:
require_runtime_fallback_allowed("session review status list")
return {
session_id: dict(record)
for session_id in session_ids
if (record := _SESSION_REVIEW_STATUS_CACHE.get(session_id)) is not None
}, False
async def load_session_review_status(
session_id: str,
principal: Principal,
) -> tuple[dict[str, Any] | None, bool]:
statuses, durable = await list_session_review_statuses([session_id], principal)
return statuses.get(session_id), durable
async def save_session_review_status(
*,
session_id: str,
reviewer_id: str,
status: str,
note: str,
principal: Principal,
) -> tuple[dict[str, Any] | None, bool]:
note = note.strip()
if runtime_fallback_allowed():
_SESSION_REVIEW_STATUS_CACHE[session_id] = _review_status_cache_record(
session_id=session_id,
reviewer_id=reviewer_id,
status=status,
note=note,
)
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(
"""
INSERT INTO app.session_review_status (
session_id, reviewer_id, status, note, reviewed_at, updated_at
)
VALUES (
$1::uuid, $2::uuid, $3, $4,
CASE WHEN $3 = 'closed' THEN now() ELSE NULL END,
now()
)
ON CONFLICT (session_id) DO UPDATE SET
reviewer_id = EXCLUDED.reviewer_id,
status = EXCLUDED.status,
note = EXCLUDED.note,
reviewed_at = CASE
WHEN EXCLUDED.status = 'closed'
THEN COALESCE(app.session_review_status.reviewed_at, now())
ELSE NULL
END,
updated_at = now()
RETURNING session_id, reviewer_id, status, note, reviewed_at, updated_at
""",
session_id,
reviewer_id,
status,
note,
)
return (_review_status_from_row(row) if row else None), True
except Exception:
require_runtime_fallback_allowed("session review status save")
return _SESSION_REVIEW_STATUS_CACHE.get(session_id), False
async def save_session_share(
*,
session_id: str,
learner_id: str,
token_hash: str,
payload: dict[str, Any],
) -> dict[str, Any] | None:
record = {
"session_id": session_id,
"payload": payload,
"created_at": time.time(),
"updated_at": time.time(),
"revoked_at": None,
}
if runtime_fallback_allowed():
old = _SESSION_SHARE_CACHE.get(session_id)
if old:
_SESSION_SHARE_TOKEN_INDEX.pop(str(old.get("token_hash") or ""), None)
_SESSION_SHARE_CACHE[session_id] = {**record, "token_hash": token_hash}
_SESSION_SHARE_TOKEN_INDEX[token_hash] = session_id
try:
get_pool()
async with acquire(role="learner", user_id=learner_id) as conn:
row = await conn.fetchrow(
"""
INSERT INTO app.session_share_link (
session_id, created_by, token_hash, payload, created_at, updated_at, revoked_at
)
VALUES ($1::uuid, $2::uuid, $3, $4::jsonb, now(), now(), NULL)
ON CONFLICT (session_id) DO UPDATE SET
created_by = EXCLUDED.created_by,
token_hash = EXCLUDED.token_hash,
payload = EXCLUDED.payload,
updated_at = now(),
revoked_at = NULL
RETURNING session_id, payload, created_at, updated_at, revoked_at
""",
session_id,
learner_id,
token_hash,
payload,
)
return _share_record_from_row(row) if row is not None else None
except Exception:
require_runtime_fallback_allowed("session share save")
return record
async def revoke_session_share(*, session_id: str, learner_id: str) -> bool:
if runtime_fallback_allowed():
old = _SESSION_SHARE_CACHE.pop(session_id, None)
if old:
_SESSION_SHARE_TOKEN_INDEX.pop(str(old.get("token_hash") or ""), None)
try:
get_pool()
async with acquire(role="learner", user_id=learner_id) as conn:
status = await conn.execute(
"""
UPDATE app.session_share_link
SET revoked_at = now(), updated_at = now()
WHERE session_id = $1::uuid
""",
session_id,
)
return status != "UPDATE 0"
except Exception:
require_runtime_fallback_allowed("session share revoke")
return True
async def load_public_session_share(token_hash: str) -> dict[str, Any] | None:
try:
get_pool()
async with acquire(ai_context=True) as conn:
row = await conn.fetchrow(
"""
SELECT session_id, payload, created_at, updated_at, revoked_at
FROM app.session_share_link
WHERE token_hash = $1
AND revoked_at IS NULL
""",
token_hash,
)
return _share_record_from_row(row) if row is not None else None
except Exception:
require_runtime_fallback_allowed("session share public load")
session_id = _SESSION_SHARE_TOKEN_INDEX.get(token_hash)
if not session_id:
return None
cached = _SESSION_SHARE_CACHE.get(session_id)
if not cached or cached.get("revoked_at") is not None:
return None
return {
"session_id": str(cached["session_id"]),
"payload": dict(cached.get("payload") or {}),
"created_at": cached.get("created_at"),
"updated_at": cached.get("updated_at"),
"revoked_at": cached.get("revoked_at"),
}
async def list_session_archives(
session_ids: list[str],
principal: Principal,
) -> tuple[dict[str, dict[str, Any]], bool]:
"""Return learner archive state keyed by session id."""
if not session_ids:
return {}, True
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 session_id, learner_id, archived_at, updated_at
FROM app.session_archive_state
WHERE session_id = ANY($1::uuid[])
""",
session_ids,
)
return {str(row["session_id"]): _archive_record_from_row(row) for row in rows}, True
except Exception:
require_runtime_fallback_allowed("session archive list")
return {
session_id: dict(record)
for session_id in session_ids
if (record := _SESSION_ARCHIVE_CACHE.get(session_id)) is not None
}, False
async def set_session_archived(
*,
session_id: str,
learner_id: str,
archived: bool,
) -> tuple[dict[str, Any] | None, bool]:
"""Archive or restore a learner-owned session without deleting evidence."""
now = datetime.now(timezone.utc)
cache_record = {
"session_id": session_id,
"learner_id": learner_id,
"archived_at": now.timestamp(),
"updated_at": now.timestamp(),
}
if runtime_fallback_allowed():
if archived:
_SESSION_ARCHIVE_CACHE[session_id] = cache_record
else:
_SESSION_ARCHIVE_CACHE.pop(session_id, None)
try:
get_pool()
async with acquire(role="learner", user_id=learner_id) as conn:
if archived:
row = await conn.fetchrow(
"""
INSERT INTO app.session_archive_state (
session_id, learner_id, archived_at, updated_at
)
VALUES ($1::uuid, $2::uuid, now(), now())
ON CONFLICT (session_id) DO UPDATE SET
learner_id = EXCLUDED.learner_id,
archived_at = COALESCE(app.session_archive_state.archived_at, EXCLUDED.archived_at),
updated_at = now()
RETURNING session_id, learner_id, archived_at, updated_at
""",
session_id,
learner_id,
)
return _archive_record_from_row(row), True
deleted = await conn.fetchrow(
"""
DELETE FROM app.session_archive_state
WHERE session_id = $1::uuid
AND learner_id = $2::uuid
RETURNING session_id, learner_id, archived_at, updated_at
""",
session_id,
learner_id,
)
return (_archive_record_from_row(deleted) if deleted is not None else None), True
except Exception:
require_runtime_fallback_allowed("session archive update")
return (_SESSION_ARCHIVE_CACHE.get(session_id) if archived else None), False
def _session_from_rows(row, state_row, turn_rows: Iterable) -> InProcSession | None:
card = _card_from_joined_session_row(row)
if card is None:
@ -1180,6 +1862,7 @@ async def load_session(
pc.affect_baseline AS card_affect_baseline,
pc.ccd AS card_ccd,
pc.dsm5_dimensional AS card_dsm5_dimensional,
pc.triggers AS card_triggers,
pc.source_provenance AS card_source_provenance,
pc.is_synthetic AS card_is_synthetic
FROM app.sessions s
@ -1398,6 +2081,7 @@ async def list_sessions(
pc.affect_baseline AS card_affect_baseline,
pc.ccd AS card_ccd,
pc.dsm5_dimensional AS card_dsm5_dimensional,
pc.triggers AS card_triggers,
pc.source_provenance AS card_source_provenance,
pc.is_synthetic AS card_is_synthetic
FROM app.sessions s