vignette/apps/api/app/services/rupture_repair_store.py
Yun Chan 16e791e044 G0~G8 성과·동맹 측정 OS 작업 일괄 고정
8월 7일까지 워킹트리에만 남아 있던 미커밋 작업을 커밋한다. 여러 사본
폴더(worktree·clone)에 흩어져 있던 중간 스냅샷을 정리하기 전에 원본을
git 이력으로 고정하는 것이 목적이다.

- contracts/routes/services: measurement, outcome_trajectory, rupture_repair,
  deliberate_practice, calibration_transfer, supervision_research,
  multimodal_alliance, continuous_improvement 계열 신규 모듈과 테스트
- infra/db/init: 07~16 마이그레이션(측정 기반~calibration transfer 실행)
- apps/web: 세션 리뷰 카드·관리 화면·E2E 스펙 추가
- docs/ops: G0~G8 라이브 통합·배포·롤백 증거 문서와 evidence JSON/PNG
- scripts: smoke·ledger·릴리스 에이전트·NAS 프리뷰 운영 스크립트

engine.public 로그 .bak과 apps/web/test-results 산출물은 커밋에서 제외했다.
2026-08-08 01:30:53 +09:00

700 lines
25 KiB
Python

"""G3 rupture/repair append-only PostgreSQL store.
Internal evaluator writes run under an explicit evaluator AI view. Human reads and
corrections run under RBAC/cohort RLS. Safety rows are only referenced and never
used to derive lifecycle or reconciliation status.
"""
from __future__ import annotations
import hashlib
import json
from collections.abc import Mapping, Sequence
from typing import Any
from uuid import UUID
import asyncpg
from .. import db
from ..contracts.rupture_repair import RuptureLifecycleState, RuptureType
from ..deps import Principal, Role
class RuptureRepairNotFoundError(LookupError):
pass
class RuptureRepairStateError(ValueError):
pass
class RuptureRepairConflictError(RuntimeError):
pass
def _value(row: Mapping[str, Any], key: str, default: Any = None) -> Any:
try:
return row[key]
except (KeyError, TypeError):
return default
def _canonical_hash(payload: Mapping[str, Any]) -> str:
serialized = json.dumps(
payload,
ensure_ascii=False,
separators=(",", ":"),
sort_keys=True,
default=str,
)
return hashlib.sha256(serialized.encode("utf-8")).hexdigest()
def _human_view(principal: Principal) -> str:
return "counselor" if principal.role == Role.LEARNER else "supervisor"
def _created_role(principal: Principal) -> str:
return "instructor" if principal.role == Role.TEACHER else principal.role.value
def _ensure_unique_nonempty_evidence(evidence_turn_ids: Sequence[UUID]) -> None:
if not evidence_turn_ids or len(set(evidence_turn_ids)) != len(evidence_turn_ids):
raise RuptureRepairStateError(
"evidence_turn_ids must be non-empty and unique"
)
def _ensure_visible_to(visible_to: Sequence[str], *, required_view: str) -> tuple[str, ...]:
allowed = {"counselor", "evaluator", "supervisor", "research"}
normalized = tuple(dict.fromkeys(item.strip() for item in visible_to if item.strip()))
if not normalized or not set(normalized) <= allowed:
raise RuptureRepairStateError("visible_to contains an unsupported AI view")
if required_view not in normalized:
raise RuptureRepairStateError(f"visible_to must include {required_view}")
return normalized
async def _visible_session(
conn: asyncpg.Connection, session_id: UUID
) -> Mapping[str, Any]:
row = await conn.fetchrow(
"""
SELECT id, case_id, learner_id
FROM app.sessions
WHERE id = $1
""",
session_id,
)
if row is None:
raise RuptureRepairNotFoundError("session not found or not visible")
if _value(row, "case_id") is None:
raise RuptureRepairStateError("rupture episode requires a case_id")
return row
async def _existing_by_idempotency(
conn: asyncpg.Connection,
*,
table: str,
id_column: str,
session_id: UUID,
idempotency_key: UUID,
content_hash: str,
) -> UUID | None:
if table not in {
"app.rupture_observation_event",
"app.rupture_reconciliation_revision",
} or id_column not in {"observation_id", "revision_id"}:
raise AssertionError("unsupported rupture idempotency lookup")
row = await conn.fetchrow(
f"""
SELECT {id_column}, content_hash
FROM {table}
WHERE session_id = $1 AND idempotency_key = $2
""",
session_id,
idempotency_key,
)
if row is None:
return None
if str(_value(row, "content_hash")) != content_hash:
raise RuptureRepairConflictError(
"idempotency key was already used with different rupture content"
)
return UUID(str(_value(row, id_column)))
async def append_evaluator_observation(
*,
conn: asyncpg.Connection,
session_id: UUID,
episode_key: str,
idempotency_key: UUID,
event_kind: str,
from_state: RuptureLifecycleState | None,
to_state: RuptureLifecycleState,
rupture_type: RuptureType,
source_kind: str,
perspective: str,
ai_view: str,
confidence: float | None,
uncertainty: float,
evidence_turn_ids: Sequence[UUID],
counterevidence: Sequence[str],
model_run_id: UUID | None,
safety_event_ids: Sequence[int] = (),
visible_to: Sequence[str] = (
"counselor",
"evaluator",
"supervisor",
"research",
),
) -> dict[str, UUID]:
"""Append one evaluator/runtime lifecycle observation with de-duplication."""
if ai_view != "evaluator":
raise RuptureRepairStateError("internal observation requires ai_view=evaluator")
if source_kind == "model_inferred":
if perspective != "independent_observer" or model_run_id is None:
raise RuptureRepairStateError(
"model observation requires independent_observer and model_run_id"
)
elif source_kind == "observed_runtime":
if perspective != "runtime_observation":
raise RuptureRepairStateError(
"runtime observation requires runtime_observation perspective"
)
else:
raise RuptureRepairStateError(
"internal observation source must be model_inferred or observed_runtime"
)
_ensure_unique_nonempty_evidence(evidence_turn_ids)
if len(set(safety_event_ids)) != len(safety_event_ids):
raise RuptureRepairStateError("safety_event_ids must be unique")
audiences = _ensure_visible_to(visible_to, required_view="evaluator")
normalized_key = episode_key.strip()
if not normalized_key:
raise RuptureRepairStateError("episode_key must not be blank")
payload = {
"session_id": str(session_id),
"episode_key": normalized_key,
"event_kind": event_kind,
"from_state": from_state,
"to_state": to_state,
"rupture_type": rupture_type,
"source_kind": source_kind,
"perspective": perspective,
"ai_view": ai_view,
"confidence": confidence,
"uncertainty": uncertainty,
"evidence_turn_ids": sorted(str(item) for item in evidence_turn_ids),
"counterevidence": list(counterevidence),
"model_run_id": str(model_run_id) if model_run_id else None,
"safety_event_ids": sorted(safety_event_ids),
"visible_to": list(audiences),
}
content_hash = _canonical_hash(payload)
await conn.execute(
"SELECT pg_advisory_xact_lock(hashtextextended($1::text, 0))",
f"rupture:{session_id}:{normalized_key}",
)
anchor = await _visible_session(conn, session_id)
episode = await conn.fetchrow(
"""
INSERT INTO app.rupture_episode (
session_id, case_id, learner_id, episode_key, visible_to,
created_by_role
) VALUES ($1, $2, $3, $4, $5::text[], 'agent')
ON CONFLICT (session_id, episode_key) DO NOTHING
RETURNING episode_id
""",
session_id,
_value(anchor, "case_id"),
_value(anchor, "learner_id"),
normalized_key,
list(audiences),
)
if episode is None:
episode = await conn.fetchrow(
"""
SELECT episode_id FROM app.rupture_episode
WHERE session_id = $1 AND episode_key = $2
""",
session_id,
normalized_key,
)
if episode is None:
raise RuptureRepairConflictError("rupture episode could not be created")
episode_id = UUID(str(_value(episode, "episode_id")))
existing = await _existing_by_idempotency(
conn,
table="app.rupture_observation_event",
id_column="observation_id",
session_id=session_id,
idempotency_key=idempotency_key,
content_hash=content_hash,
)
if existing is not None:
return {"episode_id": episode_id, "observation_id": existing}
try:
row = await conn.fetchrow(
"""
INSERT INTO app.rupture_observation_event (
episode_id, session_id, idempotency_key, content_hash,
event_kind, from_state, to_state, rupture_type,
source_kind, perspective, ai_view, confidence, uncertainty,
evidence_turn_ids, counterevidence, model_run_id, visible_to,
created_by_role
) VALUES (
$1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,
$14::uuid[],$15::text[],$16,$17::text[],'agent'
)
RETURNING observation_id
""",
episode_id,
session_id,
idempotency_key,
content_hash,
event_kind,
from_state,
to_state,
rupture_type,
source_kind,
perspective,
ai_view,
confidence,
uncertainty,
list(evidence_turn_ids),
list(counterevidence),
model_run_id,
list(audiences),
)
except (asyncpg.CheckViolationError, asyncpg.ForeignKeyViolationError) as exc:
raise RuptureRepairStateError(
"rupture observation violated evidence/provenance/state invariants"
) from exc
except asyncpg.UniqueViolationError as exc:
raise RuptureRepairConflictError(
"rupture observation idempotency or supersession conflict"
) from exc
assert row is not None
observation_id = UUID(str(_value(row, "observation_id")))
for safety_event_id in safety_event_ids:
try:
await conn.execute(
"""
INSERT INTO app.rupture_safety_reference (
episode_id, session_id, safety_event_id
) VALUES ($1,$2,$3)
ON CONFLICT (episode_id, safety_event_id) DO NOTHING
""",
episode_id,
session_id,
safety_event_id,
)
except (asyncpg.CheckViolationError, asyncpg.ForeignKeyViolationError) as exc:
raise RuptureRepairStateError(
"safety reference must belong to the rupture session"
) from exc
return {"episode_id": episode_id, "observation_id": observation_id}
async def append_reconciliation_revision(
*,
conn: asyncpg.Connection,
session_id: UUID,
episode_id: UUID,
idempotency_key: UUID,
fast_warning_observation_id: UUID,
deep_observation_id: UUID | None,
fast_warning_id: str,
provisional_status: str,
deep_status: str,
disposition: str,
uncertainty: float,
evidence_turn_ids: Sequence[UUID],
counterevidence: Sequence[str],
model_run_id: UUID,
ai_view: str,
visible_to: Sequence[str] = (
"counselor",
"evaluator",
"supervisor",
"research",
),
) -> dict[str, Any]:
if ai_view != "evaluator":
raise RuptureRepairStateError("reconciliation requires ai_view=evaluator")
audiences = _ensure_visible_to(visible_to, required_view="evaluator")
if len(set(evidence_turn_ids)) != len(evidence_turn_ids):
raise RuptureRepairStateError("evidence_turn_ids must be unique")
warning = fast_warning_id.strip()
if not warning:
raise RuptureRepairStateError("fast_warning_id must not be blank")
payload = {
"session_id": str(session_id),
"episode_id": str(episode_id),
"fast_warning_observation_id": str(fast_warning_observation_id),
"deep_observation_id": str(deep_observation_id) if deep_observation_id else None,
"fast_warning_id": warning,
"provisional_status": provisional_status,
"deep_status": deep_status,
"disposition": disposition,
"uncertainty": uncertainty,
"evidence_turn_ids": sorted(str(item) for item in evidence_turn_ids),
"counterevidence": list(counterevidence),
"model_run_id": str(model_run_id),
"visible_to": list(audiences),
}
content_hash = _canonical_hash(payload)
await conn.execute(
"SELECT pg_advisory_xact_lock(hashtextextended($1::text, 0))",
f"rupture-reconciliation:{episode_id}",
)
await _visible_session(conn, session_id)
existing = await _existing_by_idempotency(
conn,
table="app.rupture_reconciliation_revision",
id_column="revision_id",
session_id=session_id,
idempotency_key=idempotency_key,
content_hash=content_hash,
)
if existing is not None:
existing_row = await conn.fetchrow(
"""
SELECT revision_no FROM app.rupture_reconciliation_revision
WHERE revision_id = $1
""",
existing,
)
return {
"episode_id": episode_id,
"revision_id": existing,
"revision_no": int(_value(existing_row or {}, "revision_no", 1)),
}
latest = await conn.fetchrow(
"""
SELECT revision_id, revision_no
FROM app.rupture_reconciliation_revision
WHERE episode_id = $1
ORDER BY revision_no DESC
LIMIT 1
""",
episode_id,
)
revision_no = int(_value(latest or {}, "revision_no", 0)) + 1
supersedes = _value(latest or {}, "revision_id")
try:
row = await conn.fetchrow(
"""
INSERT INTO app.rupture_reconciliation_revision (
episode_id, session_id, revision_no, idempotency_key, content_hash,
supersedes_revision_id, fast_warning_observation_id, deep_observation_id,
fast_warning_id, provisional_status, deep_status, disposition,
uncertainty, evidence_turn_ids, counterevidence, model_run_id, visible_to
) VALUES (
$1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,
$14::uuid[],$15::text[],$16,$17::text[]
)
RETURNING revision_id, revision_no
""",
episode_id,
session_id,
revision_no,
idempotency_key,
content_hash,
supersedes,
fast_warning_observation_id,
deep_observation_id,
warning,
provisional_status,
deep_status,
disposition,
uncertainty,
list(evidence_turn_ids),
list(counterevidence),
model_run_id,
list(audiences),
)
except (asyncpg.CheckViolationError, asyncpg.ForeignKeyViolationError) as exc:
raise RuptureRepairStateError(
"reconciliation violated evidence/provenance/direction invariants"
) from exc
except asyncpg.UniqueViolationError as exc:
raise RuptureRepairConflictError(
"reconciliation idempotency or revision conflict"
) from exc
assert row is not None
return {
"episode_id": episode_id,
"revision_id": UUID(str(_value(row, "revision_id"))),
"revision_no": int(_value(row, "revision_no")),
}
async def append_human_correction(
*,
principal: Principal,
session_id: UUID,
episode_id: UUID,
idempotency_key: UUID,
supersedes_observation_id: UUID,
rupture_type: RuptureType,
corrected_status: str,
uncertainty: float,
evidence_turn_ids: Sequence[UUID],
counterevidence: Sequence[str],
correction_reason: str,
) -> UUID:
if principal.role not in {Role.TEACHER, Role.ADMIN}:
raise RuptureRepairStateError(
"rupture correction requires teacher or admin role"
)
_ensure_unique_nonempty_evidence(evidence_turn_ids)
reason = correction_reason.strip()
if not reason:
raise RuptureRepairStateError("correction_reason must not be blank")
if corrected_status not in {"missed", "partial", "resolved"}:
raise RuptureRepairStateError("human correction requires a terminal status")
payload = {
"session_id": str(session_id),
"episode_id": str(episode_id),
"supersedes_observation_id": str(supersedes_observation_id),
"rupture_type": rupture_type,
"corrected_status": corrected_status,
"uncertainty": uncertainty,
"evidence_turn_ids": sorted(str(item) for item in evidence_turn_ids),
"counterevidence": list(counterevidence),
"correction_reason": reason,
}
content_hash = _canonical_hash(payload)
async with db.acquire(
role=principal.role.value,
user_id=principal.user_id,
cohort_ids=principal.cohort_ids,
) as conn:
await conn.execute(
"SELECT pg_advisory_xact_lock(hashtextextended($1::text, 0))",
f"rupture-correction:{episode_id}",
)
await _visible_session(conn, session_id)
target = await conn.fetchrow(
"""
SELECT o.to_state
FROM app.rupture_observation_event o
WHERE o.observation_id = $1
AND o.episode_id = $2
AND o.session_id = $3
""",
supersedes_observation_id,
episode_id,
session_id,
)
if target is None:
raise RuptureRepairNotFoundError(
"superseded rupture observation not found or not visible"
)
existing = await _existing_by_idempotency(
conn,
table="app.rupture_observation_event",
id_column="observation_id",
session_id=session_id,
idempotency_key=idempotency_key,
content_hash=content_hash,
)
if existing is not None:
return existing
try:
row = await conn.fetchrow(
"""
INSERT INTO app.rupture_observation_event (
episode_id, session_id, idempotency_key, content_hash,
event_kind, from_state, to_state, rupture_type,
source_kind, perspective, ai_view, uncertainty,
evidence_turn_ids, counterevidence, supersedes_observation_id,
correction_reason, visible_to, created_by_uid, created_by_role
) VALUES (
$1,$2,$3,$4,'human.corrected',$5,$6,$7,
'human_rated','supervisor_human','supervisor',$8,
$9::uuid[],$10::text[],$11,$12,
ARRAY['counselor','evaluator','supervisor','research']::text[],$13,$14
)
RETURNING observation_id
""",
episode_id,
session_id,
idempotency_key,
content_hash,
_value(target, "to_state"),
corrected_status,
rupture_type,
uncertainty,
list(evidence_turn_ids),
list(counterevidence),
supersedes_observation_id,
reason,
UUID(principal.user_id),
_created_role(principal),
)
except (asyncpg.CheckViolationError, asyncpg.ForeignKeyViolationError) as exc:
raise RuptureRepairStateError(
"human correction violated evidence or one-way resolution invariants"
) from exc
except asyncpg.UniqueViolationError as exc:
raise RuptureRepairConflictError(
"human correction idempotency or supersession conflict"
) from exc
assert row is not None
return UUID(str(_value(row, "observation_id")))
async def read_rupture_repairs(
*, principal: Principal, session_id: UUID
) -> dict[str, Any]:
"""Return a role-safe, non-aggregated rupture/repair read model."""
view = _human_view(principal)
async with db.acquire(
role=principal.role.value,
user_id=principal.user_id,
cohort_ids=principal.cohort_ids,
) as conn:
await _visible_session(conn, session_id)
episodes = list(
await conn.fetch(
"""
SELECT episode_id, session_id, case_id, learner_id, episode_key, created_at
FROM app.rupture_episode
WHERE session_id = $1 AND $2 = ANY(visible_to)
ORDER BY created_at, episode_id
""",
session_id,
view,
)
)
episode_ids = [UUID(str(_value(item, "episode_id"))) for item in episodes]
if not episode_ids:
return {
"session_id": session_id,
"requested_view": view,
"clinical_claim_allowed": False,
"episodes": [],
}
observations = list(
await conn.fetch(
"""
SELECT observation_id, episode_id, sequence_no, event_kind, from_state, to_state,
rupture_type, source_kind, perspective, ai_view, confidence,
uncertainty, evidence_turn_ids, counterevidence, model_run_id,
supersedes_observation_id, correction_reason, created_at
FROM app.rupture_observation_event
WHERE episode_id = ANY($1::uuid[]) AND $2 = ANY(visible_to)
ORDER BY episode_id, sequence_no
""",
episode_ids,
view,
)
)
reconciliations = list(
await conn.fetch(
"""
SELECT revision_id, episode_id, revision_no, supersedes_revision_id,
fast_warning_observation_id, deep_observation_id, fast_warning_id,
provisional_status, deep_status, disposition, uncertainty,
evidence_turn_ids, counterevidence, model_run_id, created_at
FROM app.rupture_reconciliation_revision
WHERE episode_id = ANY($1::uuid[]) AND $2 = ANY(visible_to)
ORDER BY episode_id, revision_no
""",
episode_ids,
view,
)
)
safety = list(
await conn.fetch(
"""
SELECT rs.episode_id, rs.safety_event_id, se.turn_id,
se.ko_risk_level, se.escalated, se.created_at
FROM app.rupture_safety_reference rs
JOIN app.safety_events se ON se.id = rs.safety_event_id
WHERE rs.episode_id = ANY($1::uuid[])
ORDER BY rs.episode_id, rs.safety_event_id
""",
episode_ids,
)
)
observations_by_episode: dict[UUID, list[dict[str, Any]]] = {}
for row in observations:
episode_id = UUID(str(_value(row, "episode_id")))
observations_by_episode.setdefault(episode_id, []).append(dict(row))
reconciliation_by_episode: dict[UUID, list[dict[str, Any]]] = {}
for row in reconciliations:
episode_id = UUID(str(_value(row, "episode_id")))
reconciliation_by_episode.setdefault(episode_id, []).append(dict(row))
safety_by_episode: dict[UUID, list[dict[str, Any]]] = {}
for row in safety:
episode_id = UUID(str(_value(row, "episode_id")))
safety_by_episode.setdefault(episode_id, []).append(dict(row))
result = []
for episode in episodes:
episode_id = UUID(str(_value(episode, "episode_id")))
event_rows = observations_by_episode.get(episode_id, [])
revision_rows = reconciliation_by_episode.get(episode_id, [])
latest_event = event_rows[-1] if event_rows else None
latest_revision = revision_rows[-1] if revision_rows else None
latest_human_correction = next(
(
item
for item in reversed(event_rows)
if _value(item, "event_kind") == "human.corrected"
),
None,
)
if latest_human_correction is not None:
current_status = _value(latest_human_correction, "to_state")
status_source = "human_correction"
elif latest_revision is not None:
current_status = _value(latest_revision, "deep_status")
status_source = "deep_reconciliation"
else:
current_status = _value(latest_event or {}, "to_state")
status_source = "lifecycle_event"
result.append(
{
**dict(episode),
"rupture_type": _value(
latest_human_correction or latest_event or {}, "rupture_type"
),
"current_status": current_status,
"status_source": status_source,
"observations": event_rows,
"reconciliation_revisions": revision_rows,
"safety_references": safety_by_episode.get(episode_id, []),
}
)
return {
"session_id": session_id,
"requested_view": view,
"clinical_claim_allowed": False,
"episodes": result,
}
__all__ = [
"RuptureRepairConflictError",
"RuptureRepairNotFoundError",
"RuptureRepairStateError",
"append_evaluator_observation",
"append_human_correction",
"append_reconciliation_revision",
"read_rupture_repairs",
]