음성 재생과 운영 배포 정리
This commit is contained in:
parent
8ed185ce6c
commit
ac7db95542
1020 changed files with 46863 additions and 2175 deletions
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue