vignette/apps/api/app/services/rupture_runtime.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

1206 lines
40 KiB
Python

"""Durable-session runtime wiring for the G3 rupture/repair ledger.
The detector deliberately consumes only durable turn UUIDs and structured fast-loop
evaluation signals. It never classifies raw transcript text and never treats safety
events as classifier features. Runtime failures are isolated from the counseling
turn and are exposed through a metadata-only result for tests and operations.
"""
from __future__ import annotations
import asyncio
import hashlib
import json
import logging
from collections import OrderedDict
from collections.abc import Mapping, Sequence
from dataclasses import dataclass
from functools import wraps
from types import ModuleType
from typing import Any, Literal
from uuid import UUID, uuid5
from .. import db
from ..contracts.rupture_repair import RepairBehavior, RuptureType
from . import rupture_repair_store
logger = logging.getLogger(__name__)
RUNTIME_DETECTOR_VERSION = "1.0.0"
_RUNTIME_NAMESPACE = UUID("ce466245-f185-5d97-93b4-543d773de0a9")
_RUNTIME_EPISODE_PREFIX = f"runtime-{RUNTIME_DETECTOR_VERSION}:"
_MAX_LAST_RESULTS = 256
_NEGATIVE_CLIENT_STATES = frozenset(
{
"involuntary",
"defensive",
"conflicted",
"compliant_surface",
"externalizing",
"active_passivity",
"affect_masking",
"focus_drift_fusion",
}
)
_POSITIVE_CLIENT_STATES = frozenset(
{
"affect_contact",
"thought_organizing",
"responds_to_exploration",
"expresses_plan",
"defense_loosening",
}
)
_CURIOSITY_TECHNIQUES = frozenset(
{
"exploration",
"facilitative_question",
"clarification",
"opinion_check",
}
)
_IMPACT_TECHNIQUES = frozenset({"empathy", "reflection", "validation", "restatement"})
_FOLLOW_UP_TECHNIQUES = frozenset(
{"facilitative_question", "clarification", "opinion_check"}
)
_ADVICE_TECHNIQUES = frozenset(
{
"psychoeducation",
"homework",
"behavioral_alternative",
"skills_coaching",
}
)
_DIMENSION_ALIASES: tuple[tuple[RuptureType, frozenset[str]], ...] = (
(
"over_disclosure",
frozenset(
{
"self_disclosure",
"counselor_self_disclosure",
"자기공개",
"과도한자기공개",
}
),
),
(
"premature_advice",
frozenset(
{
"advice",
"directive",
"autonomy",
"premature_advice",
"조언",
"지시",
"자율성",
}
),
),
(
"cultural_miss",
frozenset(
{
"culture",
"cultural_context",
"identity",
"bias",
"gender",
"religion",
"race",
"문화",
"정체성",
"편견",
"젠더",
"종교",
"인종",
}
),
),
(
"boundary_tension",
frozenset(
{
"boundary",
"role_boundary",
"confidentiality",
"dual_relationship",
"limit",
"경계",
"비밀보장",
"이중관계",
"한계",
}
),
),
(
"goal_mismatch",
frozenset({"goal", "objective", "agenda", "목표", "의제"}),
),
(
"task_mismatch",
frozenset(
{
"task",
"strategy",
"process",
"pacing",
"intervention",
"homework",
"과제",
"전략",
"과정",
"페이싱",
"개입",
}
),
),
(
"empathic_miss",
frozenset(
{
"empathy",
"reflection",
"validation",
"affect",
"emotional_attunement",
"공감",
"반영",
"타당화",
"정서조율",
}
),
),
)
_REQUIRED_REPAIR_BEHAVIORS: dict[RuptureType, frozenset[RepairBehavior]] = {
"withdrawal": frozenset({"curiosity", "impact_acknowledgement", "follow_up_check"}),
"confrontation": frozenset(
{"curiosity", "impact_acknowledgement", "follow_up_check"}
),
"goal_mismatch": frozenset({"curiosity", "goal_reagreement", "follow_up_check"}),
"task_mismatch": frozenset({"curiosity", "task_reagreement", "follow_up_check"}),
"empathic_miss": frozenset(
{"curiosity", "impact_acknowledgement", "follow_up_check"}
),
"cultural_miss": frozenset(
{"curiosity", "impact_acknowledgement", "follow_up_check"}
),
"boundary_tension": frozenset(
{"naming", "impact_acknowledgement", "follow_up_check"}
),
"premature_advice": frozenset(
{"curiosity", "impact_acknowledgement", "follow_up_check"}
),
"over_disclosure": frozenset(
{"curiosity", "impact_acknowledgement", "follow_up_check"}
),
}
@dataclass(frozen=True, slots=True)
class DurableTurnWindow:
counselor_turn_id: UUID
client_turn_id: UUID
counselor_seq: int
client_seq: int
appropriateness_score: float
rapport_signal: float | None
techniques: tuple[str, ...]
client_states: tuple[str, ...]
intent_dimension: str | None
intent_severity: str | None
evaluation_error: bool = False
safety_event_ids: tuple[int, ...] = ()
@property
def evidence_turn_ids(self) -> tuple[UUID, UUID]:
return (self.counselor_turn_id, self.client_turn_id)
@dataclass(frozen=True, slots=True)
class DetectionCandidate:
rupture_type: RuptureType
confidence: float
uncertainty: float
@dataclass(frozen=True, slots=True)
class RepairAssessment:
status: Literal["missed", "partial", "resolved", "not_applicable"]
behaviors: tuple[RepairBehavior, ...]
client_response: str
uncertainty: float
counterevidence: tuple[str, ...] = ()
@dataclass(frozen=True, slots=True)
class RuntimeEpisode:
episode_id: UUID
episode_key: str
rupture_type: RuptureType
fast_observation_id: UUID
latest_observation_id: UUID
latest_state: str
confidence: float
evidence_turn_ids: tuple[UUID, ...]
reconciliation_status: str | None = None
@dataclass(frozen=True, slots=True)
class RuntimeSnapshot:
session_id: UUID
ended: bool
windows: tuple[DurableTurnWindow, ...]
@dataclass(frozen=True, slots=True)
class RuptureRuntimeResult:
session_id: str
trigger: str
status: Literal["no_evidence", "recorded", "reconciled", "error"]
detected_count: int = 0
reconciled_count: int = 0
error_code: str | None = None
_LAST_RESULTS: OrderedDict[str, RuptureRuntimeResult] = OrderedDict()
_RUNTIME_TASKS: set[asyncio.Task[RuptureRuntimeResult]] = set()
def _row_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:
encoded = json.dumps(
payload,
ensure_ascii=False,
separators=(",", ":"),
sort_keys=True,
default=str,
).encode("utf-8")
return hashlib.sha256(encoded).hexdigest()
def _stable_uuid(kind: str, *parts: object) -> UUID:
name = ":".join((RUNTIME_DETECTOR_VERSION, kind, *(str(item) for item in parts)))
return uuid5(_RUNTIME_NAMESPACE, name)
def _normalize_dimension(value: str | None) -> str:
return "".join((value or "").strip().lower().split()).replace("-", "_")
def _dimension_rupture_type(dimension: str | None) -> RuptureType | None:
normalized = _normalize_dimension(dimension)
if not normalized:
return None
tokens = {
normalized,
*(item for item in normalized.replace("/", "_").split("_") if item),
}
for rupture_type, aliases in _DIMENSION_ALIASES:
if tokens & aliases or any(alias in normalized for alias in aliases):
return rupture_type
return None
def detect_rupture(window: DurableTurnWindow) -> DetectionCandidate | None:
"""Return one conservative, deterministic fast-loop warning per turn pair."""
if window.evaluation_error or not window.evidence_turn_ids:
return None
techniques = set(window.techniques)
client_states = set(window.client_states)
warning = window.appropriateness_score <= 2.0
negative_rapport = (
window.rapport_signal is not None and window.rapport_signal <= -0.2
)
negative_client = bool(client_states & _NEGATIVE_CLIENT_STATES)
corroborated = warning or negative_rapport or negative_client
severity = (window.intent_severity or "").strip().lower()
severity_ready = severity in {"moderate", "major"}
explicit_type = _dimension_rupture_type(window.intent_dimension)
# A typed critique with only minor severity is counterevidence against
# upgrading the same turn to a generic rupture category.
if explicit_type is not None and not severity_ready:
return None
if explicit_type is not None and severity_ready and corroborated:
if explicit_type == "over_disclosure" and "self_disclosure" not in techniques:
return None
if explicit_type == "premature_advice" and not (
techniques & _ADVICE_TECHNIQUES
):
return None
confidence = 0.92 if severity == "major" else 0.84
if negative_rapport and negative_client:
confidence = min(0.96, confidence + 0.04)
return DetectionCandidate(
rupture_type=explicit_type,
confidence=confidence,
uncertainty=round(1.0 - confidence, 2),
)
if (
"confrontation" in techniques
and warning
and bool(client_states & {"defensive", "conflicted", "externalizing"})
):
return DetectionCandidate(
rupture_type="confrontation", confidence=0.86, uncertainty=0.14
)
if warning and negative_rapport and negative_client:
return DetectionCandidate(
rupture_type="withdrawal", confidence=0.82, uncertainty=0.18
)
return None
def _repair_behaviors(
rupture_type: RuptureType, techniques: Sequence[str]
) -> tuple[RepairBehavior, ...]:
tags = set(techniques)
behaviors: list[RepairBehavior] = []
if tags & _CURIOSITY_TECHNIQUES:
behaviors.append("curiosity")
if tags & _IMPACT_TECHNIQUES:
behaviors.append("impact_acknowledgement")
if tags & _FOLLOW_UP_TECHNIQUES:
behaviors.append("follow_up_check")
if rupture_type == "goal_mismatch" and tags & {
"consent_motivation_check",
"opinion_check",
}:
behaviors.append("goal_reagreement")
if rupture_type == "task_mismatch" and tags & {
"consent_motivation_check",
"opinion_check",
"clarification",
}:
behaviors.append("task_reagreement")
if rupture_type == "boundary_tension" and "principle_explanation" in tags:
behaviors.append("naming")
return tuple(dict.fromkeys(behaviors))
def _client_response(states: Sequence[str]) -> str:
observed = set(states)
positive = observed & _POSITIVE_CLIENT_STATES
negative = observed & _NEGATIVE_CLIENT_STATES
if positive and negative:
return "mixed"
if "defense_loosening" in positive or "expresses_plan" in positive:
return "explicit_alignment"
if positive:
return "engaged"
if "compliant_surface" in negative:
return "compliance_only"
if negative:
return "withdrawn"
return "mixed"
def assess_follow_up(
original: DetectionCandidate,
follow_up: DurableTurnWindow,
) -> RepairAssessment | None:
"""Deep-style deterministic revision using a complete subsequent turn pair."""
if follow_up.evaluation_error:
return None
behaviors = _repair_behaviors(original.rupture_type, follow_up.techniques)
response = _client_response(follow_up.client_states)
observed = set(behaviors)
required = _REQUIRED_REPAIR_BEHAVIORS[original.rupture_type]
present = observed & required
positive_fast = (
follow_up.appropriateness_score >= 4.0
and follow_up.rapport_signal is not None
and follow_up.rapport_signal >= 0.1
)
if required <= observed and response in {"engaged", "explicit_alignment"}:
return RepairAssessment(
status="resolved",
behaviors=behaviors,
client_response=response,
uncertainty=0.1,
)
if len(present) >= 2 and response in {"mixed", "engaged", "explicit_alignment"}:
return RepairAssessment(
status="partial",
behaviors=behaviors,
client_response=response,
uncertainty=0.2,
counterevidence=tuple(
f"missing_repair_behavior:{item}"
for item in sorted(required - observed)
),
)
if (
not behaviors
and positive_fast
and response in {"engaged", "explicit_alignment"}
):
return RepairAssessment(
status="not_applicable",
behaviors=(),
client_response=response,
uncertainty=0.15,
counterevidence=("subsequent_structured_signals_do_not_sustain_warning",),
)
return RepairAssessment(
status="missed",
behaviors=behaviors,
client_response=response,
uncertainty=0.2,
counterevidence=("required_repair_evidence_not_observed",),
)
async def _load_snapshot(conn: Any, session_id: UUID) -> RuntimeSnapshot | None:
session = await conn.fetchrow(
"""
SELECT id, ended_at
FROM app.sessions
WHERE id = $1
""",
session_id,
)
if session is None:
return None
rows = await conn.fetch(
"""
SELECT
counselor.id AS counselor_turn_id,
counselor.seq AS counselor_seq,
client.id AS client_turn_id,
client.seq AS client_seq,
appropriateness.score AS appropriateness_score,
rapport.score AS rapport_signal,
COALESCE(techniques.codes, ARRAY[]::text[]) AS techniques,
COALESCE(states.codes, ARRAY[]::text[]) AS client_states,
critique.intent_deviation,
EXISTS (
SELECT 1
FROM app.feedback_scores feedback_error
WHERE feedback_error.turn_id = counselor.id
AND feedback_error.dimension = 'error'
) AS evaluation_error,
COALESCE(safety.ids, ARRAY[]::bigint[]) AS safety_event_ids
FROM app.turns counselor
JOIN LATERAL (
SELECT turn_row.id, turn_row.seq
FROM app.turns turn_row
WHERE turn_row.session_id = counselor.session_id
AND turn_row.speaker = 'client'
AND turn_row.seq > counselor.seq
ORDER BY turn_row.seq
LIMIT 1
) client ON TRUE
LEFT JOIN LATERAL (
SELECT score
FROM app.feedback_scores
WHERE turn_id = counselor.id AND dimension = 'appropriateness'
LIMIT 1
) appropriateness ON TRUE
LEFT JOIN LATERAL (
SELECT score
FROM app.feedback_scores
WHERE turn_id = counselor.id AND dimension = 'rapport_signal'
LIMIT 1
) rapport ON TRUE
LEFT JOIN LATERAL (
SELECT array_agg(definition.code ORDER BY definition.code) AS codes
FROM app.turn_technique tagged
JOIN app.technique_label_def definition
ON definition.label_id = tagged.label_id
WHERE tagged.turn_id = counselor.id
) techniques ON TRUE
LEFT JOIN LATERAL (
SELECT array_agg(definition.code ORDER BY definition.code) AS codes
FROM app.turn_client_state tagged
JOIN app.client_state_def definition
ON definition.label_id = tagged.label_id
WHERE tagged.turn_id = counselor.id
) states ON TRUE
LEFT JOIN LATERAL (
SELECT comment.intent_deviation
FROM app.supervisor_comment comment
WHERE comment.turn_id = counselor.id
AND comment.intent_deviation IS NOT NULL
ORDER BY comment.created_at DESC, comment.id DESC
LIMIT 1
) critique ON TRUE
LEFT JOIN LATERAL (
SELECT array_agg(event.id ORDER BY event.id) AS ids
FROM app.safety_events event
WHERE event.session_id = counselor.session_id
AND event.turn_id IN (counselor.id, client.id)
) safety ON TRUE
WHERE counselor.session_id = $1
AND counselor.speaker = 'counselor'
AND appropriateness.score IS NOT NULL
ORDER BY counselor.seq
""",
session_id,
)
windows: list[DurableTurnWindow] = []
for row in rows:
deviation = _row_value(row, "intent_deviation")
if not isinstance(deviation, Mapping):
deviation = {}
windows.append(
DurableTurnWindow(
counselor_turn_id=UUID(str(_row_value(row, "counselor_turn_id"))),
client_turn_id=UUID(str(_row_value(row, "client_turn_id"))),
counselor_seq=int(_row_value(row, "counselor_seq")),
client_seq=int(_row_value(row, "client_seq")),
appropriateness_score=float(
_row_value(row, "appropriateness_score", 3.0)
),
rapport_signal=(
float(_row_value(row, "rapport_signal"))
if _row_value(row, "rapport_signal") is not None
else None
),
techniques=tuple(
str(item) for item in _row_value(row, "techniques", ())
),
client_states=tuple(
str(item) for item in _row_value(row, "client_states", ())
),
intent_dimension=(
str(deviation.get("dimension"))
if deviation.get("dimension") is not None
else None
),
intent_severity=(
str(deviation.get("severity"))
if deviation.get("severity") is not None
else None
),
evaluation_error=bool(_row_value(row, "evaluation_error", False)),
safety_event_ids=tuple(
int(item) for item in _row_value(row, "safety_event_ids", ())
),
)
)
return RuntimeSnapshot(
session_id=session_id,
ended=_row_value(session, "ended_at") is not None,
windows=tuple(windows),
)
async def _load_runtime_episodes(
conn: Any, session_id: UUID
) -> dict[str, RuntimeEpisode]:
rows = await conn.fetch(
"""
SELECT
episode.episode_id,
episode.episode_key,
first_observation.observation_id AS fast_observation_id,
first_observation.rupture_type,
first_observation.confidence,
first_observation.evidence_turn_ids,
latest_observation.observation_id AS latest_observation_id,
latest_observation.to_state AS latest_state,
latest_revision.deep_status AS reconciliation_status
FROM app.rupture_episode episode
JOIN LATERAL (
SELECT observation_id, rupture_type, confidence, evidence_turn_ids
FROM app.rupture_observation_event
WHERE episode_id = episode.episode_id
ORDER BY sequence_no
LIMIT 1
) first_observation ON TRUE
JOIN LATERAL (
SELECT observation_id, to_state
FROM app.rupture_observation_event
WHERE episode_id = episode.episode_id
ORDER BY sequence_no DESC
LIMIT 1
) latest_observation ON TRUE
LEFT JOIN LATERAL (
SELECT deep_status
FROM app.rupture_reconciliation_revision
WHERE episode_id = episode.episode_id
ORDER BY revision_no DESC
LIMIT 1
) latest_revision ON TRUE
WHERE episode.session_id = $1
AND episode.episode_key LIKE $2
""",
session_id,
f"{_RUNTIME_EPISODE_PREFIX}%",
)
return {
str(_row_value(row, "episode_key")): RuntimeEpisode(
episode_id=UUID(str(_row_value(row, "episode_id"))),
episode_key=str(_row_value(row, "episode_key")),
rupture_type=str(_row_value(row, "rupture_type")), # type: ignore[arg-type]
fast_observation_id=UUID(str(_row_value(row, "fast_observation_id"))),
latest_observation_id=UUID(str(_row_value(row, "latest_observation_id"))),
latest_state=str(_row_value(row, "latest_state")),
confidence=float(_row_value(row, "confidence", 0.8)),
evidence_turn_ids=tuple(
UUID(str(item)) for item in _row_value(row, "evidence_turn_ids", ())
),
reconciliation_status=(
str(_row_value(row, "reconciliation_status"))
if _row_value(row, "reconciliation_status") is not None
else None
),
)
for row in rows
}
def _episode_key(window: DurableTurnWindow, rupture_type: RuptureType) -> str:
return f"{_RUNTIME_EPISODE_PREFIX}{window.counselor_turn_id}:{rupture_type}"
def _anchor_turn_id(episode_key: str) -> UUID | None:
if not episode_key.startswith(_RUNTIME_EPISODE_PREFIX):
return None
remainder = episode_key[len(_RUNTIME_EPISODE_PREFIX) :]
try:
return UUID(remainder.split(":", 1)[0])
except (ValueError, IndexError):
return None
def _model_input_payload(
*,
window: DurableTurnWindow,
rupture_type: RuptureType,
phase: str,
follow_up: DurableTurnWindow | None = None,
) -> dict[str, Any]:
payload: dict[str, Any] = {
"detector_version": RUNTIME_DETECTOR_VERSION,
"phase": phase,
"rupture_type": rupture_type,
"evidence_turn_ids": [str(item) for item in window.evidence_turn_ids],
"appropriateness_score": window.appropriateness_score,
"rapport_signal": window.rapport_signal,
"techniques": sorted(window.techniques),
"client_states": sorted(window.client_states),
"intent_dimension": _normalize_dimension(window.intent_dimension),
"intent_severity": (window.intent_severity or "").lower(),
}
if follow_up is not None:
payload["follow_up"] = {
"evidence_turn_ids": [str(item) for item in follow_up.evidence_turn_ids],
"appropriateness_score": follow_up.appropriateness_score,
"rapport_signal": follow_up.rapport_signal,
"techniques": sorted(follow_up.techniques),
"client_states": sorted(follow_up.client_states),
}
return payload
_RULESET_HASH = _canonical_hash(
{
"version": RUNTIME_DETECTOR_VERSION,
"dimension_aliases": [
(rupture_type, sorted(aliases))
for rupture_type, aliases in _DIMENSION_ALIASES
],
"negative_states": sorted(_NEGATIVE_CLIENT_STATES),
"positive_states": sorted(_POSITIVE_CLIENT_STATES),
"repair_requirements": {
key: sorted(value) for key, value in _REQUIRED_REPAIR_BEHAVIORS.items()
},
}
)
async def _ensure_model_run(
conn: Any,
*,
model_run_id: UUID,
session_id: UUID,
turn_id: UUID,
input_payload: Mapping[str, Any],
phase: str,
trigger: str,
) -> None:
"""Create real audit provenance for each deterministic model-inferred write."""
input_hash = _canonical_hash(input_payload)
await conn.execute(
"""
INSERT INTO audit.model_run (
model_run_id, session_id, turn_id, agent_role, provider, model,
prompt_bundle_id, prompt_bundle_version, prompt_bundle_hash,
structured_schema_version, input_evidence_hash, status, metadata
) VALUES (
$1,$2,$3,'evaluator','vignette-runtime','rupture-runtime-deterministic',
'rupture-runtime-rules',$4,$5,
'rupture-repair-runtime-1',$6,'ready',$7::jsonb
)
ON CONFLICT (model_run_id) DO NOTHING
""",
model_run_id,
session_id,
turn_id,
RUNTIME_DETECTOR_VERSION,
_RULESET_HASH,
input_hash,
{
"phase": phase,
"trigger": trigger,
"detector_version": RUNTIME_DETECTOR_VERSION,
},
)
async def _append_detection(
conn: Any,
*,
session_id: UUID,
window: DurableTurnWindow,
candidate: DetectionCandidate,
trigger: str,
) -> RuntimeEpisode:
episode_key = _episode_key(window, candidate.rupture_type)
model_run_id = _stable_uuid("model-run-fast", episode_key)
await _ensure_model_run(
conn,
model_run_id=model_run_id,
session_id=session_id,
turn_id=window.counselor_turn_id,
input_payload=_model_input_payload(
window=window,
rupture_type=candidate.rupture_type,
phase="fast",
),
phase="fast",
trigger=trigger,
)
ids = await rupture_repair_store.append_evaluator_observation(
conn=conn,
session_id=session_id,
episode_key=episode_key,
idempotency_key=_stable_uuid("observation-detected", episode_key),
event_kind="rupture.detected",
from_state=None,
to_state="onset",
rupture_type=candidate.rupture_type,
source_kind="model_inferred",
perspective="independent_observer",
ai_view="evaluator",
confidence=candidate.confidence,
uncertainty=candidate.uncertainty,
evidence_turn_ids=window.evidence_turn_ids,
counterevidence=(),
model_run_id=model_run_id,
safety_event_ids=window.safety_event_ids,
)
return RuntimeEpisode(
episode_id=ids["episode_id"],
episode_key=episode_key,
rupture_type=candidate.rupture_type,
fast_observation_id=ids["observation_id"],
latest_observation_id=ids["observation_id"],
latest_state="onset",
confidence=candidate.confidence,
evidence_turn_ids=window.evidence_turn_ids,
)
async def _append_lifecycle_and_reconciliation(
conn: Any,
*,
session_id: UUID,
episode: RuntimeEpisode,
original: DurableTurnWindow,
follow_up: DurableTurnWindow | None,
assessment: RepairAssessment,
trigger: str,
) -> None:
follow_up_key = (
str(follow_up.counselor_turn_id) if follow_up is not None else "session-end"
)
model_run_id = _stable_uuid(
"model-run-deep", episode.episode_key, follow_up_key, assessment.status
)
evidence_turn_ids = tuple(
dict.fromkeys(
(
*episode.evidence_turn_ids,
*(follow_up.evidence_turn_ids if follow_up is not None else ()),
)
)
)
await _ensure_model_run(
conn,
model_run_id=model_run_id,
session_id=session_id,
turn_id=(
follow_up.counselor_turn_id
if follow_up is not None
else original.counselor_turn_id
),
input_payload=_model_input_payload(
window=original,
rupture_type=episode.rupture_type,
phase="deep",
follow_up=follow_up,
),
phase="deep",
trigger=trigger,
)
latest_state = episode.latest_state
deep_observation_id: UUID | None = None
if assessment.status != "not_applicable":
if assessment.behaviors and latest_state == "onset":
recognized = await rupture_repair_store.append_evaluator_observation(
conn=conn,
session_id=session_id,
episode_key=episode.episode_key,
idempotency_key=_stable_uuid(
"observation-recognized", episode.episode_key, follow_up_key
),
event_kind="rupture.recognized",
from_state="onset",
to_state="recognized",
rupture_type=episode.rupture_type,
source_kind="model_inferred",
perspective="independent_observer",
ai_view="evaluator",
confidence=episode.confidence,
uncertainty=assessment.uncertainty,
evidence_turn_ids=evidence_turn_ids,
counterevidence=assessment.counterevidence,
model_run_id=model_run_id,
safety_event_ids=(
follow_up.safety_event_ids if follow_up is not None else ()
),
)
latest_state = "recognized"
deep_observation_id = recognized["observation_id"]
if assessment.behaviors and latest_state == "recognized":
attempted = await rupture_repair_store.append_evaluator_observation(
conn=conn,
session_id=session_id,
episode_key=episode.episode_key,
idempotency_key=_stable_uuid(
"observation-attempted", episode.episode_key, follow_up_key
),
event_kind="repair.attempted",
from_state="recognized",
to_state="repair_attempted",
rupture_type=episode.rupture_type,
source_kind="model_inferred",
perspective="independent_observer",
ai_view="evaluator",
confidence=episode.confidence,
uncertainty=assessment.uncertainty,
evidence_turn_ids=evidence_turn_ids,
counterevidence=assessment.counterevidence,
model_run_id=model_run_id,
safety_event_ids=(
follow_up.safety_event_ids if follow_up is not None else ()
),
)
latest_state = "repair_attempted"
deep_observation_id = attempted["observation_id"]
if latest_state == "onset":
event_kind = "rupture.missed"
from_state = "onset"
to_state = "missed"
elif latest_state == "repair_attempted":
event_kind = f"repair.{assessment.status}"
from_state = "repair_attempted"
to_state = assessment.status
else:
event_kind = ""
from_state = latest_state
to_state = latest_state
if event_kind:
final_observation = await rupture_repair_store.append_evaluator_observation(
conn=conn,
session_id=session_id,
episode_key=episode.episode_key,
idempotency_key=_stable_uuid(
"observation-final",
episode.episode_key,
follow_up_key,
assessment.status,
),
event_kind=event_kind,
from_state=from_state, # type: ignore[arg-type]
to_state=to_state, # type: ignore[arg-type]
rupture_type=episode.rupture_type,
source_kind="model_inferred",
perspective="independent_observer",
ai_view="evaluator",
confidence=episode.confidence,
uncertainty=assessment.uncertainty,
evidence_turn_ids=evidence_turn_ids,
counterevidence=assessment.counterevidence,
model_run_id=model_run_id,
safety_event_ids=(
follow_up.safety_event_ids if follow_up is not None else ()
),
)
deep_observation_id = final_observation["observation_id"]
disposition = {
"missed": "confirmed",
"partial": "superseded_partial",
"resolved": "superseded_resolved",
"not_applicable": "dismissed",
}[assessment.status]
await rupture_repair_store.append_reconciliation_revision(
conn=conn,
session_id=session_id,
episode_id=episode.episode_id,
idempotency_key=_stable_uuid(
"reconciliation", episode.episode_key, follow_up_key, assessment.status
),
fast_warning_observation_id=episode.fast_observation_id,
deep_observation_id=deep_observation_id,
fast_warning_id=f"runtime-warning:{episode.fast_observation_id}",
provisional_status="missed",
deep_status=assessment.status,
disposition=disposition,
uncertainty=assessment.uncertainty,
evidence_turn_ids=evidence_turn_ids,
counterevidence=assessment.counterevidence,
model_run_id=model_run_id,
ai_view="evaluator",
)
async def _process_session_scan(
session_id: UUID, *, trigger: str
) -> RuptureRuntimeResult:
async with db.acquire(ai_view="evaluator", ai_context=True) as conn:
snapshot = await _load_snapshot(conn, session_id)
if snapshot is None or not snapshot.windows:
return RuptureRuntimeResult(
session_id=str(session_id), trigger=trigger, status="no_evidence"
)
episodes = await _load_runtime_episodes(conn, session_id)
windows_by_turn = {
window.counselor_turn_id: (index, window)
for index, window in enumerate(snapshot.windows)
}
candidates: dict[str, tuple[DurableTurnWindow, DetectionCandidate]] = {}
detected_count = 0
for window in snapshot.windows:
candidate = detect_rupture(window)
if candidate is None:
continue
episode_key = _episode_key(window, candidate.rupture_type)
candidates[episode_key] = (window, candidate)
if episode_key in episodes:
continue
episode = await _append_detection(
conn,
session_id=session_id,
window=window,
candidate=candidate,
trigger=trigger,
)
episodes[episode_key] = episode
detected_count += 1
reconciled_count = 0
for episode_key, episode in episodes.items():
if episode.reconciliation_status is not None:
continue
anchor_id = _anchor_turn_id(episode_key)
anchored = windows_by_turn.get(anchor_id) if anchor_id is not None else None
if anchored is None:
continue
index, original_window = anchored
follow_up = (
snapshot.windows[index + 1]
if index + 1 < len(snapshot.windows)
else None
)
if follow_up is None and not snapshot.ended:
continue
original_candidate = candidates.get(episode_key, (None, None))[1]
if original_candidate is None:
original_candidate = DetectionCandidate(
rupture_type=episode.rupture_type,
confidence=episode.confidence,
uncertainty=round(1.0 - episode.confidence, 2),
)
if follow_up is None:
assessment = RepairAssessment(
status="missed",
behaviors=(),
client_response="withdrawn",
uncertainty=0.3,
counterevidence=("session_ended_without_repair_evidence",),
)
else:
assessment = assess_follow_up(original_candidate, follow_up)
if assessment is None:
continue
await _append_lifecycle_and_reconciliation(
conn,
session_id=session_id,
episode=episode,
original=original_window,
follow_up=follow_up,
assessment=assessment,
trigger=trigger,
)
reconciled_count += 1
status_value: Literal["no_evidence", "recorded", "reconciled"]
if reconciled_count:
status_value = "reconciled"
elif detected_count:
status_value = "recorded"
else:
status_value = "no_evidence"
return RuptureRuntimeResult(
session_id=str(session_id),
trigger=trigger,
status=status_value,
detected_count=detected_count,
reconciled_count=reconciled_count,
)
def _remember_result(result: RuptureRuntimeResult) -> None:
_LAST_RESULTS[result.session_id] = result
_LAST_RESULTS.move_to_end(result.session_id)
while len(_LAST_RESULTS) > _MAX_LAST_RESULTS:
_LAST_RESULTS.popitem(last=False)
def last_runtime_result(session_id: str) -> RuptureRuntimeResult | None:
return _LAST_RESULTS.get(session_id)
async def run_session_scan(session_id: str, *, trigger: str) -> RuptureRuntimeResult:
"""Best-effort entrypoint: never propagate runtime ledger failures to a turn."""
try:
parsed_session_id = UUID(session_id)
except (TypeError, ValueError):
result = RuptureRuntimeResult(
session_id=str(session_id),
trigger=trigger,
status="error",
error_code="invalid_session_id",
)
_remember_result(result)
return result
try:
result = await _process_session_scan(parsed_session_id, trigger=trigger)
except Exception as exc:
error_type = type(exc).__name__
logger.error(
"rupture runtime scan failed: session_id=%s trigger=%s error_type=%s",
parsed_session_id,
trigger,
error_type,
)
result = RuptureRuntimeResult(
session_id=str(parsed_session_id),
trigger=trigger,
status="error",
error_code=f"runtime_{error_type.lower()}",
)
_remember_result(result)
return result
def _observe_runtime_task(task: asyncio.Task[RuptureRuntimeResult]) -> None:
_RUNTIME_TASKS.discard(task)
try:
task.result()
except asyncio.CancelledError:
return
except Exception as exc: # pragma: no cover - run_session_scan is fail-closed
logger.error(
"rupture runtime task failed outside boundary: error_type=%s",
type(exc).__name__,
)
def schedule_session_scan(
session_id: str, *, trigger: str
) -> asyncio.Task[RuptureRuntimeResult] | None:
"""Schedule a scan without adding latency or failure to counseling output."""
try:
task = asyncio.create_task(
run_session_scan(session_id, trigger=trigger),
name=f"rupture-runtime:{session_id}:{trigger}",
)
except Exception as exc:
logger.error(
"rupture runtime scheduling failed: session_id=%s trigger=%s error_type=%s",
session_id,
trigger,
type(exc).__name__,
)
return None
_RUNTIME_TASKS.add(task)
task.add_done_callback(_observe_runtime_task)
return task
def install_turn_finalize_hook(turn_runtime_module: ModuleType) -> None:
"""Install one process-local post-persistence hook shared by text/stream/voice.
The application imports the sessions route before the voice route. Wrapping the
module function (instead of an individual route call) makes every current caller
run the same background boundary while keeping the durable finalizer unchanged.
"""
finalize = getattr(turn_runtime_module, "finalize_completed_turn")
if getattr(finalize, "__rupture_runtime_hook__", False):
return
@wraps(finalize)
async def finalize_with_rupture_runtime(*args: Any, **kwargs: Any) -> Any:
learner_turn = await finalize(*args, **kwargs)
session = args[0] if args else kwargs.get("sess")
session_id = getattr(session, "session_id", None)
if session_id:
schedule_session_scan(str(session_id), trigger="turn_persisted")
return learner_turn
setattr(finalize_with_rupture_runtime, "__rupture_runtime_hook__", True)
setattr(
turn_runtime_module, "finalize_completed_turn", finalize_with_rupture_runtime
)
__all__ = [
"DetectionCandidate",
"DurableTurnWindow",
"RepairAssessment",
"RuptureRuntimeResult",
"assess_follow_up",
"detect_rupture",
"install_turn_finalize_hook",
"last_runtime_result",
"run_session_scan",
"schedule_session_scan",
]