회기 무발화 0턴 분리, 자기예측 락 불변식 및 TDD 회귀 검증 완료
Some checks failed
API contract / OpenAPI type drift (push) Failing after 3m27s
Some checks failed
API contract / OpenAPI type drift (push) Failing after 3m27s
This commit is contained in:
parent
a479db7a5a
commit
a0311c5957
100 changed files with 4884 additions and 11210 deletions
275
apps/api/app/session_evaluation_repository.py
Normal file
275
apps/api/app/session_evaluation_repository.py
Normal 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"
|
||||
)
|
||||
Loading…
Add table
Add a link
Reference in a new issue