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 산출물은 커밋에서 제외했다.
1206 lines
40 KiB
Python
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",
|
|
]
|