← Files LegalQuants LitigationARCHIVED FILE

skills/docreview/scripts/ingest_review_feedback.py

17.5 KB · Oct 5, 2026 · 18:33 UTC

↓ Download file

#!/usr/bin/env python3
"""Validate offline lawyer feedback and write an additive findings overlay.

The source findings ledger and framework are immutable inputs. The output is a
deterministic copy whose only permitted difference is a ``lawyer_ruling`` field
on findings named by the feedback artifact and an additive, document-level
``image_confirmations`` overlay.
"""

from __future__ import annotations

import argparse
import hashlib
import json
import os
import sys
import tempfile
from pathlib import Path
from typing import Any, NoReturn, cast

JsonObject = dict[str, Any]

RULINGS = {"responsive", "not-responsive", "needs-review", "privileged"}
IMAGE_CONFIRMATIONS = {"confirmed", "needs-review"}
TOP_LEVEL_KEYS = {
    "artifact",
    "corpus_id",
    "frame_id",
    "framework_digest",
    "framework_version",
    "ledger_digest",
    "review_plan_id",
    "rulings",
    "source",
    "version",
}
OPTIONAL_TOP_LEVEL_KEYS = {"image_confirmations"}
RULING_KEYS = {
    "doc_id",
    "finding_id",
    "issue_id",
    "machine_status",
    "note",
    "ruled_by",
    "ruling",
}
OVERLAY_KEYS = {"note", "ruled_by", "ruling"}
IMAGE_CONFIRMATION_KEYS = {
    "confirmation",
    "confirmed_by",
    "doc_id",
    "finding_ids",
    "note",
    "proposal_digest",
}


def fail(message: str) -> NoReturn:
    sys.exit(f"ingest_review_feedback: {message}")


def load(path: str, kind: str) -> JsonObject:
    try:
        value = json.loads(Path(path).read_text(encoding="utf-8"))
    except (OSError, json.JSONDecodeError) as error:
        fail(f"cannot read {kind} at {path}: {error}")
    if not isinstance(value, dict):
        fail(f"{kind} must be an object")
    return cast(JsonObject, value)


def canonical_digest(value: object) -> str:
    raw = json.dumps(value, sort_keys=True, separators=(",", ":"))
    return "sha256:" + hashlib.sha256(raw.encode()).hexdigest()


def proposal_ledger(findings: JsonObject) -> JsonObject:
    """Return the immutable proposal ledger, excluding only lawyer overlays."""

    proposal = cast(JsonObject, json.loads(json.dumps(findings)))
    proposal.pop("image_confirmations", None)
    rows = proposal.get("findings")
    if not isinstance(rows, list):
        fail("findings ledger has no findings array")
    for raw_row in cast(list[Any], rows):
        if not isinstance(raw_row, dict):
            fail("findings ledger contains a non-object finding")
        row = cast(JsonObject, raw_row)
        row.pop("lawyer_ruling", None)
    return proposal


def validate_overlay(value: object, finding_id: str) -> None:
    if not isinstance(value, dict) or set(value) != OVERLAY_KEYS:
        fail(f"existing lawyer overlay has invalid shape for {finding_id}")
    overlay = cast(JsonObject, value)
    if overlay.get("ruling") not in RULINGS:
        fail(f"existing lawyer overlay has invalid ruling for {finding_id}")
    if overlay.get("ruled_by") != "lawyer":
        fail(f"existing lawyer overlay is not lawyer-authored for {finding_id}")
    if not isinstance(overlay.get("note"), str):
        fail(f"existing lawyer overlay note is invalid for {finding_id}")


def validate_feedback(
    feedback: JsonObject,
) -> tuple[list[JsonObject], list[JsonObject] | None]:
    keys = set(feedback)
    allowed = TOP_LEVEL_KEYS | OPTIONAL_TOP_LEVEL_KEYS
    if not TOP_LEVEL_KEYS <= keys or not keys <= allowed:
        missing = sorted(TOP_LEVEL_KEYS - keys)
        extra = sorted(keys - allowed)
        fail(f"feedback keys are invalid; missing={missing}, extra={extra}")
    if feedback.get("artifact") != "review-feedback" or feedback.get("version") != 1:
        fail("unsupported feedback artifact")
    for key in (
        "corpus_id",
        "frame_id",
        "framework_digest",
        "ledger_digest",
        "review_plan_id",
        "source",
    ):
        if not isinstance(feedback.get(key), str) or not feedback[key]:
            fail(f"feedback {key} must be a non-empty string")
    if not isinstance(feedback.get("framework_version"), int) or isinstance(
        feedback.get("framework_version"), bool
    ):
        fail("feedback framework_version must be an integer")
    rows = feedback.get("rulings")
    if not isinstance(rows, list):
        fail("feedback rulings must be an array")
    seen: set[str] = set()
    validated: list[JsonObject] = []
    for index, raw_row in enumerate(cast(list[Any], rows)):
        if not isinstance(raw_row, dict) or set(raw_row) != RULING_KEYS:
            fail(f"feedback ruling {index} has unexpected or missing keys")
        row = cast(JsonObject, raw_row)
        finding_id = row.get("finding_id")
        issue_id = row.get("issue_id")
        doc_id = row.get("doc_id")
        if not all(
            isinstance(value, str) and value for value in (finding_id, issue_id, doc_id)
        ):
            fail(f"feedback ruling {index} has an invalid subject")
        finding_id = cast(str, finding_id)
        issue_id = cast(str, issue_id)
        doc_id = cast(str, doc_id)
        if finding_id != f"{issue_id}/{doc_id}":
            fail(f"feedback ruling {finding_id!r} has an invalid stable ID")
        if finding_id in seen:
            fail(f"feedback repeats finding {finding_id!r}")
        seen.add(finding_id)
        if row.get("machine_status") not in {"present", "absent", "unresolved"}:
            fail(f"feedback ruling {finding_id!r} has an invalid machine status")
        if row.get("ruling") not in RULINGS:
            fail(f"feedback ruling {finding_id!r} has an invalid lawyer ruling")
        if row.get("ruled_by") != "lawyer":
            fail(f"feedback ruling {finding_id!r} is not lawyer-authored")
        if not isinstance(row.get("note"), str):
            fail(f"feedback ruling {finding_id!r} has an invalid note")
        validated.append(row)
    raw_confirmations = feedback.get("image_confirmations")
    confirmations: list[JsonObject] | None = None
    if "image_confirmations" in feedback:
        if not isinstance(raw_confirmations, list):
            fail("feedback image_confirmations must be an array")
        confirmations = []
        seen_docs: set[str] = set()
        for index, raw_row in enumerate(cast(list[Any], raw_confirmations)):
            if not isinstance(raw_row, dict) or set(raw_row) != IMAGE_CONFIRMATION_KEYS:
                fail(
                    f"feedback image confirmation {index} has unexpected or "
                    "missing keys"
                )
            row = cast(JsonObject, raw_row)
            doc_id = row.get("doc_id")
            if not isinstance(doc_id, str) or not doc_id:
                fail(f"feedback image confirmation {index} has an invalid document")
            if doc_id in seen_docs:
                fail(f"feedback repeats image confirmation for {doc_id!r}")
            seen_docs.add(doc_id)
            if row.get("confirmation") not in IMAGE_CONFIRMATIONS:
                fail(f"feedback image confirmation for {doc_id!r} is invalid")
            if row.get("confirmed_by") != "lawyer":
                fail(
                    f"feedback image confirmation for {doc_id!r} is not lawyer-authored"
                )
            finding_ids = row.get("finding_ids")
            if (
                not isinstance(finding_ids, list)
                or not finding_ids
                or any(not isinstance(value, str) or not value for value in finding_ids)
                or finding_ids != sorted(set(finding_ids))
            ):
                fail(
                    f"feedback image confirmation for {doc_id!r} has invalid "
                    "finding IDs"
                )
            digest = row.get("proposal_digest")
            if (
                not isinstance(digest, str)
                or len(digest) != 71
                or not digest.startswith("sha256:")
                or any(char not in "0123456789abcdef" for char in digest[7:])
            ):
                fail(
                    f"feedback image confirmation for {doc_id!r} has invalid "
                    "proposal digest"
                )
            if not isinstance(row.get("note"), str):
                fail(f"feedback image confirmation for {doc_id!r} has an invalid note")
            confirmations.append(row)
        confirmations.sort(key=lambda row: row["doc_id"])
    return sorted(validated, key=lambda row: row["finding_id"]), confirmations


def is_image_review_row(row: JsonObject) -> bool:
    verification = row.get("quote_verification")
    reason = row.get("human_review_reason")
    return bool(
        row.get("receipt_mode") == "image-transcription"
        or (isinstance(reason, str) and "image" in reason.lower())
        or (
            isinstance(verification, dict)
            and verification.get("status") == "human-required"
            and "image" in str(verification.get("reason", "")).lower()
        )
    )


def image_bundles(findings: JsonObject) -> dict[str, tuple[list[str], str]]:
    proposal = proposal_ledger(findings)
    grouped: dict[str, list[JsonObject]] = {}
    for raw_row in cast(list[Any], proposal.get("findings", [])):
        if not isinstance(raw_row, dict):
            fail("findings ledger contains a non-object finding")
        row = cast(JsonObject, raw_row)
        if not is_image_review_row(row):
            continue
        doc_id = row.get("doc_id")
        finding_id = row.get("finding_id")
        if not isinstance(doc_id, str) or not isinstance(finding_id, str):
            fail("image-review finding has an invalid subject")
        grouped.setdefault(doc_id, []).append(row)
    bundles: dict[str, tuple[list[str], str]] = {}
    for doc_id, rows in grouped.items():
        ordered = sorted(rows, key=lambda row: row["finding_id"])
        bundles[doc_id] = (
            [cast(str, row["finding_id"]) for row in ordered],
            canonical_digest(ordered),
        )
    return bundles


def validate_image_confirmation_subjects(
    confirmations: list[JsonObject], findings: JsonObject
) -> None:
    bundles = image_bundles(findings)
    for row in confirmations:
        doc_id = cast(str, row["doc_id"])
        expected = bundles.get(doc_id)
        if expected is None:
            fail(f"image confirmation references non-image document {doc_id!r}")
        expected_ids, expected_digest = expected
        if row["finding_ids"] != expected_ids:
            fail(f"image confirmation for {doc_id!r} has stale finding IDs")
        if row["proposal_digest"] != expected_digest:
            fail(f"image confirmation for {doc_id!r} has a stale proposal digest")


def validate_bindings(
    feedback: JsonObject,
    findings: JsonObject,
    framework: JsonObject,
    manifest: JsonObject,
) -> None:
    raw_frame = framework.get("frame")
    if not isinstance(raw_frame, dict) or raw_frame.get("kind") != "requests":
        fail("framework must have frame.kind 'requests'")
    frame = cast(JsonObject, raw_frame)
    if feedback["framework_version"] != framework.get("framework_version"):
        fail("framework version mismatch")
    if findings.get("framework_version") != framework.get("framework_version"):
        fail("findings and framework versions differ")
    if feedback["framework_digest"] != canonical_digest(framework):
        fail("framework digest mismatch; feedback is stale")
    if feedback["ledger_digest"] != canonical_digest(proposal_ledger(findings)):
        fail("ledger digest mismatch; feedback is stale")
    if feedback["review_plan_id"] != findings.get("review_plan_id"):
        fail("review plan mismatch; feedback is stale")
    if feedback["frame_id"] != frame.get("frame_id"):
        fail("frame ID mismatch; feedback is stale")
    corpus_id = manifest.get("corpus_id")
    if not isinstance(corpus_id, str) or not corpus_id:
        fail("manifest has no corpus_id")
    if frame.get("corpus_id") != corpus_id:
        fail("framework and manifest corpus IDs differ")
    if feedback["corpus_id"] != corpus_id:
        fail("corpus ID mismatch; feedback is stale")


def apply_overlay(
    findings: JsonObject,
    rulings: list[JsonObject],
    image_confirmations: list[JsonObject] | None,
) -> JsonObject:
    output = cast(JsonObject, json.loads(json.dumps(findings)))
    existing_confirmations = output.pop("image_confirmations", None)
    if existing_confirmations is not None:
        if not isinstance(existing_confirmations, list):
            fail("existing image confirmation overlay has invalid shape")
        seen_docs: set[str] = set()
        for index, raw_row in enumerate(cast(list[Any], existing_confirmations)):
            if not isinstance(raw_row, dict) or set(raw_row) != IMAGE_CONFIRMATION_KEYS:
                fail(f"existing image confirmation overlay {index} has invalid shape")
            doc_id = raw_row.get("doc_id")
            if not isinstance(doc_id, str) or not doc_id or doc_id in seen_docs:
                fail(
                    f"existing image confirmation overlay {index} has invalid "
                    "document identity"
                )
            seen_docs.add(doc_id)
            if raw_row.get("confirmation") not in IMAGE_CONFIRMATIONS:
                fail(f"existing image confirmation for {doc_id!r} is invalid")
            if raw_row.get("confirmed_by") != "lawyer":
                fail(
                    f"existing image confirmation for {doc_id!r} is not lawyer-authored"
                )
            if not isinstance(raw_row.get("finding_ids"), list) or not isinstance(
                raw_row.get("note"), str
            ):
                fail(f"existing image confirmation for {doc_id!r} has invalid shape")
        validate_image_confirmation_subjects(
            cast(list[JsonObject], existing_confirmations), findings
        )
    rows = output.get("findings")
    if not isinstance(rows, list):
        fail("findings ledger has no findings array")
    by_id: dict[str, JsonObject] = {}
    for raw_row in cast(list[Any], rows):
        if not isinstance(raw_row, dict):
            fail("findings ledger contains a non-object finding")
        row = cast(JsonObject, raw_row)
        finding_id = row.get("finding_id")
        issue_id = row.get("issue_id")
        doc_id = row.get("doc_id")
        if not all(
            isinstance(value, str) and value for value in (finding_id, issue_id, doc_id)
        ):
            fail("source finding has an invalid subject")
        finding_id = cast(str, finding_id)
        issue_id = cast(str, issue_id)
        doc_id = cast(str, doc_id)
        if finding_id != f"{issue_id}/{doc_id}":
            fail(f"source finding {finding_id!r} has an invalid stable ID")
        if finding_id in by_id:
            fail(f"source findings repeat {finding_id!r}")
        if "lawyer_ruling" in row:
            validate_overlay(row["lawyer_ruling"], finding_id)
            del row["lawyer_ruling"]
        by_id[finding_id] = row
    for ruling in rulings:
        finding_id = ruling["finding_id"]
        row = by_id.get(finding_id)
        if row is None:
            fail(f"feedback references unknown finding {finding_id!r}")
        for key in ("issue_id", "doc_id"):
            if ruling[key] != row.get(key):
                fail(f"feedback subject drifted for {finding_id!r}")
        if ruling["machine_status"] != row.get("status"):
            fail(f"feedback machine status drifted for {finding_id!r}")
        row["lawyer_ruling"] = {
            "note": ruling["note"],
            "ruled_by": "lawyer",
            "ruling": ruling["ruling"],
        }
    if image_confirmations is not None:
        output["image_confirmations"] = image_confirmations
    elif existing_confirmations is not None:
        output["image_confirmations"] = existing_confirmations
    return output


def write_json(path: str, value: JsonObject) -> None:
    output = Path(path)
    output.parent.mkdir(parents=True, exist_ok=True)
    descriptor, temporary = tempfile.mkstemp(
        prefix=f".{output.name}.", suffix=".tmp", dir=output.parent
    )
    try:
        with os.fdopen(descriptor, "w", encoding="utf-8", newline="\n") as handle:
            json.dump(value, handle, indent=2, sort_keys=True)
            handle.write("\n")
        os.replace(temporary, output)
    except Exception:
        try:
            os.unlink(temporary)
        except OSError:
            pass
        raise


def main() -> None:
    parser = argparse.ArgumentParser(description=__doc__.splitlines()[0])
    parser.add_argument("--feedback", required=True)
    parser.add_argument("--findings", required=True)
    parser.add_argument("--framework", required=True)
    parser.add_argument("--manifest", required=True)
    parser.add_argument("--out", required=True)
    args = parser.parse_args()
    paths = [
        Path(value).expanduser().absolute().resolve()
        for value in (
            args.feedback,
            args.findings,
            args.framework,
            args.manifest,
            args.out,
        )
    ]
    if len(set(paths)) != len(paths):
        fail("--out must be a distinct new artifact path; inputs are immutable")
    feedback = load(args.feedback, "feedback")
    findings = load(args.findings, "findings")
    framework = load(args.framework, "framework")
    manifest = load(args.manifest, "manifest")
    rulings, image_confirmations = validate_feedback(feedback)
    validate_bindings(feedback, findings, framework, manifest)
    if image_confirmations is not None:
        validate_image_confirmation_subjects(image_confirmations, findings)
    output = apply_overlay(findings, rulings, image_confirmations)
    try:
        write_json(args.out, output)
    except OSError as error:
        fail(f"cannot write {args.out}: {error}")
    print(
        f"wrote {args.out}: {len(rulings)} lawyer ruling(s), "
        f"{len(image_confirmations or [])} image confirmation(s)"
    )


if __name__ == "__main__":
    main()

SHA-256: 567277aaab998d0a7bf19862d2827bf48cdadee8cba63c9d5cb2514c0cb2a4e1