Generating Daily QC Reports with Python

You need a single batch job that consumes a day’s caption deliverables and emits one machine-readable verdict per asset — provenance digest, cue count, every reading-rate breach, and a pass/fail flag — serialized so the same input always produces the same bytes. The failure mode this prevents is unprovable compliance: a report that scores assets correctly today but cannot be reproduced months later when an FCC inquiry asks which exact file passed and against which threshold. The reading-rate ceiling enforced below is derived from FCC 47 CFR § 79.1(j) — captions must track the program’s pace — with a sustained cap of 20 characters per second (CPS) for adult-rate content, the value CEA-708 authoring guidance commonly applies. Determinism is the other hard requirement: the report must hash identically across machines and scheduler retries so it can be committed to write-once storage.

Minimal working implementation

import hashlib
import json
from datetime import datetime, timezone
from pathlib import Path

import pysrt  # pip install pysrt==1.1.2

# FCC 47 CFR § 79.1(j) — captions must match program reading rate. CEA-708
# authoring guidance caps sustained presentation at ~20 CPS for adult content.
MAX_CPS = 20.0
RUN_UTC = datetime.now(timezone.utc).replace(microsecond=0).isoformat()


def sha256_file(path: Path) -> str:
    """Chain-of-custody digest, streamed at 64 KiB so memory stays O(1) per asset."""
    h = hashlib.sha256()
    with path.open("rb") as fh:
        for chunk in iter(lambda: fh.read(65536), b""):
            h.update(chunk)
    return h.hexdigest()


def audit_asset(path: Path) -> dict:
    """Parse one SRT and flag every cue over the § 79.1 reading-rate ceiling."""
    subs = pysrt.open(str(path), encoding="utf-8-sig")   # encoding strips a UTF-8 BOM
    violations = []
    for cue in subs:
        dur_s = (cue.end.ordinal - cue.start.ordinal) / 1000   # .ordinal is integer ms
        chars = len(cue.text_without_tags.replace("\n", ""))   # ignore markup + breaks
        cps = chars / dur_s if dur_s > 0 else float("inf")     # zero-dur cue = hard fail
        if cps > MAX_CPS:
            violations.append({
                "cue": cue.index,
                "start_ms": cue.start.ordinal,
                "measured_cps": round(cps, 2),
            })
    return {
        "asset": path.name,
        "sha256": sha256_file(path),
        "cue_count": len(subs),
        "violations": violations,
        "compliant": not violations,
    }


def generate_daily_report(ingest_dir: Path, run_id: str) -> str:
    assets = sorted(ingest_dir.glob("*.srt"))            # stable, sorted input order
    results = [audit_asset(p) for p in assets]
    report = {
        "schema_version": "1.2.0",
        "run_id": run_id,
        "generated_utc": RUN_UTC,
        "threshold_cps": MAX_CPS,
        "assets": results,
        "summary": {
            "total": len(results),
            "passed": sum(r["compliant"] for r in results),
            "failed": sum(not r["compliant"] for r in results),
        },
    }
    # sort_keys=True => identical bytes for identical input, so the report hash is
    # stable across machines and scheduler retries (required for WORM audit storage).
    return json.dumps(report, indent=2, sort_keys=True, ensure_ascii=False)


if __name__ == "__main__":
    out = generate_daily_report(Path("/ingest/today"), run_id="2026-06-28")
    Path("qc-report-2026-06-28.json").write_text(out, encoding="utf-8")

Code walkthrough

sha256_file reads each asset in 64 KiB chunks through iter(lambda: fh.read(65536), b"") rather than fh.read(), so a single multi-hour deliverable never loads into memory whole. The digest is computed before parsing, which fixes the asset’s identity at the moment it entered the report — this is the chain-of-custody record a regulator needs to confirm that the file that passed is byte-for-byte the file that aired. Hashing belongs in the same pass as validation so the proof and the verdict cannot drift apart.

audit_asset uses pysrt’s real object model: pysrt.open returns a SubRipFile whose items expose .index, .start, .end, and .text. Each SubRipTime carries an integer .ordinal in milliseconds, so cue duration is exact integer subtraction with no float timestamp parsing. text_without_tags strips <i>/<b> markup before counting characters, and removing \n collapses the two display lines into one rate calculation — counting the line break would understate CPS on a tightly timed two-liner. A zero-duration cue yields inf and fails immediately, because a cue with no on-screen time is itself a § 79.1 synchronicity defect, not a divide-by-zero to swallow. Opening with encoding="utf-8-sig" discards a leading byte-order mark; a BOM glued to cue index 1 is a routine cause of a corrupted first cue and a poisoned character count.

generate_daily_report makes the artifact reproducible. sorted(ingest_dir.glob("*.srt")) removes filesystem enumeration order from the output, and json.dumps(..., sort_keys=True) guarantees that identical telemetry serializes to identical bytes — the property that lets you store the report’s own SHA-256 alongside it and detect any later tampering. ensure_ascii=False preserves accented and non-Latin caption characters as UTF-8 instead of \uXXXX escapes, keeping the report faithful to the source. The summary block gives a scheduler or dashboard a single integer triplet to alert on without re-parsing the per-asset detail.

Threshold reference

Value Setting Source
Reading rate (adult) 20 CPS sustained FCC 47 CFR § 79.1(j) / CEA-708 authoring
Children’s content rate ~17 CPS CEA-708 authoring guidance
Zero-duration cue hard fail § 79.1 synchronicity defect
Report retention 13 months minimum FCC recordkeeping for captioned programming
Digest algorithm SHA-256 NIST FIP 180-4

Edge cases & known gotchas

  • Empty or missing cue text: text_without_tags on a blank cue gives an empty string and cps == 0 (a pass), but an empty cue is usually an authoring error. Add an explicit zero-character check if your house rules treat blank cues as defects.
  • Overlapping cues: pysrt reads cues positionally and does not detect overlap; two cues sharing screen time each pass their own CPS check while the combined on-screen text exceeds the readable rate. Run an overlap pass before trusting per-cue CPS.
  • Non-SRT deliverables in the folder: glob("*.srt") silently skips SCC, TTML, and WebVTT files. For mixed ingest, parse SCC and WebVTT with their own readers and normalize to the same cue model before scoring — see the parsing links below.
  • Wall-clock RUN_UTC: computed at import time, so a long-running batch stamps every asset with the start time, not its own completion. That is intentional for a “daily run” identity, but do not treat it as a per-asset timestamp.
  • Idempotency: writing qc-report-<date>.json will overwrite a prior run for the same date. Gate the write on a content hash, or fail loudly, if a scheduler retry must never replace an already-signed report.

Where this plugs in

This script is the aggregation-and-serialization core of scheduled QC report generation; that parent overview wraps it with the idempotent trigger, Parquet/JSON dual output, and immutable storage policy. In a full pipeline the per-cue rate check shown here is the same one isolated in enforcing character rate limits in QC, and the compliant flag feeds straight into CI/CD gating for caption builds. Run it after SRT timestamp normalization has snapped cue edges to the frame grid so durations are exact, and merge its output with the metrics from automated sync drift detection for a single per-asset verdict that satisfies FCC Part 79 compliance.

Deterministic data flow for a daily caption QC report A sorted folder of SRT deliverables flows into audit_asset, which per asset streams a SHA-256 digest, parses cues with pysrt, and applies the 20 CPS § 79.1 reading-rate check. Verdicts are serialized byte-stable with json.dumps(sort_keys=True) into a JSON report that lands in a write-once WORM audit store; the report's own SHA-256 is stored beside it as the integrity seal. Daily QC report — deterministic data flow One day's deliverables → a byte-stable verdict, sealed by its own hash Ingest folder /ingest/today sorted(*.srt) audit_asset — per asset SHA-256 digest · streamed 64 KiB pysrt parse · cue model CPS ≤ 20 check · § 79.1(j) Deterministic JSON json.dumps(sort_keys=True) identical input → identical bytes WORM audit store write-once ≥ 13-month retention SHA-256(report) stored alongside as integrity seal

For library-scale ingest, fan audit_asset out across workers with the queue and backpressure model in async batch caption processing, keeping each worker on an isolated memory space, then merge the per-asset dicts before the single deterministic json.dumps.

Part of: Automated QC Validation & Reporting.