"""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", ]