평가 및 화면 구조 정리
This commit is contained in:
parent
1248ae8ca4
commit
391639c1de
44 changed files with 5816 additions and 4501 deletions
140
apps/api/app/services/case_worksheet_rubric.py
Normal file
140
apps/api/app/services/case_worksheet_rubric.py
Normal file
|
|
@ -0,0 +1,140 @@
|
|||
"""Validation helpers for externally owned case worksheet rubrics.
|
||||
|
||||
The clinical team owns scoring criteria. This module only validates the
|
||||
machine-readable scaffold that lets those criteria live outside application
|
||||
code.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
from typing import Any, Mapping
|
||||
|
||||
SCHEMA_VERSION = "vignette.case_worksheet_rubric.v1"
|
||||
VALID_STATUSES = {"scaffold_only", "draft", "approved"}
|
||||
EXPECTED_CONTENT_OWNER = "clinical_team"
|
||||
|
||||
|
||||
def load_rubric(path: Path) -> dict[str, Any]:
|
||||
data = json.loads(path.read_text(encoding="utf-8"))
|
||||
if not isinstance(data, dict):
|
||||
raise ValueError("case worksheet rubric must be a JSON object")
|
||||
return data
|
||||
|
||||
|
||||
def validate_rubric(
|
||||
rubric: Mapping[str, Any],
|
||||
*,
|
||||
expected_item_keys: Mapping[str, set[str]] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
errors: list[str] = []
|
||||
warnings: list[str] = []
|
||||
|
||||
schema_version = str(rubric.get("schema_version") or "")
|
||||
status = str(rubric.get("status") or "")
|
||||
scoring_enabled = bool(rubric.get("scoring_enabled"))
|
||||
sections = rubric.get("sections")
|
||||
|
||||
if schema_version != SCHEMA_VERSION:
|
||||
errors.append("schema_version must be vignette.case_worksheet_rubric.v1")
|
||||
if status not in VALID_STATUSES:
|
||||
errors.append("status must be one of scaffold_only, draft, approved")
|
||||
if str(rubric.get("content_owner") or "") != EXPECTED_CONTENT_OWNER:
|
||||
errors.append("content_owner must be clinical_team")
|
||||
if scoring_enabled and status != "approved":
|
||||
errors.append("scoring_enabled requires status=approved")
|
||||
if status == "approved":
|
||||
approval = rubric.get("approval")
|
||||
if not isinstance(approval, Mapping):
|
||||
errors.append("approved rubric requires approval metadata")
|
||||
else:
|
||||
if not str(approval.get("clinical_reviewer") or ""):
|
||||
errors.append("approved rubric requires approval.clinical_reviewer")
|
||||
if not str(approval.get("approved_at") or ""):
|
||||
errors.append("approved rubric requires approval.approved_at")
|
||||
|
||||
section_count = 0
|
||||
item_count = 0
|
||||
section_item_keys: dict[str, set[str]] = {}
|
||||
if not isinstance(sections, list) or not sections:
|
||||
errors.append("sections must be a non-empty list")
|
||||
else:
|
||||
seen_sections: set[str] = set()
|
||||
for section in sections:
|
||||
if not isinstance(section, Mapping):
|
||||
errors.append("each section must be an object")
|
||||
continue
|
||||
section_key = str(section.get("key") or "")
|
||||
if not section_key:
|
||||
errors.append("section.key is required")
|
||||
continue
|
||||
if section_key in seen_sections:
|
||||
errors.append(f"duplicate section key: {section_key}")
|
||||
seen_sections.add(section_key)
|
||||
section_count += 1
|
||||
items = section.get("items")
|
||||
if not isinstance(items, list) or not items:
|
||||
errors.append(f"{section_key}: items must be a non-empty list")
|
||||
continue
|
||||
seen_items: set[str] = set()
|
||||
for item in items:
|
||||
if not isinstance(item, Mapping):
|
||||
errors.append(f"{section_key}: each item must be an object")
|
||||
continue
|
||||
item_key = str(item.get("key") or "")
|
||||
if not item_key:
|
||||
errors.append(f"{section_key}: item.key is required")
|
||||
continue
|
||||
if item_key in seen_items:
|
||||
errors.append(f"{section_key}: duplicate item key: {item_key}")
|
||||
seen_items.add(item_key)
|
||||
item_count += 1
|
||||
criteria = item.get("criteria")
|
||||
score_scale = item.get("score_scale")
|
||||
if scoring_enabled:
|
||||
if not isinstance(criteria, list) or not criteria:
|
||||
errors.append(f"{section_key}.{item_key}: scoring requires non-empty criteria")
|
||||
if not _valid_score_scale(score_scale):
|
||||
errors.append(f"{section_key}.{item_key}: scoring requires a valid score_scale")
|
||||
elif not criteria:
|
||||
warnings.append(f"{section_key}.{item_key}: criteria pending clinical team input")
|
||||
section_item_keys[section_key] = seen_items
|
||||
|
||||
if expected_item_keys is not None:
|
||||
expected_sections = set(expected_item_keys)
|
||||
actual_sections = set(section_item_keys)
|
||||
for missing_section in sorted(expected_sections - actual_sections):
|
||||
errors.append(f"missing worksheet section: {missing_section}")
|
||||
for extra_section in sorted(actual_sections - expected_sections):
|
||||
errors.append(f"unexpected worksheet section: {extra_section}")
|
||||
for section_key in sorted(expected_sections & actual_sections):
|
||||
missing_items = expected_item_keys[section_key] - section_item_keys[section_key]
|
||||
extra_items = section_item_keys[section_key] - expected_item_keys[section_key]
|
||||
for item_key in sorted(missing_items):
|
||||
errors.append(f"{section_key}: missing worksheet item: {item_key}")
|
||||
for item_key in sorted(extra_items):
|
||||
errors.append(f"{section_key}: unexpected worksheet item: {item_key}")
|
||||
|
||||
return {
|
||||
"schema_version": SCHEMA_VERSION,
|
||||
"rubric_id": str(rubric.get("rubric_id") or ""),
|
||||
"status": status,
|
||||
"scoring_enabled": scoring_enabled,
|
||||
"sections_total": section_count,
|
||||
"items_total": item_count,
|
||||
"passed": not errors,
|
||||
"errors": errors,
|
||||
"warnings": warnings,
|
||||
}
|
||||
|
||||
|
||||
def _valid_score_scale(value: object) -> bool:
|
||||
if not isinstance(value, Mapping):
|
||||
return False
|
||||
minimum = value.get("min")
|
||||
maximum = value.get("max")
|
||||
anchors = value.get("anchors")
|
||||
if not isinstance(minimum, int) or not isinstance(maximum, int) or minimum >= maximum:
|
||||
return False
|
||||
return isinstance(anchors, list) and len(anchors) >= 2
|
||||
|
|
@ -42,13 +42,21 @@ _KOREAN_SURNAME_CHARS = (
|
|||
"명기반왕금옥육인맹제모탁국어은편용예봉경"
|
||||
)
|
||||
_KOREAN_FULL_NAME = rf"[{_KOREAN_SURNAME_CHARS}][가-힣]{{1,3}}"
|
||||
_KOREAN_FULL_NAME_BEFORE_SUFFIX = rf"[{_KOREAN_SURNAME_CHARS}][가-힣]{{1,3}}?"
|
||||
_KOREAN_NAME_STOPWORDS = {
|
||||
"연락",
|
||||
"연락처",
|
||||
"이메일",
|
||||
"주민번호",
|
||||
"번호",
|
||||
"이름",
|
||||
"이야기",
|
||||
"생각",
|
||||
"마음",
|
||||
"기분",
|
||||
"상담",
|
||||
"기록",
|
||||
"진료",
|
||||
"학교",
|
||||
"엄마",
|
||||
"아빠",
|
||||
|
|
@ -82,12 +90,33 @@ _PII_PATTERNS: list[tuple[str, re.Pattern[str]]] = [
|
|||
r"(?=$|[\s,.;!?。])"
|
||||
),
|
||||
),
|
||||
# 한국어 이름: "제 이름은 김서연입니다", "보호자 이름은 박민수입니다" 같은 자연 발화형 라벨.
|
||||
(
|
||||
"NAME",
|
||||
re.compile(
|
||||
r"(?P<prefix>(?:(?:제|저의|내|나의|보호자|학생|내담자|상담자|친구|엄마|아빠|어머니|아버지)\s+)?"
|
||||
r"(?:이름|성명|실명|본명)\s*(?:은|는|이|가)?\s*)"
|
||||
rf"(?P<value>{_KOREAN_FULL_NAME_BEFORE_SUFFIX})"
|
||||
r"(?P<suffix>\s*(?:입니다|이에요|예요|이고|이고요|이라고|라고)?)"
|
||||
r"(?=$|[\s,.;!?。])"
|
||||
),
|
||||
),
|
||||
# 한국어 이름: "저는 김서연입니다", "제가 박민수예요", "김서연입니다" 같은 자기소개형 문장.
|
||||
(
|
||||
"NAME",
|
||||
re.compile(
|
||||
r"(?P<prefix>(?:(?:저는|나는|제가|내가)\s*)?)"
|
||||
rf"(?P<value>{_KOREAN_FULL_NAME_BEFORE_SUFFIX})"
|
||||
r"(?P<suffix>\s*(?:입니다|이에요|예요|이고|이고요))"
|
||||
r"(?=$|[\s,.;!?。])"
|
||||
),
|
||||
),
|
||||
# 한국어 이름: 역할/관계 명사 뒤에 붙은 인명 + 조사/호칭.
|
||||
(
|
||||
"NAME",
|
||||
re.compile(
|
||||
r"(?P<prefix>(?:내담자|상담자|학생|보호자|담임|교수|선생님|친구|엄마|아빠|어머니|아버지|동생|언니|오빠|형|누나)\s+)"
|
||||
rf"(?P<value>{_KOREAN_FULL_NAME})"
|
||||
rf"(?P<value>{_KOREAN_FULL_NAME_BEFORE_SUFFIX})"
|
||||
r"(?P<suffix>\s*(?:님|씨|학생|상담자|내담자)?"
|
||||
r"(?:은|는|이|가|을|를|와|과|에게|한테|라고|이라는|입니다|이에요|예요|이고|이고요))"
|
||||
),
|
||||
|
|
@ -96,7 +125,7 @@ _PII_PATTERNS: list[tuple[str, re.Pattern[str]]] = [
|
|||
(
|
||||
"NAME",
|
||||
re.compile(
|
||||
rf"(?<![가-힣])(?P<value>{_KOREAN_FULL_NAME})"
|
||||
rf"(?<![가-힣])(?P<value>{_KOREAN_FULL_NAME_BEFORE_SUFFIX})"
|
||||
r"(?P<suffix>(?:은|는|이|가|을|를|와|과|에게|한테|라고|이라는))"
|
||||
),
|
||||
),
|
||||
|
|
@ -104,21 +133,21 @@ _PII_PATTERNS: list[tuple[str, re.Pattern[str]]] = [
|
|||
(
|
||||
"NAME",
|
||||
re.compile(
|
||||
rf"(?<![가-힣])(?P<value>{_KOREAN_FULL_NAME})"
|
||||
rf"(?<![가-힣])(?P<value>{_KOREAN_FULL_NAME_BEFORE_SUFFIX})"
|
||||
r"(?P<suffix>\s?(?:씨|님)(?:은|는|이|가|을|를|와|과|에게|한테|고|이고|인데)?)"
|
||||
r"(?=$|[\s,.;!?。])"
|
||||
),
|
||||
),
|
||||
# 주민등록번호 (6자리-7자리)
|
||||
("RRN", re.compile(r"\b\d{6}[-\s]?\d{7}\b")),
|
||||
("RRN", re.compile(r"(?<!\d)\d{6}[-\s]?\d{7}(?!\d)")),
|
||||
# 휴대폰 (010-1234-5678 등)
|
||||
("PHONE", re.compile(r"\b01[016789][-\s]?\d{3,4}[-\s]?\d{4}\b")),
|
||||
("PHONE", re.compile(r"(?<!\d)01[016789][-\s]?\d{3,4}[-\s]?\d{4}(?!\d)")),
|
||||
# 일반 전화
|
||||
("PHONE", re.compile(r"\b0\d{1,2}[-\s]?\d{3,4}[-\s]?\d{4}\b")),
|
||||
("PHONE", re.compile(r"(?<!\d)0\d{1,2}[-\s]?\d{3,4}[-\s]?\d{4}(?!\d)")),
|
||||
# 이메일
|
||||
("EMAIL", re.compile(r"\b[\w.+-]+@[\w-]+\.[\w.-]+\b")),
|
||||
# 카드/계좌 유사 긴 숫자열 (12자리 이상)
|
||||
("NUMID", re.compile(r"\b\d{12,}\b")),
|
||||
("NUMID", re.compile(r"(?<!\d)\d{12,}(?!\d)")),
|
||||
# 구체적 날짜(생년월일 등): 2001.4.18 / 2001-04-18 / 2001년 4월 18일
|
||||
("DATE", re.compile(r"(?:19|20)\d{2}\s?[.\-/년]\s?\d{1,2}\s?[.\-/월]\s?\d{1,2}\s?일?")),
|
||||
# 금액(원): 1,200원 / 1200원 (3자리+ 또는 콤마구분) — 식별 맥락 보호
|
||||
|
|
|
|||
|
|
@ -10,6 +10,16 @@ from . import guardrail
|
|||
|
||||
MaskFunc = Callable[[str], guardrail.MaskResult]
|
||||
|
||||
REPORT_SCHEMA_VERSION = "vignette.pii_masking_eval_report.v1"
|
||||
INPUT_SCHEMA_VERSION = "vignette.pii_masking_eval_input.v1"
|
||||
|
||||
DEFAULT_CASE_META = {
|
||||
"locale": "ko-KR",
|
||||
"source": "synthetic",
|
||||
"category": "unspecified",
|
||||
"severity": "medium",
|
||||
}
|
||||
|
||||
|
||||
def load_cases(path: Path) -> list[dict[str, Any]]:
|
||||
data = json.loads(path.read_text(encoding="utf-8"))
|
||||
|
|
@ -18,9 +28,18 @@ def load_cases(path: Path) -> list[dict[str, Any]]:
|
|||
return [dict(item) for item in data]
|
||||
|
||||
|
||||
def evaluate_case(case: Mapping[str, Any], *, mask_func: MaskFunc = guardrail.mask_pii) -> dict[str, Any]:
|
||||
def evaluate_case(
|
||||
case: Mapping[str, Any],
|
||||
*,
|
||||
mask_func: MaskFunc = guardrail.mask_pii,
|
||||
include_evidence_text: bool = False,
|
||||
) -> dict[str, Any]:
|
||||
case_id = str(case.get("id") or "")
|
||||
text = str(case.get("text") or "")
|
||||
locale = str(case.get("locale") or DEFAULT_CASE_META["locale"])
|
||||
source = str(case.get("source") or DEFAULT_CASE_META["source"])
|
||||
category = str(case.get("category") or DEFAULT_CASE_META["category"])
|
||||
severity = str(case.get("severity") or DEFAULT_CASE_META["severity"])
|
||||
result = mask_func(text)
|
||||
entities = set(result.entities)
|
||||
expected_entities = {str(item) for item in case.get("expected_entities") or []}
|
||||
|
|
@ -34,25 +53,36 @@ def evaluate_case(case: Mapping[str, Any], *, mask_func: MaskFunc = guardrail.ma
|
|||
required_missing = [item for item in required_substrings if item and item not in result.text_masked]
|
||||
passed = not (missing_entities or unexpected_detected or forbidden_remaining or required_missing)
|
||||
|
||||
return {
|
||||
report = {
|
||||
"id": case_id,
|
||||
"locale": locale,
|
||||
"source": source,
|
||||
"category": category,
|
||||
"severity": severity,
|
||||
"passed": passed,
|
||||
"entities": sorted(entities),
|
||||
"masked_text": result.text_masked,
|
||||
"missing_entities": missing_entities,
|
||||
"unexpected_entities": unexpected_detected,
|
||||
"forbidden_remaining": forbidden_remaining,
|
||||
"forbidden_remaining_count": len(forbidden_remaining),
|
||||
"required_missing": required_missing,
|
||||
}
|
||||
if include_evidence_text:
|
||||
report["masked_text"] = result.text_masked
|
||||
report["forbidden_remaining"] = forbidden_remaining
|
||||
return report
|
||||
|
||||
|
||||
def evaluate_cases(
|
||||
cases: Iterable[Mapping[str, Any]],
|
||||
*,
|
||||
mask_func: MaskFunc = guardrail.mask_pii,
|
||||
include_evidence_text: bool = False,
|
||||
) -> dict[str, Any]:
|
||||
case_list = list(cases)
|
||||
results = [evaluate_case(case, mask_func=mask_func) for case in case_list]
|
||||
results = [
|
||||
evaluate_case(case, mask_func=mask_func, include_evidence_text=include_evidence_text)
|
||||
for case in case_list
|
||||
]
|
||||
total_expected_entities = 0
|
||||
matched_expected_entities = 0
|
||||
total_forbidden = 0
|
||||
|
|
@ -64,11 +94,17 @@ def evaluate_cases(
|
|||
total_expected_entities += len(expected_entities)
|
||||
matched_expected_entities += len(expected_entities) - len(result["missing_entities"])
|
||||
total_forbidden += len(forbidden)
|
||||
removed_forbidden += len(forbidden) - len(result["forbidden_remaining"])
|
||||
remaining_forbidden = int(result.get("forbidden_remaining_count", len(result.get("forbidden_remaining", []))))
|
||||
removed_forbidden += len(forbidden) - remaining_forbidden
|
||||
unexpected_violations += len(result["unexpected_entities"])
|
||||
|
||||
passed_cases = sum(1 for result in results if result["passed"])
|
||||
return {
|
||||
"schema_version": REPORT_SCHEMA_VERSION,
|
||||
"input_schema_version": INPUT_SCHEMA_VERSION,
|
||||
"run_mode": "technical_dry_run",
|
||||
"data_source": "local_fixture",
|
||||
"evidence_text_included": include_evidence_text,
|
||||
"passed": passed_cases == len(results),
|
||||
"cases_total": len(results),
|
||||
"cases_passed": passed_cases,
|
||||
|
|
@ -76,15 +112,45 @@ def evaluate_cases(
|
|||
"expected_entity_recall": _ratio(matched_expected_entities, total_expected_entities),
|
||||
"forbidden_substring_removal": _ratio(removed_forbidden, total_forbidden),
|
||||
"unexpected_entity_violations": unexpected_violations,
|
||||
"by_source": _breakdown(case_list, results, "source"),
|
||||
"by_category": _breakdown(case_list, results, "category"),
|
||||
"by_severity": _breakdown(case_list, results, "severity"),
|
||||
"results": results,
|
||||
}
|
||||
|
||||
|
||||
def evaluate_fixture(path: Path, *, mask_func: MaskFunc = guardrail.mask_pii) -> dict[str, Any]:
|
||||
return evaluate_cases(load_cases(path), mask_func=mask_func)
|
||||
def evaluate_fixture(
|
||||
path: Path,
|
||||
*,
|
||||
mask_func: MaskFunc = guardrail.mask_pii,
|
||||
include_evidence_text: bool = False,
|
||||
) -> dict[str, Any]:
|
||||
return evaluate_cases(load_cases(path), mask_func=mask_func, include_evidence_text=include_evidence_text)
|
||||
|
||||
|
||||
def _ratio(numerator: int, denominator: int) -> float:
|
||||
if denominator <= 0:
|
||||
return 1.0
|
||||
return round(numerator / denominator, 4)
|
||||
|
||||
|
||||
def _case_meta(case: Mapping[str, Any], key: str) -> str:
|
||||
fallback = DEFAULT_CASE_META.get(key, "unspecified")
|
||||
return str(case.get(key) or fallback)
|
||||
|
||||
|
||||
def _breakdown(
|
||||
cases: list[Mapping[str, Any]],
|
||||
results: list[Mapping[str, Any]],
|
||||
key: str,
|
||||
) -> dict[str, dict[str, int]]:
|
||||
grouped: dict[str, dict[str, int]] = {}
|
||||
for case, result in zip(cases, results):
|
||||
value = _case_meta(case, key)
|
||||
bucket = grouped.setdefault(value, {"cases_total": 0, "cases_passed": 0, "cases_failed": 0})
|
||||
bucket["cases_total"] += 1
|
||||
if result.get("passed"):
|
||||
bucket["cases_passed"] += 1
|
||||
else:
|
||||
bucket["cases_failed"] += 1
|
||||
return dict(sorted(grouped.items()))
|
||||
|
|
|
|||
|
|
@ -21,6 +21,9 @@ from .services import session_metrics
|
|||
from .store import InProcSession, TurnRecord
|
||||
|
||||
StageLabel = Literal["라포", "탐색", "개입", "정리"]
|
||||
WorksheetSpeaker = Literal["learner", "client"]
|
||||
WorksheetItemSpec = tuple[str, str, list[str], WorksheetSpeaker | None]
|
||||
WorksheetSectionSpec = tuple[str, str, list[WorksheetItemSpec]]
|
||||
|
||||
LEARNER_VISIBLE_AI_ROLE = "counselor"
|
||||
_PHASE_KEY_BY_LABEL = {
|
||||
|
|
@ -259,6 +262,68 @@ class ReviewCaseWorksheetSaveRequest(BaseModel):
|
|||
limitations: list[str] = Field(default_factory=list)
|
||||
|
||||
|
||||
CASE_WORKSHEET_SECTION_SPECS: list[WorksheetSectionSpec] = [
|
||||
(
|
||||
"exploration_11",
|
||||
"탐색 11항목",
|
||||
[
|
||||
("presenting_complaint", "주호소", ["힘들", "문제", "걱정", "불안", "우울", "스트레스", "관계"], "client"),
|
||||
("trigger_context", "계기·상황", ["언제", "상황", "최근", "계기", "때"], "client"),
|
||||
("emotion", "정서", ["불안", "우울", "화", "슬프", "답답", "무섭", "외롭", "걱정"], "client"),
|
||||
("cognition", "생각", ["생각", "느낌", "해야", "못", "실패", "의미"], "client"),
|
||||
("behavior", "행동", ["피하", "잠", "먹", "울", "말", "연락", "공부", "멈"], "client"),
|
||||
("body", "신체·수면", ["잠", "식욕", "몸", "두통", "심장", "숨", "피곤"], "client"),
|
||||
("relationship", "관계", ["친구", "가족", "부모", "엄마", "아빠", "교수", "사람", "관계"], "client"),
|
||||
("resources", "자원", ["도움", "지지", "친구", "상담", "선생님", "가족"], "client"),
|
||||
("risk", "위험 신호", ["죽", "자살", "해치", "사라지고", "끝내", "위험"], "client"),
|
||||
("motivation", "변화동기", ["원", "바라", "변화", "해보고", "싶"], None),
|
||||
("first_goal", "상담 목표 초안", ["목표", "계획", "다음", "해볼", "원하"], "learner"),
|
||||
],
|
||||
),
|
||||
(
|
||||
"five_domains",
|
||||
"호소 5영역",
|
||||
[
|
||||
("domain_emotion", "정서", ["불안", "우울", "화", "슬프", "답답", "외롭"], "client"),
|
||||
("domain_cognition", "인지", ["생각", "걱정", "실패", "못", "의미"], "client"),
|
||||
("domain_behavior", "행동", ["피하", "연락", "공부", "잠", "멈"], "client"),
|
||||
("domain_relationship", "대인관계", ["친구", "가족", "사람", "관계", "부모"], "client"),
|
||||
("domain_body", "신체", ["잠", "식욕", "몸", "두통", "피곤", "숨"], "client"),
|
||||
],
|
||||
),
|
||||
(
|
||||
"cognitive_triad_emotions",
|
||||
"인지삼제·1/2차 감정",
|
||||
[
|
||||
("triad_self", "자기", ["나는", "내가", "나 자신", "스스로"], "client"),
|
||||
("triad_world", "타인·세계", ["사람", "세상", "학교", "가족", "친구"], "client"),
|
||||
("triad_future", "미래", ["앞으로", "미래", "계속", "나중"], "client"),
|
||||
("primary_emotion", "1차 감정", ["불안", "슬프", "무섭", "외롭", "걱정"], "client"),
|
||||
("secondary_emotion", "2차 감정", ["화", "짜증", "수치", "죄책", "부끄"], "client"),
|
||||
],
|
||||
),
|
||||
(
|
||||
"protective_barrier_quadrants",
|
||||
"보호·방해 4사분면",
|
||||
[
|
||||
("internal_protective", "내적 보호요인", ["해보고", "버텼", "노력", "원", "견뎠"], None),
|
||||
("internal_barrier", "내적 방해요인", ["못", "두려", "불안", "회피", "걱정"], "client"),
|
||||
("external_protective", "외적 보호요인", ["친구", "가족", "상담", "교수", "도움"], "client"),
|
||||
("external_barrier", "외적 방해요인", ["갈등", "압박", "비난", "스트레스", "혼자"], "client"),
|
||||
],
|
||||
),
|
||||
(
|
||||
"biopsychosocial_goals",
|
||||
"생물·심리·사회 목표",
|
||||
[
|
||||
("bio_goal", "생물", ["잠", "식사", "운동", "몸", "피곤"], "client"),
|
||||
("psy_goal", "심리", ["생각", "감정", "불안", "연습", "조절"], None),
|
||||
("social_goal", "사회", ["관계", "대화", "연락", "도움", "친구"], None),
|
||||
],
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
class SessionTeacherReviewStatus(BaseModel):
|
||||
status: Literal["pending", "viewed", "closed"] = "pending"
|
||||
note: str = ""
|
||||
|
|
@ -824,7 +889,7 @@ def _worksheet_item(
|
|||
def _worksheet_section(
|
||||
key: str,
|
||||
title: str,
|
||||
specs: list[tuple[str, str, list[str], Literal["learner", "client"] | None]],
|
||||
specs: list[WorksheetItemSpec],
|
||||
turns: list[ReviewTurn],
|
||||
fallback_client: ReviewTurn | None,
|
||||
fallback_learner: ReviewTurn | None,
|
||||
|
|
@ -845,6 +910,13 @@ def _worksheet_section(
|
|||
return ReviewWorksheetSection(key=key, title=title, items=items)
|
||||
|
||||
|
||||
def case_worksheet_template_item_keys() -> dict[str, set[str]]:
|
||||
return {
|
||||
section_key: {item_key for item_key, _, _, _ in item_specs}
|
||||
for section_key, _, item_specs in CASE_WORKSHEET_SECTION_SPECS
|
||||
}
|
||||
|
||||
|
||||
def case_worksheet_from_turns(turns: list[ReviewTurn]) -> ReviewCaseWorksheet:
|
||||
if not turns:
|
||||
return ReviewCaseWorksheet(
|
||||
|
|
@ -855,68 +927,6 @@ def case_worksheet_from_turns(turns: list[ReviewTurn]) -> ReviewCaseWorksheet:
|
|||
|
||||
fallback_client = next((turn for turn in turns if turn.speaker == "client"), None)
|
||||
fallback_learner = next((turn for turn in turns if turn.speaker == "learner"), None)
|
||||
section_specs: list[
|
||||
tuple[str, str, list[tuple[str, str, list[str], Literal["learner", "client"] | None]]]
|
||||
] = [
|
||||
(
|
||||
"exploration_11",
|
||||
"탐색 11항목",
|
||||
[
|
||||
("presenting_complaint", "주호소", ["힘들", "문제", "걱정", "불안", "우울", "스트레스", "관계"], "client"),
|
||||
("trigger_context", "계기·상황", ["언제", "상황", "최근", "계기", "때"], "client"),
|
||||
("emotion", "정서", ["불안", "우울", "화", "슬프", "답답", "무섭", "외롭", "걱정"], "client"),
|
||||
("cognition", "생각", ["생각", "느낌", "해야", "못", "실패", "의미"], "client"),
|
||||
("behavior", "행동", ["피하", "잠", "먹", "울", "말", "연락", "공부", "멈"], "client"),
|
||||
("body", "신체·수면", ["잠", "식욕", "몸", "두통", "심장", "숨", "피곤"], "client"),
|
||||
("relationship", "관계", ["친구", "가족", "부모", "엄마", "아빠", "교수", "사람", "관계"], "client"),
|
||||
("resources", "자원", ["도움", "지지", "친구", "상담", "선생님", "가족"], "client"),
|
||||
("risk", "위험 신호", ["죽", "자살", "해치", "사라지고", "끝내", "위험"], "client"),
|
||||
("motivation", "변화동기", ["원", "바라", "변화", "해보고", "싶"], None),
|
||||
("first_goal", "상담 목표 초안", ["목표", "계획", "다음", "해볼", "원하"], "learner"),
|
||||
],
|
||||
),
|
||||
(
|
||||
"five_domains",
|
||||
"호소 5영역",
|
||||
[
|
||||
("domain_emotion", "정서", ["불안", "우울", "화", "슬프", "답답", "외롭"], "client"),
|
||||
("domain_cognition", "인지", ["생각", "걱정", "실패", "못", "의미"], "client"),
|
||||
("domain_behavior", "행동", ["피하", "연락", "공부", "잠", "멈"], "client"),
|
||||
("domain_relationship", "대인관계", ["친구", "가족", "사람", "관계", "부모"], "client"),
|
||||
("domain_body", "신체", ["잠", "식욕", "몸", "두통", "피곤", "숨"], "client"),
|
||||
],
|
||||
),
|
||||
(
|
||||
"cognitive_triad_emotions",
|
||||
"인지삼제·1/2차 감정",
|
||||
[
|
||||
("triad_self", "자기", ["나는", "내가", "나 자신", "스스로"], "client"),
|
||||
("triad_world", "타인·세계", ["사람", "세상", "학교", "가족", "친구"], "client"),
|
||||
("triad_future", "미래", ["앞으로", "미래", "계속", "나중"], "client"),
|
||||
("primary_emotion", "1차 감정", ["불안", "슬프", "무섭", "외롭", "걱정"], "client"),
|
||||
("secondary_emotion", "2차 감정", ["화", "짜증", "수치", "죄책", "부끄"], "client"),
|
||||
],
|
||||
),
|
||||
(
|
||||
"protective_barrier_quadrants",
|
||||
"보호·방해 4사분면",
|
||||
[
|
||||
("internal_protective", "내적 보호요인", ["해보고", "버텼", "노력", "원", "견뎠"], None),
|
||||
("internal_barrier", "내적 방해요인", ["못", "두려", "불안", "회피", "걱정"], "client"),
|
||||
("external_protective", "외적 보호요인", ["친구", "가족", "상담", "교수", "도움"], "client"),
|
||||
("external_barrier", "외적 방해요인", ["갈등", "압박", "비난", "스트레스", "혼자"], "client"),
|
||||
],
|
||||
),
|
||||
(
|
||||
"biopsychosocial_goals",
|
||||
"생물·심리·사회 목표",
|
||||
[
|
||||
("bio_goal", "생물", ["잠", "식사", "운동", "몸", "피곤"], "client"),
|
||||
("psy_goal", "심리", ["생각", "감정", "불안", "연습", "조절"], None),
|
||||
("social_goal", "사회", ["관계", "대화", "연락", "도움", "친구"], None),
|
||||
],
|
||||
),
|
||||
]
|
||||
|
||||
sections = [
|
||||
_worksheet_section(
|
||||
|
|
@ -927,7 +937,7 @@ def case_worksheet_from_turns(turns: list[ReviewTurn]) -> ReviewCaseWorksheet:
|
|||
fallback_client,
|
||||
fallback_learner,
|
||||
)
|
||||
for key, title, specs in section_specs
|
||||
for key, title, specs in CASE_WORKSHEET_SECTION_SPECS
|
||||
]
|
||||
return ReviewCaseWorksheet(
|
||||
status="draft_from_transcript",
|
||||
|
|
|
|||
93
apps/api/app/test_case_worksheet_rubric.py
Normal file
93
apps/api/app/test_case_worksheet_rubric.py
Normal file
|
|
@ -0,0 +1,93 @@
|
|||
import copy
|
||||
import json
|
||||
import subprocess
|
||||
import sys
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
try:
|
||||
from jsonschema import Draft202012Validator
|
||||
except ModuleNotFoundError: # pragma: no cover - optional test helper dependency
|
||||
Draft202012Validator = None
|
||||
|
||||
from app.services.case_worksheet_rubric import load_rubric, validate_rubric
|
||||
from app.session_read_model import case_worksheet_template_item_keys
|
||||
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parents[3]
|
||||
RUBRIC_PATH = REPO_ROOT / "data" / "rubrics" / "case-worksheet-rubric.json"
|
||||
SCHEMA_PATH = REPO_ROOT / "data" / "rubrics" / "case-worksheet-rubric.schema.json"
|
||||
SCRIPT_PATH = REPO_ROOT / "scripts" / "check-case-worksheet-rubric.py"
|
||||
|
||||
|
||||
class CaseWorksheetRubricTests(unittest.TestCase):
|
||||
def test_scaffold_matches_generated_worksheet_keys_without_enabling_scoring(self) -> None:
|
||||
rubric = load_rubric(RUBRIC_PATH)
|
||||
report = validate_rubric(rubric, expected_item_keys=case_worksheet_template_item_keys())
|
||||
|
||||
self.assertTrue(report["passed"], report)
|
||||
self.assertEqual(report["schema_version"], "vignette.case_worksheet_rubric.v1")
|
||||
self.assertEqual(report["status"], "scaffold_only")
|
||||
self.assertFalse(report["scoring_enabled"])
|
||||
self.assertEqual(report["sections_total"], 5)
|
||||
self.assertEqual(report["items_total"], 28)
|
||||
self.assertGreaterEqual(len(report["warnings"]), 20)
|
||||
self._validate_with_schema(rubric, SCHEMA_PATH)
|
||||
|
||||
def test_scoring_requires_clinical_approval(self) -> None:
|
||||
rubric = load_rubric(RUBRIC_PATH)
|
||||
draft = copy.deepcopy(rubric)
|
||||
draft["status"] = "draft"
|
||||
draft["scoring_enabled"] = True
|
||||
|
||||
report = validate_rubric(draft, expected_item_keys=case_worksheet_template_item_keys())
|
||||
|
||||
self.assertFalse(report["passed"])
|
||||
self.assertIn("scoring_enabled requires status=approved", report["errors"])
|
||||
|
||||
def test_missing_worksheet_item_fails_validation(self) -> None:
|
||||
rubric = load_rubric(RUBRIC_PATH)
|
||||
broken = copy.deepcopy(rubric)
|
||||
broken["sections"][0]["items"] = broken["sections"][0]["items"][1:]
|
||||
|
||||
report = validate_rubric(broken, expected_item_keys=case_worksheet_template_item_keys())
|
||||
|
||||
self.assertFalse(report["passed"])
|
||||
self.assertIn("exploration_11: missing worksheet item: presenting_complaint", report["errors"])
|
||||
|
||||
def test_cli_reports_json(self) -> None:
|
||||
completed = subprocess.run(
|
||||
[
|
||||
sys.executable,
|
||||
"-X",
|
||||
"utf8",
|
||||
str(SCRIPT_PATH),
|
||||
"--rubric",
|
||||
str(RUBRIC_PATH),
|
||||
"--json",
|
||||
],
|
||||
cwd=str(REPO_ROOT),
|
||||
check=True,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
report = json.loads(completed.stdout)
|
||||
self.assertTrue(report["passed"], report)
|
||||
self.assertEqual(report["items_total"], 28)
|
||||
self.assertFalse(report["scoring_enabled"])
|
||||
self.assertEqual(report["rubric_path"], "data/rubrics/case-worksheet-rubric.json")
|
||||
self.assertRegex(report["content_sha256"], r"^[a-f0-9]{64}$")
|
||||
|
||||
def _validate_with_schema(self, instance: object, schema_path: Path) -> None:
|
||||
schema = json.loads(schema_path.read_text(encoding="utf-8"))
|
||||
self.assertEqual(schema.get("$schema"), "https://json-schema.org/draft/2020-12/schema")
|
||||
if Draft202012Validator is None:
|
||||
return
|
||||
Draft202012Validator.check_schema(schema)
|
||||
Draft202012Validator(schema).validate(instance)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
|
@ -5,14 +5,46 @@ import unittest
|
|||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
try:
|
||||
from jsonschema import Draft202012Validator
|
||||
except ModuleNotFoundError: # pragma: no cover - optional test helper dependency
|
||||
Draft202012Validator = None
|
||||
|
||||
from app.services import guardrail
|
||||
from app.services.pii_masking_eval import evaluate_fixture, load_cases
|
||||
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parents[3]
|
||||
FIXTURE_PATH = REPO_ROOT / "data" / "privacy" / "pii-masking-ko-fixtures.json"
|
||||
INPUT_SCHEMA_PATH = REPO_ROOT / "data" / "privacy" / "pii-masking-eval-input.schema.json"
|
||||
REPORT_SCHEMA_PATH = REPO_ROOT / "data" / "privacy" / "pii-masking-eval-report.schema.json"
|
||||
SCRIPT_PATH = REPO_ROOT / "scripts" / "evaluate-pii-masking.py"
|
||||
|
||||
EXPECTED_CATEGORIES = {
|
||||
"contact",
|
||||
"name",
|
||||
"national_id",
|
||||
"negative_control",
|
||||
"organization",
|
||||
"quasi_identifier",
|
||||
}
|
||||
RAW_IDENTIFIERS = (
|
||||
"김서연",
|
||||
"박민수",
|
||||
"최하늘",
|
||||
"한신대학교",
|
||||
"상담심리학과",
|
||||
"마음봄상담센터",
|
||||
"새봄병원",
|
||||
"010-1234-5678",
|
||||
"seoyeon@example.com",
|
||||
"990101-1234567",
|
||||
"123456789012",
|
||||
"2001년 4월 18일",
|
||||
"서울시 강남구 역삼동",
|
||||
"1200원",
|
||||
)
|
||||
|
||||
|
||||
class PiiMaskingEvalTests(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
|
|
@ -27,22 +59,57 @@ class PiiMaskingEvalTests(unittest.TestCase):
|
|||
def test_fixture_cases_are_valid_json_list(self) -> None:
|
||||
cases = load_cases(FIXTURE_PATH)
|
||||
|
||||
self.assertGreaterEqual(len(cases), 5)
|
||||
self.assertEqual(len(cases), 15)
|
||||
self.assertTrue(all(case.get("id") for case in cases))
|
||||
self.assertTrue(all(case.get("text") for case in cases))
|
||||
self.assertTrue(all(case.get("locale") == "ko-KR" for case in cases))
|
||||
self.assertTrue(all(case.get("source") == "synthetic" for case in cases))
|
||||
self.assertTrue(all("expected_entities" in case for case in cases))
|
||||
self.assertTrue(all("forbidden_substrings" in case for case in cases))
|
||||
self.assertEqual({case["category"] for case in cases}, EXPECTED_CATEGORIES)
|
||||
self._validate_with_schema(cases, INPUT_SCHEMA_PATH)
|
||||
|
||||
def test_ko_name_org_fixture_passes_without_raw_identifier_leak(self) -> None:
|
||||
report = evaluate_fixture(FIXTURE_PATH)
|
||||
|
||||
self.assertEqual(report["schema_version"], "vignette.pii_masking_eval_report.v1")
|
||||
self.assertEqual(report["input_schema_version"], "vignette.pii_masking_eval_input.v1")
|
||||
self.assertEqual(report["run_mode"], "technical_dry_run")
|
||||
self.assertEqual(report["data_source"], "local_fixture")
|
||||
self.assertFalse(report["evidence_text_included"])
|
||||
self.assertTrue(report["passed"], report)
|
||||
self.assertEqual(report["cases_total"], 15)
|
||||
self.assertEqual(report["cases_passed"], 15)
|
||||
self.assertEqual(report["cases_failed"], 0)
|
||||
self.assertEqual(report["expected_entity_recall"], 1.0)
|
||||
self.assertEqual(report["forbidden_substring_removal"], 1.0)
|
||||
self.assertEqual(report["unexpected_entity_violations"], 0)
|
||||
self.assertEqual(report["by_source"]["synthetic"]["cases_passed"], 15)
|
||||
self.assertEqual(set(report["by_category"]), EXPECTED_CATEGORIES)
|
||||
self.assertEqual(report["by_severity"]["critical"]["cases_passed"], 2)
|
||||
for result in report["results"]:
|
||||
self.assertNotIn("masked_text", result)
|
||||
self.assertNotIn("forbidden_remaining", result)
|
||||
self.assertIn("forbidden_remaining_count", result)
|
||||
self._validate_with_schema(report, REPORT_SCHEMA_PATH)
|
||||
blob = json.dumps(report, ensure_ascii=False)
|
||||
for raw in ("김서연", "박민수", "한신대학교", "상담심리학과", "마음봄상담센터"):
|
||||
for raw in RAW_IDENTIFIERS:
|
||||
self.assertNotIn(raw, blob)
|
||||
|
||||
def test_natural_language_name_contexts_are_masked_without_label_false_positive(self) -> None:
|
||||
cases = {
|
||||
"제 이름은 김서연입니다.": "제 이름은 [NAME]입니다.",
|
||||
"보호자 이름은 박민수입니다.": "보호자 이름은 [NAME]입니다.",
|
||||
"저는 최하늘입니다.": "저는 [NAME]입니다.",
|
||||
"김서연입니다.": "[NAME]입니다.",
|
||||
"이름은 중요하지 않고 상담 내용만 이야기하고 싶어요.": "이름은 중요하지 않고 상담 내용만 이야기하고 싶어요.",
|
||||
}
|
||||
|
||||
for raw, expected in cases.items():
|
||||
with self.subTest(raw=raw):
|
||||
result = guardrail.mask_pii(raw)
|
||||
self.assertEqual(result.text_masked, expected)
|
||||
|
||||
def test_cli_reports_json_and_nonzero_gate_shape(self) -> None:
|
||||
completed = subprocess.run(
|
||||
[
|
||||
|
|
@ -63,7 +130,19 @@ class PiiMaskingEvalTests(unittest.TestCase):
|
|||
|
||||
report = json.loads(completed.stdout)
|
||||
self.assertTrue(report["passed"])
|
||||
self.assertEqual(report["cases_total"], 5)
|
||||
self.assertEqual(report["cases_total"], 15)
|
||||
self.assertFalse(report["evidence_text_included"])
|
||||
self.assertEqual(set(report["by_category"]), EXPECTED_CATEGORIES)
|
||||
self.assertTrue(all("masked_text" not in result for result in report["results"]))
|
||||
self._validate_with_schema(report, REPORT_SCHEMA_PATH)
|
||||
|
||||
def _validate_with_schema(self, instance: object, schema_path: Path) -> None:
|
||||
schema = json.loads(schema_path.read_text(encoding="utf-8"))
|
||||
self.assertEqual(schema.get("$schema"), "https://json-schema.org/draft/2020-12/schema")
|
||||
if Draft202012Validator is None:
|
||||
return
|
||||
Draft202012Validator.check_schema(schema)
|
||||
Draft202012Validator(schema).validate(instance)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue