세션 계약과 메모리 경계 보강

This commit is contained in:
Yun Chan 2026-06-28 23:52:18 +09:00
parent 391639c1de
commit 2bb052f624
12 changed files with 836 additions and 116 deletions

View file

@ -26,6 +26,12 @@ _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"}
_WORKSHEET_REVIEW_STATUS_VALUES = {
"pending",
"approved",
"changes_requested",
"rejected",
}
_APPROPRIATENESS_SCORE = {
"warn": 1.0,
"neutral": 3.0,
@ -39,6 +45,17 @@ class CaseContext:
last_session_no: int
@dataclass(slots=True)
class SessionSummaryWrite:
session_id: str
case_id: str
session_no: int
end_state: dict[str, Any]
rapport_delta: float
digest: str
open_threads: list[str]
_JOINED_CARD_COLUMNS = (
"card_persona_id",
"card_code",
@ -935,11 +952,24 @@ async def ensure_review_tables() -> None:
status TEXT NOT NULL DEFAULT 'pending'
CHECK (status IN ('pending','viewed','closed')),
note TEXT NOT NULL DEFAULT '',
worksheet_status TEXT NOT NULL DEFAULT 'pending',
worksheet_note TEXT NOT NULL DEFAULT '',
worksheet_reviewed_at TIMESTAMPTZ,
reviewed_at TIMESTAMPTZ,
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
)
"""
)
await conn.execute(
"""
ALTER TABLE app.session_review_status
ADD COLUMN IF NOT EXISTS worksheet_status TEXT NOT NULL DEFAULT 'pending';
ALTER TABLE app.session_review_status
ADD COLUMN IF NOT EXISTS worksheet_note TEXT NOT NULL DEFAULT '';
ALTER TABLE app.session_review_status
ADD COLUMN IF NOT EXISTS worksheet_reviewed_at TIMESTAMPTZ;
"""
)
await conn.execute(
"""
ALTER TABLE app.session_review_status ENABLE ROW LEVEL SECURITY;
@ -1324,17 +1354,29 @@ def _review_status_from_row(row: Any) -> dict[str, Any]:
"reviewer_id": str(row["reviewer_id"] or ""),
"status": str(row["status"] or "pending"),
"note": str(row["note"] or ""),
"worksheet_status": _worksheet_review_status(row["worksheet_status"]),
"worksheet_note": str(row["worksheet_note"] or ""),
"worksheet_reviewed_at": _iso_dt(row["worksheet_reviewed_at"]),
"reviewed_at": _iso_dt(row["reviewed_at"]),
"updated_at": _iso_dt(row["updated_at"]),
}
def _worksheet_review_status(value: object) -> str:
raw = str(value or "pending")
if raw in _WORKSHEET_REVIEW_STATUS_VALUES:
return raw
return "pending"
def _review_status_cache_record(
*,
session_id: str,
reviewer_id: str,
status: str,
note: str,
worksheet_status: str | None = None,
worksheet_note: str | None = None,
) -> dict[str, Any]:
now = datetime.now(timezone.utc)
previous = _SESSION_REVIEW_STATUS_CACHE.get(session_id) or {}
@ -1343,11 +1385,25 @@ def _review_status_cache_record(
reviewed_at = now.isoformat().replace("+00:00", "Z")
if status != "closed":
reviewed_at = None
worksheet_reviewed_at = previous.get("worksheet_reviewed_at")
if worksheet_status is None:
worksheet_status = _worksheet_review_status(previous.get("worksheet_status"))
worksheet_note = str(previous.get("worksheet_note") or "")
else:
worksheet_status = _worksheet_review_status(worksheet_status)
worksheet_note = str(worksheet_note or "").strip()
if worksheet_status == "pending":
worksheet_reviewed_at = None
else:
worksheet_reviewed_at = now.isoformat().replace("+00:00", "Z")
return {
"session_id": session_id,
"reviewer_id": reviewer_id,
"status": status,
"note": note,
"worksheet_status": worksheet_status,
"worksheet_note": worksheet_note,
"worksheet_reviewed_at": worksheet_reviewed_at,
"reviewed_at": reviewed_at,
"updated_at": now.isoformat().replace("+00:00", "Z"),
}
@ -1368,7 +1424,8 @@ async def list_session_review_statuses(
) as conn:
rows = await conn.fetch(
"""
SELECT session_id, reviewer_id, status, note, reviewed_at, updated_at
SELECT session_id, reviewer_id, status, note, worksheet_status,
worksheet_note, worksheet_reviewed_at, reviewed_at, updated_at
FROM app.session_review_status
WHERE session_id = ANY($1::uuid[])
""",
@ -1402,14 +1459,20 @@ async def save_session_review_status(
status: str,
note: str,
principal: Principal,
worksheet_status: str | None = None,
worksheet_note: str | None = None,
) -> tuple[dict[str, Any] | None, bool]:
note = note.strip()
if worksheet_status is not None:
worksheet_note = str(worksheet_note or "").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,
worksheet_status=worksheet_status,
worksheet_note=worksheet_note,
)
try:
get_pool()
@ -1421,10 +1484,17 @@ async def save_session_review_status(
row = await conn.fetchrow(
"""
INSERT INTO app.session_review_status (
session_id, reviewer_id, status, note, reviewed_at, updated_at
session_id, reviewer_id, status, note, worksheet_status,
worksheet_note, worksheet_reviewed_at, reviewed_at, updated_at
)
VALUES (
$1::uuid, $2::uuid, $3, $4,
COALESCE($5, 'pending'),
CASE WHEN $5::text IS NULL THEN '' ELSE COALESCE($6, '') END,
CASE
WHEN $5::text IS NULL OR $5 = 'pending' THEN NULL
ELSE now()
END,
CASE WHEN $3 = 'closed' THEN now() ELSE NULL END,
now()
)
@ -1432,18 +1502,38 @@ async def save_session_review_status(
reviewer_id = EXCLUDED.reviewer_id,
status = EXCLUDED.status,
note = EXCLUDED.note,
worksheet_status = CASE
WHEN $5::text IS NULL
THEN app.session_review_status.worksheet_status
ELSE $5
END,
worksheet_note = CASE
WHEN $5::text IS NULL
THEN app.session_review_status.worksheet_note
ELSE COALESCE($6, '')
END,
worksheet_reviewed_at = CASE
WHEN $5::text IS NULL
THEN app.session_review_status.worksheet_reviewed_at
WHEN $5 = 'pending'
THEN NULL
ELSE now()
END,
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
RETURNING session_id, reviewer_id, status, note, worksheet_status,
worksheet_note, worksheet_reviewed_at, reviewed_at, updated_at
""",
session_id,
reviewer_id,
status,
note,
worksheet_status,
worksheet_note,
)
return (_review_status_from_row(row) if row else None), True
except Exception:
@ -2165,14 +2255,31 @@ async def _upsert_pinned_fact_candidates(conn: Any, sess: InProcSession) -> None
turn_id=fact.source_turn_id,
)
def _build_session_summary_write(sess: InProcSession, carry: memory.CarryOver) -> SessionSummaryWrite:
digest_input = memory.build_session_digest_input(
session_id=sess.session_id,
case_id=sess.case_id,
session_no=sess.session_no,
masked_turns=sess.masked_turns(visible_to="client"),
open_threads=carry.compression_job.open_threads if carry.compression_job else [],
)
digest_result = memory.build_fallback_digest_result(digest_input, end_state=carry.end_state)
return SessionSummaryWrite(
session_id=sess.session_id,
case_id=sess.case_id,
session_no=sess.session_no,
end_state=carry.end_state,
rapport_delta=carry.rapport_delta,
digest=digest_result.digest,
open_threads=list(digest_result.open_threads),
)
async def end_session(sess: InProcSession, carry: memory.CarryOver) -> bool:
try:
get_pool()
digest = memory.build_fallback_session_digest(
session_no=sess.session_no,
masked_turns=sess.masked_turns(visible_to="client"),
end_state=carry.end_state,
)
summary_write = _build_session_summary_write(sess, carry)
async with acquire(role="learner", user_id=sess.learner_id) as conn:
await conn.execute(
"""
@ -2196,13 +2303,13 @@ async def end_session(sess: InProcSession, carry: memory.CarryOver) -> bool:
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 []),
summary_write.session_id,
summary_write.case_id,
summary_write.session_no,
summary_write.end_state,
summary_write.rapport_delta,
summary_write.digest,
summary_write.open_threads,
)
case_row = await conn.fetchrow(
"""
@ -2222,7 +2329,7 @@ async def end_session(sess: InProcSession, carry: memory.CarryOver) -> bool:
case_digest = memory.merge_case_digest(
existing_digest=case_row["case_digest"],
session_no=sess.session_no,
session_digest=digest,
session_digest=summary_write.digest,
)
rapport_trajectory = memory.merge_rapport_trajectory(
case_row["rapport_trajectory"],