C-001 임상 검토 게이트 보강

This commit is contained in:
Yun Chan 2026-08-28 19:04:17 +09:00
parent 391fb9f4d0
commit 6988280b30
18 changed files with 1896 additions and 67 deletions

View file

@ -23,8 +23,9 @@ from difflib import SequenceMatcher
from enum import Enum
from typing import Iterable, Protocol
from .state_machine import IDEATION_STAGE_CAP, clamp_ideation_stage
# ── 출력 가드레일 상한 (R5) ──────────────────────────────
IDEATION_STAGE_CAP = 3 # 내담자 발화/상태가 넘을 수 없는 자살사고 단계 상한
CRISIS_HOTLINE_NUMBER = "109"
CRISIS_HOTLINE_LABEL = "자살예방상담전화 109"
CRISIS_RESOURCE_MESSAGE = (
@ -648,8 +649,8 @@ def sanitize_client_reply(
"""내담자 AI 응답 출력 가드레일.
1. 자살/자해 수단·방법 정보가 있으면 needs_regeneration=True(차단, 재생성 신호).
2. ideation_stage > IDEATION_STAGE_CAP(3) 이면 안전 상한 위반으로 기록(상태머신이 상한 강제하나
이중방어). 응답 자체 텍스트는 정서 표현이면 유지.
2. ideation_stage > IDEATION_STAGE_CAP(3) 이면 상태머신 계약 위반으로 차단하고
needs_regeneration=True를 반환한다(영속 상태 상한에 대한 이중방어).
"""
reasons: list[str] = []
blocked = False
@ -678,6 +679,7 @@ def sanitize_client_reply(
if ideation_stage > IDEATION_STAGE_CAP:
reasons.append(f"ideation_over_cap:{ideation_stage}>{IDEATION_STAGE_CAP}")
blocked = True
needs_regen = True
return OutputGuardResult(
text=humanize_pii_placeholders(text),
@ -689,7 +691,7 @@ def sanitize_client_reply(
def clamp_ideation(stage: int) -> int:
"""ideation_stage 를 안전 상한(3)으로 클램프 (R5)."""
return max(1, min(IDEATION_STAGE_CAP, stage))
return clamp_ideation_stage(stage)
def crisis_resource() -> dict[str, str]:

View file

@ -46,6 +46,15 @@ STAGE_ADVANCE_RAPPORT: dict[Stage, float] = {
Stage.INTERVENE: 0.55, # 개입 작업이 진행됨
}
# 내담자 발화와 회기 영속 상태가 공유하는 자살사고 단계의 절대 범위(R5).
# 상태를 소유하는 이 모듈이 단일 정의를 제공하고, 출력 가드레일도 이를 재사용한다.
IDEATION_STAGE_CAP = 3
def clamp_ideation_stage(stage: int) -> int:
"""자살사고 단계를 영속 가능한 안전 범위(1..3)로 제한한다."""
return max(1, min(IDEATION_STAGE_CAP, int(stage)))
@dataclass(slots=True)
class SessionState:
@ -59,10 +68,15 @@ class SessionState:
effective_openness: float = 0.15
rapport_credit: float = 0.0 # 회기 누적(회기말 0.7 이월)
resistance: float = 0.65 # base_resistance 에서 시작, decay 로 완화
ideation_stage: int = 1 # 1~5 (출력 가드레일 상한 3)
ideation_stage: int = 1 # 영속 상태 절대 범위 1..IDEATION_STAGE_CAP
turns_in_stage: int = 0 # 현재 단계 체류 턴 수
affect_state: dict[str, float] = field(default_factory=dict)
def __post_init__(self) -> None:
# DB row나 테스트 fixture가 이 dataclass를 직접 만들더라도 과상한 값이
# prompt/guardrail 입력으로 잠깐 노출되지 않게 생성 경계에서 정규화한다.
self.ideation_stage = clamp_ideation_stage(self.ideation_stage)
def snapshot(self) -> dict:
"""무손실 carry-over용 snapshot (memory.end_state). 코드 복사, LLM 미경유."""
return {
@ -186,10 +200,11 @@ def evolve(
else:
resistance = min(1.0, state.resistance - 0.06 * rapport_signal) # signal<0 → 증가
# 5) ideation 보수적 유지(절대 내려가지 않음, 안전)
ideation_stage = state.ideation_stage
# 5) ideation 보수적 유지(절대 내려가지 않음, 안전). 이미 저장된 과상한 상태와
# 새 관측값을 각각 먼저 제한해 과거 drift가 다음 snapshot으로 전파되지 않게 한다.
ideation_stage = clamp_ideation_stage(state.ideation_stage)
if ideation_observed is not None:
ideation_stage = max(state.ideation_stage, ideation_observed)
ideation_stage = max(ideation_stage, clamp_ideation_stage(ideation_observed))
# 3) 개방도 재계산
eff = compute_effective_openness(
@ -250,16 +265,18 @@ def init_state(
stage = Stage.RAPPORT
resistance = params.base_resistance
rapport_credit = 0.0
ideation_stage = params.ideation_baseline
ideation_baseline = clamp_ideation_stage(params.ideation_baseline)
ideation_stage = ideation_baseline
if carry:
rapport_credit = float(carry.get("rapport_credit", 0.0)) * 0.7 # P2 이월
# inter-session drift: 라포가 쌓였으면 저항 소폭 완화된 채로 재시작
prev_resist = float(carry.get("resistance", params.base_resistance))
resistance = _clamp01((prev_resist + params.base_resistance) / 2.0)
ideation_stage = max(
int(carry.get("ideation_stage", params.ideation_baseline)), params.ideation_baseline
carried_ideation = clamp_ideation_stage(
int(carry.get("ideation_stage", ideation_baseline))
)
ideation_stage = max(carried_ideation, ideation_baseline)
eff = compute_effective_openness(
stage=stage,
@ -284,8 +301,10 @@ __all__ = [
"Stage",
"STAGE_BASE_OPENNESS",
"STAGE_ORDER",
"IDEATION_STAGE_CAP",
"SessionState",
"OpennessParams",
"clamp_ideation_stage",
"estimate_rapport_signal",
"compute_effective_openness",
"next_stage",

View file

@ -2,6 +2,7 @@
from __future__ import annotations
import importlib.util
import json
import unittest
@ -9,33 +10,101 @@ from .paths import repo_path
from .services import guardrail
def _load_clinical_review_checker():
checker_path = repo_path("scripts", "check-clinical-crisis-review.py")
spec = importlib.util.spec_from_file_location("clinical_crisis_review_checker", checker_path)
if spec is None or spec.loader is None:
raise RuntimeError(f"임상 검토 checker를 불러올 수 없다: {checker_path}")
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
return module
class ClientReplyQualityGateTest(unittest.TestCase):
def test_crisis_protocol_manifest_preserves_external_clinical_approval_gate(self) -> None:
def test_crisis_protocol_manifest_obeys_status_specific_external_review_contract(self) -> None:
manifest = json.loads(
repo_path("data", "clinical", "crisis-protocol-validation.json").read_text(
encoding="utf-8",
),
)
case_set = json.loads(
repo_path("data", "clinical", "p1-crisis-review-cases.json").read_text(
encoding="utf-8",
),
)
self.assertEqual(manifest["technical_status"], "verified")
self.assertEqual(manifest["clinical_status"], "pending_external_review")
status = manifest["clinical_status"]
status_decisions = {
"pending_external_review": None,
"approved": "approved",
"conditional": "conditional",
"rejected": "rejected",
}
self.assertIn(status, status_decisions)
required_approval_fields = {
"reviewer",
"organization",
"reviewed_at",
"decision",
"notes",
"evidence_ref",
"evidence_sha256",
"reviewed_protocol_version",
"reviewed_case_set_sha256",
}
approval = manifest["approval"]
self.assertTrue(required_approval_fields.issubset(approval))
for field in required_approval_fields:
self.assertIsNone(approval[field])
technical_gate_text = " ".join(manifest["technical_gates"])
for field in required_approval_fields:
self.assertIn(f"approval.{field}", technical_gate_text)
authorities = {source["authority"] for source in manifest["sources"]}
self.assertEqual(authorities, {"SAMHSA", "NIMH", "대한민국 보건복지부"})
self.assertEqual(set(approval), required_approval_fields)
self.assertEqual(approval["decision"], status_decisions[status])
assessments = [case["reviewer_assessment"] for case in case_set["cases"]]
if status == "pending_external_review":
self.assertTrue(all(value is None for value in approval.values()))
self.assertTrue(
all(value is None for assessment in assessments for value in assessment.values()),
)
else:
completed_fields = required_approval_fields - {"notes"}
self.assertTrue(all(approval[field] for field in completed_fields))
self.assertTrue(all(assessment["decision"] for assessment in assessments))
self.assertTrue(all(assessment["rationale"] for assessment in assessments))
self.assertTrue(all(assessment["reviewed_at"] for assessment in assessments))
source_ids = {source["source_id"] for source in manifest["sources"]}
self.assertEqual(
source_ids,
{"samhsa_safe_t", "nimh_youth_outpatient_bssa", "mohw_109", "nice_ng225"},
)
self.assertGreaterEqual(len(manifest["review_sequence"]), 6)
self.assertGreaterEqual(len(manifest["technical_gates"]), 5)
self.assertGreaterEqual(len(manifest["technical_gates"]), 6)
self.assertIn("외부 임상 검토와 승인을 대신하지 않는다", manifest["external_review_boundary"])
checker = _load_clinical_review_checker()
self.assertEqual(checker.validate_review_contract(repo_root=repo_path()), [])
def test_synthetic_crisis_review_cases_contain_only_technical_invariants(self) -> None:
case_set = json.loads(
repo_path("data", "clinical", "p1-crisis-review-cases.json").read_text(
encoding="utf-8",
),
)
self.assertEqual(
case_set["content_safety"],
{
"synthetic_only": True,
"method_or_means_detail": "forbidden",
"clinical_answer_included": False,
},
)
self.assertEqual(
{case["case_id"] for case in case_set["cases"]},
{f"P1-CRISIS-{index:03d}" for index in range(1, 7)},
)
for case in case_set["cases"]:
with self.subTest(case_id=case["case_id"]):
self.assertFalse(case["synthetic_scenario"]["method_or_means_detail_present"])
self.assertGreaterEqual(len(case["technical_invariants"]), 1)
self.assertNotIn("clinical_answer", case)
def test_crisis_protocol_technical_gates(self) -> None:
counselor_inquiry = guardrail.classify_crisis(

View file

@ -0,0 +1,43 @@
"""자살사고 단계 절대 상한의 출력 fail-closed 회귀 테스트."""
from __future__ import annotations
import unittest
from .services import guardrail, state_machine
class GuardrailIdeationCapTest(unittest.TestCase):
def test_over_cap_is_blocked_and_requires_regeneration(self) -> None:
result = guardrail.sanitize_client_reply(
"요즘 버티기 어렵다는 생각이 자주 들어요.",
ideation_stage=5,
turn_seq=4,
)
self.assertTrue(result.blocked)
self.assertTrue(result.needs_regeneration)
self.assertIn("ideation_over_cap:5>3", result.reasons)
def test_at_cap_without_other_violation_is_unchanged(self) -> None:
text = "요즘 버티기 어렵다는 생각이 자주 들어요."
result = guardrail.sanitize_client_reply(
text,
ideation_stage=state_machine.IDEATION_STAGE_CAP,
turn_seq=4,
)
self.assertFalse(result.blocked)
self.assertFalse(result.needs_regeneration)
self.assertEqual(result.text, text)
self.assertEqual(result.reasons, [])
def test_guardrail_reuses_state_machine_cap_and_clamp(self) -> None:
self.assertEqual(guardrail.IDEATION_STAGE_CAP, state_machine.IDEATION_STAGE_CAP)
self.assertEqual(guardrail.clamp_ideation(5), state_machine.IDEATION_STAGE_CAP)
self.assertEqual(guardrail.clamp_ideation(0), 1)
if __name__ == "__main__":
unittest.main()

View file

@ -68,6 +68,85 @@ class ResistanceEngineTest(unittest.TestCase):
self.assertTrue(all(state.rapport_credit == 0 for state in advice_curve))
self.assertGreaterEqual(advice_curve[-1].resistance, 0.95)
def test_evolve_clamps_existing_ideation_state_over_cap(self) -> None:
state = state_machine.SessionState(ideation_stage=5)
self.assertEqual(state.ideation_stage, state_machine.IDEATION_STAGE_CAP)
# 이전 버전에서 만들어진 비정상 객체가 메모리에 남은 상황도 전이 경계에서
# 다시 정규화되는지 확인한다.
state.ideation_stage = 5
evolved = state_machine.evolve(
state,
rapport_signal=0.0,
unlock_rate=P1.unlock_rate(),
decay_floor=P1.decay_floor(),
)
self.assertEqual(evolved.ideation_stage, state_machine.IDEATION_STAGE_CAP)
self.assertEqual(evolved.snapshot()["ideation_stage"], state_machine.IDEATION_STAGE_CAP)
def test_evolve_clamps_observed_ideation_over_cap(self) -> None:
state = state_machine.SessionState(ideation_stage=1)
evolved = state_machine.evolve(
state,
rapport_signal=0.0,
unlock_rate=P1.unlock_rate(),
decay_floor=P1.decay_floor(),
ideation_observed=5,
)
self.assertEqual(evolved.ideation_stage, state_machine.IDEATION_STAGE_CAP)
def test_init_state_clamps_baseline_and_carry_over_cap(self) -> None:
baseline_over_cap = state_machine.init_state(
params=state_machine.OpennessParams(
base_resistance=0.65,
unlock_rate=0.25,
decay_floor=0.2,
ideation_baseline=5,
)
)
carry_over_cap = state_machine.init_state(
params=P1.openness_params(),
carry={"ideation_stage": 5},
)
self.assertEqual(baseline_over_cap.ideation_stage, state_machine.IDEATION_STAGE_CAP)
self.assertEqual(carry_over_cap.ideation_stage, state_machine.IDEATION_STAGE_CAP)
def test_valid_ideation_values_preserve_monotonic_behavior(self) -> None:
state = state_machine.SessionState(ideation_stage=2)
raised = state_machine.evolve(
state,
rapport_signal=0.0,
unlock_rate=P1.unlock_rate(),
decay_floor=P1.decay_floor(),
ideation_observed=3,
)
unchanged = state_machine.evolve(
state,
rapport_signal=0.0,
unlock_rate=P1.unlock_rate(),
decay_floor=P1.decay_floor(),
ideation_observed=1,
)
initialized = state_machine.init_state(
params=state_machine.OpennessParams(
base_resistance=0.65,
unlock_rate=0.25,
decay_floor=0.2,
ideation_baseline=2,
),
carry={"ideation_stage": 1},
)
self.assertEqual(raised.ideation_stage, 3)
self.assertEqual(unchanged.ideation_stage, 2)
self.assertEqual(initialized.ideation_stage, 2)
if __name__ == "__main__":
unittest.main()