#!/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" "## 이 폴더의 상태\n\n" "이 디렉터리는 빈 입력 틀이지 증거가 아니다. 이름, 이메일, 원음, 축어록, " "자유서술을 넣지 마라. 최종 pack도 임상 효과를 주장할 수 없다.\n\n" "## 작성 순서\n\n" "1. held-out 라벨을 열기 전에 protocol, consent, split, labeling, analysis plan을 " "사전등록하고 각 산출물의 SHA-256을 고정한다.\n" "2. `manifest.json`의 provenance, 두 모델 identity, reliability report를 실제 값으로 " "채우고 `template_only`를 `false`로 바꾼다. `registered_at`은 " "`held_out_labels_opened_at`보다 빨라야 한다.\n" "3. 아래 CSV 계약에 맞춰 비식별 키와 수치만 입력한다.\n" "4. compile 명령으로 production gate를 다시 계산한다. 성공한 경우에만 최종 pack이 " "원자적으로 생성된다.\n" "5. standalone checker로 최종 pack을 한 번 더 검증한 뒤 G7 runner에 전달한다.\n\n" "## CSV 계약\n\n" "- `participants.csv`: `split`은 `calibration` 또는 `held_out`이다. key는 각각 " "`calibration-` 또는 `held-`로 시작하고 consent receipt SHA-256은 참가자마다 고유해야 한다.\n" "- `labelers.csv`: 최소 2명의 독립 평가자를 선언한다. 세 blind/independent 열은 모두 " "소문자 `true`여야 하고 attestation SHA-256을 넣는다.\n" "- `observations.csv`: `axis`는 `goal`, `task`, `bond` 중 하나다. 각 held-out 회기는 " "세 축을 모두 가져야 하며 모든 행에 동일한 blind labeler panel을 사용한다.\n" "- prediction `status`는 `observed`, `missing`, `error` 중 하나다. `observed`일 때만 " "0 이상 1 이하 score를 쓰고, `missing` 또는 `error`면 score 셀을 비운다.\n" "- `label_score`는 0 이상 1 이하이고 `label_category`는 labeling protocol에 " "사전 정의한 동일 taxonomy의 ASCII token을 사용한다.\n\n" "## Production gate\n\n" "최소 calibration 참가자 1명, held-out 참가자 30명, 완전 paired held-out 회기 50개, " "paired 축 150개, blind labeler 2명이 필요하다. compiler는 ICC(A,1) 0.75 이상, " "categorical kappa 0.70 이상, paired gain 0.01 이상, participant-cluster bootstrap " "10,000회의 95% CI lower가 0보다 큰지 행에서 다시 계산한다. 한 조건이라도 결측이면 " "양 조건을 최대오류로 처리해 gain 부풀림을 막는다.\n\n" "## 실행\n\n" "```powershell\n" "python -X utf8 -B scripts/prepare-g7-human-voice-gain-intake.py " "--compile --out \n" "python -X utf8 -B scripts/check-g7-human-voice-gain.py --input \n" "```\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())