대시보드 이슈 정리 1차

This commit is contained in:
Yun Chan 2026-06-27 17:51:54 +09:00
parent 94cc56592f
commit f472883c31
13 changed files with 592 additions and 311 deletions

View file

@ -5,6 +5,7 @@ from __future__ import annotations
import argparse
import csv
import hashlib
import json
import re
import sys
@ -39,6 +40,44 @@ FORBIDDEN_HEADER_TERMS = {
EMAIL_RE = re.compile(r"[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}", re.IGNORECASE)
PHONE_RE = re.compile(r"(?:\+?\d[\d .()-]{7,}\d)")
APPROVED_EXPORT_STATUS = "approved_for_recursive_learning_seed"
CSV_ALLOWED_VALUES = {
"00-intake/pilot_roster.csv": {
"withdrawal_state": {
"active",
"withdrawn_before_data_use",
"withdrawn_after_data_use",
"excluded_by_operator",
},
},
"01-sessions/session_completion.csv": {
"completion_state": {
"completed",
"abandoned",
"operator_cancelled",
"excluded_from_analysis",
},
},
"02-measures/prepost_measures.csv": {
"timepoint": {"pre", "post"},
},
"04-privacy/withdrawal_log.csv": {
"scope": {
"future_sessions_only",
"exclude_from_analysis",
"exclude_from_export",
"delete_where_policy_allows",
},
"status": {
"received",
"in_progress",
"completed",
"rejected_by_policy",
"needs_legal_review",
},
},
}
@dataclass(frozen=True)
@ -104,6 +143,27 @@ KPI_METRICS = {
"top1",
}
KPI_REPORT_KEYS = {
"cohort_size",
"exclusions",
"generated_at",
"metrics",
"open_schema_gaps",
"pilot_id",
"review",
"source_window",
}
KPI_METRIC_REQUIRED_KEYS = {
"denominator",
"method",
"numerator",
"pass",
"source_files",
"threshold",
"value",
}
MANIFEST_KEYS = {
"agreement",
"anonymization",
@ -114,8 +174,12 @@ MANIFEST_KEYS = {
"export_id",
"export_status",
"files",
"known_limitations",
"pii_scan",
"purpose",
"selection_criteria",
"source_tables",
"source_window",
}
@ -182,6 +246,29 @@ def scan_csv_values(path: Path, report: Report, max_rows: int) -> None:
report.error(f"{path}: cannot scan CSV: {exc}")
def validate_csv_allowed_values(root: Path, spec: CsvSpec, report: Report) -> None:
allowed_by_column = CSV_ALLOWED_VALUES.get(spec.path)
if not allowed_by_column:
return
path = root / spec.path
try:
with path.open("r", encoding="utf-8-sig", newline="") as handle:
reader = csv.DictReader(handle)
for row_number, row in enumerate(reader, start=2):
for column, allowed_values in allowed_by_column.items():
value = (row.get(column) or "").strip()
if value and value not in allowed_values:
expected = ", ".join(sorted(allowed_values))
report.error(
f"{spec.path}:{row_number}: invalid {column} '{value}' "
f"(expected one of: {expected})"
)
except UnicodeDecodeError:
report.error(f"{path}: not valid UTF-8/UTF-8-SIG")
except OSError as exc:
report.error(f"{path}: cannot validate CSV values: {exc}")
def validate_csv(root: Path, spec: CsvSpec, report: Report, max_scan_rows: int) -> None:
path = root / spec.path
if not path.exists():
@ -202,6 +289,7 @@ def validate_csv(root: Path, spec: CsvSpec, report: Report, max_scan_rows: int)
report.error(f"{spec.path}: forbidden identifier/secret-like headers: {', '.join(forbidden)}")
scan_csv_values(path, report, max_rows=max_scan_rows)
validate_csv_allowed_values(root, spec, report)
def read_json(path: Path, report: Report) -> dict[str, Any] | None:
@ -233,6 +321,10 @@ def validate_kpi_report(root: Path, report: Report) -> None:
if data is None:
return
missing_top_level = sorted(KPI_REPORT_KEYS - set(data))
if missing_top_level:
report.error(f"{rel_path}: missing keys: {', '.join(missing_top_level)}")
metrics = data.get("metrics")
if not isinstance(metrics, dict):
report.error(f"{rel_path}: missing object key 'metrics'")
@ -245,9 +337,70 @@ def validate_kpi_report(root: Path, report: Report) -> None:
if not isinstance(metric, dict):
report.error(f"{rel_path}: metric '{metric_name}' must be an object")
continue
for key in ("value", "threshold", "pass", "method", "source_files"):
for key in sorted(KPI_METRIC_REQUIRED_KEYS):
if key not in metric:
report.warn(f"{rel_path}: metric '{metric_name}' missing '{key}'")
report.error(f"{rel_path}: metric '{metric_name}' missing '{key}'")
if "source_files" in metric and not isinstance(metric["source_files"], list):
report.error(f"{rel_path}: metric '{metric_name}' source_files must be a list")
def sha256_file(path: Path) -> str:
digest = hashlib.sha256()
with path.open("rb") as handle:
for chunk in iter(lambda: handle.read(1024 * 1024), b""):
digest.update(chunk)
return digest.hexdigest()
def numeric_value(value: Any) -> float | None:
if isinstance(value, bool):
return None
if isinstance(value, (int, float)):
return float(value)
return None
def validate_manifest_file_entry(
root: Path,
rel_path: str,
item: dict[str, Any],
report: Report,
*,
approved: bool,
) -> None:
required_keys = ("path", "rows", "sha256", "schema")
for key in required_keys:
if key not in item:
message = f"{rel_path}: file entry missing '{key}'"
if approved:
report.error(message)
else:
report.warn(message)
file_path = item.get("path")
expected_sha = item.get("sha256")
if not isinstance(file_path, str) or not file_path.strip():
if approved:
report.error(f"{rel_path}: approved file entry requires non-empty path")
return
try:
root_resolved = root.resolve()
candidate = (root / file_path).resolve()
candidate.relative_to(root_resolved)
except ValueError:
report.error(f"{rel_path}: file path escapes evidence root: {file_path}")
return
if not approved:
return
if not candidate.exists():
report.error(f"{rel_path}: approved file missing: {file_path}")
return
if not candidate.is_file():
report.error(f"{rel_path}: approved file path is not a file: {file_path}")
return
if isinstance(expected_sha, str) and expected_sha:
actual_sha = sha256_file(candidate)
if actual_sha != expected_sha:
report.error(f"{rel_path}: sha256 mismatch for {file_path}")
def validate_manifest(root: Path, report: Report) -> None:
@ -265,7 +418,8 @@ def validate_manifest(root: Path, report: Report) -> None:
report.error(f"{rel_path}: missing keys: {', '.join(missing)}")
status = data.get("export_status")
if status == "approved_for_recursive_learning_seed":
approved = status == APPROVED_EXPORT_STATUS
if approved:
approvals = data.get("approvals")
if not isinstance(approvals, dict):
report.error(f"{rel_path}: approved export requires approvals object")
@ -273,16 +427,40 @@ def validate_manifest(root: Path, report: Report) -> None:
for key in ("data_steward", "legal_or_privacy_reviewer", "technical_operator", "approved_at"):
if not approvals.get(key):
report.error(f"{rel_path}: approved export missing approval '{key}'")
pii_scan = data.get("pii_scan")
if not isinstance(pii_scan, dict):
report.error(f"{rel_path}: approved export requires pii_scan object")
elif pii_scan.get("status") != "pass":
report.error(f"{rel_path}: approved export requires pii_scan.status='pass'")
agreement = data.get("agreement")
if not isinstance(agreement, dict):
report.error(f"{rel_path}: approved export requires agreement object")
else:
kappa = numeric_value(agreement.get("kappa"))
icc = numeric_value(agreement.get("icc"))
if kappa is None or kappa < 0.60:
report.error(f"{rel_path}: approved export requires agreement.kappa >= 0.60")
if icc is None or icc < 0.75:
report.error(f"{rel_path}: approved export requires agreement.icc >= 0.75")
selection_criteria = data.get("selection_criteria")
if not isinstance(selection_criteria, dict):
report.error(f"{rel_path}: approved export requires selection_criteria object")
elif selection_criteria.get("include_withdrawn") is not False:
report.error(f"{rel_path}: approved export requires selection_criteria.include_withdrawn=false")
consent_scope = data.get("consent_scope")
allowed_uses = consent_scope.get("allowed_uses") if isinstance(consent_scope, dict) else None
if not isinstance(allowed_uses, list) or "recursive_learning_seed" not in allowed_uses:
report.error(f"{rel_path}: approved export requires recursive_learning_seed consent scope")
files = data.get("files")
if isinstance(files, list):
if approved and not files:
report.error(f"{rel_path}: approved export requires at least one file entry")
for item in files:
if not isinstance(item, dict):
report.error(f"{rel_path}: files[] entries must be objects")
continue
for key in ("path", "rows", "sha256", "schema"):
if key not in item:
report.warn(f"{rel_path}: file entry missing '{key}'")
validate_manifest_file_entry(root, rel_path, item, report, approved=approved)
elif files is not None:
report.error(f"{rel_path}: 'files' must be a list")