Build an Evidence Pack for an AI Answer

November 12, 2026

Build an Evidence Pack for an AI Answer

Turn an AI answer and its citations into one PDF that a reviewer, an auditor or a customer can check without any other tools.

Highlights in the source PDF work well when one person checks an answer on screen. Audits, compliance reviews and client deliverables need more: a single file that stands on its own, shows the evidence for every claim, flags anything that could not be verified, and records exactly which document was used.

In this article we build that file with PyMuPDF: an evidence pack. It starts from the located citations of the first article in this series and needs no other libraries.

What goes in the pack

  • A cover with the question, how many citations were located, each source file with its SHA-256 fingerprint, and the time the pack was created.
  • One entry per claim: the claim, the quoted text, its status, an image of the evidence with the surrounding lines, and the source page.
  • Claims whose citation could not be found, marked for review instead of left out.
  • The cited source pages at the end, with the evidence highlighted. Clicking an evidence image jumps to its page.

Starting point

We need the located citations from the first article: for each claim, the cited text, the document, the page and the quads. If you use the PyMuPDF grounding skill, ground_citations() and snippet() are in its helper script.

import pymupdf
from grounding import ground_citations      # from the the grounding skill

path = "acme_report.pdf"
doc = pymupdf.open(path)
response = client.messages.create(...)      # Claude with citations enabled
results = ground_citations(response, [doc])

The code

pack.py:

import hashlib
import html
from datetime import datetime, timezone

import pymupdf

from grounding import snippet, highlight

def build_evidence_pack(question, results, docs, paths, out_path):
    """Write one PDF that shows the evidence for every claim in an answer.

    results: located citations, as returned by ground_citations()
    docs, paths: the source documents and their file paths, in the same order
    """
    pack = pymupdf.open()
    width, height, margin = 595, 842, 50                  # A4 in points
    page, y = None, height

    def new_page():
        nonlocal page, y
        page, y = pack.new_page(width=width, height=height), margin

    def write(html_text, box_height=400):
        """Write HTML at the cursor and move the cursor below it."""
        nonlocal y
        rect = pymupdf.Rect(margin, y, width - margin, y + box_height)
        spare, _ = page.insert_htmlbox(rect, html_text)
        y += box_height - spare + 6

    # Cover: question, sources with fingerprints, time of creation
    new_page()
    located = sum(1 for r in results if r["quads"])
    write(f"<h2>Evidence pack</h2><p><b>Question:</b> {html.escape(question)}</p>"
          f"<p>{located} of {len(results)} citations located in the source.</p>")
    for path in paths:
        digest = hashlib.sha256(open(path, "rb").read()).hexdigest()
        write(f"<p style='font-size:9px'>Source: {html.escape(path)}<br>SHA-256: <span style='font-family:monospace'>{digest}</span></p>")
    created = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M UTC")
    write(f"<p style='font-size:9px'>Created {created}</p>")

    # Render evidence images before highlighting, then add the highlighted source pages
    images = [snippet(docs, r) if r["quads"] else None for r in results]
    highlight(docs, results)
    appended = {}                                          # (doc, page) -> page in the pack

    # One entry per claim
    links = []
    for n, (r, pix) in enumerate(zip(results, images), start=1):
        needed = 120 + (pix.height * 72 / 150 if pix else 0)
        if y + needed > height - margin:
            new_page()
        found = r["quads"] != []
        status = "Found" if found else "<span style='color:#c00'>NOT FOUND, review needed</span>"
        write(f"<p><b>Claim {n}.</b> {html.escape(r['claim'])}<br>"
              f"<span style='font-size:9px'>Cited text: “{html.escape(r['cited_text'].strip())}”<br>"
              f"Status: {status}</span></p>")
        if found:
            scale = min(1, (width - 2 * margin) / (pix.width * 72 / 150))
            w, h = pix.width * 72 / 150 * scale, pix.height * 72 / 150 * scale
            img_rect = pymupdf.Rect(margin, y, margin + w, y + h)
            page.insert_image(img_rect, pixmap=pix)
            y += h + 4
            ref = f"{paths[r['doc']]}, page {r['page'] + 1}"
            write(f"<p style='font-size:9px'>{html.escape(ref)} (click the image to open the highlighted page)</p>", 30)
            links.append((page.number, img_rect, (r["doc"], r["page"])))
        y += 8

    # Append each cited source page once, with its highlights, and link to it
    for doc_index, pno in sorted({key for _, _, key in links}):
        pack.insert_pdf(docs[doc_index], from_page=pno, to_page=pno)
        appended[(doc_index, pno)] = pack.page_count - 1
    for page_number, rect, key in links:
        pack[page_number].insert_link({"kind": pymupdf.LINK_GOTO, "from": rect,
                                       "page": appended[key], "to": pymupdf.Point(0, 0)})

    pack.save(out_path, garbage=3, deflate=True)
    return out_path

How it works

  • Text layout. Page.insert_htmlbox() writes simple HTML into a rectangle and returns the unused height, so the write() helper can move a cursor down the page. A new page starts when the next entry would not fit.
  • Fingerprints. The SHA-256 of each source file ties the pack to the exact version of the document. The hash is set in a monospace font: in a proportional font, letter pairs such as "ff" can be joined into ligatures, and the hash would no longer copy correctly.
  • Evidence images. snippet() renders each cited region with a temporary highlight and the surrounding lines, and leaves the document unchanged.
  • Status. A citation that was not found is listed in red as "NOT FOUND, review needed". An evidence pack that silently drops unverified claims would defeat its purpose.
  • Source pages and links. After the images are rendered, highlight() adds highlight annotations to the documents in memory. Document.insert_pdf() copies each cited page into the pack once, highlights included, and Page.insert_link() turns each evidence image into a link to that page.

Build the pack

from pack import build_evidence_pack

build_evidence_pack(
    "How did Acme perform in 2025, and what are the risks?",
    results, [doc], [path], "evidence_pack.pdf",
)
Page 1 of the evidence pack, with the cover and the first two claims
Page 2 of the evidence pack, with a located claim and a claim marked NOT FOUND

The sample answer has four claims. Three were located; the fourth, "Revenue in Asia doubled", cites text that is not in the report, so the pack flags it for review.

Variations

  • Several documents. Pass all documents and paths in the order they were sent to the model. The cover lists each source with its own fingerprint.
  • Tables and figures. Use snippet(docs, r, full_width=True) when the content next to a passage gives it meaning.
  • The full answer. Add the model's complete answer to the cover, so the pack records what the user saw.

Limits to know

  • highlight() adds annotations to the source documents in memory. Do not save those documents over the originals.
  • The timestamp comes from the machine that builds the pack. It is a record, not a trusted timestamp.
  • The pack is only as good as the citations: a claim without any citation does not appear in it. A later article in this series covers catching uncited and hallucinated citations.

Why this matters

For regulated work, "the AI said so" is not an answer. An evidence pack turns each AI answer into a record that can be filed, reviewed and checked months later: what was asked, which document version was used, what supports each claim, and what could not be verified. It is built entirely with PyMuPDF, runs on your own infrastructure, and needs no extra model.

Next steps


Discuss This Article with the Community

Have a question, a different approach, or something you built after reading this? Share it on the forum or join the Discord, we'd love to hear from you.

PyMuPDF Logo

© 2026 Artifex Software Inc. All rights reserved.