회기 무발화 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

@ -9,7 +9,7 @@ import time
import uuid
from dataclasses import dataclass
from datetime import datetime, timezone
from typing import Any, Awaitable, Callable, Iterable, Literal, Protocol
from typing import Any, Awaitable, Callable, Iterable, Literal
from .db import acquire, get_pool
from .deps import Principal
@ -21,6 +21,12 @@ from .persona_repository import (
seed_persona_id,
)
from .runtime_policy import require_runtime_fallback_allowed, runtime_fallback_allowed
from .session_persistence_values import (
_clean_masked_text,
_clean_text,
_mask_json_text_values,
_ts,
)
from .runtime_schema import (
REVIEW_SCHEMA_CONTRACT,
runtime_schema_bootstrap_required,
@ -54,7 +60,6 @@ class CaseProgressUnavailableError(RuntimeError):
"""DB 전체 집계가 필요한 사례 진행 수치를 안전하게 읽지 못했다."""
_EVALUATION_CACHE: dict[str, dict[str, Any]] = {}
_CASE_WORKSHEET_CACHE: dict[str, dict[str, Any]] = {}
_SESSION_REVIEW_STATUS_CACHE: dict[str, dict[str, Any]] = {}
_SESSION_SHARE_CACHE: dict[str, dict[str, Any]] = {}
@ -128,13 +133,6 @@ class LiveCoachCreditExhausted(RuntimeError):
"""Raised when a learner tries to use live coaching without credits."""
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"
@dataclass(slots=True)
class CaseContext:
case_id: str
@ -152,103 +150,6 @@ class SessionSummaryWrite:
open_threads: list[str]
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,
}
_JOINED_CARD_COLUMNS = (
"card_persona_id",
"card_code",
@ -272,14 +173,6 @@ _JOINED_CARD_COLUMNS = (
)
def _ts(value: datetime | None) -> float | None:
if value is None:
return None
if value.tzinfo is None:
value = value.replace(tzinfo=timezone.utc)
return value.timestamp()
def share_token_hash(token: str) -> str:
return hashlib.sha256(token.encode("utf-8")).hexdigest()
@ -551,90 +444,6 @@ def _stage(stage: object) -> str:
return getattr(stage, "value", str(stage))
def _clean_text(value: Any) -> str | None:
if value is None:
return None
text = str(value).strip()
return text or None
def _clean_masked_text(value: Any) -> str | None:
text = _clean_text(value)
if text is None:
return None
masked = guardrail.mask_pii(text).text_masked.strip()
return masked or None
def _clean_role_masked_text(
value: Any,
*,
counselor_identity: str | None = None,
client_identity: str | None = None,
synthetic_generated: bool = False,
) -> str | None:
text = _clean_text(value)
if text is None:
return None
masked = guardrail.mask_role_identities(
text,
counselor_identity=counselor_identity,
client_identity=client_identity,
synthetic_generated=synthetic_generated,
).text_masked.strip()
return masked or None
def _mask_json_text_values(
value: Any,
*,
counselor_identity: str | None = None,
client_identity: str | None = None,
synthetic_generated: bool = False,
) -> Any:
if isinstance(value, str):
return (
_clean_role_masked_text(
value,
counselor_identity=counselor_identity,
client_identity=client_identity,
synthetic_generated=synthetic_generated,
)
or ""
)
if isinstance(value, dict):
return {
key: _mask_json_text_values(
child,
counselor_identity=counselor_identity,
client_identity=client_identity,
synthetic_generated=synthetic_generated,
)
for key, child in value.items()
}
if isinstance(value, list):
return [
_mask_json_text_values(
child,
counselor_identity=counselor_identity,
client_identity=client_identity,
synthetic_generated=synthetic_generated,
)
for child in value
]
if isinstance(value, tuple):
return [
_mask_json_text_values(
child,
counselor_identity=counselor_identity,
client_identity=client_identity,
synthetic_generated=synthetic_generated,
)
for child in value
]
return value
def _safe_float(value: Any) -> float | None:
if isinstance(value, (int, float)):
return float(value)
@ -1769,148 +1578,6 @@ async def ensure_review_tables() -> None:
return
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
async def _fetch_session_runtime_rows(
conn: Any,
session_ids: list[str],
@ -2825,7 +2492,14 @@ async def list_case_summaries(
SELECT
s.case_id,
count(*)::int AS total_sessions,
count(*) FILTER (WHERE s.ended_at IS NOT NULL)::int AS completed_sessions,
count(*) FILTER (
WHERE s.ended_at IS NOT NULL
AND EXISTS (
SELECT 1 FROM app.turns AS ct
WHERE ct.session_id = s.id
AND ct.speaker = 'counselor'
)
)::int AS completed_sessions,
COALESCE(
sum(
GREATEST(
@ -3702,12 +3376,3 @@ def _iso_dt(value: datetime | None) -> str:
if value is None:
return ""
return value.astimezone(timezone.utc).isoformat()
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"
)