275 lines
8.8 KiB
Python
275 lines
8.8 KiB
Python
"""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"
|
|
)
|