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 산출물은 커밋에서 제외했다.
660 lines
25 KiB
Python
660 lines
25 KiB
Python
"""Deterministic hidden behavior opportunities for G3 rupture/repair practice.
|
|
|
|
The director does not script a learner-visible answer. It occasionally gives the
|
|
client agent one conditional, autonomy-preserving behavior cue. Taxonomy labels,
|
|
selection criteria, IDs, and provenance stay outside the model messages and are
|
|
available only as request metadata.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import hashlib
|
|
import math
|
|
import re
|
|
from dataclasses import dataclass
|
|
from typing import Any, Mapping, Sequence
|
|
from uuid import UUID, uuid5
|
|
|
|
from .. import db
|
|
from ..contracts.outcome_trajectory import (
|
|
RELATIONSHIP_EVENT_TYPES,
|
|
TRAJECTORY_STATUSES,
|
|
)
|
|
from ..contracts.rupture_repair import RUPTURE_TYPES, RuptureType
|
|
|
|
|
|
SCENARIO_DIRECTOR_VERSION = "2.0.0"
|
|
SCENARIO_TAXONOMY_SOURCE = "contracts.rupture_repair.RUPTURE_TYPES"
|
|
SCENARIO_SELECTOR = "stored-context-gap-cycle-v2"
|
|
_SCENARIO_NAMESPACE = UUID("eef69e4d-7060-5c10-a7e1-8448569815c9")
|
|
_MIN_GAP = 4
|
|
_GAP_VARIANTS = (4, 5, 6)
|
|
_COPRIME_STEPS = (1, 2, 4, 5, 7, 8)
|
|
_COMPETENCY_ID_RE = re.compile(r"^competency\.[a-z0-9_.-]+$")
|
|
_WEAK_COMPETENCY_BANDS = {"fragile": 0, "developing": 1}
|
|
_COMPETENCY_RUPTURE_TYPES: dict[str, tuple[RuptureType, ...]] = {
|
|
"competency.empathic_reflection": ("empathic_miss",),
|
|
"competency.open_question": ("premature_advice",),
|
|
"competency.rupture_repair": ("withdrawal", "confrontation"),
|
|
"competency.collaborative_goal": ("goal_mismatch",),
|
|
}
|
|
_RELATIONSHIP_RUPTURE_TYPES: dict[str, tuple[RuptureType, ...]] = {
|
|
"rupture_withdrawal": ("withdrawal",),
|
|
"rupture_confrontation": ("confrontation",),
|
|
"unresolved_rupture": ("withdrawal", "confrontation"),
|
|
}
|
|
_TRAJECTORY_RUPTURE_TYPES: dict[str, tuple[RuptureType, ...]] = {
|
|
"watch": ("empathic_miss",),
|
|
"off_track": ("goal_mismatch", "task_mismatch"),
|
|
"deteriorating": ("withdrawal", "confrontation"),
|
|
}
|
|
_CASE_ARC_RUPTURE_TYPES: dict[int, tuple[RuptureType, ...]] = {
|
|
1: ("goal_mismatch", "task_mismatch"),
|
|
2: ("empathic_miss", "premature_advice"),
|
|
3: ("task_mismatch", "cultural_miss"),
|
|
4: ("boundary_tension", "withdrawal"),
|
|
5: ("over_disclosure", "confrontation"),
|
|
}
|
|
_RUPTURE_STATES = {
|
|
"onset",
|
|
"recognized",
|
|
"repair_attempted",
|
|
"missed",
|
|
"partial",
|
|
"resolved",
|
|
}
|
|
|
|
|
|
# The strings are deliberately conditional. They preserve client agency and do
|
|
# not instruct the model to reward, punish, trap, shame, or force the learner.
|
|
_BEHAVIOR_CUES: dict[RuptureType, tuple[str, ...]] = {
|
|
"withdrawal": (
|
|
"상담자의 말이 지금 받아들이기 벅차다면, 평소 말투 범위에서 답을 조금 짧게 하고 잠시 생각할 시간을 둔다. 충분히 안전하다고 느끼면 다시 말할 여지를 남긴다.",
|
|
"지금 대화에서 마음이 닫히는 느낌이 든다면, 억지로 동의하지 말고 한두 문장으로만 반응한다. 상담자가 여유를 주면 자신의 속도로 다시 이어 간다.",
|
|
),
|
|
"confrontation": (
|
|
"상담자의 해석이 자신의 경험과 분명히 다르다면, 공격하지 말고 무엇이 다른지 짧고 단호하게 자기 관점으로 말한다.",
|
|
"상담자의 말에 실제로 납득되지 않는 부분이 있다면 공손한 순응으로 덮지 말고, 사실과 느낌이 다른 지점을 현실적인 말투로 짚는다.",
|
|
),
|
|
"goal_mismatch": (
|
|
"지금 대화가 자신에게 중요한 문제와 멀어졌다고 느껴질 때만, 당장 다루고 싶은 주제가 무엇인지 자기 말로 분명히 제안한다.",
|
|
"상담자가 잡은 대화의 방향이 자신의 바람과 다르다면 무조건 따라가지 말고, 이번 시간에 얻고 싶은 것을 자연스럽게 다시 말한다.",
|
|
),
|
|
"task_mismatch": (
|
|
"제안받은 활동이나 진행 방식이 자신에게 맞지 않는다고 느껴질 때만, 어려운 이유와 더 편한 진행 방식을 솔직하게 말한다.",
|
|
"지금 요구받은 방식이 부담스럽거나 어색하다면 억지로 수행하지 말고, 가능한 속도나 다른 방법이 있는지 내담자답게 묻는다.",
|
|
),
|
|
"empathic_miss": (
|
|
"상담자의 말이 자신의 감정 핵심과 빗나갔다고 느껴질 때만, '그런 뜻이라기보다…'처럼 실제로 다른 느낌을 조심스럽게 바로잡는다.",
|
|
"이해받았다는 느낌이 들지 않는다면 맞장구로 넘기지 말고, 놓친 감정이나 의미를 한 가지 구체적으로 덧붙인다.",
|
|
),
|
|
"cultural_miss": (
|
|
"자신의 가족·세대·성별·지역·종교 등 배경이 단순하게 일반화됐다고 느낄 때만, 자기 경험은 어떻게 다른지 구체적으로 말한다.",
|
|
"상담자의 전제가 자신의 생활 맥락과 맞지 않는다면 상대를 몰아세우지 말고, 그 맥락에서 중요한 차이를 자기 경험 중심으로 짚는다.",
|
|
),
|
|
"boundary_tension": (
|
|
"상담 관계의 역할, 연락, 비밀보장, 시간 같은 경계가 실제로 모호하게 느껴질 때만, 추측해서 따르지 말고 궁금함이나 불편함을 질문한다.",
|
|
"상담자와 어디까지 이야기하거나 기대해도 되는지 헷갈린다면, 불안을 숨기지 말고 확인이 필요한 한 가지를 현실적으로 묻는다.",
|
|
),
|
|
"premature_advice": (
|
|
"충분히 이해받기 전에 해결책이 먼저 나왔다고 느껴질 때만, 그 방법이 지금은 어렵다는 점이나 먼저 더 들어줬으면 하는 부분을 말한다.",
|
|
"조언이 자신의 상황보다 앞서 간다고 느껴진다면 억지로 수락하지 말고, 왜 바로 실행하기 어려운지 한 가지 현실적인 이유를 설명한다.",
|
|
),
|
|
"over_disclosure": (
|
|
"상담자의 개인 이야기가 자신의 이야기보다 중심이 됐다고 느껴질 때만, 자연스러운 거리감을 보이거나 대화를 자신의 경험으로 조심스럽게 돌린다.",
|
|
"상담자의 자기 이야기가 부담스럽거나 비교당하는 느낌을 줄 때만, 형식적으로 위로하지 말고 자신이 지금 말하고 싶은 경험을 다시 꺼낸다.",
|
|
),
|
|
}
|
|
|
|
if set(_BEHAVIOR_CUES) != set(
|
|
RUPTURE_TYPES
|
|
): # pragma: no cover - import-time SSOT guard
|
|
raise RuntimeError(
|
|
"scenario director behavior cues must cover the G3 rupture taxonomy"
|
|
)
|
|
|
|
|
|
_SCENARIO_ID_RE = re.compile(r"\bg3-scenario-[0-9a-f]{32}\b", re.IGNORECASE)
|
|
_INTERNAL_LEAKAGE_MARKERS = (
|
|
"scenario_id",
|
|
"scenario director",
|
|
"scenario_director",
|
|
"provenance",
|
|
"taxonomy_source",
|
|
"taxonomy_type",
|
|
"director_version",
|
|
"context_fingerprint",
|
|
"trajectory_status",
|
|
"case_session_no",
|
|
"competency.",
|
|
"weakness",
|
|
"rupture_type",
|
|
"rupture type",
|
|
"rupture taxonomy",
|
|
"평가기준",
|
|
"채점기준",
|
|
"정답 라벨",
|
|
"내부 상태",
|
|
"effective_openness",
|
|
"rapport_credit",
|
|
"ideation_stage",
|
|
"state_before",
|
|
"state_after",
|
|
"resistance:",
|
|
"저항 수치",
|
|
"핵심신념",
|
|
"자동적 사고",
|
|
"진단 차원",
|
|
"이 턴의 자연스러운 반응 단서",
|
|
)
|
|
|
|
|
|
@dataclass(frozen=True, slots=True)
|
|
class StoredScenarioContext:
|
|
"""Role-safe categorical projection of stored learning and case ledgers.
|
|
|
|
The context deliberately carries no transcript, model rationale, rubric answer,
|
|
evidence sentence, or role-private relationship summary. ``available=False``
|
|
is a fail-closed sentinel and must never produce a scenario directive.
|
|
"""
|
|
|
|
available: bool
|
|
fingerprint: str
|
|
weak_competency_ids: tuple[str, ...] = ()
|
|
unresolved_rupture_types: tuple[RuptureType, ...] = ()
|
|
relationship_event_types: tuple[str, ...] = ()
|
|
trajectory_status: str | None = None
|
|
case_session_no: int | None = None
|
|
|
|
@classmethod
|
|
def unavailable(cls) -> "StoredScenarioContext":
|
|
return cls(available=False, fingerprint="")
|
|
|
|
@classmethod
|
|
def from_stored_signals(
|
|
cls,
|
|
*,
|
|
weak_competency_ids: Sequence[str] = (),
|
|
unresolved_rupture_types: Sequence[str] = (),
|
|
relationship_event_types: Sequence[str] = (),
|
|
trajectory_status: str | None = None,
|
|
case_session_no: int | None = None,
|
|
) -> "StoredScenarioContext":
|
|
competencies = tuple(sorted(set(weak_competency_ids)))
|
|
if any(not _COMPETENCY_ID_RE.fullmatch(item) for item in competencies):
|
|
raise ValueError("invalid competency id in stored scenario context")
|
|
unresolved_values = tuple(sorted(set(unresolved_rupture_types)))
|
|
if any(item not in RUPTURE_TYPES for item in unresolved_values):
|
|
raise ValueError("invalid rupture type in stored scenario context")
|
|
relationship_values = tuple(sorted(set(relationship_event_types)))
|
|
if any(item not in RELATIONSHIP_EVENT_TYPES for item in relationship_values):
|
|
raise ValueError("invalid relationship event in stored scenario context")
|
|
if trajectory_status is not None and trajectory_status not in TRAJECTORY_STATUSES:
|
|
raise ValueError("invalid trajectory status in stored scenario context")
|
|
if case_session_no is not None and case_session_no < 1:
|
|
raise ValueError("case session number must be positive")
|
|
|
|
canonical = (
|
|
"|".join(competencies),
|
|
"|".join(unresolved_values),
|
|
"|".join(relationship_values),
|
|
trajectory_status or "none",
|
|
str(case_session_no or 0),
|
|
)
|
|
fingerprint = hashlib.sha256("\x1f".join(canonical).encode("utf-8")).hexdigest()
|
|
return cls(
|
|
available=True,
|
|
fingerprint=fingerprint,
|
|
weak_competency_ids=competencies,
|
|
unresolved_rupture_types=unresolved_values, # type: ignore[arg-type]
|
|
relationship_event_types=relationship_values,
|
|
trajectory_status=trajectory_status,
|
|
case_session_no=case_session_no,
|
|
)
|
|
|
|
|
|
@dataclass(frozen=True, slots=True)
|
|
class ScenarioDirective:
|
|
"""Internal-only selection result; never serialize this object to learner APIs."""
|
|
|
|
scenario_id: str
|
|
rupture_type: RuptureType
|
|
behavior_cue: str
|
|
turn_seq: int
|
|
opportunity_index: int
|
|
context_fingerprint: str
|
|
|
|
def request_metadata(self) -> dict[str, Any]:
|
|
"""Return provenance without including the behavior prompt itself."""
|
|
|
|
return {
|
|
"scenario_id": self.scenario_id,
|
|
"taxonomy_type": self.rupture_type,
|
|
"context_fingerprint": self.context_fingerprint,
|
|
"provenance": {
|
|
"director": "g3-rupture-scenario-director",
|
|
"version": SCENARIO_DIRECTOR_VERSION,
|
|
"selector": SCENARIO_SELECTOR,
|
|
"taxonomy_source": SCENARIO_TAXONOMY_SOURCE,
|
|
},
|
|
}
|
|
|
|
|
|
def _digest(*parts: object) -> bytes:
|
|
payload = "\x1f".join(str(part) for part in parts).encode("utf-8")
|
|
return hashlib.sha256(payload).digest()
|
|
|
|
|
|
def _stable_schedule(
|
|
case_id: str, session_id: str, context_fingerprint: str
|
|
) -> tuple[int, int, int]:
|
|
seed = _digest(
|
|
SCENARIO_DIRECTOR_VERSION,
|
|
case_id,
|
|
session_id,
|
|
context_fingerprint,
|
|
)
|
|
gap = _GAP_VARIANTS[seed[0] % len(_GAP_VARIANTS)]
|
|
first_turn = 3 + (seed[1] % gap)
|
|
type_offset = seed[2] % len(RUPTURE_TYPES)
|
|
return gap, first_turn, type_offset
|
|
|
|
|
|
def _dedupe_types(values: Sequence[RuptureType]) -> tuple[RuptureType, ...]:
|
|
return tuple(dict.fromkeys(values))
|
|
|
|
|
|
def _types_for_competency(competency_id: str) -> tuple[RuptureType, ...]:
|
|
explicit = _COMPETENCY_RUPTURE_TYPES.get(competency_id)
|
|
if explicit is not None:
|
|
return explicit
|
|
token_map: tuple[tuple[str, tuple[RuptureType, ...]], ...] = (
|
|
("empath", ("empathic_miss",)),
|
|
("reflect", ("empathic_miss",)),
|
|
("goal", ("goal_mismatch",)),
|
|
("task", ("task_mismatch",)),
|
|
("cultur", ("cultural_miss",)),
|
|
("bound", ("boundary_tension",)),
|
|
("advice", ("premature_advice",)),
|
|
("disclos", ("over_disclosure",)),
|
|
("repair", ("withdrawal", "confrontation")),
|
|
)
|
|
lowered = competency_id.casefold()
|
|
for token, rupture_types in token_map:
|
|
if token in lowered:
|
|
return rupture_types
|
|
return ()
|
|
|
|
|
|
def _candidate_types(context: StoredScenarioContext) -> tuple[RuptureType, ...]:
|
|
# An unresolved rupture is the strongest continuity signal. Lower-priority
|
|
# context still participates in the fingerprint, so any ledger change creates
|
|
# a new deterministic schedule and scenario identity.
|
|
if context.unresolved_rupture_types:
|
|
return context.unresolved_rupture_types
|
|
|
|
competency_types = _dedupe_types(
|
|
tuple(
|
|
rupture_type
|
|
for competency_id in context.weak_competency_ids
|
|
for rupture_type in _types_for_competency(competency_id)
|
|
)
|
|
)
|
|
if competency_types:
|
|
return competency_types
|
|
|
|
relationship_types = _dedupe_types(
|
|
tuple(
|
|
rupture_type
|
|
for event_type in context.relationship_event_types
|
|
for rupture_type in _RELATIONSHIP_RUPTURE_TYPES.get(event_type, ())
|
|
)
|
|
)
|
|
if relationship_types:
|
|
return relationship_types
|
|
|
|
if context.trajectory_status is not None:
|
|
trajectory_types = _TRAJECTORY_RUPTURE_TYPES.get(context.trajectory_status, ())
|
|
if trajectory_types:
|
|
return trajectory_types
|
|
|
|
if context.case_session_no is not None:
|
|
arc_types = _CASE_ARC_RUPTURE_TYPES.get(
|
|
min(context.case_session_no, max(_CASE_ARC_RUPTURE_TYPES)),
|
|
(),
|
|
)
|
|
if arc_types:
|
|
return arc_types
|
|
return RUPTURE_TYPES # type: ignore[return-value]
|
|
|
|
|
|
def _stable_step(seed: bytes, candidate_count: int) -> int:
|
|
if candidate_count <= 1:
|
|
return 1
|
|
steps = tuple(
|
|
step for step in _COPRIME_STEPS if math.gcd(step, candidate_count) == 1
|
|
)
|
|
return steps[seed[0] % len(steps)]
|
|
|
|
|
|
def select_scenario_directive(
|
|
*,
|
|
case_id: str | None,
|
|
session_id: str,
|
|
turn_seq: int,
|
|
safety_escalated: bool,
|
|
scenario_context: StoredScenarioContext | None = None,
|
|
) -> ScenarioDirective | None:
|
|
"""Select a stable opportunity using only a role-safe stored projection."""
|
|
|
|
if (
|
|
safety_escalated
|
|
or turn_seq < 1
|
|
or not session_id.strip()
|
|
or scenario_context is None
|
|
or not scenario_context.available
|
|
or not scenario_context.fingerprint
|
|
):
|
|
return None
|
|
stable_case_id = (case_id or "no-case").strip() or "no-case"
|
|
stable_session_id = session_id.strip()
|
|
candidates = _candidate_types(scenario_context)
|
|
gap, first_turn, type_offset = _stable_schedule(
|
|
stable_case_id,
|
|
stable_session_id,
|
|
scenario_context.fingerprint,
|
|
)
|
|
if turn_seq < first_turn or (turn_seq - first_turn) % gap != 0:
|
|
return None
|
|
opportunity_index = (turn_seq - first_turn) // gap
|
|
step_seed = _digest(
|
|
stable_case_id,
|
|
stable_session_id,
|
|
scenario_context.fingerprint,
|
|
"type-step",
|
|
)
|
|
step = _stable_step(step_seed, len(candidates))
|
|
type_index = (type_offset + opportunity_index * step) % len(candidates)
|
|
rupture_type = candidates[type_index]
|
|
cue_seed = _digest(
|
|
stable_case_id,
|
|
stable_session_id,
|
|
turn_seq,
|
|
rupture_type,
|
|
scenario_context.fingerprint,
|
|
"cue",
|
|
)
|
|
cue_variants = _BEHAVIOR_CUES[rupture_type]
|
|
behavior_cue = cue_variants[cue_seed[0] % len(cue_variants)]
|
|
scenario_uuid = uuid5(
|
|
_SCENARIO_NAMESPACE,
|
|
":".join(
|
|
(
|
|
SCENARIO_DIRECTOR_VERSION,
|
|
stable_case_id,
|
|
stable_session_id,
|
|
str(turn_seq),
|
|
rupture_type,
|
|
scenario_context.fingerprint,
|
|
)
|
|
),
|
|
)
|
|
return ScenarioDirective(
|
|
scenario_id=f"g3-scenario-{scenario_uuid.hex}",
|
|
rupture_type=rupture_type,
|
|
behavior_cue=behavior_cue,
|
|
turn_seq=turn_seq,
|
|
opportunity_index=opportunity_index,
|
|
context_fingerprint=scenario_context.fingerprint,
|
|
)
|
|
|
|
|
|
def _row_value(row: Any, key: str, default: Any = None) -> Any:
|
|
if isinstance(row, Mapping):
|
|
return row.get(key, default)
|
|
try:
|
|
return row[key]
|
|
except (KeyError, TypeError):
|
|
return default
|
|
|
|
|
|
def _weak_competency_ids(raw_states: Any) -> tuple[str, ...]:
|
|
if raw_states is None:
|
|
return ()
|
|
if not isinstance(raw_states, Sequence) or isinstance(raw_states, (str, bytes)):
|
|
raise ValueError("competency states must be an array")
|
|
ranked: list[tuple[int, float, float, str]] = []
|
|
for raw in raw_states:
|
|
if not isinstance(raw, Mapping):
|
|
raise ValueError("competency state must be an object")
|
|
competency_id = str(raw.get("competency_id") or "")
|
|
band = str(raw.get("band") or "")
|
|
if not _COMPETENCY_ID_RE.fullmatch(competency_id):
|
|
raise ValueError("malformed competency state id")
|
|
if band not in {
|
|
"unassessed",
|
|
"fragile",
|
|
"developing",
|
|
"consistent_local",
|
|
"transfer_verified",
|
|
}:
|
|
raise ValueError("malformed competency state band")
|
|
try:
|
|
forgetting_risk = float(raw.get("forgetting_risk"))
|
|
uncertainty = float(raw.get("uncertainty"))
|
|
except (TypeError, ValueError) as exc:
|
|
raise ValueError("malformed competency state score") from exc
|
|
if not 0 <= forgetting_risk <= 1 or not 0 <= uncertainty <= 1:
|
|
raise ValueError("competency state score outside 0..1")
|
|
rank = _WEAK_COMPETENCY_BANDS.get(band)
|
|
if rank is not None:
|
|
ranked.append((rank, -forgetting_risk, -uncertainty, competency_id))
|
|
ranked.sort()
|
|
return tuple(item[3] for item in ranked[:3])
|
|
|
|
|
|
async def _load_context_with_connection(
|
|
connection: Any,
|
|
*,
|
|
session_id: UUID,
|
|
case_id: UUID | None,
|
|
) -> StoredScenarioContext:
|
|
anchor = await connection.fetchrow(
|
|
"""
|
|
SELECT
|
|
s.case_id,
|
|
s.learner_id,
|
|
s.session_no,
|
|
competency.competency_states,
|
|
trajectory.trajectory_status
|
|
FROM app.sessions s
|
|
LEFT JOIN LATERAL (
|
|
SELECT (
|
|
SELECT COALESCE(
|
|
jsonb_agg(
|
|
jsonb_build_object(
|
|
'competency_id', state->>'competency_id',
|
|
'band', state->>'band',
|
|
'forgetting_risk', state->'forgetting_risk',
|
|
'uncertainty', state->'uncertainty'
|
|
) ORDER BY state->>'competency_id'
|
|
),
|
|
'[]'::jsonb
|
|
)
|
|
FROM jsonb_array_elements(snapshot.graph_payload->'states') AS state
|
|
) AS competency_states
|
|
FROM app.competency_graph_snapshot snapshot
|
|
WHERE snapshot.learner_id = s.learner_id
|
|
ORDER BY snapshot.snapshot_no DESC, snapshot.snapshot_id DESC
|
|
LIMIT 1
|
|
) competency ON TRUE
|
|
LEFT JOIN LATERAL (
|
|
SELECT item->>'status' AS trajectory_status
|
|
FROM app.outcome_trajectory_revision revision
|
|
CROSS JOIN LATERAL jsonb_array_elements(revision.assessment->'sessions') item
|
|
WHERE revision.case_id = s.case_id
|
|
AND revision.learner_id = s.learner_id
|
|
ORDER BY revision.revision_no DESC,
|
|
(item->>'session_no')::int DESC,
|
|
revision.revision_id DESC
|
|
LIMIT 1
|
|
) trajectory ON TRUE
|
|
WHERE s.id = $1
|
|
AND ($2::uuid IS NULL OR s.case_id = $2)
|
|
""",
|
|
session_id,
|
|
case_id,
|
|
)
|
|
if anchor is None or _row_value(anchor, "case_id") is None:
|
|
return StoredScenarioContext.unavailable()
|
|
stored_case_id = UUID(str(_row_value(anchor, "case_id")))
|
|
|
|
relationship_rows = await connection.fetch(
|
|
"""
|
|
SELECT e.event_type
|
|
FROM app.relationship_memory_event e
|
|
WHERE e.case_id = $1
|
|
AND 'client' = ANY(e.visible_to)
|
|
AND e.event_type IN (
|
|
'goal_agreement','task_agreement','rupture_withdrawal',
|
|
'rupture_confrontation','repair_attempt','repair_confirmed',
|
|
'unresolved_rupture'
|
|
)
|
|
AND NOT EXISTS (
|
|
SELECT 1
|
|
FROM app.relationship_memory_event repair
|
|
WHERE repair.resolves_event_id = e.memory_event_id
|
|
AND repair.event_type = 'repair_confirmed'
|
|
AND 'client' = ANY(repair.visible_to)
|
|
)
|
|
ORDER BY e.created_at DESC, e.memory_event_id DESC
|
|
LIMIT 24
|
|
""",
|
|
stored_case_id,
|
|
)
|
|
rupture_rows = await connection.fetch(
|
|
"""
|
|
WITH latest AS (
|
|
SELECT DISTINCT ON (observation.episode_id)
|
|
observation.episode_id,
|
|
observation.rupture_type,
|
|
observation.to_state
|
|
FROM app.rupture_observation_event observation
|
|
JOIN app.rupture_episode episode
|
|
ON episode.episode_id = observation.episode_id
|
|
WHERE episode.case_id = $1
|
|
AND 'counselor' = ANY(observation.visible_to)
|
|
ORDER BY observation.episode_id,
|
|
observation.sequence_no DESC,
|
|
observation.observation_id DESC
|
|
)
|
|
SELECT rupture_type, to_state
|
|
FROM latest
|
|
ORDER BY rupture_type, episode_id
|
|
""",
|
|
stored_case_id,
|
|
)
|
|
|
|
relationship_events: list[str] = []
|
|
for row in relationship_rows:
|
|
event_type = str(_row_value(row, "event_type") or "")
|
|
if event_type not in RELATIONSHIP_EVENT_TYPES:
|
|
raise ValueError("malformed relationship event type")
|
|
relationship_events.append(event_type)
|
|
|
|
unresolved_types: list[str] = []
|
|
for row in rupture_rows:
|
|
rupture_type = str(_row_value(row, "rupture_type") or "")
|
|
to_state = str(_row_value(row, "to_state") or "")
|
|
if rupture_type not in RUPTURE_TYPES or to_state not in _RUPTURE_STATES:
|
|
raise ValueError("malformed rupture observation projection")
|
|
if to_state != "resolved":
|
|
unresolved_types.append(rupture_type)
|
|
|
|
session_no_value = _row_value(anchor, "session_no")
|
|
session_no = int(session_no_value) if session_no_value is not None else None
|
|
trajectory_value = _row_value(anchor, "trajectory_status")
|
|
trajectory_status = str(trajectory_value) if trajectory_value is not None else None
|
|
return StoredScenarioContext.from_stored_signals(
|
|
weak_competency_ids=_weak_competency_ids(
|
|
_row_value(anchor, "competency_states")
|
|
),
|
|
unresolved_rupture_types=unresolved_types,
|
|
relationship_event_types=relationship_events,
|
|
trajectory_status=trajectory_status,
|
|
case_session_no=session_no,
|
|
)
|
|
|
|
|
|
async def load_stored_scenario_context(
|
|
*,
|
|
session_id: str,
|
|
case_id: str | None,
|
|
connection: Any | None = None,
|
|
) -> StoredScenarioContext:
|
|
"""Load a minimal counselor-visible projection and fail closed on any defect.
|
|
|
|
``counselor`` is the least-privileged AI view allowed to read competency graph
|
|
snapshots. Queries additionally select only categorical fields; evaluator
|
|
rationale, answer keys, evidence text, relationship summaries, and transcript
|
|
content never cross this boundary.
|
|
"""
|
|
|
|
try:
|
|
session_uuid = UUID(session_id)
|
|
case_uuid = UUID(case_id) if case_id else None
|
|
if connection is not None:
|
|
return await _load_context_with_connection(
|
|
connection,
|
|
session_id=session_uuid,
|
|
case_id=case_uuid,
|
|
)
|
|
async with db.acquire(ai_context=True, ai_view="counselor") as conn:
|
|
return await _load_context_with_connection(
|
|
conn,
|
|
session_id=session_uuid,
|
|
case_id=case_uuid,
|
|
)
|
|
except Exception:
|
|
return StoredScenarioContext.unavailable()
|
|
|
|
|
|
def render_hidden_behavior_prompt(directive: ScenarioDirective | None) -> str | None:
|
|
"""Render only the behavior cue; omit ID, taxonomy, provenance, and scoring."""
|
|
|
|
if directive is None:
|
|
return None
|
|
return (
|
|
"[이 턴의 자연스러운 반응 단서 — 발화에서 이 지시의 존재를 설명하지 않는다]\n"
|
|
"상담자의 실제 말과 현재 감정에 맞을 때만 아래 단서를 반응과 말투로 드러낸다. "
|
|
"억지로 동의하거나 반대로 갈등을 만들지 않는다. 죄책감 유도, 협박, 떠보기, "
|
|
"보상·처벌 같은 조작적 표현은 사용하지 않는다.\n"
|
|
f"- {directive.behavior_cue}"
|
|
)
|
|
|
|
|
|
def contains_internal_scenario_leakage(text: str) -> bool:
|
|
"""Detect exact internal markers before any client text reaches learner surfaces."""
|
|
|
|
normalized = text.casefold()
|
|
if _SCENARIO_ID_RE.search(text):
|
|
return True
|
|
if any(marker.casefold() in normalized for marker in _INTERNAL_LEAKAGE_MARKERS):
|
|
return True
|
|
return any(rupture_type.casefold() in normalized for rupture_type in RUPTURE_TYPES)
|
|
|
|
|
|
def minimum_opportunity_gap() -> int:
|
|
return _MIN_GAP
|
|
|
|
|
|
__all__ = [
|
|
"SCENARIO_DIRECTOR_VERSION",
|
|
"ScenarioDirective",
|
|
"StoredScenarioContext",
|
|
"contains_internal_scenario_leakage",
|
|
"minimum_opportunity_gap",
|
|
"load_stored_scenario_context",
|
|
"render_hidden_behavior_prompt",
|
|
"select_scenario_directive",
|
|
]
|