G7 독립 평가 입력 검증 도구 추가

This commit is contained in:
Yun Chan 2026-08-10 02:32:16 +09:00
parent fcc45e083e
commit 3067b72526
8 changed files with 881 additions and 5 deletions

View file

@ -0,0 +1,578 @@
#!/usr/bin/env python3
"""Prepare or compile a deidentified G7 human voice-gain intake.
Template mode creates empty, explicitly non-evidence input files. Compile mode
accepts only deidentified metrics, recomputes reliability and production gates,
and writes a final pack only when every production requirement passes. Console
output never includes input paths, participant/labeler keys, labels, or values
from invalid cells.
"""
from __future__ import annotations
import argparse
import csv
import json
import os
import re
import sys
import tempfile
from dataclasses import dataclass
from pathlib import Path
from typing import Any, Iterable
from pydantic import ValidationError
REPO_ROOT = Path(__file__).resolve().parents[1]
API_ROOT = REPO_ROOT / "apps/api"
if str(API_ROOT) not in sys.path:
sys.path.insert(0, str(API_ROOT))
from app.contracts.g7_external_evidence import ( # noqa: E402
G7HumanVoiceGainEvidencePack,
)
from app.services.g7_voice_gain_evidence import ( # noqa: E402
evaluate_human_voice_gain,
)
MANIFEST_NAME = "manifest.json"
PARTICIPANTS_NAME = "participants.csv"
LABELERS_NAME = "labelers.csv"
OBSERVATIONS_NAME = "observations.csv"
README_NAME = "README.md"
INTAKE_SCHEMA_VERSION = "g7_human_voice_gain_intake_v1"
MANIFEST_KEYS = {
"intake_schema_version",
"template_only",
"provenance",
"text_only_model",
"voice_enabled_model",
"power_plan",
"reported_icc",
"reported_categorical_kappa",
"reliability_report_sha256",
}
PARTICIPANT_FIELDS = (
"participant_key",
"split",
"consent_receipt_sha256",
)
LABELER_FIELDS = (
"labeler_key",
"blinded_to_model_condition",
"blinded_to_other_labelers",
"labeled_independently",
"attestation_sha256",
)
OBSERVATION_FIELDS = (
"observation_id",
"participant_key",
"session_key",
"axis",
"text_only_status",
"text_only_score",
"voice_enabled_status",
"voice_enabled_score",
"labeler_key",
"label_score",
"label_category",
)
DEIDENTIFIED_PARTICIPANT_RE = re.compile(
r"^(?:calibration|held)-[A-Za-z0-9][A-Za-z0-9._:-]{2,95}$"
)
DEIDENTIFIED_LABELER_RE = re.compile(r"^labeler-[A-Za-z0-9][A-Za-z0-9._:-]{2,95}$")
DEIDENTIFIED_SESSION_RE = re.compile(r"^session-[A-Za-z0-9][A-Za-z0-9._:-]{2,95}$")
@dataclass(frozen=True, slots=True)
class IntakeError(Exception):
code: str
source: str = "intake"
row: int | None = None
field: str | None = None
def _base_report() -> dict[str, Any]:
return {
"schema_version": "vignette.g7-human-voice-gain-intake.v1",
"passed": False,
"pack_written": False,
"clinical_claim_allowed": False,
"privacy_boundary": {
"input_paths_logged": False,
"participant_keys_logged": False,
"labeler_keys_logged": False,
"labels_logged": False,
"raw_cell_values_logged": False,
"raw_audio_or_transcript_accepted": False,
},
"errors": [],
"result": {},
}
def _safe_error(error: IntakeError) -> dict[str, Any]:
result: dict[str, Any] = {"code": error.code, "source": error.source}
if error.row is not None:
result["row"] = error.row
if error.field is not None:
result["field"] = error.field
return result
def _json_pointer(location: tuple[int | str, ...]) -> str:
if not location:
return "/"
return "/" + "/".join(
str(item).replace("~", "~0").replace("/", "~1") for item in location
)
def _validation_errors(error: ValidationError) -> list[dict[str, Any]]:
return [
{
"code": f"contract:{item['type']}",
"source": "compiled_pack",
"pointer": _json_pointer(tuple(item["loc"])),
}
for item in error.errors(
include_url=False,
include_context=False,
include_input=False,
)
]
def _require_exact_keys(payload: dict[str, Any]) -> None:
if set(payload) != MANIFEST_KEYS:
raise IntakeError("manifest_fields_invalid", source=MANIFEST_NAME)
def _read_manifest(directory: Path) -> dict[str, Any]:
try:
value = json.loads((directory / MANIFEST_NAME).read_text(encoding="utf-8"))
except (OSError, UnicodeError, json.JSONDecodeError) as exc:
raise IntakeError(
f"manifest_read:{type(exc).__name__}", source=MANIFEST_NAME
) from None
if not isinstance(value, dict):
raise IntakeError("manifest_object_required", source=MANIFEST_NAME)
_require_exact_keys(value)
if value.get("intake_schema_version") != INTAKE_SCHEMA_VERSION:
raise IntakeError("intake_schema_version_invalid", source=MANIFEST_NAME)
if value.get("template_only") is not False:
raise IntakeError("template_cannot_compile", source=MANIFEST_NAME)
return value
def _read_csv(
directory: Path, filename: str, fields: tuple[str, ...]
) -> list[dict[str, str]]:
try:
with (directory / filename).open(
"r", encoding="utf-8-sig", newline=""
) as handle:
reader = csv.DictReader(handle)
if reader.fieldnames != list(fields):
raise IntakeError("csv_headers_invalid", source=filename)
rows = []
for row_number, row in enumerate(reader, start=2):
if None in row or any(value is None for value in row.values()):
raise IntakeError(
"csv_shape_invalid", source=filename, row=row_number
)
if any(len(value) > 256 for value in row.values()):
raise IntakeError(
"csv_cell_too_long", source=filename, row=row_number
)
rows.append(dict(row))
except IntakeError:
raise
except (OSError, UnicodeError, csv.Error) as exc:
raise IntakeError(f"csv_read:{type(exc).__name__}", source=filename) from None
if not rows:
raise IntakeError("csv_rows_required", source=filename)
return rows
def _parse_true(value: str, *, source: str, row: int, field: str) -> bool:
if value != "true":
raise IntakeError("literal_true_required", source, row, field)
return True
def _parse_score(
value: str,
*,
nullable: bool,
source: str,
row: int,
field: str,
) -> float | None:
if value == "" and nullable:
return None
try:
result = float(value)
except ValueError:
raise IntakeError("score_invalid", source, row, field) from None
if not 0.0 <= result <= 1.0:
raise IntakeError("score_out_of_range", source, row, field)
return result
def _parse_participants(rows: list[dict[str, str]]) -> list[dict[str, Any]]:
participants = []
for row_number, row in enumerate(rows, start=2):
key = row["participant_key"]
split = row["split"]
if DEIDENTIFIED_PARTICIPANT_RE.fullmatch(key) is None:
raise IntakeError(
"deidentified_participant_key_required",
PARTICIPANTS_NAME,
row_number,
"participant_key",
)
expected_prefix = "calibration-" if split == "calibration" else "held-"
if split not in {"calibration", "held_out"} or not key.startswith(
expected_prefix
):
raise IntakeError(
"participant_split_key_mismatch",
PARTICIPANTS_NAME,
row_number,
"split",
)
participants.append(row)
return participants
def _parse_labelers(rows: list[dict[str, str]]) -> list[dict[str, Any]]:
labelers = []
for row_number, row in enumerate(rows, start=2):
key = row["labeler_key"]
if DEIDENTIFIED_LABELER_RE.fullmatch(key) is None:
raise IntakeError(
"deidentified_labeler_key_required",
LABELERS_NAME,
row_number,
"labeler_key",
)
labelers.append(
{
"labeler_key": key,
"blinded_to_model_condition": _parse_true(
row["blinded_to_model_condition"],
source=LABELERS_NAME,
row=row_number,
field="blinded_to_model_condition",
),
"blinded_to_other_labelers": _parse_true(
row["blinded_to_other_labelers"],
source=LABELERS_NAME,
row=row_number,
field="blinded_to_other_labelers",
),
"labeled_independently": _parse_true(
row["labeled_independently"],
source=LABELERS_NAME,
row=row_number,
field="labeled_independently",
),
"attestation_sha256": row["attestation_sha256"],
}
)
return labelers
def _parse_observations(rows: list[dict[str, str]]) -> list[dict[str, Any]]:
grouped: dict[tuple[str, str, str, str], dict[str, Any]] = {}
order: list[tuple[str, str, str, str]] = []
for row_number, row in enumerate(rows, start=2):
participant_key = row["participant_key"]
session_key = row["session_key"]
labeler_key = row["labeler_key"]
if DEIDENTIFIED_PARTICIPANT_RE.fullmatch(participant_key) is None:
raise IntakeError(
"deidentified_participant_key_required",
OBSERVATIONS_NAME,
row_number,
"participant_key",
)
if DEIDENTIFIED_SESSION_RE.fullmatch(session_key) is None:
raise IntakeError(
"deidentified_session_key_required",
OBSERVATIONS_NAME,
row_number,
"session_key",
)
if DEIDENTIFIED_LABELER_RE.fullmatch(labeler_key) is None:
raise IntakeError(
"deidentified_labeler_key_required",
OBSERVATIONS_NAME,
row_number,
"labeler_key",
)
key = (
row["observation_id"],
participant_key,
session_key,
row["axis"],
)
core = {
"observation_id": row["observation_id"],
"participant_key": participant_key,
"session_key": session_key,
"axis": row["axis"],
"text_only_status": row["text_only_status"],
"text_only_score": _parse_score(
row["text_only_score"],
nullable=True,
source=OBSERVATIONS_NAME,
row=row_number,
field="text_only_score",
),
"voice_enabled_status": row["voice_enabled_status"],
"voice_enabled_score": _parse_score(
row["voice_enabled_score"],
nullable=True,
source=OBSERVATIONS_NAME,
row=row_number,
field="voice_enabled_score",
),
"raw_audio_included": False,
"transcript_included": False,
}
if key not in grouped:
grouped[key] = {**core, "labels": []}
order.append(key)
elif any(grouped[key][name] != value for name, value in core.items()):
raise IntakeError(
"observation_core_mismatch", OBSERVATIONS_NAME, row_number
)
grouped[key]["labels"].append(
{
"labeler_key": labeler_key,
"score": _parse_score(
row["label_score"],
nullable=False,
source=OBSERVATIONS_NAME,
row=row_number,
field="label_score",
),
"category": row["label_category"],
}
)
return [grouped[key] for key in order]
def _draft_pack(directory: Path) -> dict[str, Any]:
manifest = _read_manifest(directory)
participants = _parse_participants(
_read_csv(directory, PARTICIPANTS_NAME, PARTICIPANT_FIELDS)
)
labelers = _parse_labelers(_read_csv(directory, LABELERS_NAME, LABELER_FIELDS))
observations = _parse_observations(
_read_csv(directory, OBSERVATIONS_NAME, OBSERVATION_FIELDS)
)
labeler_keys = [item["labeler_key"] for item in labelers]
return {
"provenance": manifest["provenance"],
"text_only_model": manifest["text_only_model"],
"voice_enabled_model": manifest["voice_enabled_model"],
"power_plan": manifest["power_plan"],
"participants": participants,
"labeler_attestations": labelers,
"reliability": {
"labeler_keys": labeler_keys,
"reported_icc": manifest["reported_icc"],
"reported_categorical_kappa": manifest["reported_categorical_kappa"],
"report_sha256": manifest["reliability_report_sha256"],
},
"observations": observations,
}
def compile_intake(directory: Path) -> tuple[dict[str, Any], dict[str, Any] | None]:
report = _base_report()
try:
payload = _draft_pack(directory)
pack = G7HumanVoiceGainEvidencePack.model_validate(payload)
result = evaluate_human_voice_gain(pack)
except IntakeError as exc:
report["errors"] = [_safe_error(exc)]
return report, None
except ValidationError as exc:
report["errors"] = _validation_errors(exc)
return report, None
except Exception as exc:
report["errors"] = [
{"code": f"evaluation:{type(exc).__name__}", "source": "compiled_pack"}
]
return report, None
report["passed"] = result.passed
report["result"] = result.model_dump(mode="json")
if not result.passed:
return report, None
return report, pack.model_dump(mode="json")
def _atomic_create_json(path: Path, payload: dict[str, Any]) -> None:
if path.exists():
raise IntakeError("output_exists", source="output")
path.parent.mkdir(parents=True, exist_ok=True)
temporary: Path | None = None
try:
with tempfile.NamedTemporaryFile(
"w",
encoding="utf-8",
newline="\n",
dir=path.parent,
prefix=f".{path.name}.",
suffix=".tmp",
delete=False,
) as handle:
temporary = Path(handle.name)
json.dump(payload, handle, ensure_ascii=False, indent=2, sort_keys=True)
handle.write("\n")
handle.flush()
os.fsync(handle.fileno())
try:
os.link(temporary, path)
except FileExistsError:
raise IntakeError("output_exists", source="output") from None
except OSError as exc:
raise IntakeError(
f"output_link:{type(exc).__name__}", source="output"
) from None
try:
temporary.unlink()
except OSError as exc:
path.unlink(missing_ok=True)
raise IntakeError(
f"output_cleanup:{type(exc).__name__}", source="output"
) from None
temporary = None
finally:
if temporary is not None:
temporary.unlink(missing_ok=True)
def create_template(directory: Path) -> None:
if directory.exists():
raise IntakeError("template_directory_exists", source="template")
directory.mkdir(parents=True)
manifest = {
"intake_schema_version": INTAKE_SCHEMA_VERSION,
"template_only": True,
"provenance": {
"protocol_sha256": "",
"consent_protocol_sha256": "",
"dataset_manifest_sha256": "",
"split_manifest_sha256": "",
"labeling_protocol_sha256": "",
"analysis_plan_sha256": "",
"registered_at": "",
"held_out_labels_opened_at": "",
},
"text_only_model": {
"role": "text_only_baseline",
"provider": "",
"model_id": "",
"model_version": "",
"artifact_sha256": "",
"configuration_sha256": "",
},
"voice_enabled_model": {
"role": "voice_enabled_candidate",
"provider": "",
"model_id": "",
"model_version": "",
"artifact_sha256": "",
"configuration_sha256": "",
},
"power_plan": {
"primary_metric": "paired_one_minus_mae_gain",
"clustering_unit": "participant",
"required_held_out_participants": 30,
"required_held_out_sessions": 50,
"required_paired_axis_observations": 150,
"alpha": 0.05,
"target_power": 0.8,
"minimally_detectable_gain": 0.01,
"planned_bootstrap_samples": 10000,
},
"reported_icc": None,
"reported_categorical_kappa": None,
"reliability_report_sha256": "",
}
(directory / MANIFEST_NAME).write_text(
json.dumps(manifest, ensure_ascii=False, indent=2, sort_keys=True) + "\n",
encoding="utf-8",
newline="\n",
)
for filename, fields in (
(PARTICIPANTS_NAME, PARTICIPANT_FIELDS),
(LABELERS_NAME, LABELER_FIELDS),
(OBSERVATIONS_NAME, OBSERVATION_FIELDS),
):
with (directory / filename).open("w", encoding="utf-8", newline="") as handle:
csv.writer(handle, lineterminator="\n").writerow(fields)
(directory / README_NAME).write_text(
"# G7 human voice-gain intake\n\n"
"이 디렉터리는 빈 입력 틀이지 증거가 아니다. `manifest.json`의 모든 provenance를 "
"실제 외부 연구 산출물 SHA-256으로 채우고 `template_only`를 `false`로 바꿔라. "
"CSV에는 비식별 키와 수치만 넣고 이름, 이메일, 원음, 축어록, 자유서술을 넣지 마라. "
"각 held-out 회기는 goal/task/bond 3축과 동일한 blind labeler panel을 가져야 한다.\n",
encoding="utf-8",
newline="\n",
)
def parser() -> argparse.ArgumentParser:
result = argparse.ArgumentParser(description=__doc__)
mode = result.add_mutually_exclusive_group(required=True)
mode.add_argument("--create-template", type=Path, metavar="DIR")
mode.add_argument("--compile", type=Path, metavar="DIR")
result.add_argument("--out", type=Path, help="final pack JSON; compile only")
return result
def main(argv: Iterable[str] | None = None) -> int:
args = parser().parse_args(list(argv) if argv is not None else None)
report = _base_report()
if args.create_template is not None:
if args.out is not None:
parser().error("--out is compile-only")
try:
create_template(args.create_template)
except IntakeError as exc:
report["errors"] = [_safe_error(exc)]
print(json.dumps(report, ensure_ascii=False, indent=2, sort_keys=True))
return 1
report["template_created"] = True
report["template_is_evidence"] = False
print(json.dumps(report, ensure_ascii=False, indent=2, sort_keys=True))
return 0
if args.out is None:
parser().error("--compile requires --out")
report, pack_payload = compile_intake(args.compile)
if pack_payload is not None:
try:
_atomic_create_json(args.out, pack_payload)
except IntakeError as exc:
report["passed"] = False
report["errors"] = [_safe_error(exc)]
else:
report["pack_written"] = True
print(json.dumps(report, ensure_ascii=False, indent=2, sort_keys=True))
return 0 if report["passed"] is True and report["pack_written"] else 1
if __name__ == "__main__":
raise SystemExit(main())