회기 무발화 0턴 분리, 자기예측 락 불변식 및 TDD 회귀 검증 완료
Some checks failed
API contract / OpenAPI type drift (push) Failing after 3m27s

This commit is contained in:
Yun Chan 2026-09-08 23:28:06 +09:00
parent a479db7a5a
commit a0311c5957
100 changed files with 4884 additions and 11210 deletions

View file

@ -0,0 +1,275 @@
"""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"
)