"""DB 기반 세션 평가 저장소와 in-process 폴백.""" from __future__ import annotations from dataclasses import dataclass from typing import Any, Protocol from .db import acquire, get_pool from .deps import Principal from .runtime_policy import require_runtime_fallback_allowed, runtime_fallback_allowed from .session_persistence_values import ( _clean_role_masked_text, _mask_json_text_values, _ts, ) _EVALUATION_CACHE: dict[str, dict[str, Any]] = {} def _coerce_error_message(error: BaseException | str) -> str: if isinstance(error, BaseException): message = str(error).strip() return message or error.__class__.__name__ return str(error).strip() or "unknown session evaluation error" class _SessionEvaluationResult(Protocol): scope: str stage: str error: str | None def to_dict(self) -> dict[str, Any]: ... @dataclass(slots=True) class SessionEvaluationWrite: session_id: str learner_id: str status: str source: str scope: str stage: str payload: dict[str, Any] error: str | None = None counselor_identity: str | None = None client_identity: str | None = None @classmethod def from_result( cls, *, session_id: str, learner_id: str, result: _SessionEvaluationResult, source: str = "engine", counselor_identity: str | None = None, client_identity: str | None = None, ) -> "SessionEvaluationWrite": return cls( session_id=session_id, learner_id=learner_id, status="error" if result.error else "ready", source=source, scope=result.scope, stage=result.stage, payload=_mask_json_text_values( result.to_dict(), counselor_identity=counselor_identity, client_identity=client_identity, synthetic_generated=True, ), error=_clean_role_masked_text( result.error, counselor_identity=counselor_identity, client_identity=client_identity, synthetic_generated=True, ), counselor_identity=counselor_identity, client_identity=client_identity, ) @classmethod def from_error( cls, *, session_id: str, learner_id: str, scope: str, stage: str, error: BaseException | str, source: str = "engine", counselor_identity: str | None = None, client_identity: str | None = None, ) -> "SessionEvaluationWrite": return cls( session_id=session_id, learner_id=learner_id, status="error", source=source, scope=scope, stage=stage, payload={}, error=_clean_role_masked_text( _coerce_error_message(error), counselor_identity=counselor_identity, client_identity=client_identity, synthetic_generated=True, ), counselor_identity=counselor_identity, client_identity=client_identity, ) def cache_record(self) -> dict[str, Any]: return { "status": self.status, "source": self.source, "scope": self.scope, "stage": self.stage, "payload": self.payload, "error": self.error, } async def save_session_evaluation(write: SessionEvaluationWrite) -> bool: write.payload = _mask_json_text_values( write.payload, counselor_identity=write.counselor_identity, client_identity=write.client_identity, synthetic_generated=True, ) write.error = _clean_role_masked_text( write.error, counselor_identity=write.counselor_identity, client_identity=write.client_identity, synthetic_generated=True, ) record = write.cache_record() if runtime_fallback_allowed(): existing = _EVALUATION_CACHE.get(write.session_id) if _should_replace_evaluation_record(existing, record): _EVALUATION_CACHE[write.session_id] = record try: get_pool() async with acquire(role="learner", user_id=write.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() WHERE app.session_evaluation.status <> 'ready' OR EXCLUDED.status = 'ready' """, write.session_id, write.status, write.source, write.scope, write.stage, write.payload, write.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: cached = ( _EVALUATION_CACHE.get(session_id) if runtime_fallback_allowed() else None ) return cached, cached is None 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 async def list_session_evaluations( 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::text AS session_id, status, source, scope, stage, payload, error, updated_at FROM app.session_evaluation WHERE session_id = ANY($1::uuid[]) """, session_ids, ) records = { str(row["session_id"]): { "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"]), } for row in rows } cached_used = False if runtime_fallback_allowed(): for session_id in session_ids: if session_id not in records and session_id in _EVALUATION_CACHE: records[session_id] = _EVALUATION_CACHE[session_id] cached_used = True return records, not cached_used except Exception: require_runtime_fallback_allowed("session evaluation") return { session_id: _EVALUATION_CACHE[session_id] for session_id in session_ids if session_id in _EVALUATION_CACHE }, False def _should_replace_evaluation_record( existing: dict[str, Any] | None, replacement: dict[str, Any], ) -> bool: """늦게 도착한 실패가 이미 확정된 ready 평가를 덮지 못하게 한다.""" return not ( str((existing or {}).get("status") or "") == "ready" and str(replacement.get("status") or "") != "ready" )