평가 및 화면 구조 정리

This commit is contained in:
Yun Chan 2026-06-28 21:46:22 +09:00
parent 1248ae8ca4
commit 391639c1de
44 changed files with 5816 additions and 4501 deletions

View 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