회기 무발화 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
|
|
@ -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"
|
||||
)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue