THE WORLD IS NOT STANDING STILL.RSS
BIG CHANGE.
BIG CHANGE, one tap away
BIG CHANGE, one tap away

Make room for the big picture.

Add BIG CHANGE to your home screen or desktop. It opens in its own window, with the latest stories when you’re online.

iPhone & iPad
Open bigchange.ai in Safari. Tap Share, then Add to Home Screen. Turn on Open as Web App if shown, then tap Add.
Android
Open bigchange.ai in Chrome. In the browser menu, choose Install app or Add to Home screen.
Desktop
In Chrome or Edge, use the install icon in the address bar or the browser’s app menu. In Safari on Mac, choose File → Add to Dock.

Markdown edition

# Research a public topic and make a checked, linked PDF report

> Use live OpenAI web search to draft a four-section report, export a complete cited PDF, correct claims locally and approve its exact bytes after human review.

By BIGCHANGE.AI Editorial

Published: 2026-10-26T21:58:38.398Z
Updated: 2026-10-26T21:58:38.398Z
Canonical: https://bigchange.ai/blog/openai-web-search-checked-linked-pdf-report-guide

![Conceptual charcoal illustration of a reader inspecting a multipage PDF with orange inline links on one monitor.](https://bigchange.ai/api/media/file/research-pdf-link-review-hero-v2.png)
AI-generated conceptual illustration by BIG CHANGE; no actual report, source verification or approval is depicted.

A research PDF is useful only when a reader can follow its claims back to the pages that support them. This workflow takes one specific public topic, asks [OpenAI's Responses API](https://developers.openai.com/api/docs/guides/tools-web-search.md) to run a live web search, and drafts a report with Overview, Evidence, Limits and Actions sections. A local Python script turns the entire draft into a PDF with clickable inline citation markers. You then check the underlying pages, correct errors locally, and approve the exact PDF bytes for sharing. The code passed 26 synthetic offline checks with Python 3.12 and PyMuPDF 2.0; this guide has not run an OpenAI account, paid search or real-topic report.

## The big change

- **What changed:** The searchable draft, full PDF, source links and response record can travel together. A complete search action and document readback are checked before the PDF is accepted as a draft.
- **Why it matters:** Analysts and small teams can hand someone a report whose cited statements link directly to their sources, then keep a separate record of which claims a person actually checked. The human review is the evidence decision; working links alone do not prove accuracy.
- **What to watch:** A bad claim needs a new local PDF and a new review bound to its hash. The correction step keeps the original search record, so reviewers can distinguish edited prose from new research.

## What you need

Use Windows PowerShell **7.3 or newer**, an installed **Python 3.12** with the `py` launcher, an internet connection and an OpenAI API project with access to Responses, `gpt-5.5` and hosted `web_search`. These commands use Python 3.12 explicitly. Check the interpreter and shell in the same window:

```powershell
$PSVersionTable.PSVersion
py --list-paths
py -3.12 --version
```

Choose a specific, permitted **public** topic of at most 183 characters. Avoid private documents, credentials, personal or prospect data and regulated material. The request sets `store:false`, but [OpenAI's data-controls documentation](https://developers.openai.com/api/docs/guides/your-data) describes separate abuse-monitoring retention and account-specific controls. Search can reach third-party pages. Keep the response, draft, PDF and review records in a private working directory. The API key is read from `OPENAI_API_KEY` or requested through hidden terminal input; do not put it in a command, screenshot, report or source file.

The local PDF dependency is pinned to **PyMuPDF 2.0**, the version used in the offline checks. Before installing, check whether [PyMuPDF's AGPL or commercial license](https://pymupdf.readthedocs.io/en/latest/about.html) fits your use. Copy the complete four labelled files below into one private directory, retaining their filenames and indentation. `build_report.py` needs `requirements.txt`; `revise_report.py` imports the builder from the same directory; keep `finalize_review.py` there to run the final approval command. These are the complete reviewed files, with no separate download or private fixture required.

```powershell
py -3.12 -m venv .venv
.\.venv\Scripts\python -m pip install -r requirements.txt
```

## 1. Search and make the draft PDF

From that directory, run the following with your own public topic. The default model is `gpt-5.5`. Use `--model` only after checking that your project can access another model and that it supports the Responses `web_search` tool; a model name alone does not establish compatibility. Review current [model access](https://developers.openai.com/api/docs/models/gpt-5.5) and [pricing](https://developers.openai.com/api/docs/pricing) before the call. At the 26 October 2026 check, standard GPT-5.5 was listed at **$5 per million input tokens and $30 per million output tokens**; web search was **$10 per 1,000 calls plus search-content tokens at the model rate**. One response can make multiple search calls. Actual token use, entitlement and bill are unknown until a real request.

```powershell
.\.venv\Scripts\python build_report.py "One specific public research topic" --out private/report-draft.pdf
```

The script first checks the title locally, before a paid request. It requires a completed response with an actual completed search action, four nonempty ordered sections, valid source URLs and citation spans. It saves the exact returned HTTP body in `private/report-draft.response.json` before decoding, validating or laying out the PDF. It also writes `private/report-draft.draft.txt`, `private/report-draft.receipt.json` and `private/report-draft.human-review-template.json`. The receipt records response ID, model, search actions and provider usage when available; missing usage is `unknown`. No file is silently overwritten.

Open the complete `private/report-draft.pdf`. Read its first and last pages and the pages between them, follow every inline citation and linked URL, and compare its four sections with the saved text. The exporter checks text and inline-link readback before writing; a structural pass does not establish that a source supports a claim. Its [font handling](https://pymupdf.readthedocs.io/en/latest/faq/index.html) has limited Unicode coverage. The checked accented Latin and CJK fixtures survived, while an unsupported emoji was rejected. If a character cannot render faithfully, remove it with editorial care or supply a properly licensed font and rerun the checks. Do not accept a silently substituted glyph.

## 2. Check claims and correct the local draft

Open the cited original pages yourself. Record each material claim, its supporting URL and source date in a **separate copy** of the generated human-review template. Check date, scope, conflicting evidence, quotation and reuse rights, including any chart or image you intend to share. The template's `materialClaims` array needs one entry per material assertion, not one entry per URL. A person must make the factual decision; neither the search result nor the script certifies it.

If the draft contains an error, copy its original text and edit that copy. Keep all four section headings and the existing source-ID set. Add no new source under an old citation number. Then make a **new** PDF without another provider call:

```powershell
Copy-Item private/report-draft.draft.txt private/corrected.txt
notepad private/corrected.txt
.\.venv\Scripts\python revise_report.py --original private/report-draft.pdf --edited private/corrected.txt --out private/report-revised.pdf --editor "Your name" --reason "Corrected the dated claim against source [1]"
```

This checks the original response, text and PDF hashes, preserves their source chain, and produces a fresh PDF with fresh sidecars and a blank review template. Inspect its complete text and links again. The editor name and reason record a change; they are not a factual endorsement. Review and approve the new PDF hash. A wrong original draft, altered response, changed source-ID set or synthetic fixture cannot be promoted through this path. For another correction, start again from the preserved original `private/report-draft.pdf` and original `.draft.txt`, combine every needed edit in a new text file, and choose a fresh revised output basename. The reviser does not accept an already revised PDF as its `--original`. If the correction needs a new source or fresh search, keep the old receipt and decide whether to make a new request; do not present the old response as evidence for it.

## 3. Approve the exact PDF and keep the record

Copy the generated template for the **final** draft into a separate review file. For the original PDF, keep its generated template and make a working copy:

```powershell
Copy-Item private/report-draft.human-review-template.json private/signed-human-review.json
notepad private/signed-human-review.json
```

For a corrected PDF, preserve both the original template and any original review file. Use the **revised** template and a different working name:

```powershell
Copy-Item private/report-revised.human-review-template.json private/signed-revised-human-review.json
notepad private/signed-revised-human-review.json
```

The generated template starts pending, with a PDF hash already bound to that specific draft. Fill in a named reviewer, a real ISO UTC time, every material claim with `claim`, `sourceUrl`, `sourceDate` and `decisionAndCorrection: "verified"`, and set the four review booleans to true only after actually checking claims and dates, permitted reuse and rights, the quotation budget across every public surface, and approval for sharing. This illustrative blank shape must not be used in place of the generated template; its hash field below is only a placeholder:

```json
{
  "reviewedPdfSha256": "PLACEHOLDER_USE_HASH_IN_GENERATED_TEMPLATE",
  "reviewer": "",
  "reviewedAtUtc": "",
  "materialClaims": [
    {
      "claim": "",
      "sourceUrl": "",
      "sourceDate": "",
      "decisionAndCorrection": ""
    }
  ],
  "allClaimsAndDatesChecked": false,
  "rightsAndPermittedUseChecked": false,
  "sourceQuotationBudgetAcrossAllPublicSurfacesChecked": false,
  "approvedForSharing": false
}
```

For the original draft, use:

```powershell
.\.venv\Scripts\python finalize_review.py private/report-draft.pdf --receipt private/report-draft.receipt.json --human-review private/signed-human-review.json --out approved/report.pdf
```

If you corrected it, use the revised PDF and revised receipt instead:

```powershell
.\.venv\Scripts\python finalize_review.py private/report-revised.pdf --receipt private/report-revised.receipt.json --human-review private/signed-revised-human-review.json --out approved/report.pdf
```

The finalizer checks the raw response, draft text and PDF against the receipt and the review's exact PDF hash. It refuses synthetic fixture provenance or an unverified claim. It copies those exact approved bytes to `approved/report.pdf` and writes `approved/report.approval-receipt.json`. The finalizer can check the record's completeness and binding, but cannot know whether the reviewer opened every source or judged it correctly.

If a request fails or times out, inspect the receipt and any saved response before acting. A returned malformed or incomplete response keeps its exact bytes and does not create an approved PDF. A transport error with no response is recorded as `request-outcome-unknown`; usage and billing may also be unknown. The scripts do not retry automatically. Resolve access, model, citation or layout errors against the current documentation, and check account usage before choosing a new paid request. The 26 synthetic offline checks used Python 3.12 and PyMuPDF 2.0, including a 2,300-word eight-page report whose text and links survived readback. They did not test a live account, current model entitlement, a real report or factual accuracy.

## Complete files to copy

Save each labelled block under the stated filename in the same directory. These exact scripts are the implementation reviewed for the workflow above.

### `requirements.txt`

```plaintext
PyMuPDF==2.0
```

### `build_report.py`

```python
"""Actual web search -> complete cited report -> verified draft PDF. No provider call with --fixture."""
import argparse
import hashlib
import html
import json
import os
import getpass
from pathlib import Path
import re
import unicodedata
import urllib.parse
import urllib.request
from datetime import datetime, timezone

import pymupdf

API = "https://api.openai.com/v1/responses"
SECTIONS = ("Overview", "Evidence", "Limits", "Actions")
HEAD = re.compile(r"(?im)^(?:#{1,3}\s*)?(Overview|Evidence|Limits|Actions)\s*$")
MARK = re.compile(r"\[\[BC-CITE-(\d+)\]\]")


class LocalRequestNotSent(ValueError):
    pass


def digest(data):
    return hashlib.sha256(data).hexdigest()


def utc_now():
    return datetime.now(timezone.utc).isoformat().replace("+00:00", "Z")


def write_new(path, data):
    with path.open("xb") as target:
        target.write(data)


def write_owned_receipt(path, value):
    data = (json.dumps(value, indent=2, ensure_ascii=False) + "\n").encode("utf-8")
    temporary = path.with_name(path.name + ".tmp")
    write_new(temporary, data)
    os.replace(temporary, path)


def valid_url(url):
    if not isinstance(url, str) or len(url) > 2048 or any(ord(c) < 32 for c in url):
        return False
    p = urllib.parse.urlsplit(url)
    return p.scheme == "https" and bool(p.hostname) and not p.username and not p.password


def request_report(topic, model):
    key = os.environ.get("OPENAI_API_KEY") or getpass.getpass("OpenAI API key (hidden input): ")
    if not key:
        raise LocalRequestNotSent("A reader-owned OpenAI API key is required for a live run")
    prompt = (
        "Research this permitted, reader-owned topic: " + topic + "\n"
        "Actually search the public web. Return one plain-text report with these four ordered "
        "headings on separate lines: Overview, Evidence, Limits, Actions. Give checkable "
        "citations and dates for material factual claims; mark unknowns and disagreements. "
        "Paraphrase source text; no quotation over 20 words from any source. "
        "No invented people, scenes, results, forecasts or claim of human fact checking. "
        "Keep below 2,500 words."
    )
    payload = {"model": model, "store": False,
               "tools": [{"type": "web_search", "external_web_access": True}],
               "tool_choice": "required", "include": ["web_search_call.action.sources"],
               "input": prompt}
    req = urllib.request.Request(
        API, data=json.dumps(payload).encode("utf-8"), method="POST",
        headers={"Authorization": "Bearer " + key, "Content-Type": "application/json"})
    with urllib.request.urlopen(req, timeout=180) as res:
        return res.read()


def extract_report(response):
    """Return body with link placeholders. Offsets are Unicode code-point indexes.

    The provider manual gives ranges but no explicit non-BMP index convention. Reject
    mid-word/ambiguous spans rather than guessing a UTF-16 or byte conversion.
    """
    if not isinstance(response, dict) or response.get("status") != "completed":
        raise ValueError("Response did not complete")
    if response.get("error") or response.get("incomplete_details"):
        raise ValueError("Response has an error or incomplete details")
    if not isinstance(response.get("id"), str) or not response["id"].startswith("resp_"):
        raise ValueError("Missing response ID")
    if not isinstance(response.get("model"), str) or not response["model"].strip():
        raise ValueError("Missing actual model")
    output = response.get("output")
    if not isinstance(output, list):
        raise ValueError("Missing output")
    searches = []
    for item in output:
        if not isinstance(item, dict):
            raise ValueError("Malformed output item")
        if item.get("type") != "web_search_call":
            continue
        if item.get("status") != "completed":
            raise ValueError("Failed or incomplete web action")
        action = item.get("action") or {}
        if action.get("type") == "search":
            qs = action.get("queries") or ([action["query"]] if isinstance(action.get("query"), str) else [])
            if not isinstance(qs, list) or not qs or not all(isinstance(q, str) and 0 < len(q.strip()) <= 500 for q in qs):
                raise ValueError("Completed search lacks an actual bounded query")
            searches.append(item)
    if not searches:
        raise ValueError("No completed search action")
    messages = [x for x in output if x.get("type") == "message"]
    if len(messages) != 1 or messages[0].get("status") != "completed" or messages[0].get("role") != "assistant":
        raise ValueError("Expected one completed assistant message")
    content = messages[0].get("content")
    if not isinstance(content, list) or any(x.get("type") == "refusal" for x in content):
        raise ValueError("Refusal or malformed message")
    blocks = [x for x in content if x.get("type") == "output_text"]
    if len(blocks) != 1:
        raise ValueError("Expected one complete text block")
    block = blocks[0]
    text = block.get("text")
    if not isinstance(text, str) or not text.strip() or len(text) > 60000 or len(text.split()) > 3000:
        raise ValueError("Report missing or outside bounds")
    if MARK.search(text):
        raise ValueError("Reserved citation marker collision")
    heads = list(HEAD.finditer(text))
    if len(heads) != 4 or tuple(h.group(1).title() for h in heads) != SECTIONS:
        raise ValueError("Report lacks four ordered sections")
    if text[:heads[0].start()].strip() or re.search(r"(?im)^Sources\s*$", text):
        raise ValueError("Unexpected text before report or reserved Sources heading")
    for i, h in enumerate(heads):
        value = text[h.end():heads[i + 1].start() if i < 3 else len(text)].strip()
        if len(value) < 12 or len(value.split()) < 3:
            raise ValueError("Empty section: " + h.group(1))
    annotations = block.get("annotations")
    if not isinstance(annotations, list):
        raise ValueError("Missing citations")
    spans = []
    for a in annotations:
        if not isinstance(a, dict) or a.get("type") != "url_citation":
            continue
        start, end, url = a.get("start_index"), a.get("end_index"), a.get("url")
        if type(start) is not int or type(end) is not int or not (0 <= start < end <= len(text)):
            raise ValueError("Invalid citation code-point offsets")
        if not valid_url(url) or not isinstance(a.get("title"), str) or not a["title"].strip():
            raise ValueError("Invalid cited URL or title")
        if start and text[start - 1].isalnum() and text[start].isalnum():
            raise ValueError("Citation begins inside a word")
        if end < len(text) and text[end - 1].isalnum() and text[end].isalnum():
            raise ValueError("Citation ends inside a word")
        if any(start < h.end() and end > h.start() for h in heads):
            raise ValueError("Citation overlaps heading")
        spans.append((start, end, url))
    spans.sort()
    if any(spans[i][0] < spans[i - 1][1] for i in range(1, len(spans))):
        raise ValueError("Overlapping or duplicate citations")
    urls = list(dict.fromkeys(s[2] for s in spans))
    if len(urls) < 2:
        raise ValueError("At least two distinct cited URLs are required")
    consulted = {s.get("url") for item in output if item.get("type") == "web_search_call"
                 for s in ((item.get("action") or {}).get("sources") or []) if isinstance(s, dict)}
    if not consulted or any(url not in consulted for url in urls):
        raise ValueError("Cited URL absent from actual search source list")
    rendered = text
    for start, end, url in reversed(spans):
        original = text[start:end]
        marker = f"[[BC-CITE-{urls.index(url) + 1}]]"
        token = original.startswith(("cite", "【")) or bool(re.fullmatch(r"\[[^]]+\]\(https://[^)]+\)", original))
        rendered = rendered[:start] + (marker if token else original + " " + marker) + rendered[end:]
    if "cite" in rendered or "【turn" in rendered:
        raise ValueError("Unmapped provider citation token")
    return rendered, urls


def source_receipt(response, urls):
    calls = []
    for item in response["output"]:
        if item.get("type") == "web_search_call":
            a = item.get("action") or {}
            calls.append({"id": item.get("id"), "status": item.get("status"),
                          "actionType": a.get("type"), "query": a.get("query"),
                          "queries": a.get("queries"), "sourceCount": len(a.get("sources") or [])})
    return {"responseId": response["id"], "actualModel": response["model"],
            "status": response["status"], "webActions": calls, "citedUrls": urls,
            "usage": response.get("usage") if isinstance(response.get("usage"), dict) else "unknown"}


def normalized(value):
    value = MARK.sub(lambda m: f"[{m.group(1)}]", value)
    return "".join(unicodedata.normalize("NFKC", value).replace("\u00ad", "").split())


def html_fragment(value, urls, heading=False, source=False):
    if source:
        number, url = value.split(" ", 1)
        text = f'{html.escape(number)} <a href="{html.escape(url, quote=True)}">{html.escape(url)}</a>'
    else:
        text = html.escape(value)
        def anchor(m):
            i = int(m.group(1))
            if not 1 <= i <= len(urls):
                raise ValueError("Citation source missing")
            return f'<a href="{html.escape(urls[i-1], quote=True)}">[{i}]</a>'
        text = MARK.sub(anchor, text)
    return f'<div style="font-family:sans-serif;font-size:{15 if heading else 10}pt;line-height:1.3">{text}</div>'


def report_parts(body, urls):
    parts = []
    heads = list(HEAD.finditer(body))
    for i, head in enumerate(heads):
        parts.append((head.group(0).strip(), True, False))
        section = body[head.end():heads[i + 1].start() if i + 1 < len(heads) else len(body)]
        for paragraph in re.split(r"\n\s*\n", section):
            paragraph = paragraph.strip()
            if paragraph:
                parts.append((paragraph, False, False))
    parts.append(("Sources", True, False))
    parts.extend((f"[{i}] {url}", False, True) for i, url in enumerate(urls, 1))
    return parts


def export_pdf(title, body, urls, path):
    if path.exists():
        raise FileExistsError("Choose a new output path")
    if not isinstance(title, str) or not title.strip() or len(title) > 200:
        raise ValueError("Invalid title")
    doc = pymupdf.open()
    page = doc.new_page(width=595, height=842)
    y = 46.0
    source_start = None
    expected = [title]

    def attempt(value, heading=False, source=False):
        nonlocal page, y, source_start
        fragment = html_fragment(value, urls, heading, source)
        for fresh in (False, True):
            top = 46.0 if fresh else y
            rect = pymupdf.Rect(46, top, 549, 792)
            trial = pymupdf.open()
            trial_page = trial.new_page(width=595, height=842)
            spare, _ = trial_page.insert_htmlbox(rect, fragment, scale_low=1)
            trial.close()
            if spare < 0:
                continue
            if fresh:
                page = doc.new_page(width=595, height=842)
                y = 46.0
            spare, _ = page.insert_htmlbox(pymupdf.Rect(46, y, 549, 792), fragment, scale_low=1)
            if spare < 0:
                raise ValueError("PDF placement changed after dry run")
            if value == "Sources":
                source_start = (doc.page_count - 1, y)
            y = 792 - spare + 10
            return True
        return False

    def place(value, heading=False, source=False):
        if attempt(value, heading, source):
            return
        if heading or source:
            raise ValueError("Heading or source URL will not fit")
        words = value.split()
        if len(words) < 2:
            raise ValueError("Unbreakable paragraph will not fit")
        mid = len(words) // 2
        place(" ".join(words[:mid]))
        place(" ".join(words[mid:]))

    place(title, True)
    for value, heading, source in report_parts(body, urls):
        expected.append(value)
        place(value, heading, source)
    data = doc.tobytes(garbage=3, deflate=True)
    doc.close()
    verify = pymupdf.open(stream=data, filetype="pdf")
    actual = "\n".join(p.get_text() for p in verify)
    if normalized(actual) != normalized("\n".join(expected)):
        verify.close()
        raise ValueError("PDF readback differs from complete report and source text")
    if source_start is None:
        verify.close()
        raise ValueError("Sources heading missing")
    for i, url in enumerate(urls, 1):
        found = False
        for page_no, p in enumerate(verify):
            if page_no > source_start[0]:
                break
            links = [x for x in p.get_links() if x.get("uri") == url]
            for w in p.get_text("words"):
                box = pymupdf.Rect(w[:4])
                if w[4] != f"[{i}]" or (page_no == source_start[0] and box.y0 >= source_start[1]):
                    continue
                if any(box.intersects(pymupdf.Rect(link["from"])) for link in links):
                    found = True
                    break
            if found:
                break
        if not found:
            verify.close()
            raise ValueError(f"Inline citation [{i}] is not clickable")
    count = verify.page_count
    verify.close()
    with path.open("xb") as out:
        out.write(data)
    return {"pages": count, "sha256": digest(data), "bytes": len(data), "inlineLinkedSources": len(urls)}


def build(topic, out, model="gpt-5.5", fixture=None):
    title = "Research report: " + topic
    if not topic.strip() or len(title) > 200:
        raise ValueError("Topic must be nonempty and at most 183 characters with the required title prefix")
    if not isinstance(model, str) or not model.strip():
        raise ValueError("Model must be nonempty")
    trial = pymupdf.open()
    trial_page = trial.new_page(width=595, height=842)
    spare, _ = trial_page.insert_htmlbox(pymupdf.Rect(46, 46, 549, 792),
                                        html_fragment(title, [], heading=True), scale_low=1)
    trial.close()
    if spare < 0:
        raise ValueError("Title cannot fit the PDF page; shorten or add word breaks before a provider call")
    out.parent.mkdir(parents=True, exist_ok=True)
    side = {k: out.with_suffix(ext) for k, ext in {
        "response": ".response.json", "text": ".draft.txt",
        "receipt": ".receipt.json", "human": ".human-review-template.json"}.items()}
    if len({out, *side.values()}) != 5 or any(x.exists() for x in (out, *side.values())):
        raise FileExistsError("Choose a new output basename; evidence files cannot be overwritten")
    try:
        received = fixture.read_bytes() if fixture else request_report(topic, model)
    except Exception as exc:
        # After a transport error, whether the provider accepted or billed the request is unknown.
        status = ("local-fixture-read-failed" if fixture else
                  "local-request-not-sent" if isinstance(exc, LocalRequestNotSent) else
                  "request-outcome-unknown")
        write_new(side["receipt"], (json.dumps({"state": status, "topic": topic,
                  "draftPdf": str(out), "fixtureOnly": bool(fixture), "recordedAtUtc": utc_now(),
                  "errorType": type(exc).__name__}, indent=2) + "\n").encode("utf-8"))
        raise
    raw = received if isinstance(received, bytes) else (
        json.dumps(received, indent=2, ensure_ascii=False) + "\n").encode("utf-8")
    write_new(side["response"], raw)
    try:
        response = json.loads(raw.decode("utf-8-sig"))
    except (UnicodeDecodeError, json.JSONDecodeError) as exc:
        write_new(side["receipt"], (json.dumps({"state": "returned-invalid-json", "topic": topic,
                  "draftPdf": str(out), "fixtureOnly": bool(fixture), "recordedAtUtc": utc_now(),
                  "rawResponseSha256": digest(raw), "errorType": type(exc).__name__}, indent=2) + "\n").encode("utf-8"))
        raise
    receipt = {"state": "returned-unvalidated", "topic": topic, "draftPdf": str(out),
               "rawResponseSha256": digest(raw), "fixtureOnly": bool(fixture),
               "recordedAtUtc": utc_now(),
               "responseId": response.get("id") if isinstance(response, dict) else None,
               "actualModel": response.get("model") if isinstance(response, dict) else None,
               "providerStatus": response.get("status") if isinstance(response, dict) else None,
               "usage": response.get("usage", "unknown") if isinstance(response, dict) else "unknown"}
    write_new(side["receipt"], (json.dumps(receipt, indent=2, ensure_ascii=False) + "\n").encode("utf-8"))
    try:
        body, urls = extract_report(response)
        draft = (MARK.sub(lambda m: f"[{m.group(1)}]", body) + "\n").encode("utf-8")
        write_new(side["text"], draft)
        receipt.update(source_receipt(response, urls))
        receipt["draftTextSha256"] = digest(draft)
        receipt["state"] = "validated-awaiting-export"
        write_owned_receipt(side["receipt"], receipt)
        stats = export_pdf(title, body, urls, out)
        receipt.update({"state": "draft-ready", "pdfSha256": stats["sha256"], "verification": stats})
        write_owned_receipt(side["receipt"], receipt)
    except Exception as exc:
        receipt.update({"state": "validation-or-export-failed", "errorType": type(exc).__name__,
                        "error": str(exc)[:500]})
        write_owned_receipt(side["receipt"], receipt)
        raise
    human = {"reviewedPdfSha256": stats["sha256"], "reviewer": "", "reviewedAtUtc": "",
             "materialClaims": [{"claim": "", "sourceUrl": "", "sourceDate": "", "decisionAndCorrection": ""}],
             "allClaimsAndDatesChecked": False, "rightsAndPermittedUseChecked": False,
             "sourceQuotationBudgetAcrossAllPublicSurfacesChecked": False, "approvedForSharing": False}
    write_new(side["human"], (json.dumps(human, indent=2, ensure_ascii=False) + "\n").encode("utf-8"))
    print(json.dumps({"draftPdf": str(out), "reviewTemplate": str(side["human"]), **stats}))
    print("Open the complete draft, verify all material claims/sources/dates/rights, and record human approval before sharing.")


def main():
    p = argparse.ArgumentParser()
    p.add_argument("topic", help="Specific permitted reader-owned topic, up to 183 characters")
    p.add_argument("--out", type=Path, required=True, help="New private draft PDF")
    p.add_argument("--model", default="gpt-5.5", help="Accessible model supporting Responses web_search")
    p.add_argument("--fixture", type=Path, help="Offline response JSON: no model call")
    a = p.parse_args()
    build(a.topic, a.out, a.model, a.fixture)


if __name__ == "__main__":
    main()
```

### `revise_report.py`

```python
"""Revise a preserved report locally; never calls a provider or alters the first draft."""
import argparse
import json
from pathlib import Path
import re

from build_report import (MARK, HEAD, SECTIONS, digest, extract_report, export_pdf,
                          utc_now, write_new)

CITE = re.compile(r"\[(\d+)\]")


def checked_edited(text, urls):
    if not isinstance(text, str) or len(text) > 60000 or len(text.split()) > 3000:
        raise ValueError("Edited report outside size bounds")
    if "[[BC-CITE-" in text or "cite" in text or "【turn" in text:
        raise ValueError("Provider or internal citation token in edited report")
    heads = list(HEAD.finditer(text))
    if len(heads) != 4 or tuple(h.group(1).title() for h in heads) != SECTIONS:
        raise ValueError("Edited report requires four ordered sections")
    if text[:heads[0].start()].strip() or re.search(r"(?im)^Sources\s*$", text):
        raise ValueError("Unexpected report preface or Sources heading")
    for i, head in enumerate(heads):
        section = text[head.end():heads[i + 1].start() if i < 3 else len(text)].strip()
        if len(section) < 12 or len(section.split()) < 3:
            raise ValueError("Edited section is empty")
    numbers = [int(n) for n in CITE.findall(text)]
    if set(numbers) != set(range(1, len(urls) + 1)):
        raise ValueError("Every original source must retain at least one numbered inline citation; no new source IDs")
    for token in re.findall(r"\[[^\]]*\]", text):
        if not CITE.fullmatch(token):
            raise ValueError("Unrecognized bracketed citation in edited report")
    return CITE.sub(lambda m: f"[[BC-CITE-{m.group(1)}]]", text.rstrip("\n"))


def revise(original_pdf, edited_path, out, editor, reason):
    if not editor.strip() or not reason.strip():
        raise ValueError("Editor and factual correction reason are required")
    original = {k: original_pdf.with_suffix(ext) for k, ext in {
        "response": ".response.json", "text": ".draft.txt", "receipt": ".receipt.json"}.items()}
    dest = {k: out.with_suffix(ext) for k, ext in {
        "response": ".response.json", "text": ".draft.txt", "receipt": ".receipt.json",
        "human": ".human-review-template.json"}.items()}
    if any(p.exists() for p in (out, *dest.values())):
        raise FileExistsError("Use a new output basename for each revision")
    raw = original["response"].read_bytes()
    receipt_bytes = original["receipt"].read_bytes()
    prior = json.loads(receipt_bytes)
    if prior.get("rawResponseSha256") != digest(raw) or type(prior.get("fixtureOnly")) is not bool:
        raise ValueError("Original response provenance or fixture status differs from receipt")
    response = json.loads(raw)
    body, urls = extract_report(response)
    original_draft = (MARK.sub(lambda m: f"[{m.group(1)}]", body) + "\n").encode("utf-8")
    if prior.get("draftTextSha256") != digest(original_draft) or original["text"].read_bytes() != original_draft:
        raise ValueError("Original draft revision differs from returned response or receipt")
    if prior.get("responseId") != response["id"] or prior.get("actualModel") != response["model"]:
        raise ValueError("Original response identity differs from receipt")
    if prior.get("usage") != response.get("usage", "unknown") or prior.get("citedUrls") != urls:
        raise ValueError("Original usage or cited source URLs differ from receipt")
    if prior.get("state") != "draft-ready" or prior.get("pdfSha256") != digest(original_pdf.read_bytes()):
        raise ValueError("Original PDF/receipt revision is not the validated draft")
    text = edited_path.read_text(encoding="utf-8")
    if text.encode("utf-8") == original_draft:
        raise ValueError("Edited draft is unchanged")
    rendered = checked_edited(text, urls)
    title = "Research report: " + prior["topic"]
    if len(title) > 200:
        raise ValueError("Original title outside PDF bounds")
    stats = export_pdf(title, rendered, urls, out)
    # All writes after exact source, revision and PDF checks; no provider call occurs.
    write_new(dest["response"], raw)
    draft = (text.rstrip("\n") + "\n").encode("utf-8")
    write_new(dest["text"], draft)
    next_receipt = {**prior, "state": "draft-ready", "draftPdf": str(out),
                    "pdfSha256": stats["sha256"], "verification": stats,
                    "draftTextSha256": digest(draft), "recoveryOf": {
                        "originalPdfSha256": prior["pdfSha256"],
                        "originalReceiptSha256": digest(receipt_bytes),
                        "originalDraftTextSha256": digest(original_draft)},
                    "humanEdit": {"editor": editor, "reason": reason,
                                  "editedAtUtc": utc_now(), "editedInputSha256": digest(edited_path.read_bytes())}}
    write_new(dest["receipt"], (json.dumps(next_receipt, indent=2, ensure_ascii=False) + "\n").encode("utf-8"))
    human = {"reviewedPdfSha256": stats["sha256"], "reviewer": "", "reviewedAtUtc": "",
             "materialClaims": [{"claim": "", "sourceUrl": "", "sourceDate": "", "decisionAndCorrection": ""}],
             "allClaimsAndDatesChecked": False, "rightsAndPermittedUseChecked": False,
             "sourceQuotationBudgetAcrossAllPublicSurfacesChecked": False, "approvedForSharing": False}
    write_new(dest["human"], (json.dumps(human, indent=2, ensure_ascii=False) + "\n").encode("utf-8"))
    return next_receipt


def main():
    p = argparse.ArgumentParser()
    p.add_argument("--original", required=True, type=Path, help="Original preserved private PDF")
    p.add_argument("--edited", required=True, type=Path, help="Corrected copy of original .draft.txt")
    p.add_argument("--out", required=True, type=Path, help="New private revised PDF basename")
    p.add_argument("--editor", required=True)
    p.add_argument("--reason", required=True)
    a = p.parse_args()
    result = revise(a.original, a.edited, a.out, a.editor, a.reason)
    print(json.dumps({"revisedDraftPdf": str(a.out), "pdfSha256": result["pdfSha256"],
                      "humanReviewTemplate": str(a.out.with_suffix(".human-review-template.json"))}))


if __name__ == "__main__":
    main()
```

### `finalize_review.py`

```python
"""Copy a verified draft PDF only after a separate human review record is complete."""
import argparse
import hashlib
import json
from pathlib import Path
from datetime import datetime, timezone, timedelta
import shutil


def digest(path):
    return hashlib.sha256(path.read_bytes()).hexdigest()


def validate(draft, receipt, review):
    if receipt.get("state") != "draft-ready":
        raise ValueError("Only a complete, verified draft can be approved")
    if receipt.get("fixtureOnly") is not False:
        raise ValueError("Synthetic fixture PDFs cannot be approved for sharing")
    if receipt.get("pdfSha256") != digest(draft) or review.get("reviewedPdfSha256") != receipt["pdfSha256"]:
        raise ValueError("PDF bytes changed after research or human review")
    if not isinstance(review.get("reviewer"), str) or not review["reviewer"].strip():
        raise ValueError("Named human reviewer missing")
    try:
        moment = datetime.fromisoformat(review["reviewedAtUtc"].replace("Z", "+00:00"))
        if moment.tzinfo is None or moment > datetime.now(timezone.utc) + timedelta(minutes=5):
            raise ValueError("Review time must include a valid timezone and cannot be future")
    except (KeyError, AttributeError, ValueError) as exc:
        raise ValueError("Review time must be a real ISO timestamp") from exc
    for name in ("allClaimsAndDatesChecked", "rightsAndPermittedUseChecked",
                 "sourceQuotationBudgetAcrossAllPublicSurfacesChecked", "approvedForSharing"):
        if review.get(name) is not True:
            raise ValueError("Human review is incomplete: " + name)
    claims = review.get("materialClaims")
    if not isinstance(claims, list) or not claims:
        raise ValueError("No claim-by-claim review record")
    cited = set(receipt.get("citedUrls") or [])
    for i, claim in enumerate(claims, 1):
        if not isinstance(claim, dict) or not all(isinstance(claim.get(k), str) and claim[k].strip()
                                                for k in ("claim", "sourceUrl", "sourceDate", "decisionAndCorrection")):
            raise ValueError(f"Claim {i} lacks source/date/decision")
        if claim["sourceUrl"] not in cited:
            raise ValueError(f"Claim {i} cites a URL absent from this PDF")
        if claim["decisionAndCorrection"].strip().lower() != "verified":
            raise ValueError(f"Claim {i} needs a corrected draft and new review before approval")
    return {"draftSha256": receipt["pdfSha256"], "reviewSha256": None, "claimsReviewed": len(claims)}


def main():
    p = argparse.ArgumentParser()
    p.add_argument("draft", type=Path)
    p.add_argument("--receipt", required=True, type=Path)
    p.add_argument("--human-review", required=True, type=Path)
    p.add_argument("--out", required=True, type=Path, help="New approved PDF path")
    a = p.parse_args()
    if a.out.exists() or a.out.with_suffix(".approval-receipt.json").exists():
        raise FileExistsError("Choose a new approved output basename")
    receipt = json.loads(a.receipt.read_text(encoding="utf-8"))
    review = json.loads(a.human_review.read_text(encoding="utf-8"))
    response_path = a.draft.with_suffix(".response.json")
    draft_text_path = a.draft.with_suffix(".draft.txt")
    if (receipt.get("draftPdf") != str(a.draft)
            or receipt.get("rawResponseSha256") != digest(response_path)
            or receipt.get("draftTextSha256") != digest(draft_text_path)):
        raise ValueError("Draft response, text, PDF path or provenance differs from receipt")
    result = validate(a.draft, receipt, review)
    result["reviewSha256"] = digest(a.human_review)
    result["reviewer"] = review["reviewer"]
    result["reviewedAtUtc"] = review["reviewedAtUtc"]
    a.out.parent.mkdir(parents=True, exist_ok=True)
    with a.draft.open("rb") as source, a.out.open("xb") as target:
        shutil.copyfileobj(source, target)
    if digest(a.out) != result["draftSha256"]:
        a.out.unlink()
        raise ValueError("Approved copy differs from reviewed PDF")
    a.out.with_suffix(".approval-receipt.json").write_text(json.dumps(result, indent=2) + "\n", encoding="utf-8")
    print(json.dumps({"approvedPdf": str(a.out), **result}))


if __name__ == "__main__":
    main()
```

## Sources

- [OpenAI: Web search guide (checked October 26, 2026)](https://developers.openai.com/api/docs/guides/tools-web-search.md) — Documents native Responses web_search, action distinction, source inclusion and visible clickable citations; source links alone do not prove claims.
- [OpenAI: GPT-5.5 model (checked October 26, 2026)](https://developers.openai.com/api/docs/models/gpt-5.5) — Supports dated model access and tool compatibility; actual project entitlement was not tested.
- [OpenAI: API pricing (checked October 26, 2026)](https://developers.openai.com/api/docs/pricing) — Supports dated standard GPT-5.5 token and web-search call prices; real usage and bill are unknown.
- [OpenAI: API data controls (checked October 26, 2026)](https://developers.openai.com/api/docs/guides/your-data) — Explains store:false and separate abuse-monitoring retention.
- [PyMuPDF: License](https://pymupdf.readthedocs.io/en/latest/about.html) — Explains AGPL or commercial licensing for intended use.
- [PyMuPDF: Font FAQ](https://pymupdf.readthedocs.io/en/latest/faq/index.html) — Documents font coverage and fallback limits; installed fixtures checked selected Unicode behavior.
- [PyMuPDF: Page.insert_htmlbox](https://pymupdf.readthedocs.io/en/latest/page.html#Page.insert_htmlbox) — Documents layout/overflow return semantics used by the complete-text gate.