C-001 임상 검토 게이트 보강
This commit is contained in:
parent
391fb9f4d0
commit
6988280b30
18 changed files with 1896 additions and 67 deletions
|
|
@ -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]:
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
43
apps/api/app/test_guardrail_ideation_cap.py
Normal file
43
apps/api/app/test_guardrail_ideation_cap.py
Normal 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()
|
||||
|
|
@ -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()
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue