Extract structured information from a set of construction-drawing PDFs using Claude vision. Each PDF page is treated as one sheet; every sheet is rendered to an overview image plus a grid chosen for that page's physical size — and its vector text layer is extracted and sent verbatim alongside the images — to Claude Opus 5.5 in a single vision request, which returns a structured text digest of the sheet (sheet number, discipline, equipment, tags, notes, schedules, etc.). An optional cross-sheet synthesis pass reconciles tags and conflicts across the set, and an optional per-run focus (anything you particularly want pulled out — see below) adds a dedicated Focus Report on top of the standard output.
The output comes two ways: a self-contained HTML report (report.html) — one
portable file with a sidebar table of contents, full-text search, and category
filters so you can isolate, say, just the coordination items or the conflicts the
model flagged across the whole set — and the underlying plain Markdown, for
reading, diffing, or feeding to anything downstream. The HTML is a lossless
re-presentation of the same content (it even embeds the verbatim Markdown), so
nothing the model returned is lost.
The HTML report also includes an Ask AI assistant (bottom-right button): a chat
grounded in the report's own text, so you can ask things like "what are the biggest
conflicts?" or "which sheets mention VAV-3?" right inside the file. It streams
answers, shows its reasoning, and can search / read the web (codes, standards,
product data) — all by calling the Anthropic API directly from your browser;
there is no server. The assistant is present by default, whether or not the
report was built with a key. By default the report does not contain your API
key: the assistant asks for one the first time you use it and keeps it only in
that browser tab (sessionStorage), so the file is safe to share and the key never
touches disk (a Forget key control clears it). If you'd rather have a
zero-friction, double-click-and-ask file, tick Embed API key in HTML report
(GUI) or pass embed_api_key=True — the key is then baked into the HTML, the report
shows a red "don't share this file" warning, and you should treat the file like a
credential (a runtime "forget" cannot remove an embedded key — only regenerating or
deleting the file can).
An embedded key is a default, not a lock. The key field is present in both
modes, and a key the reader saves takes precedence over the embedded one for
their browser tab — so a report that got shared anyway does not bill every
question to whoever generated it, and a report whose embedded key has since been
rotated stays usable (a 401 on the embedded key points the reader at the field
instead of telling them to regenerate a report they cannot regenerate). Use my
own key opens the field, Use the report's key hands it back, and Forget
key clears the reader's key while saying plainly that the file's own credential
is still in the file. Pass include_chat=False to omit the assistant entirely.
Organizations that block browser calls. Some organizations cannot call the
Anthropic API directly from a browser — for example those with zero data
retention or the Compliance API. Every request from the assistant then returns
a 401 whose message begins "CORS requests are not allowed for this
Organization". That is not a bad key, and no other key will get past it, so
the assistant says so, keeps the key you entered, and does not reopen the
key field. The rest of the report works normally; only the chat needs a key
from an organization that allows browser calls. Likewise, a 429 caused by the
organization's monthly spend cap is reported as the spend cap, not as a
rate limit to wait out.
The chat model defaults to Sonnet 5.5, which supports the web-search and
web-fetch server tools at half Opus 5.5's per-token price (the reader's key pays
for it), and can be overridden with DRAWING_ANALYZER_CHAT_MODEL. It runs at a
fixed high reasoning effort: there is deliberately no per-question "deep"
switch, because asking a reader to predict, before seeing the answer, whether
their question deserves more reasoning is a question they cannot answer. The
footer meter reports how much
context is left, since the only question it exists to answer is whether the
next question still fits.
Conversations are kept. The thread auto-saves in your browser for that
report, so a refresh, a close, or reopening the file later picks up where you
left off (New chat clears it). Save writes the conversation to a
chat_history.json you choose — put it next to report.html in the export
folder, archive it, or hand it to a colleague — and Load reads one back and
lets you carry on asking questions from where it ended. The transcript is data,
not a picture: the questions, the answers, the reasoning, the tool steps and the
citations all come back. Your API key is never part of it (any sk-ant-… in the
conversation is redacted before anything is written), and nothing is uploaded —
saving and loading happen entirely on your machine.
Charts, tables and memos, right in the chat. Ask for "a chart of the
findings by sheet and severity", "a table of every high-severity finding", or
"draft an RFI for the VAV-3 clearance conflict", and the assistant puts it into
the conversation as an artifact: a card under its answer, numbered A1,
A2, … so later answers can refer to it. Each kind has its own downloads:
| Artifact | On screen | Downloads |
|---|---|---|
| Chart (bar, horizontal bar, line, donut) | drawn at the panel's width, hover a mark for its values, Data shows the numbers as a table | .svg (vector, with title and caption), .png (2×), .csv (the data) |
| Table | sortable columns (numbers sort as numbers) | .csv (opens in Excel; formula-looking cells are neutralized like findings.csv), Copy pastes into a spreadsheet as cells |
| Memo | rendered Markdown | .md, .html (a standalone page that also opens in Word), Copy |
Every card also has Print, which prints that one artifact on its own (pick Save as PDF in the print dialog for a PDF). The assistant is told to take an artifact's numbers from the report, the findings ledger, or its exact calculator — never to estimate them — and an invalid chart or table comes back to it as an error saying what to fix rather than as a broken card.
Artifacts are durable: an artifact is the recorded tool call that made it, so
it is saved with the conversation and comes back on reload, on Load, and in
a colleague's copy of a saved chat_history.json. New chat clears them with
the thread.
The Files panel. The Files button in the chat header (its badge counts the conversation's artifacts) opens one place for everything you can take away:
- From this conversation — every artifact with its download buttons (click a title to jump to it in the chat), Download all (.zip) for every artifact in every format plus the transcript, and the transcript's own Save and Print rows.
- Saved with this report — in a folder export (Export All… /
write_drawing_export), every file in the export folder as a real link: the marked-up PDFs,findings.csv,run.log,run_manifest.json,diagnostics/, the evidence crops, grouped and filterable. The list is taken from the folder after everything else is written, so it never names a file that is not there; the links are relative, so they work wherever the folder is moved as long asreport.htmlstays in it. A single-file report (Save HTML Report…) has no folder beside it and says so.
The assistant can see the same list (list_files) and drop a clickable file
straight into its answer (share_file) — ask "where's the marked-up PDF?" and
you get the link, not directions.
See SECURITY.md for the report's trust boundary (all model output is treated as hostile and can never execute), API-key handling, secret redaction in logs, and what project data each artifact and the Ask-AI assistant contains.
Drawing Analyzer ships as a normal downloadable Windows app — there is no server to host and nothing to run in the cloud. The app runs entirely on your own PC and talks only to the Anthropic API using your own API key.
- Go to the latest release
and download
DrawingAnalyzerSetup.exe. - Run it. Because the app is not code-signed, Windows SmartScreen may show a "Windows protected your PC" notice — click More info → Run anyway. This is expected for independent software; you'll see it on the first install and on each update.
- The installer shows the license (AGPL-3.0-or-later, with the copyright
notice and a link to the source) — the same page appears on every update.
It installs per-user (no admin prompt), adds a Start-menu shortcut plus a
Drawing Analyzer License shortcut to the installed
LICENSE.txt, and launches. Paste your Anthropic API key into the field at the top and you're ready.
Staying up to date. The app checks for a newer version once a day at launch,
and you can check any time with Check for Updates in the bottom-right
corner. When an update is available it shows what's new and offers to download
and install it — you decide when. Every download is verified against the SHA-256
hash in that release's latest.json before it runs, so a corrupted, truncated or
substituted download is rejected automatically. Be clear about what that hash
does and does not cover: it is published in the same GitHub release as the
installer, by the same workflow, so it protects the transfer — not the publisher.
Trust in the contents of a release rests on HTTPS to github.com and on who can
push a release to this repository, and the installer is not code-signed. (Building and publishing a release is documented in
docs/RELEASE_WINDOWS.md.)
pip install -e ".[gui]" # GUI + engine
pip install -e ".[dev]" # engine + test depsRequires Python 3.11+. Set your API key:
export ANTHROPIC_API_KEY=sk-ant-...Or skip the env var and paste the key into the Anthropic API Key field at the top of the GUI — it takes effect as soon as you enter it (no extra step), and is saved (OS keyring when available, otherwise a local key file) once you finish editing so it's remembered next launch. The env var still takes precedence when both are set.
drawing-analyzer # or: python -m drawing_analyzerDrop in (or browse to) PDFs, confirm the estimated cost, and the analyzer digests every sheet. Save the result as a navigable HTML report (Save HTML Report… — opens in your browser, searchable and filterable).
Closing the window while a run is in flight asks first, and names which job it
would discard: the worker threads are daemons and the export happens after the
analysis returns, so a stray click on the X used to throw away an hour of work and
the API spend behind it with no prompt at all (the help, focus and update windows
each already confirmed on close). Quitting mid-analysis stops the run first
(the same kill switch as Stop, below) and closes once the stop has settled —
at most 30 seconds — because the worker dying with the process used to leave a
remote Message Batch running, and billing, after the window was gone. A second
click on the X closes at once. An idle window still closes immediately. Two
other silent failures are gone with it: pip install drawing-analyzer without the
gui extra now reports the missing toolkit in a dialog naming the fix instead of
producing no window and no message — the launcher is a console-less Windows
executable, so there was nowhere for the traceback to go — and an exception inside
a button handler is recorded in the diagnostics log and reported, rather than
looking like the click did nothing.
The Activity section near the bottom of the window has two views, switched by the Live view | Log toggle on its first row:
-
Live view (the default) — an animated view of the run as it happens, drawn on a dark blueprint grid. Under its header is a row of five tabs — Board | Story | Thinking | Plan | Findings — described below. Click a tab to show it; the mouse wheel scrolls its lists. A dot beside a tab's name means something new arrived there since you last looked, and the number is how much it holds (reasoning summaries, checklist items, findings). The tab you were on is remembered. The header and the ticker stay up on every tab. The Board tab is the animated board:
- Header — a pulsing LIVE pill (then COMPLETE / PARTIAL /
STOPPED / FAILED), the elapsed clock, the stages running right now
with their progress (
▸ Read 34/120), and the live figures: tokens per second, calls in flight (or items waiting in the batch queue), total tokens and the estimated spend so far. - Pipeline track — one node per stage the run's own configuration plans (Inputs → Read → Identify → Plan → Critique → X-Sheet → Overview → Audit → Harvest → Editions → Anchor → Absence → Verify → Probe → Cite → Markup for a full QC run; just the first few for a standard run). Active nodes pulse and spin; data packets stream along the track into them — fast and blue while real-time reads stream, slow and amber while work waits in the Message Batch queue. A finished node bursts and settles green (complete), amber (partial) or red (failed); a stage the run never reached ends dimmed.
- Sheet board — one cell per sheet. Cells cascade in when you load files, shimmer amber while queued in a batch, run a scan line while being read, then flash and settle green (read), teal (served from the cache, no charge) or red (failed). A purple corner marks the critique reads. When the run completes a sweep crosses the board.
- Telemetry — a live token-throughput chart with counters (calls done, live, queued, retries — a retry flashes the counter). At the end of a QC run it becomes the findings breakdown: a bar of verified / uncertain / not checked / rejected findings, the high / medium / low counts, and whether every markup was proven in the saved PDF.
- Ticker — the latest notable event, typed out ("6 of 48 sheet(s) already in the cache — free", "Findings ledger sealed: 87 finding(s)", "API busy (overloaded) — backing off 2.0s, then retrying").
- Hover a cell for that sheet's name, state, critique reads and tokens; hover a stage node for its status, time and call counts.
Before a run the board previews the loaded set and the stages the current QC options would run (tick QC Markups and the track grows the QC stages). After a run the final board stays up until you load a different set.
The other four tabs say what the run is doing and why:
- Story — the pipeline as a timeline. Every stage has one plain sentence saying what it does ("Pins each finding to its spot on the PDF by finding its quote in the sheet's text layer. A quote that cannot be found is a warning sign."). The stage running now opens up to show its live progress ("40 critique read(s) back · 4 still out") and its recipe: the model, the effort level, the thinking mode, whether it went through the half-price batch queue, and its calls, tokens and spend so far. Finished stages show their outcome and time; a stage's warning replaces its description. Beside the timeline, Where the spend goes is a bar per stage, and Insights are a few computed observations: how many sheets the cache served free, what one sheet read costs in tokens, which stage dominates the spend, how many duplicate reports the ledger merged, how often the API pushed back. Before a run it is a preview of the stages your options will run.
- Thinking — the model's own reasoning, call by call. Each entry names the stage, the sheet (or finding, or code reference) and the model, then shows the reasoning summary as it streams in; the right-hand column lists the calls in flight and whether each is still thinking or already writing its answer. The feed follows the newest entry until you scroll up to read something, and resumes when you scroll back to the end. See What the Thinking tab is below.
- Plan — what the set was identified as (project and set type, where it
sits, language and units, its disciplines, and the code editions it
adopts, with an edition you set marked set by you and one that is only
mentioned marked as such), and the review checklist: your own
checklists and the plans the model wrote, item by item with their ids
(
U1.3,M2.7). As each sheet's critique comes back, every item's meter fills in: flagged, unclear, reported met, not applicable or unanswered, counted in sheets. Hover an item for its full text and counts. A standard run writes no checklist, and the tab says so. - Findings — the path from report to proof, as four steps that light up
as the run reaches them: reported by the reads (first read, critique
reads, cross-sheet), in the ledger (after duplicate reports are
merged), placed on the sheet (exact quote / close match / tile only /
not placed) and re-checked (verified / uncertain / not checked /
rejected). Under it, every finding in the ledger: severity,
QC-###, sheet, text and its current status, updating as verdicts land. Hover a row for the whole finding, who reported it and how it was placed.
- Header — a pulsing LIVE pill (then COMPLETE / PARTIAL /
STOPPED / FAILED), the elapsed clock, the stages running right now
with their progress (
-
Log — the timestamped activity log, as before. While the board is showing, warnings and errors logged behind it are counted beside the toggle (● 2 warnings in the log); click it to jump to the log.
Turning the visuals off. Pick Log and the board stops drawing
entirely; collapse the Activity header and nothing is drawn either (during a
run the collapsed header still shows a one-line summary, e.g. Activity —
Critique · 48/48 sheets · ≈$12.40, refreshed once a second). The choice is
remembered across launches (gui_preferences.json in the app's config folder,
the same file as the full-QC confirmation choice).
What the Thinking tab is. The API can return a readable summary of a
model's reasoning with each reply. It is a summary written by the API, not the
raw chain of thought, which no setting returns. A run started from the GUI
asks for these summaries (thinking: {"type": "adaptive", "display": "summarized"}); they change nothing about what the model thinks, what it
answers or what the run costs, and no cache key depends on them, so a cached
sheet is still a cache hit. Real-time calls stream their summary as it is
written. Batch reads report theirs when the batch's results come back, and a
sheet served from the cache makes no call and has none. Some calls return no
summary at all; the tab counts those. The summaries are shown on this screen
only. They are held in memory for the run and are never written to the
export folder, run.log, the diagnostics folder or the cache. Set
DRAWING_ANALYZER_SHOW_THINKING=0 to stop asking for them. The setting can
never cost a sheet read: a request refused for it is sent again without it, a
batch item refused for it is resubmitted without it, and a batch that has no
resubmission (the critique's) asks only a model that has already accepted the
setting in this session. A refusal turns it off for the rest of the session.
What it shows, and what it cannot change. The board is a read-only window
onto the run's own records: the pipeline hands a
live_view.RunMonitor (extract_drawing_context(monitor=…)) its sanitized
run journal (the same events run.log is rendered from — no key, no path) and
its per-call telemetry. Nothing the board does can alter, slow or fail a run,
and a failure inside it is logged once and otherwise ignored. The spend shown
during a run is an estimate (each finished call priced at the model that
answered it; marked ≈); when the run returns it is replaced by the run's own
usage-ledger figure — the same number run.log and the report show. Drawing
paces itself — up to 25 frames a second, backing off so it never takes more
than about a third of the UI thread (in development testing, a 600-sheet board
with every cell animating drew in ~11 ms a frame), and it stops completely
when nothing is moving.
Everything a run uses is listed in the Loaded files section, by category, with each file's token count and its own Remove button (Remove takes out that one file; Clear still empties everything). The section expands by itself whenever you add something and collapses when a run starts, so the Activity section (the Live view board, or the log) gets the room. Its buttons add inputs; every pick adds to what is already loaded, and picking a file again replaces its earlier copy:
| Button | Category | What it does |
|---|---|---|
| Add drawings… | Drawings | The same as the drop zone / Browse…. Each PDF shows its sheet count and its image + text-layer tokens for one digest read — measured from the real page shapes once the background preflight has scanned the files, a conservative per-sheet allowance until then. |
| Add specs… | Specifications | The real project specifications (.pdf / .docx / .txt / .md), after the same quality warning as before. Sent with every sheet's digest; conflicts become ordinary findings. |
| Add context… | Additional context | Background that is not the specifications — a basis of design, owner or client standards, a code analysis, prior review comments. Same formats as specs. Sent with every sheet beside the specs, under its own <project_context> block framed as background; requirements it states are checked like the specs. Budgeted at 200,000 characters (~50k tokens). |
| Add checklists… | Checklists | Your own QC checklists: .xlsx/.xlsm/.csv (one item per row; a header row and item-number cells are dropped), .docx, .pdf, .md or .txt (list bullets, or one item per line), or a .md in the review-profile format. Each becomes a review profile the QC Markups critique applies item by item and answers item by item. Scope… on a loaded checklist limits it to sheets whose number starts with the prefixes you type (F, FP, SP); blank applies it to every sheet. |
| Load AHJ Atlas Input… | AHJ Atlas Input (optional) | An Excel workbook of the code editions the AHJ adopted. Its editions become the run's governing code editions. If it lists several jurisdictions you pick one. Without it, the editions on the drawings are used. |
Token counts are per digest read of each sheet at ≈4 characters per text token; specs and context are added to every sheet's request (prompt-cached after the first on the real-time path), and the cost line below the list prices it all.
AHJ Atlas Input layouts. There is no fixed template; the loader
(drawing_analyzer.ahj_atlas.load_ahj_atlas) recognizes, per worksheet:
- one code per row — a header row with an edition column (
Edition,Year,Version,Adopted) and a column of code names, with optionalJurisdiction/AHJ/City/CountyandAmendments/Notescolumns (a blank jurisdiction cell continues the one above, as merged cells read); - one column per code — one row per jurisdiction, a column per code
(
IBC,IFC,NFPA 13, …) with the edition years in the cells; - one tab per jurisdiction — either layout above on several tabs, each tab named for its AHJ;
- free text — any cell naming a known code with a year (
NFPA 13 2025,2024 IBC).
Spelled-out names are normalized to their designations (International
Building Code → IBC, Life Safety Code → NFPA 101) so they line up with
what the identity stage detects; an amendments note rides along as the
edition's note. .xlsx, .xlsm and .csv are read; an old .xls must be
saved as .xlsx first. A row naming a code with no year is skipped with a
warning in the activity log.
The Save tile images checkbox under Open Diagnostics Log keeps every tile
image the run renders; Export All… then writes them to a tiles/ folder with
per-tile notes, so you can see exactly what the model read. It places no call
and changes no result. It starts ticked when DRAWING_ANALYZER_SAVE_TILES is
set.
GUI export options. The GUI keeps the buttons a reviewer actually reaches for: the HTML report (Save HTML Report…), the marked-up PDFs (Save Reviewed PDF(s)…, after a QC run), and the one-click full folder export (Export All…, which also writes the
run.log/run_manifest.jsonrun record even for a run that analyzed nothing — every input rejected, or a preflight block — because that record is the diagnostic for exactly those failures. A run that raised is the one case it cannot cover: there is no context to export, so the button stays disabled and the failure is reported in the activity log and the diagnostics file instead). The per-artifact Save Markdown… and Save Findings CSV… buttons were removed from the GUI to keep it fast and uncluttered — both artifacts are still written inside the Export All… folder. This is a GUI-only change: none of that functionality was deleted. The two removed buttons' handlers remain in the codebase as dead code (see thebtn_rownote ingui.py), the folder export still emitsfindings.json/findings.csv/sheet_text// the raw Markdown /run_manifest.json, and the library API (write_drawing_export,write_findings_csv,build_html_report) produces all of it. Either button can be brought back later just by re-adding it ingui.py.
Stop (the red button beside Analyze Drawings) is live for as long as an analysis runs, in every processing mode — Economy, Hybrid, Fast and XFast. After a one-click confirmation it:
- sends nothing new — every real-time request, Message Batch submission and
Files-API upload is refused from that moment
(
core.api_config._dispatch_messagesis the single gate every real-time stage goes through); - abandons streamed requests in flight (digest, critique, synthesis, focus, review plan, investigation) at their next streamed event, which closes the connection so the model stops generating;
- cancels every Message Batch the run has open, at once — from a background
thread, wherever the run happens to be — and says so in the activity log
(
Canceled remote batch msgbatch_…). A cancel the API refuses on a batch that may still be running is reported in red with the batch id, so you can cancel it from the Batches page of the Anthropic Console. Each batch id is also logged when it is submitted; - wakes every poll and backoff wait immediately, so a run queued for hours stops in seconds, not at its next 2-minute poll.
What it cannot do: a short, non-streamed request already in flight (verification, citation check, cross-sheet QC, set identity, prose harvest) runs to completion, and requests already made are billed. Nothing paid is thrown away: every sheet that finished is in the cache, so re-running the same files reuses it at no cost, and a canceled batch keeps its receipt, so the next run collects whatever the batch finished before the cancel landed (Anthropic finishes, and bills, items that were already generating when a batch is canceled). Uploads a stopped batch submission never sent are deleted immediately. A stop that arrives after the run's last paid request — once markup writing has begun — lets the run finish, and the log says so.
While a run works, the line under the Analyze Drawings row shows its spend, refreshed every second:
Spent $12.40 · in flight ~$3.10–$5.80 (24 batch request(s)) · projected $41.20–$58.00 · quoted $30.00–$190.00 · cap $60.00
- Spent is every response received so far, priced the moment it arrives with the same rule the usage ledger uses (batch results at the batch rate, each attempt of a refusal fallback at the model that ran it, web searches per use). "at least" appears if a response ran on a model the price table does not know.
- In flight is Message Batch requests that have been submitted and not yet read. A batch reports no usage until its results exist, so this is a band rather than a figure; it moves to Spent as results are read.
- Projected is where the total is heading. It starts at the quoted estimate and narrows as the run learns: each stage's remaining items are priced at a rate that moves from the estimate's per-item figure to the stage's own observed average as its items complete, a stage that finishes counts exactly what it billed, and a stage that turns out to be cached or unnecessary drops out. When the run ends the line reads Final cost, taken from the usage ledger.
- Quoted is the estimate shown before the run, for comparison.
Every figure is this app's price table applied to the tokens the API reports — an estimate, not your invoice. The Live view's header shows its own running spend, priced from the same responses; the spend line adds what is in flight, the projection and the cap, and stays visible when the Activity section shows the log or is collapsed.
Stop the run at $ (beside the spend line) is a budget cap. Tick the box, type an amount, and the run is stopped through the kill switch above when its spend reaches it. The box and the amount stay live during a run: change the amount or untick the box and the change applies to the run in progress. Setting a cap at or below what the run has already spent asks for confirmation first, because it stops the run at once. An amount that cannot be read leaves the current cap in force, and Analyze will not start with the box ticked and no usable amount. The setting is remembered between sessions.
What the cap compares against is Spent plus a floor on In flight: the low per-item rate, counted only when it can be trusted (the estimate priced the pages from their measured sizes, or the stage has already shown what its items cost). An over-wide estimate of queued work therefore never stops a run that is within its cap. The other side of that choice: work already sent is billed for whatever it completes, so a batch run can finish above the cap by up to the batch that was in flight when it tripped. A run stopped by the cap behaves like any stopped run — nothing is exported, finished sheets are in the cache, and the next run picks up from there once the cap is raised or turned off.
Optionally, type a per-run focus before pressing Analyze — e.g. "the rooms, and what types of plumbing fixtures each has". You always get the standard digest; a focus adds the Focus Report on top (see Per-run focus).
Even with neither box checked, a run is not throwaway: it retains each sheet's
extracted text and the digest's parsed findings, anchors them offline for free,
shows them in the report, and exports findings.json / findings.csv / sheet_text/
(DA-012). No paid QC stage runs.
Two QC review checkboxes sit beside the focus:
- QC Markups — the full QC stack (DA-010). Checking it runs the
entire stack: synthesis, two critique reads per sheet, cross-sheet QC, the
deterministic auditors, prose harvest, anchoring, the absence cross-check,
verification, citation checks, markup, and coverage reconciliation — then produces a marked-up PDF + findings
CSV (see Reviewed PDFs & findings CSV). By
default every ledger entry gets ink except the ones the verifier proved wrong
(§18 gating): unverified findings
draw dashed with an
[UNVERIFIED]prefix, rect-less ones become margin callouts. Two sub-toggles adjust that — Verified & deterministic only (off by default; the conservative opt-in that suppresses unverified ink) and Include rejected (grey) (also inks verifier-rejected findings, struck grey). Because it runs the whole stack, it costs substantially more than a plain digest; the cost line says so. Analyze shows the full per-stage estimate in a Confirm full QC run dialog before anything is sent. Ticking Don't show this again there (and clicking Yes) skips that dialog on later full QC runs — the choice is remembered ingui_preferences.jsonin the app's config folder (on Windows,%LOCALAPPDATA%\DrawingAnalyzer\), and the estimate is written to the activity log instead. The Confirm estimated cost before each full QC run box under QC Markups brings the dialog back. Ticking the box and clicking No remembers nothing. The standard (non-QC) cost confirmation is unaffected. - Deterministic audit only — a free, zero-API pass that adds the whole deterministic auditor battery (references, arithmetic, naming, title-block, and sheet-index — see Deterministic auditors) on top of the standard analysis, with no additional API calls. It is already included in QC Markups' exhaustive stack, so the checkbox is disabled/redundant while QC Markups is checked.
After a QC run, the completion summary reports the finding count, how many were
clouded, and the receipt-derived coverage tally
(Ledger 47: 39 clouded, 5 margin, 2 rejected (indexed), 1 gated (verified-only mode), 0 failed; coverage COMPLETE), and the Save Reviewed PDF(s)… button
lights up (it copies every *_reviewed.pdf into a folder you pick). The standalone
findings-CSV button is no longer in the GUI (see GUI export options above),
but the CSV is still written by Export All… and the library write_findings_csv.
That tally is artifact-backed, not aspirational (Phase 21): after each
reviewed PDF is saved it is reopened and reconciled against the plan — every
cloud, callout, and index row is stamped with a private placement id, and a
placement counts only when its stamp is found again in the saved file. If a
planned markup is missing, failed, or a source changed mid-run, the run's
coverage is INCOMPLETE: the completion line reads QC incomplete (distinct
from Completed and Completed with QC warnings), the affected reviewed PDF is
named …_reviewed_INCOMPLETE.pdf, the HTML report shows a red Markup coverage:
INCOMPLETE banner, and the exported markup_manifest.json records every planned
placement and its terminal receipt. Pre-existing annotations on the source (or
markups from an earlier review run) never distort this accounting — they carry no
stamp from this run and are ignored.
An exhaustive run also carries one normalized QC status — NOT_REQUESTED,
COMPLETE, PARTIAL, or FAILED (ctx.qc_status) — rolled up deterministically
from a typed StageResult per stage (ctx.stage_results). The GUI completion
dialog, the export index, and an HTML report banner all lead with it. A stage that
failed or a coverage that came back INCOMPLETE makes the run PARTIAL/FAILED;
disabling a normally-required stage from the advanced API (e.g. qc_markups=True, critique=False) marks the configuration DEBUG_OVERRIDE and likewise caps it at
PARTIAL. A clean NORMAL exhaustive run earns COMPLETE — "All QC stages
complete". That means every stage ran to completion, not that the set has been
fully reviewed: the tool still misses things a good reviewer catches. (The
label used to read "Exhaustive QC complete", which overstated it.) The Phase 23
completeness gate opened in Phase 26B once cross-set reconciliation,
claim-complete citations, complete evidence, and callout-overflow handling
landed. That claim is guarded, not assumed: a failed reconciliation, an
unchecked cited claim, a missing verification crop, or a source that changed
mid-run each hold a required stage at PARTIAL, and the roll-up can never call
such a run COMPLETE.
The HTML report (the folder export's report.html, or Save HTML Report… in the
GUI) makes a large set easy to navigate:
-
Sidebar table of contents — jump to the QC findings, the cross-sheet overview, or any sheet; each sheet shows an OK / cached / failed dot.
-
QC Findings card (when a QC run produced findings) — a pinned, sortable table (ID, sheet, category, severity, status, finding, quote). Each row carries a color-coded status chip —
Verified(green),Deterministic(blue),Uncertain(amber),Not checked(grey),Unanchored(red outline),Rejected(struck grey) — and links to the sheet it sits on. The chips honour the filter chips and ⚠ Issues only; folder exports also thumbnail the verifier's evidence crop on each row.UncertainandNot checkedare different claims.Uncertainmeans a verifier examined the finding and could not settle it;Not checkedmeans no verification stage ran for it — the state of every finding on a standard run, whereverificationisNOT_REQUESTED. Collapsing the two would report a clean standard run as several hundred inconclusive verdicts. -
Overview and focus report are pre-QC narrative — both are written from the sheet digests before the QC stages run, and they ship as written. A conflict the overview alleges can therefore outlive a finding that verification later
Rejected. On a QC run with findings, each card opens with a one-line note pointing to the QC Findings table, and the Ask AI assistant is told the same thing: where the narrative and a finding's status disagree, the status is the later judgment. -
Grouped repeats — a general note printed on every plan sheet is one real ledger finding per sheet, and correctly so: fifteen sheets carrying the same note are fifteen places to check. But fifteen identical table rows are noise — in one 39-sheet fire-protection set, 190 of 287 findings shared a quote with another, and
PRELIMINARYalone was raised eighteen times. Rows quoting the same verbatim text therefore collapse behind the first one, which grows a+N more sheetscontrol that expands the group in place. This is display only: the ledger, the exports, the markups and the badge total keep every finding (§18.6), the grouping recomputes after every sort and filter (so a collapsed row is never stranded without a lead), search still reaches a collapsed row, and Group repeated quotes turns the whole thing off. -
Search — live full-text filter across every sheet. When a QC run captured the sheets' raw text layers, search runs over what each sheet actually says (a collapsed Sheet text layer block per sheet), not only what the digest said about it. Scanned / pasted-raster sheets are badged Raster.
-
Category filters — one-click chips, including ⚠ Issues only, which isolates the Coordination and Conflict sections (and every finding) the model flagged across the entire set. Those sections are also highlighted inline.
-
Lossless — a collapsed Complete raw Markdown block carries the exact model output, so the original text is always one copy away.
from pathlib import Path
from drawing_analyzer import extract_drawing_context
from drawing_analyzer.cancellation import CancelToken
token = CancelToken() # token.cancel() from any thread stops the run (optional)
ctx = extract_drawing_context(
[Path("M-101.pdf"), Path("P-201.pdf")],
use_batch=True, # Message Batches API (≈50% cheaper)
use_cache=True, # skip re-paying for unchanged sheets
focus="the rooms, and what types of plumbing fixtures each has", # optional
# One product switch turns on the exhaustive stack (DA-010): synthesis, two
# critique reads/sheet, cross-sheet QC, the deterministic auditors, prose
# harvest, anchoring, the absence cross-check, verification, citation checks,
# markup, and coverage.
qc_markups=True,
profiles=["my-office-checklist"], # optional user checklists (added to the model plan)
markup_verified_only=False, # opt-in conservative ink gate (§18 default: ink all but REJECTED)
ink_rejected=False, # also draw verifier-rejected findings (grey/struck)
focus_findings_to_markups=False, # harvest Focus sections into markups too
qc_work_dir=Path("run-out"), # where evidence crops + reviewed PDFs land
# Expert overrides (bool | None): leave a stage None to take the product default;
# an explicit True/False overrides it. Disabling a required exhaustive stage
# marks the run DEBUG_OVERRIDE and caps qc_status at PARTIAL:
# critique=False, cross_qc=False, citation_check=False, verify_findings=False,
# synthesize=False, identity=False, review_plan=False, absence_check=False,
# governing_editions="NFPA 13 2025; IBC 2024" (or a list of entries) names
# the code editions that govern when the drawings state the wrong one (see
# Set identity below); load_ahj_atlas(path) reads them from an AHJ Atlas
# workbook: governing_editions=entries_for([atlas.jurisdictions[0]]).
# project_context="..." adds background documents beside the specs.
# fast_mode=True (all real-time) sends Opus requests in API fast mode (XFast).
# save_tile_artifacts=True keeps every rendered tile for the export's tiles/.
# reference_audit=True alone (without qc_markups) is the free, zero-API battery.
cancel=token, # optional kill switch (see below)
spend=spend, # optional live spend + budget cap (see below)
# monitor=RunMonitor() (drawing_analyzer.live_view) is a read-only window a UI
# can poll while the run works — what the GUI's Live view draws from.
)
print(ctx.combined_text)
print(ctx.qc_status) # NOT_REQUESTED | COMPLETE | PARTIAL | FAILED (§3.3)
for s in ctx.stage_results: # one typed outcome per QC stage
print(s.stage, s.status)
print(ctx.focus_report_text) # the set-level answer to the focus ("" if none)
for sheet in ctx.sheets:
print(sheet.ref.display_label, "->", "ok" if sheet.ok else sheet.error)
# QC results — findings/text are retained even in a standard run (DA-012):
for f in ctx.all_findings: # model findings + deterministic reference findings
print(f.sheet_id, f.category, f.anchor.status, f.verification.status, "-", f.text)
print(ctx.finding_count, "findings,", ctx.clouded_finding_count, "clouded")
for pdf in ctx.reviewed_pdf_paths: # the *_reviewed.pdf files (qc_markups only)
print("marked-up:", pdf)The kill switch is a drawing_analyzer.cancellation.CancelToken. Call
token.cancel() from any thread and the run stops exactly as the GUI's Stop does, on every transport;
extract_drawing_context then raises RunCancelled instead of returning.
RunCancelled derives from BaseException (like asyncio.CancelledError) so
the stages' non-fatal except Exception guards cannot swallow it; catch it by
name. token.wait_for_remote_cancels(timeout) waits for the stop's remote batch
cancels to be sent, which matters if the process is about to exit.
To follow a run from your own UI, pass a drawing_analyzer.live_view.RunMonitor
as monitor= and poll a LiveRunModel from your UI thread
(model.begin(), then model.poll(monitor) on a timer, then
model.finish(ctx)). The run attaches its own journal, per-call telemetry,
resolved configuration and sheet list to the monitor; the model folds them into
per-stage and per-sheet state, call/token counters, a live spend estimate and a
ticker of notable events. live_scene.LiveScene + CanvasPainter render that
model onto any Tk-like canvas, and live_panel.LivePanel is the GUI's
self-pacing driver. All three are toolkit-free and never raise into the run.
The same model feeds the insight tabs. As the run goes it publishes its own
objects to the monitor by name (monitor.facts()): identity (the
SetIdentity), checklist (the ChecklistPlan) and checklist_answers (the
dict the critique fills per sheet), digests, critique_findings,
cross_findings and ledger (the findings ledger). They are references to
the run's live objects: read them, never write them. The model re-reads them
twice a second into model.identity, model.checklist, model.ledger_findings
and the model.ledger_by counts. Passing a monitor also makes the run collect
the models' reasoning summaries (ctx.resource_pressure.thoughts, a
thought_stream.ThoughtStream; model.thoughts holds copies) unless
DRAWING_ANALYZER_SHOW_THINKING=0. A run with no monitor sends exactly the
requests it always sent. live_tabs holds the four insight views, and
LiveScene.set_tab / click / wheel are the host's inputs.
Live spend is a drawing_analyzer.live_spend.SpendMonitor:
from drawing_analyzer import CancelToken, SpendMonitor
from drawing_analyzer.cost import estimate_exhaustive_run_cost, spend_forecast
token = CancelToken()
spend = SpendMonitor(
budget=60.0, # optional cap, in dollars
forecast=spend_forecast( # optional: enables the projection
estimate_exhaustive_run_cost(sheet_count, batch=True)
),
)
# ...run extract_drawing_context(..., cancel=token, spend=spend) on a worker thread...
snap = spend.snapshot() # any thread, any time
print(snap.billed, snap.in_flight_items, snap.projected_low, snap.projected_high)
spend.set_budget(80.0) # raise, lower or clear (None) while the run workssnapshot() returns a SpendSnapshot: billed, in_flight_items with its
in_flight_low / in_flight_high band, projected_low / projected_high
(None without a forecast until the run ends), the quoted_* estimate, a
per-family stages breakdown, unpriced_calls, and budget_hit once the cap
has stopped the run. The cap acts through cancel, so a monitor with a budget
needs cancel= (ValueError otherwise), and a run it stops raises
RunCancelled with token.reason set. After a normal return the snapshot is
final and equals ctx.run_usage.
extract_drawing_context returns a DrawingContext (combined text, per-sheet
SheetDigests, token totals, errors, optional synthesis_text, and — when a
focus was given — focus / focus_report_text). It always carries the normalized
run status: qc_status (NOT_REQUESTED / COMPLETE / PARTIAL / FAILED), the
typed stage_results, and the resolved run_configuration (§3.3 / §15.1); the
append-only usage ledger run_usage backs the derived total_input_tokens /
total_output_tokens / total_estimated_cost and the usage_by_family breakdown
(§15.6). Even a standard run carries the QC record — findings are retained and
offline-anchored (DA-012). When exhaustive QC or the free audit ran it fills the rest:
findings (the model's, anchored + verified),
reference_findings (the deterministic auditors', anchored + DETERMINISTIC),
the all_findings / finding_count / clouded_finding_count conveniences,
reviewed_pdf_paths, the lightweight sheet_geometries, audit_stats (the
auditors' checks-passed tally), ledger_tally / ledger_tally_line (the
receipt-derived coverage tally), coverage_status + markup_run (the Phase 21
placement receipts, COMPLETE / INCOMPLETE / NOT_REQUESTED), and
qc_work_dir (holding evidence/ crops and the reviewed PDFs). Every finding
carries its sequential qc_id review number (QC-001 …),
its provenance tags (sources — see the findings ledger),
and, when the citation check ran, a citation verdict. write_drawing_export(ctx, parent_dir, ...) folds all of it
into the export folder — findings.json, findings.csv, sheet_text/<sheet>.txt
per sheet, the *_reviewed.pdf copies (incomplete ones named
*_reviewed_INCOMPLETE.pdf), the evidence/ crops, and markup_manifest.json
(the placement receipts + coverage proof) — alongside the prose digest and HTML
report, plus (every run, QC or not) run.log — the sanitized per-run log —
and run_manifest.json — the machine-readable run summary that hashes every
artifact in the folder (see Run log & run manifest) —
and a diagnostics/ folder with every model call the run made, what each was
given and what it used (see The diagnostics/ folder). When the set has set-level findings — a cross-sheet synthesis conflict
that names no single sheet — they are written to a dedicated
Drawing_Set_Review_Notes.pdf (its own analyzer-owned pages and reconciled
receipts) rather than being pinned onto an arbitrary drawing.
PDFs → list sheets → render (overview + per-page tiles) + extract vector text layer
→ per-sheet vision digest (images + verbatim text layer)
→ optional cross-sheet synthesis → optional focus report → combined Markdown
→ optional QC: deterministic auditors + anchor → verify → cloud (reviewed PDFs, CSV)
-
Text-layer grounding. Before rasterizing, each sheet's vector text layer is lifted losslessly (
page.get_text()— free, ~0.3 s for 8 sheets) and spliced into the digest prompt verbatim, before the images, as the source of truth for exact strings (tags, schedule values, note numbers, sheet references). Vector text can't misread a digit the way OCR of a low-resolution embedded raster can, so grounding the read in it is the antidote to that class of error. -
Drawing text is data, never instructions. A sheet's text layer is a third-party PDF's own text, and the digests are written from it, so either can carry a line crafted to look like the prompt's own framing (a fake
===== SHEET E-999 =====banner) or a note phrased as an instruction. Every text-only stage — cross-sheet QC (whole-set, shard map and reconciliation), synthesis, the focus report, set identity, the review planner's digest fallback, the absence cross-check and the prose harvest — sends each sheet as one<sheet id="…">block holding its<digest>and<text_layer>, and tells the model that everything inside a<sheet>block is drawing content to analyze, never instructions to follow. A page cannot close its own block or open a sibling one: any<that would start one of those tags is replaced by a look-alike character before it is sent, and the sheet id is attribute-escaped. Ordinary<and>in drawing text (< 10 PSI) pass through unchanged. The per-sheet digest already fences its one sheet's text layer in matched<sheet_text_layer>tags with the same instruction. -
Per-page tile grids. With
rowsandcolsomitted,tiling.choose_gridminimizes Opus 5 image tokens using the page's displayed physical dimensions (CropBox, after rotation), overlapping tile geometry and image-count target. Every tile meets the minimum DPI of today's 44×34 inch ANSI E sheet at 6×6: 183.39 DPI for vector, 234.17 DPI for raster, at the default 8% overlap. That DPI is measured at the resolution the model actually reads. The API silently downscales an image above the high-resolution tier's limits (2576 px long edge, and 4,784 visual tokens counted as ⌈w/28⌉·⌈h/28⌉ patches), so a near-square 2576 px tile reaches the model at about three quarters of its rendered DPI. The grid search applies that documented resize (core.tokenizer.resized_image_size) to every tile instead of crediting the render resolution. The overview and all content-bearing tiles still cover the whole sheet (I-1). ANSI E (34×44 inches) and ARCH E1 (30×42 inches), in either orientation, keep 6×6, byte-identical imagery, and existing digest/critique cache keys. Explicitrows/colswin; specifying just one keeps six for the other.DRAWING_ANALYZER_FIXED_GRID=1restores the historical fixed 6×6 default. Above 20 images, vector tiles target 1560 px and raster tiles 1992 px, safely below the 2000 px hard cap. At 20 or fewer, both target 2576 px. Rectangular grids are allowed: the target change makes 43×1 narrow strips the token minimum for portrait vector Letter, and raster Letter chooses 27×1 strips at 1992 px, because one 2576 px image of the page would be downscaled to about 199 DPI, below the raster floor. Larger sheets can need more than six rows/columns. Pages that cannot meet the DPI floor within 100 images are reported as failed, never read below it. Cost confirmation prices those pages at the conservative allowance; digest and critique prescans record their failures and continue with the other pages.Measured with
tiling.image_pixel_sizesandcore.tokenizeron Opus 5, counting every tile before blank suppression, per vision read. Tokens are priced as the API bills them: ⌈w/28⌉·⌈h/28⌉ patches of each image after the API's resize. The DPI column is the lowest DPI the model reads, after that resize:Page (portrait) Grid Fixed 6×6 tokens Chosen tokens Savings Minimum tile DPI read Vector Letter, 8.5×11 in 43×1 91,168 7,280 92.0% 183.53 Vector Tabloid, 11×17 in 2×1 76,216 14,125 81.5% 190.91 Raster Letter, 8.5×11 in 27×1 148,248 11,736 92.1% 234.35 Vector ANSI D, 22×34 in 7×3 76,216 47,040 38.3% 183.39 Vector ARCH D, 24×36 in 5×2 78,288 51,952 33.6% 184.29 Vector ANSI E, 34×44 in 6×6 91,168 91,168 0% 183.39 Vector ARCH E1, 30×42 in 6×6 83,328 83,328 0% 192.12 A sheet larger than ANSI E can cost more than the fixed 6×6, because 6×6 reads it below the floor: vector ARCH E (36×48 in) chooses 7×5 at 107,408 tokens, where 6×6 would cost 87,024 but read at 168.10 DPI.
The same savings apply to the digest and both critique reads. Tile labels, findings bounds, anchors, crops, cross-QC legs, upload/spool reuse and tile artifacts all use each sheet's own grid.
-
Cost preview from each page's real shape. The old image figure assumed every image was a square at the raster target hitting the model's per-image token cap.
cost.estimate_image_tokens_for_basesnow prices the chosen grid, including every tile, from the page's size and whether it has selectable words. Without measurements,pipeline.estimate_image_tokens_for_setbounds the full adaptive image budget: 478,400 tokens/page on Opus 5, including oversized pages requiring more than 36 tiles. Explicit 6×6 or the fixed-grid switch retains the historical 177,008 tokens/page allowance. The dialog says which basis produced its number — "Based on each page's measured size and text layer" or "Conservative planning estimate" — so the figure is never ambiguous about what it rests on. Nothing extra is opened to get this. The profile preflight already walks every page to read its title-block sheet id; it now keeps the page shape too, which costs 0.4 ms for 120 sheets against 6,896 ms for a separate scan of the same set. Click Analyze before that finishes and you get the conservative figure, labelled as such — never a wait, never a spinner. The conservative allowance also stays the answer for a page that cannot be classified or measured: an unclassifiable page is never assumed to be vector, since vector is the cheaper target and guessing it would quote low on exactly the pages the tool understands least. A page that cannot be measured at all is still quoted, never silently dropped from the total. -
Every stage is priced with the model it will actually run on. Synthesis and the focus report have their own overrides and their own default (Sonnet 5.5) rather than the digest's model, so they are priced at Sonnet's rate even under an Opus digest. The critique line follows
DRAWING_ANALYZER_CRITIQUE_RUNSinstead of assuming two reads, and its images are counted with the critique model's own token cap. -
Economy mode (
use_batch=True, the GUI default) digests every uncached sheet through the Message Batches API, uploading images via the Files API so no request body approaches the 32 MB limit. It uses the same models, prompts, inputs, and review contract at roughly 50% of the real-time token rate. In the GUI, Processing offers Economy, Hybrid, Fast, and XFast; the cost preview and confirmation dialog reprice every paid stage to match. A library caller can opt every run into Batch globally withDRAWING_ANALYZER_USE_BATCH=1— no call-site edits — while an explicituse_batch=argument always wins. If the Files API is unavailable for your key/workspace (uploads return404), each affected sheet falls back to an inline real-time digest so the run still completes instead of producing nothing. In exhaustive QC the critique also rides the Batches path: each uncached sheet's images upload once and both self-consistency reads reference that one shared upload (distinctcustom_ids), so the reviewer earns the same batch rate and the sheet is neither re-rendered nor re-uploaded per read. The uploaded files are released on every exit — a fully-collected batch, a confirmed cancel, or an unexpected collection error (best-effort cancel, then release); a batch this run can't cancel keeps its files to expire server-side. A user Stop cancels the open batch at once and keeps both its files and its receipt: the canceled batch can still finish (and bill) items that were already generating, and the next run collects them, then releases the files (see Stopping a run). -
Hybrid mode uses real-time digests (
use_batch=False) and Batch critique (critique_use_batch=True). Standard analysis therefore starts immediately; exhaustive QC can still wait for its two critique reads in the shared queue. -
Fast mode (
use_batch=False,critique_use_batch=False) digests sheets concurrently on a bounded thread pool while rendering stays sequential (PyMuPDF is not thread-safe). Running the exhaustive stack real-time is the most expensive configuration (three vision reads per sheet), so the run logs a one-time nudge toward batch. Three vision reads is not three full-price image reads: the two self-consistency critique reads are byte-identical in their image prefix, so the second bills those ~90k image tokens at the cache-read multiplier (~0.1×) — identical tokens in, so findings are unchanged (see the cost note below). What a run cost is what its usage records say it cost; multiplying one read's price by three quotes a configuration nobody ran. -
XFast mode (
fast_mode=True, with both transports real-time) is Fast plus Anthropic's API fast mode: every real-time request to a model that supports it carriesspeed: "fast"— the same model and output, up to 2.5x faster output tokens per second, at a premium per-token price. On Opus 5.5 that is $8 / $40 per million input / output tokens, 2x the standard $4 / $20 (prompt-caching multipliers apply on top). Only Opus 5.5 requests (digest, critique, cross-sheet QC, review plan, investigation) go fast; stages on Sonnet or Haiku run at their normal speed and price, so a full QC run costs less than double. The Batch API has no fast mode, sofast_moderesolves off whenever either transport batches. The cost preview, the confirmation dialog and the run's usage ledger all price fast responses at the fast rate (a response reportsusage.speed; each usage record carriesspeed: "fast"). If the account or platform rejectsspeed(a 400), fast mode turns off for the rest of the process and the request is re-sent at standard speed — the run never fails over a speed choice. Fast mode has its own rate limit; its 429s go through the ordinary retry loop. -
Caching is content-keyed per sheet, so re-running a set after editing one sheet only re-pays vision for the changed sheet. It is now two-level — a cheap pre-render key recognizes an unchanged sheet before rasterizing, so a cached re-run skips rendering entirely (see Performance).
Two headline savings, both measured on real dense sets, plus a deliberate non-goal:
- Skip-render on a cache hit (two-level key). Rasterizing a sheet's overview
- 36 tiles is the dominant re-run cost (~4.5 s/sheet; ~2.5 min for a 33-sheet
set). A digest is deterministic given the rendered images, so before rendering
the pipeline computes a level-1 key from cheap page access alone and, on a
hit, serves the cached digest without rendering. The level-1 identity uses a
conservative page dependency hash rooted in the source
content_sha256: content streams, forms, images, fonts, inherited page attributes, rendered annotations/appearance streams, and relevant document rendering globals. An edit to one page therefore rekeys that page while unchanged siblings keep their hits. If dependency isolation is ever ambiguous, it safely falls back to the whole-source hash (a possible false miss, never a stale hit). It also includes the render config (grid / overlap / target / blank-suppression), the canonical coordinate-space version, and the renderer environment (OS + PyMuPDF/MuPDF build, so a cache copied between installations misses rather than serving pixels this one wouldn't reproduce). A fully-cached 33-sheet re-run drops ~2.5 min of render to ~0. On a miss the sheet renders, the digest is computed, and it is stored under both the level-1 key and the existing rendered-bytes (level-2) key, so the next run skips it. The critique has its own level-1 key too, so an unchanged exhaustive re-run skips both its API calls and rasterization — previously the critique had to re-render every sheet just to discover its result was already cached.
- 36 tiles is the dominant re-run cost (~4.5 s/sheet; ~2.5 min for a 33-sheet
set). A digest is deterministic given the rendered images, so before rendering
the pipeline computes a level-1 key from cheap page access alone and, on a
hit, serves the cached digest without rendering. The level-1 identity uses a
conservative page dependency hash rooted in the source
- Parallel Files-API uploads. An E-size sheet's 37 images upload on a small pool
(default 6,
DRAWING_ANALYZER_UPLOAD_WORKERS) instead of one at a time — the dominant batch-path latency after rendering. Parallelism changes only scheduling: each image keeps the same retry taxonomy (transient503s re-issued with backoff; ambiguous connection/timeout errors left to the SDK's idempotent retries), so a lost response still can't orphan a stored file, and the first hard failure stops the sheet's remaining uploads. - Exact paid-stage reuse. Successful synthesis, focus, cross-sheet QC, prose-structuring, verification, and investigation results are content-addressed over their complete model-visible inputs, prompts, models, and output-shaping parameters. A warm identical run makes zero calls for those stages; partial, malformed, degraded, or unsaved-evidence results are never admitted.
- Render/upload reuse within a run. Real-time digest renders are spooled and consumed byte-for-byte by critique instead of rasterizing the same page again. Economy-mode digest uploads transfer ownership to critique, which changes only the final task instruction and reuses the same remote file IDs; every ID still has exactly one cleanup owner. Batch rendering also looks ahead by one sheet so the next page can render while the current page uploads, with a hard two-sheet memory bound and original-order assembly.
- Scalable persistent cache. The legacy whole-file JSON store migrates in place to transactional SQLite/WAL storage (the configured filename remains compatible). Reads and upserts touch one row instead of rewriting every cached drawing, concurrent app instances coordinate through SQLite, and a corrupt row degrades to one cache miss instead of losing the store.
- Bounded deterministic fan-out. Independent cross-QC shards, prose stragglers, and verification calls run on small configurable pools. PDF crop rendering/evidence persistence stays serialized; independent source-PDF markup writers use isolated spawned processes. Independent set-level synthesis, focus, and cross-QC calls also overlap the identity/planning/critique path when the real SDK client is in use. Results still fold back in input order, so completion timing cannot reorder the exported review.
- Blank-tile suppression. A tile whose pixmap is pixel-uniform (a truly
empty crop of a sparse sheet) carries no information, so it is dropped before
upload and disclosed to the model ("Tiles omitted as completely blank: …").
The strict, uniform-only check is always on and can never drop a tile with any
mark on it. An opt-in near-blank heuristic (
DRAWING_ANALYZER_SUPPRESS_NEAR_BLANK=1, a PNG-byte threshold) is far more aggressive — it dropped ~9 % of tiles on a dense fire-protection set and up to a third on schedule sheets — but can drop a tile bearing a few faint marks, so it is off by default: data over savings. - Non-goal — render-once-then-crop. Benchmarked against the current per-tile clip rendering on a real dense set (34.4 s vs 35.7 s — a wash: the ~75-megapixel full-page raster on sparse sheets eats the display-list savings on dense ones), so the current render path is left as-is. Process-parallel rendering across sheets remains a legitimate future option for 100+-sheet sets.
Note: the digest cache's schema version folds into every key, so a change to what is stored or how a sheet is identified invalidates every pre-existing entry once; the first run after upgrading re-digests each sheet, then caches as before. It was bumped for the two-level key and blank-tile suppression, for the safe whole-source identity, and again for conservative page-local dependency hashing (so sibling pages survive unrelated edits without reintroducing stale hits).
Cost of the exhaustive critique. A plain digest is roughly $0.4–0.6/sheet
real-time (~half that via Batches). Turning on the critique pass
adds a second full-coverage read run twice — but in a use_batch run those two
reads ride the Message Batches path at the ~50% batch rate (Phase 23C), so the
reviewer is no longer double-priced at real time. On the real-time path the two
self-consistency reads instead prompt-cache their shared image prefix: they are
byte-identical requests issued back-to-back, so the first writes the cache and the
second serves the ~90k image tokens at ~0.1× — the cache write/read tokens are
priced by their own rate class in the usage ledger, so total_estimated_cost stays
honest. An exhaustive critique=True run lands around $2–3.5/sheet once
verification (real-time) is included. The offline stages (reference audit,
anchoring, markup, text extraction) stay $0. Every stage is individually cached, so
a re-run of an unchanged set skips the model calls.
Usage & cost accounting. Every API call/attempt appends a priced
UsageRecord to an append-only ledger on DrawingContext.run_usage
(stage_family, transport real-time / batch / cache, model, input/output/cache
tokens, tool uses, cache-hit, parse-success, and estimated_cost); the run's
total_input_tokens / total_output_tokens / total_estimated_cost are
derived sums, so no stage can overwrite another's counters and the totals
always equal the exact sum of the records. A cache hit records zero billed tokens;
a batch call is priced at the batch rate, a real-time call at the standard rate —
per record. Before an exhaustive run the GUI shows a per-stage estimate with a
low–high range (verification and citation scale with how many findings/citations
turn up); while it runs the spend line shows what is billed, in flight and
projected (see Live spend and the budget cap);
after the run the completion summary and a collapsible Token usage &
estimated cost by stage table in the report show the actuals. Pricing constants
carry a verified effective date (core.pricing.PRICING_EFFECTIVE_DATE) —
re-verify against the published rates before relying on a dollar figure.
Haiku 5.5 is the default for set identity and prose-to-finding structuring,
both at medium effort. The visual review, cross-sheet QC and verification
stages retain their existing models. Synthesis and the focus report run on
Sonnet 5.5 at high effort: both are text-only calls over the digests the Opus
vision read already wrote, so Sonnet does the same reconciliation at half Opus
5.5's per-token price. Use DRAWING_ANALYZER_IDENTITY_MODEL and
DRAWING_ANALYZER_HARVEST_MODEL to select another model for either extraction
stage. Haiku 5.5 is also registered for the other model overrides, including
the report assistant.
Haiku's input/output rates are $0.10/$0.50 per million tokens for prompts up to 100,000 tokens, and $0.50/$2.50 above that threshold. The threshold counts ordinary input plus cached reads and writes in each API request; output tokens and the run's cumulative token total do not select the tier. The preview, usage ledger and report chat price requests separately, with cache multipliers and the Batch discount applied afterwards. Refused or truncated extraction replies are not cached as valid results; prose harvest retains the original item through its existing degraded finding path. See the Haiku 5.5 overview and pricing.
Alongside the prose digest, the vision model emits a machine-readable findings
block — a fenced json block appended after all prose:
{"findings": [
{"sheet_id": "M-101", "category": "code", "severity": "high",
"text": "VAV-3 has no shown clearance to the wall.",
"source_quote": "VAV-3", "tile": [2, 3], "refs": ["CMC 310"]}
]}Each finding carries a category (code / conflict / coordination /
question), a severity (high / medium / low), a one-to-two-sentence
text, a source_quote copied verbatim from the sheet's text layer (the
hook the anchor resolver uses to place it, and the hallucination alarm when a
quote can't be found), the tile where the model saw it, and any refs it
believes apply. The model emits at most 40 per sheet.
The parser reads that block line-anchored: a fence must start its own line,
may be three or more backticks or tildes, and a closing fence has to match its
opener's character and be at least as long. That is stricter than it looks and
deliberately so. Scanning for a bare ``` at any offset meant an inline
triple-backtick span inside the prose opened a phantom block, which the real
json opener then closed — the findings came back empty while the whole JSON
body leaked into the prose digest, and the prose after the inline span was
silently dropped. A truncated block (the model cut off mid-JSON) is still
recognised and stripped, so a partial machine block never reaches the prose.
A reply the model did not finish is never treated as one it did. When a digest
stops at max_tokens the request is retried once at a raised output cap; if it
is still cut off, the sheet is reported as failed and the result is not
cached — a stored truncation would otherwise be served on every later run,
indistinguishable from a complete read. The partial prose still ships,
including in the combined digest, where a note under it says the sheet's
digest is partial. If the raised-cap retry fails, or comes back with no text at
all, the first read's partial prose is what ships, in every processing mode.
A reply the model declined (stop_reason="refusal", part-way through or
before any output) is handled the same way, except that it is not re-sent. A
real-time digest (Hybrid and Fast modes) has already been through the
server-side refusal fallback; a Message Batches item (Economy mode) cannot carry
that fallback, so the same request would only be declined again — a sheet
declined in Economy mode may still succeed in Hybrid or Fast. A digest that an
earlier version cached under either stop reason is no longer served: the next
run reads that sheet again. The cross-sheet synthesis and the focus
report follow the same rule. A reply cut off at its 32k cap is retried once at
a raised cap; one that is still cut off, or declined, ships its partial text
with a note in the combined digest saying it is partial, holds that stage at
partial, appears in the run's errors, and is never cached. A partial overview
is not mined for findings.
A tolerant parser splits this block off and strips it from the prose, so the
digest text — and the combined_text a downstream spec reviewer consumes — is
byte-for-byte what it was before the block existed (the prose digest is sacred).
The parser absorbs the small ways models drift: it takes the last fenced block,
trims to the outermost {…}, tolerates a trailing comma, and drops any item
that fails validation (logging the count). It is line-aware, so even an
unclosed block — output cut off mid-JSON by max_tokens, with no closing fence —
is recognised and stripped from the prose (and, when the JSON object it holds is
nonetheless complete, still parsed); a truncated machine block can never contaminate
combined_text (DA-009). A malformed or missing block is never fatal — the prose
digest still ships; the findings simply come back empty. Parsed findings are cached
with the digest, so a cached re-run restores them for free.
The digest describes a sheet; the critique attacks it. Turned on with
critique=True (the exhaustive QC mode), the analyzer makes a second
full-coverage vision read of each sheet — the same overview + tile grid +
verbatim text layer the digest saw — but under a different persona: a senior
engineer performing a rigorous back-check / QA markup of a check print before it
is issued, whose only job is to find problems. It is instructed to report, with
severity: outright errors; likely code concerns (cited conservatively); RFI-worthy
ambiguities; internal inconsistencies; stale / copy-paste text; and — crucially —
absences: content a complete sheet of that discipline should show but this one
doesn't (a required test, drain, sign, clearance, note, or detail), each phrased
"expected X; not found on this sheet." The critique sees one sheet, and much of
that content properly lives elsewhere: a fire protection plan sheet's test,
signage and bracing requirements usually sit on the general-notes sheet, its
details on the 500-series sheets. So an absence is reported at low severity
unless the missing item belongs on that sheet in particular (part of what the
sheet itself draws, schedules or details), and then the finding says why.
Absence and sheet-level findings carry an anchor_hint of SHEET instead of a
quote, so the anchor resolver places them against the whole sheet. After the
critique, the absence cross-check searches the rest of
the set's text for each one.
The critique emits only the machine-readable findings block — no prose — so the
prose digest (and combined_text) is untouched (I-2). The shared block parser is
line-aware: even a response cut off mid-JSON (a max_tokens truncation with no
closing fence) is recognised and stripped, so a partial machine block can never
leak into the sacred prose (DA-009).
Self-consistency. The critique runs twice. A read counts as a real read
only if it returned a valid findings schema — an explicit {"findings": []}
included; a prose-only, truncated, or malformed body is a failed read, never an
empty success, so it is never merged as a clean sheet or cached as corroborated
(DA-008). Each read stamps its own provenance (critique_1 / critique_2) at
production, and the merge follows the honest verdict: a finding both valid reads
surface is REPRODUCED (reproduced = true); one only a single read raised is a
SINGLETON (reproduced = false) but kept (more markups is better); and when a
requested read failed, a surviving finding is NOT_ASSESSED_PARTIAL — never
silently marked reproduced because a broken read "agreed". Finding.confidence
carries this verdict; reproduced is derived from it and, like before, is a soft
signal that never suppresses a finding. The merge deduplicates on semantic
sameness with compatible critical signatures (a shared tile or overlapping
rectangle alone never merges — see the ledger).
The digest's findings and the merged critique's then pool into one per-sheet set
before anchoring — an issue the digest and the critique independently raised is
also marked reproduced. The ledger applies the
same rule when it merges across channels, and raises confidence with it: an
entry can never read "corroborated by two channels" and "only one of the two
reads saw it" at the same time. The checklist a sheet receives is sent identically to
both of its reads (never split across them), so the two stay directly comparable.
The merged critique is cached under its own key, so a re-run skips the extra
calls. The digest's images are gone by the time the critique runs (the batch path
streams and discards them), so the critique renders each uncached sheet once more —
but in a use_batch run it uploads that render once and runs both reads off
the shared upload through the Message Batches API (the ~50% batch rate), rather than
inlining the images twice at real-time price. (Sharing a single upload across the
digest and the critique — so the sheet is neither rendered nor uploaded a second
time — is a deferred follow-up; today the reuse is within the critique's two reads.)
critique=True is still more expensive than a plain digest (see
Performance), just no longer double-priced. It is additive and
non-fatal: a failure is recorded and the standard deliverable ships — a critique
batch that can't be collected degrades those sheets' critique, never the digest. The model defaults to Opus 5.5
(DRAWING_ANALYZER_CRITIQUE_MODEL); the run count is DRAWING_ANALYZER_CRITIQUE_RUNS
(default 2; set 1 to disable self-consistency).
The critique is the one high-volume call in the pipeline whose reply is JSON and
only JSON — "no prose before or after it." That makes it the single stage a
whole-response schema actually fits. The digest cannot use one at all: it writes
a full Markdown digest and then a findings block, and output_config.format
constrains the entire response, so there is no schema that describes "prose,
then JSON."
Set DRAWING_ANALYZER_CRITIQUE_STRUCTURED_OUTPUTS=1 and each critique read
carries a JSON schema the decoder itself enforces, with the "output a fenced
json block" sentence swapped out of the prompt. Everything else in that prompt
is unchanged — it is derived from the normal instruction by substitution
rather than maintained as a second copy, so the category enum, the
verbatim-quote rule, the 40-finding cap and the whole claims contract keep
exactly one author.
It is off by default, and that is a statement about evidence rather than about confidence. Anthropic documents citations and message prefill as the only incompatibilities of structured outputs, and says nothing either way about image inputs — while every critique request carries an overview plus a full grid of tiles. No hermetic test can settle that; one live call can:
ANTHROPIC_API_KEY=sk-ant-... python -m pytest -m network \
tests/test_live_api_canary.py -k output_config_formatThat canary asserts the request is accepted, that the reply comes back with no fence, and that the self-consistency merge still completes both reads. If it passes on your account, turn the flag on.
Three things keep it safe to try. The run self-heals: the first 400 naming the contract turns the feature off for the rest of the process and re-sends that same read under the fenced contract, so a sheet is never lost learning that the platform won't take it — and that retry does not consume the sheet's transient retry budget, because a capability answer is permanent rather than a blip. The tolerant parser stays, and a schema-constrained request whose reply comes back fenced anyway parses on the ordinary path without the latch ever firing. And structured and fenced reads cache under separate keys, so nothing you have already paid for is discarded and flipping the flag back and forth costs one re-read per sheet each way rather than invalidating both sides. The Message-Batches transport ignores the flag entirely: a batch item's shape is fixed at submit and a rejection surfaces per item after the whole batch is built and billed, so the transport that cannot degrade does not opt in.
The same contract is available, behind its own flag, on the two other stages
whose reply is JSON and nothing else. The prose harvest's per-straggler
structuring call (DRAWING_ANALYZER_HARVEST_STRUCTURED_OUTPUTS=1) is text-only,
Haiku 5.5, one flat finding object — the lowest-risk place to try the feature —
and its schema mirrors the prompt's field list one-for-one, with the same
category and severity enums the host validator checks. The verifier's
verdict (DRAWING_ANALYZER_VERIFY_STRUCTURED_OUTPUTS=1) becomes verdict as a
three-way enum plus note, on the single-crop and cross-sheet calls alike; it
is vision-involved, so it carries the critique's caveat and its own canary. All
three stages share one gate (core/structured_outputs.py: env opt-in → model
capability → self-healing latch), but each owns its own latch — a rejection
on the critique's multi-image request says nothing about a text-only call and must
not switch it off — and each keys structured and plain results apart, so
nothing already paid for is discarded. Each has a live canary
(-k harvest_under_output_config_format, -k verify_under_output_config_format);
flip a default only after its canary passes on your account.
The universal-reviewer stages (Phase A): before the critique runs, the pipeline first works out what the set IS, then what a specialist would check.
Set identity. One text-only call reads a budgeted corpus — every sheet's
digest head, the earliest sheets' verbatim text layers (cover sheet / index /
general notes), and verbatim windows around every code-edition mention anywhere
in the set — and returns a structured SetIdentity: the disciplines present, a
sheet → discipline map, the project's jurisdiction / country / region, its
language and units, and the adopted codes (each with a verbatim evidence
quote and source sheet). Codes may come from any country's system — NFPA, IBC,
Eurocode/EN, BS, DIN, AS/NZS, GB, … — and the deterministic regex edition
harvest is kept as a backstop, so a stated edition can never be argued away.
The regex finds a code next to a year and cannot tell an adoption from a
mention: NFPA 13 2016 is not the governing edition matches as readily as
all work per NFPA 13 2016. So an edition only the regex found is recorded as
a mentioned code (mentioned_codes, origin="regex"), never an adopted
one. Mentions stay visible, labelled not stated to govern the work, in
set_identity.json, the Set Identity section, the review plan's facts and
the citation check's request. The identity is advisory: it steers the stages below but never
gates or suppresses a finding; a wrong detection is visible (not laundered) in
set_identity.json, the run manifest, and the combined text's Set Identity
section. The identity call runs on Haiku 5.5 at medium effort by default
(DRAWING_ANALYZER_IDENTITY_MODEL): it is structured extraction, it is
advisory only — nothing gates a finding on it — and the deterministic regex
edition harvest is the backstop the model cannot argue away.
Governing code editions (your override). Detection reads what the drawings
say, and the drawings can be wrong or silent: a general note copied from an
older job, or an AHJ that adopted NFPA 13 a cycle or two behind the current
edition. In the GUI, load your AHJ Atlas Input (Loaded files → Load AHJ
Atlas Input…, an Excel workbook — see Loaded files);
from the library pass governing_editions="NFPA 13 2025; IBC 2024" or a list of
entries. Separate entries with semicolons or new lines; each needs a
code and a 4-digit year, with an optional note in parentheses
(NFPA 13 2022 (state amendments)). The year is read from the end of the
entry, so NFPA 2001 2022 is NFPA 2001, 2022 edition, and one code may be
listed at two editions (NFPA 13 2016 (existing system)). An entry without a
year stops the run before anything is spent. For each code you name, your
edition replaces the detected one as the adopted edition seen by the citation
check, the review plan, cross-sheet QC and the edition audit; the edition the
drawings stated is kept as a mention, and codes you don't name keep the
detected record. The audit treats your edition as text-grounded, so a citation
of any other edition of that code is flagged at medium severity when the cited
edition is in the sheet text. Your entries are marked set by the reviewer in
set_identity.json and the Set Identity section, and recorded in the run
manifest's configuration. They are applied after the identity cache, so the
identity call itself is not repeated. On a QC Markups run, though, setting or
changing them rewrites the review plan, and the plan is part of the critique
prompt, so every sheet's critique re-runs once (later runs with the same
entries read from cache). A run with no QC ignores them.
Model-authored review plan. From the identity's canonical review facts, a second
text-only call authors the per-discipline checklist a specialist for this set
would apply — one-line "flag X when Y" items in the exact review-profile style,
bounded host-side (≤60 items by default, DRAWING_ANALYZER_MAX_PLAN_ITEMS, with
the overage shared across disciplines rather than spent on the alphabetically-last
one).
Every code-based item must name its code + section + edition inline and never
invent a section number: the critique echoes those refs into the findings, and
the citation check verifies them against the published section text — the
plan's code claims are never trusted, they are checked. The plan is injected through the profile
machinery below (after any user checklists), snapshotted with
source="model", exported as review_plan.md, and cached content-addressed so
warm re-runs rebuild it byte-identically (keeping the critique cache hot).
The planner consumes and caches only canonical review facts: disciplines,
project and set type, jurisdiction, country/region, language, units, adopted
code editions/amendments, and editions the drawings only mention (under a
separate mentioned_not_adopted_codes key, present only when there are some).
Routine revisions retain the checklist whenever those
facts match, even if no sheets remain unchanged. Reuse requires detected
disciplines plus a project type, jurisdiction, or a named adopted or mentioned
code. With an
insufficient identity, the planner falls back to digest-based authoring without
caching that plan.
Renaming a PDF, adding/removing a page, revising a sheet, or recovering a failed
digest therefore preserves paid critique results for unchanged sheets. Evidence,
confidence, sheet classifications, and code source labels do not reset the plan.
The scope cache persists across restarts; its key never includes filenames,
page counts, or digest prose. Changed review requirements or planner settings
require a new plan. Older corpus-based plan entries miss once under the new
planner prompt; a newly authored checklist can also miss existing critique
entries once. Per-sheet caches keep their existing schema and keys. Retained
plans are identified in review_plan.md and the run journal. The critique cache
continues to hash the exact injected checklist,
including any edited user profiles.
Both stages ride the critique stack automatically (qc_markups=True, or an
expert critique=True / citation_check=True) and add two real-time text
calls on a cold run, disclosed in the pre-run cost estimate; standard digest-only runs make
neither call. Expert overrides: identity=False / review_plan=False
(recorded debug overrides inside exhaustive QC). The identity also feeds the
citation check (an adopted-codes line, a separate line for editions only
mentioned, and a jurisdiction/locale line) and
cross-sheet QC (a set-context preamble that kills metric-vs-imperial false
conflicts). Models are overridable via DRAWING_ANALYZER_IDENTITY_MODEL /
DRAWING_ANALYZER_REVIEW_PLAN_MODEL.
The critique reads with a senior engineer's general judgment, plus the model-authored plan above. A review profile adds your own judgment: the owner's QC knowledge written down as a versioned checklist, injected into the critique prompt so the model applies each item deliberately, not incidentally. Pass the profile names to a critique run:
ctx = extract_drawing_context(
pdfs, critique=True, qc_markups=True,
profiles=["my-office-checklist"], # names of user profiles to apply
)Uploaded checklists. The GUI's Add checklists… turns a checklist file —
Excel (one item per row), Word, PDF, Markdown or text — into a profile for this
run without installing it (profiles.load_checklist_file(path); pass the
returned Profile in profiles= alongside names). Its name is
upload:<file name>, so it never shadows an installed profile, and the run
manifest records its source as uploaded.
Selected profiles' checklists are appended to the critique prompt under "APPLY
THIS REVIEW CHECKLIST EXPLICITLY, ITEM BY ITEM" (user profiles first, then the
model-authored plan). Every item is printed with an id — U1.3 is the third
item of your first checklist, M2.7 the seventh of the second model plan — and
the ids are assigned over the whole selection, so an item keeps one id on every
sheet. The exact text injected into a sheet's request folds into that sheet's
critique cache key — so editing a checklist re-critiques the sheets that
received it, while every other sheet is served from cache. Both of a sheet's
self-consistency reads receive the identical checklist (the merge's
reproduced flag depends on it). Profiles only take effect with
critique=True; unknown names are skipped without failing the run.
A checklist is no longer sent to every sheet regardless of discipline:
| Checklist | Scoped by | Example |
|---|---|---|
Yours (installed profile or uploaded file) with disciplines |
the sheet's own number prefix, read from its title block | disciplines: F, FP → FP-101, F-D-01-1; not E-201 |
Yours with no disciplines |
nothing | every sheet |
| A model-authored plan | the discipline the set-identity stage classified the sheet under | the mechanical plan → sheets classified mechanical |
Scoping narrows only on evidence and fails open everywhere else. A sheet
whose number cannot be read, a sheet the identity stage did not classify, and a
sheet classified under a discipline no plan was written for all receive the
checklist. A wrong guess costs attention, never a skipped check. An uploaded
spreadsheet has no header to carry disciplines, so the GUI's Scope…
button sets it (profiles.scoped_to(profile, "F, FP") from code).
The reviewer must account for every item on every sheet it was given:
violated, met, not applicable, or undetermined. A finding names the item(s) it
reports against (checklist_items), and that link survives every merge. After
verification the run rolls this up (ctx.checklist_report) into three places:
report.html— a Checklist Results card: each item, its result, how many sheets ended at each result, and theQC-###findings against it. Each finding row shows[checklist U1.3].checklist_matrix.csv— the full grid, one row per item and one column per sheet (UTF-8 BOM, CRLF, opens in Excel).run_manifest.json— achecklistblock with each item's result, per-result sheet counts and finding numbers.findings.csvgains achecklist_itemscolumn.
| Result | What it means |
|---|---|
| Violated | A finding stands against the item. It went through anchoring and verification like every other finding. |
| Flagged, no finding | The reviewer called it violated but no finding reached the ledger. Nothing backs it. |
| Flagged, rejected on verification | Every finding against it was rejected by the crop re-check. |
| Undetermined | The sheet shows the subject but cannot settle it alone. |
| Reported met | The reviewer says the sheet complies. Nothing verified this. It is never worded "pass". |
| Not applicable | The sheet does not show what the item concerns. |
| Not answered | The reviewer did not account for the item. Never assumed met. |
| Not reviewed | The sheet had no valid critique read. |
| Out of scope | The sheet was never given the item. |
The host decides, the model only reports: an id the sheet was never given is dropped, a finding that cites an item makes it violated whatever list the reply put it in, and when a sheet's two reads disagree the answer that hides least wins (violated, then undetermined, then met). An item's set-level result is its most attention-worthy sheet, so one unanswered sheet outranks ten that reported it met. Unanswered checks are counted in a stage warning and in the GUI's completion line; they do not lower the run's QC status.
No built-in checklists ship — the model authors the review plan per set,
so the tool carries no discipline bias. A worked NFPA 13 sprinkler example
(the former starter profile) lives at docs/examples/fire_protection.md; copy
it into your profiles directory to use or adapt it.
drawing_analyzer.profiles.suggest_profiles(sheet_ids) proposes profiles whose
disciplines match a set's sheet numbering — via a shared, project-prefix-aware
discipline detector (auditors/sheet_ids.py), so a project-coded id like
AVC10-F-D-01-1 is matched on its fire-protection segment (F), not the project
code, and compact forms like FP101 work too. suggest_profiles_for_paths(paths)
runs a cheap text-only preflight (no rasterization) to read each sheet id and
auto-suggest for a whole set; the GUI uses it to pre-check applicable profiles once
files are loaded (manual choice always wins). The profiles actually applied are
snapshotted (name + version + content hash + source) at Analyze time onto
ctx.profile_snapshots.
A profile is a Markdown file with a small frontmatter header and a flat list of one-line checklist items:
---
name: fire-protection
title: Fire Protection — NFPA 13 sprinkler QC
disciplines: F, FP, SP
version: 1
author: Your Name
date: 2026-07-08
---
- Dry/DIPA design areas carry the +30% increase over the wet base; flag a dry
row whose remote area equals the wet base. [high] (NFPA 13 §19.2.3.2.5)
- EH standard-spray coverage ≤ 100 ft²; flag a larger max-area value. [medium]
- Every dry/preaction system must show its ITV, air supply, low-point drains,
and gauges; flag any missing (an absence). [medium]- Frontmatter (between the
---lines):name(stable id — a user file reusing a packaged profile's name shadows it),title,disciplines(comma-separated tags matched against sheet-id prefixes for auto-suggest),version,author,date. - Items: each Markdown bullet is one check, injected verbatim — say what
to check, what "wrong" looks like, a severity hint in
[brackets], and a code reference where one applies. Absences read best as "expected X; not found on this sheet."
Profiles are discovered from the packaged profiles directory (empty by default)
and from your own — ~/.drawing_analyzer/profiles/ (override with
DRAWING_ANALYZER_PROFILES_DIR), where your file wins on a name collision.
Bump version (or just edit — the content hash changes either way) to re-run
the critique against the new checklist.
The digest and critique read each sheet on its own. A whole class of real errors
is only visible between sheets: the same tag valued two ways, a standard note
that diverged on a sibling sheet, a note on one sheet contradicted by another, a
cross-reference sending you to a sheet that disclaims what the pointer promised.
Turned on with cross_qc=True, the cross-sheet QC pass hunts exactly those.
It is a text-reasoning task — for a set up to 40 sheets, one Opus call over all
the per-sheet digests + verbatim text layers, with no images. It is
distinct from the prose synthesis, which stays as-is, and like
the critique it never touches combined_text (the conflicts live only in the
findings artifacts).
Whole-set above 40 sheets. A larger set shards by discipline and uses a
map → reconcile pass: each shard call returns its local conflicts and a set
of compact, grounded facts (the comparable values another shard might contradict),
then a final reconciliation call compares those facts across all shards — so a
conflict whose two sheets fell in different shards is still found (a plain
shard-and-union would miss it). The model sees only request-local opaque
handles in the sharded path — never source identity — and every fact/leg quote is
validated against the retained source text before it is trusted, so an ungrounded
quote never becomes a dual-anchor finding. Each sheet's text layer is budgeted with
the omission counted and surfaced (never a silent truncation) — and so is the
per-response findings cap: a response carrying more conflicts than the cap used
to be truncated with no counter and still report itself complete, which on a
large set (exactly where coordination conflicts matter most) is
indistinguishable from a set that simply had fewer. A failed shard, a failed
reconciliation, or a degraded budget holds the stage at PARTIAL while its
findings stay usable.
Its findings carry dual anchors: a primary anchor on one sheet plus one or
more also_on legs on the other sheets in the conflict, each resolved to its own
sheet (via the set's title-block sheet-ids). The markup writer then clouds
both sheets — each cloud's popup cross-references the other ("Conflicts with
F-A-01-1: 'COLO 1'") — so a reviewer opening either drawing sees the conflict and
where its counterpart lives. The model defaults to Opus 5.5
(DRAWING_ANALYZER_CROSS_QC_MODEL).
A cross-sheet conflict can't be judged from one sheet's crop, so these findings
get a dual-crop verification: the verifier is sent one crop per sheet in a
single call and asked whether the sheets actually conflict — so a cross-sheet
finding can reach VERIFIED / REJECTED on the merits like any other finding
(and a rejected one loses its ink, §18)
instead of idling at UNCERTAIN.
A finding is only useful on a marked-up drawing if it sits on the thing it's
about. The anchor resolver maps each finding's verbatim source_quote back to
a rectangle on its page — in the canonical PAGE_VIEW_V2 coordinate space
(see Rotation & page boxes), offline and with no model
call, using a tiered strategy that records which tier fired:
-
EXACT — the normalized quote matches a run of words verbatim. When the quote appears more than once on the sheet (the "BATTERY ROOM in two schedule rows" trap), the occurrence inside the tile the model reported is preferred; if that still doesn't settle it, the finding is flagged
exact_ambiguous. -
FUZZY — no exact run, but a sliding window of words overlaps the quote's tokens ≥ 85%, or the longest distinctive sub-phrase (≥ 3 tokens) of the quote appears verbatim. Whitespace/linebreak artifacts and Unicode punctuation (dashes, curly quotes, the
2 1/2"vs2-1/2"and″/"inch marks, theØdiameter symbol) are the usual reason exact fails; normalization folds most of them — including vulgar fractions, which need care: Unicode normalization expands½to1⁄2with no separating space, so2½"used to become21⁄2"— twenty-one halves — and could never match a sheet reading2-1/2". On a pipe size that is the difference between a 2.5 inch drain and a 21 inch one.A fuzzy match must additionally clear the numeric veto. Token overlap is blind to the substitution that matters most on a drawing: swap one digit and the score barely moves. On a sheet reading
PROVIDE 6 INCH DRAIN AT COLUMN LINE 4, every one-number substitution tried —4,12,2-1/2, a changed column line — scored 6/7 = 0.857 and cleared the 85% floor, so a finding claiming a 4-inch drain was clouded onto text specifying 6. On a fire-sprinkler drain that is not cosmetic, and it defeated the hallucination signal precisely where it matters. So every measurement the quote carries must appear at its own position in the matched span, within a drift budget derived from the overlap floor itself, and each position in the span counts once — a note reading4 4-INCH DRAINScannot satisfy both mentions from a sheet's single4. A sub-phrase match may not drop a measurement either. A quote carrying no digits is unaffected, and ordinary transcription variance (an extra word on the sheet, an abbreviation, a paraphrase) still matches. The 85% threshold itself is unchanged: this is a veto, not a stricter score. -
TILE — a graphics-only finding (empty quote) is anchored to its reported tile's rectangle: coarse, but honest.
-
UNANCHORED — a non-empty quote that matches nothing anywhere. This is the hallucination signal: with no rectangle to cloud, the finding lands as a margin callout with an
[UNANCHORED]prefix (§18) — flagged loudly, never dropped, and never drawn as if it had a location.
Findings the deterministic auditors already placed arrive pre-anchored and are left untouched. Like the tile geometry, the resolver imports no PDF engine — it works on the extracted word rectangles alone.
A drawing sheet can be rotated (a portrait page displayed landscape via
/Rotate 90) or cropped (a CropBox smaller than, or offset from, the
MediaBox). PyMuPDF reports and accepts coordinates in two different spaces
that diverge on such a page — text extraction and annotation writing use an
un-rotated, CropBox-relative space, while the rasterizer clips in the rotated
page-view space the model actually sees. Using one where the other is meant
mis-places findings on rotated pages (the class of bug this release closes).
The pipeline settles on one canonical space, PAGE_VIEW_V2: top-left origin,
post-CropBox, post-rotation — exactly the frame of the overview + tile images the
model reads. Every word rectangle, anchor, verification crop, and persisted
finding rectangle lives in it. The two PyMuPDF-touching modules convert only at
their own boundaries: render.py transforms extracted words into view space once
(via the page's rotation matrix), and annotate.py transforms each rectangle back
to page space (via the derotation matrix) just before it draws — with callout text
rotated so it reads upright on a rotated sheet. Everything in between works on
plain view-space numbers with no PDF engine. On an ordinary un-rotated page with a
default CropBox both transforms are the identity, so nothing changes. Each page's
frame and the two transforms are captured in a PageGeometry record (matrices as
plain floats, so no PyMuPDF type leaks past render.py).
A GOTO destination is a third space again, and the reason a QC bookmark could
land nowhere near its mark. A /XYZ destination is expressed in default user
space — the same space an annotation's /Rect uses, which is what lets the two be
compared and is how this was pinned by measurement — while annotation placement
goes through the un-rotated CropBox-relative space above. Handing a destination the
annotation-space point drops both the CropBox origin and the MediaBox origin.
Measured across rotation × CropBox × MediaBox-origin, 24 cases: the index and
overflow links were wrong in 12 of them and the bookmark outline in 22,
with errors up to ~550 pt. The two PyMuPDF entry points that write destinations
apply different internal transforms, so each is inverted separately, composed
from PyMuPDF's own published matrices rather than a hard-coded offset. Generated
index and notes pages also pin an explicit CropBox equal to their own
MediaBox, because /CropBox is an inheritable page-tree attribute and a set
whose /Pages node carries one made a new 612×792 page render as 512×712.
An absence is the one kind of finding the crop verifier never sees: it has no
quote and no rectangle, only a sheet. And the critique reads one sheet at a
time, so "expected an inspector's test connection; not found on this sheet" is
often answered by a note on the general-notes sheet two pages away. The absence
cross-check closes that gap before markup. It runs with the critique (so with
QC Markups), and absence_check=False turns it off as a debug override.
A keyword search cannot do this job. Drawings abbreviate (ITC, INSPECTOR'S TEST CONN.), and a false match would tell the reviewer an item is covered when it is not. So a model does the search and the host checks its answer:
- Every sheet-level absence in the ledger (
anchor_hint="SHEET", one sheet, absence wording) is sent, up to 25 per request, with the verbatim text layers of every sheet in the set, each under an opaque handle (S001) and its sheet id. Each request is one text-only call on Haiku 5.5 at medium effort (DRAWING_ANALYZER_ABSENCE_CHECK_MODEL) and re-reads the whole set's text, under a cent per request at 40 sheets. The pre-run estimate quotes 2–5 requests for a 40-sheet set (about 2–4 cents), assuming 15% of the findings band are sheet-level absences. - For each absence the model answers
FOUND(a sheet handle and a verbatim quote) orNOT_FOUND. It is told that a note requiring a component does not show the component; only a missing note or requirement is answered by a note. - The host accepts a
FOUNDonly when the quote (at least two words and six characters, compared case- and whitespace-insensitively) is in the named sheet's own source text. A quote that is on a different sheet, a handle that does not exist, a single generic word (SPRINKLER), or an invented string is discarded and counted in a stage warning. - An accepted hit is recorded on the finding (
located_elsewhere: the sheet id, the quote, and whether it is the same sheet) and lowers a high or medium finding to low. Nothing is deleted: the reviewer still sees the finding, now saying where the content is.
NOT_FOUND changes nothing. A text layer cannot show content drawn only as
graphics, so its silence proves no absence. Specifications are not searched:
a spec that requires an item is not evidence that a drawing shows it.
The located sheet and quote appear in the popup on the drawing (Shown on
FP-001: "PROVIDE ITC AT REMOTE END" (lowered from medium)), in the HTML
report's findings table, and in three findings.csv columns (located_sheet,
located_quote, located_lowered_from). Each request's corpus is held to
280,000 characters, under Haiku 5.5's long-context price tier; a larger set
keeps short sheets whole and trims the longest ones equally from the end, and
the trimmed character count is a stage warning. A reply that does not parse,
is cut off, or skips an absence leaves that absence unchecked and holds the
stage at PARTIAL. Complete replies are cached on the set's text and the
absences sent, and a cached hit is re-checked against the sheet text on every
read.
Coverage proposes; precision disposes. Before any finding is clouded onto an issued drawing, the verification pass takes a surgical second look: for each anchored finding the model itself produced, it renders a high-DPI crop around the anchor and asks one small model call whether the finding actually holds in that crop — instructed to judge only what's visible, not re-argue the whole issue.
Every crop the verifier saw is preserved byte-for-byte: it is saved and hashed
before it is sent, and only saved crops are sent — a verdict may never rest on an
image absent from the trail. Each finding gets an evidence/<QC-ID>/ directory with
one leg-NN__<sheet>_pN.png per leg (a cross-sheet conflict saves every sheet's
crop, in request order) plus a request.json recording the ordered artifact
metadata and the verdict. A cross-sheet conflict is never decided from a single
crop — fewer than two saved legs degrades it to SKIPPED with a precise missing-leg
reason. (The report/popup still surface verification.evidence_png, now an alias to
the first saved crop.)
The model answers CONFIRMED / CONTRADICTED / NOT_VISIBLE, mapped to a
finding status:
| Status | Source | Meaning |
|---|---|---|
VERIFIED |
verifier CONFIRMED |
the crop shows the finding is correct. For a code finding that means the drawing fact it rests on, not the violation: the popup says so, the index reads Fact confirmed, and the report chip reads Drawing fact confirmed (the citation check is what tests the code reference) |
REJECTED |
verifier CONTRADICTED |
the crop shows the finding is wrong — pulled from the ink and listed in the index's Rejected by verification section (grey struck ink only with ink_rejected=True) |
UNCERTAIN |
verifier NOT_VISIBLE (or a garbled reply) |
can't be decided from this crop (e.g. it depends on another sheet) — a perfectly fine outcome |
DETERMINISTIC |
the offline auditors | trusted without a model re-check (references, naming, title-block, sheet-index; arithmetic only when its operands are text-extracted from the quote — a model-transcribed mismatch is UNCERTAIN and crop-verified) |
SKIPPED |
— | nothing to look at (unanchored), no crop, or the pass was unavailable (no key) |
The pass is additive and non-fatal: crops render sequentially (PyMuPDF is not
thread-safe) while the small verify calls run on a bounded pool; a per-finding
failure degrades that finding to UNCERTAIN, and a fatal auth failure marks the
rest SKIPPED — the run always completes. Each call sends one ~2–5k-token crop
image and a short prompt. The model defaults to Sonnet 5.5 at medium effort,
overridable with DRAWING_ANALYZER_VERIFICATION_MODEL (or the older
DRAWING_ANALYZER_VERIFY_MODEL, which wins when set).
Not every UNCERTAIN is a judgment. A garbled reply, a reply cut off by
the output envelope or declined, and a call that failed outright all land on
UNCERTAIN too — the safe status, never REJECTED — and until now nothing kept
them apart from a real NOT_VISIBLE. The pass counts the live calls that
returned no verdict (malformed, truncated, failed, a breakdown of the
UNCERTAIN tally, never additional to it), and when any did, the verification
stage carries one warning line in run.log and run_manifest.json:
verification: 3 of 120 live verdict calls returned no judgment (malformed=2, truncated=1, failed=0); each was left UNCERTAIN
Observational only: no status moves and the stage's completeness is unchanged.
It answers the question the UNCERTAIN share alone cannot — how much of it the
parser or the envelope produced before you blame the drawings — and it is the
number that says whether DRAWING_ANALYZER_VERIFY_STRUCTURED_OUTPUTS is worth
turning on for your sets: a near-zero malformed count means the schema buys
nothing, a persistent one is a measured quality win.
The verdict itself is one or two sentences, but the call runs adaptive
thinking at medium effort — judging whether a crop supports a finding is a
reasoning task, and thinking draws from the same output envelope as the answer.
(It always did: omitting the thinking parameter does not disable thinking on
the current models. The pass previously ran with a 1,000-token cap that had to
fit both, so verdicts came back empty and degraded to UNCERTAIN, pushing
findings into the far more expensive investigation loop for no reason.)
One crop is sometimes not enough: the answer lives in a schedule elsewhere on
the sheet, or on another sheet entirely, and the verifier honestly answers
NOT_VISIBLE. On markup runs those UNCERTAIN findings get a second,
agentic look: the model can request more evidence through three
host-executed tools — crop_region (another region of a sheet, up to 300 DPI),
find_text (a free, deterministic search of a sheet's vector text layer that
returns match rectangles to crop next), and view_sheet (an overview of
another sheet in the set) — iterating until it can conclude
CONFIRMED/CONTRADICTED/NOT_VISIBLE. Every requested image is saved to the
finding's evidence directory before it is sent (the trail also gets an
investigation.json with the ordered tool trace), and the verdict updates the
finding in place: a confirmation turns the dashed "double-check" callout into a
solid verified cloud; a contradiction removes the ink.
The loop is hard-bounded: DRAWING_ANALYZER_INVESTIGATION_MAX_ROUNDS evidence
requests per finding (default 6) — counted per request, not per turn, since
the model may ask for several crops in one turn and each is a real image
rendered, saved and sent — and DRAWING_ANALYZER_INVESTIGATION_MAX_FINDINGS
investigations per run, severity-first. That per-run budget scales with the
set (10 plus one per 4 sheets, capped at 40): a flat number is generous on a
20-sheet permit set and severe on a 200-sheet one, which simply has more
UNCERTAIN findings worth resolving.
Each investigation also carries an advisory task budget — a token countdown
the model can see while it works, so it converges on an answer rather than being
cut off mid-thought when the host withdraws its tools. At the budget the host
forces a final text-only answer, and a finding that still can't be decided
stays UNCERTAIN — a budget cap or a garbled reply can never mark a finding
wrong. Runs on the escalation model (Opus 5.5, high effort) by default,
overridable with DRAWING_ANALYZER_INVESTIGATION_MODEL. Each turn re-reads the
previous turn's cached prefix within seconds, so the breakpoints use the
5-minute cache entry (a 1.25x write, not the 1-hour entry's 2x); the forced
text-only close changes tool_choice, which voids the message cache, so it
writes none. Concluded verdicts cache
content-addressed against a whole-set content fingerprint; a warm re-run
replays the tool trace (re-render + hash-compare, zero API calls) so the
evidence bytes are recreated exactly and any source edit falls back to a live
investigation.
The three evidence tools are declared with Anthropic strict tool use, so the API guarantees each request validates against its schema before the host sees it. That matters here for one specific reason: the evidence budget is charged per granted request, and a malformed request still spends its slot — costing the model one of six scarce looks and buying nothing.
Strict mode is not a substitute for the host's own checks, and the schemas say
so. It constrains the schema language as well as the model: numeric bounds,
string bounds and array length are all rejected by the compiler, so those rules
moved into each parameter's description — where they still steer the model — and
stay enforced where they always were. The sharpest case is the crop rectangle.
Strict can promise it is present and is an array of numbers; it cannot promise
it holds exactly four, which is precisely what the old minItems/maxItems
pair expressed. So a three-number rect is schema-valid, still reaches the host,
and is still rejected there. As with the critique flag above, a rejection of the
strict schemas latches the feature off for the process and re-sends the same
turn unconstrained rather than failing the stage.
Turned on with the GUI's QC Markups checkbox or qc_markups=True in the
library. The payoff is a marked-up copy of the original drawing — <stem>_reviewed.pdf
— that reads like a numbered, navigable, senior plan-review set. Every annotation
is a real PDF object authored "Drawing Analyzer (AI review)" so provenance
is unmistakable: opened in Bluebeam Revu they populate the Markups List
(filter, sort, reply, export all work), and Acrobat and Chromium render them too
(the appearance stream is built explicitly, so nothing shows up blank).
Each input is tracked by a host-owned source id (SRC-0001 …), so two PDFs
that share a filename (M-101.pdf from two folders) are never confused: a
finding is anchored, verified, and clouded only onto the drawing it actually
came from, and the reviewed copies of duplicate-stem inputs are named
<stem>__SRC-0002_reviewed.pdf (source-identifying) rather than a bare _2.
The source_id is also a column in findings.csv / findings.json; absolute
paths never appear in any exported artifact.
- QC numbers. Every finding in the run gets a sequential review number
(
QC-001…, ordered sheet → position). Each inked finding carries the number as a small FreeText tag beside its markup, in the severity color; the same number appears infindings.csv,findings.json, the HTML report, and the index page — one namespace everywhere. - Severity colors. high = red, medium = orange, low and any question-category finding = blue.
- Border styles say who found it. A model finding that verified draws a
revision cloud; a
DETERMINISTIC(auditor) finding draws a solid border — the host computed it, no cloud theatrics; an opted-in unverified finding draws dashed with an[UNVERIFIED]popup prefix. - Sheet-level / absence findings (
anchor_hint="SHEET"— "expected X; not found on this sheet") have no rectangle to cloud. They become callout boxes packed into visually-clear bands — each box validated against the sheet's word rectangles, a rendered occupancy mask (so a text-free band that actually sits on piping, symbols, or a raster block is not used), and its neighbours, so a callout never obscures drawing content. The mask samples at 1:1 and asks whether any ink falls in each small cell, rather than what fraction of the box's pixels are dark: at a coarse scale a 0.5 pt pipe line antialiases lighter than the ink threshold, so a single sprinkler main across a band used to read as clear. Bands are found per vertical column as well as full-width, because a right-hand title block spans nearly the full sheet height and used to leave the whole clear drawing area beside it unusable. A leader-line arrow points to the reported tile when one is known and it would not cross another callout. A callout that will not fit any clear band overflows to an appended "AI Review Notes" page (with a GOTO link back to its source sheet) rather than being stacked over the drawing. - Index page(s). Each reviewed PDF opens with "AI DRAFT REVIEW - FINDINGS INDEX" — a table (ID, sheet, severity, status, one-line finding) where every row is a GOTO link that jumps straight to the page its mark actually landed on and the rectangle (works in Revu, Acrobat, and Chromium): for a callout that overflowed, that is the AI Review Notes page, not its source sheet, so a row never sends you to a page with nothing on it. Multi-page as needed. Every cell is fitted to its column by measured width and folded to characters the page fonts can actually draw, so an uppercase drawing note does not run into the next column and an inch mark does not render as a stray dot.
- Popups are exhaustive: the finding text, the verbatim quote, the cross-sheet pointer (dual-anchored conflicts cite each other by QC number), verification status + verifier note, code refs plus the citation-check verdict, the reproduced flag (when a self-consistency read didn't corroborate it), the evidence-crop filename, and both ids.
- Optional appendix (
DRAWING_ANALYZER_MARKUP_APPENDIX=1, off by default): a final "checked and consistent" page listing what the deterministic auditors verified clean — numeric relationships that checked out, references that resolved — the balance column of a real review.
The exhaustive default puts every ledger entry on the paper except the ones the verifier proved wrong:
| Entry | Default ink |
|---|---|
Anchored, VERIFIED / DETERMINISTIC |
cloud (or solid border for deterministic) |
Anchored, UNCERTAIN / SKIPPED |
dashed cloud, [UNVERIFIED] prefix |
| Rect-less: sheet-level / absence | margin callout, [SHEET] prefix |
| Rect-less: quote matched nothing | margin callout, [UNANCHORED] prefix — the hallucination signal, flagged loudly, never dropped |
REJECTED (verifier contradicted it) |
no ink — but a reconciled index row under "Rejected by verification (n)" with page links, so nothing is invisible. ink_rejected=True (GUI: Include rejected (grey)) additionally draws them grey and dashed with a [REJECTED] prefix. |
| Gated (verified-only mode suppressed the ink) | no ink — but a reconciled index row under "Not inked by operator gate (n)", so a deliberately-gated finding is accounted, never silently dropped. |
The conservative mode is the opt-in: markup_verified_only=True (GUI:
Verified & deterministic only, default off) suppresses everything but
VERIFIED + DETERMINISTIC; suppressed entries are tallied as gated and get a
real "Not inked by operator gate" index row (a proven placement, not a bare
status), so nothing is invisible.
The index pages read as a punch list (Phase 26B §18.7): rows sort high →
medium → low severity, then source input order, page, and position — the
stable QC-### ids never change, only their display order. The rejected and
operator-gated sections follow the main table as before.
At the end of a markup run the coverage tally is derived from receipts, not
intention. The writer follows a plan → draw → stamp → save → reopen →
reconcile protocol: it plans one placement per finding (and one per
cross-sheet leg), draws each mark, stamps every annotation and index row with a
private PDF object key carrying its placement id, saves a new file (the
source is never touched), then reopens the saved file and reconciles — a
placement counts only when its stamped, mandatory component is found again in the
artifact. The result is a
MarkupRunResult: per-placement
MarkupReceipts (WRITTEN / INDEXED / FAILED), the receipt-derived tally,
and a coverage_status.
- The tally (
Ledger 47: 39 clouded, 5 margin, 2 rejected (indexed), 1 gated (verified-only mode), 0 failed; coverage COMPLETE) appears in the GUI completion summary, the report's findings card + a coverage banner, andctx.ledger_tally/ctx.ledger_tally_line. It describes PDF ink, so runs without markups (reference audit only) leave it empty. - If any planned markup is missing, failed, duplicated, or a source changed
mid-run, coverage is
INCOMPLETE: the completion line reads QC incomplete, the affected reviewed PDF is renamed…_reviewed_INCOMPLETE.pdf, the report shows a red banner, and the run is never presented as a clean success. - Because each stamp embeds a per-run id, pre-existing annotations on the source and markups from an earlier review run carry no stamp from this run and are ignored — they can neither satisfy nor break a placement.
- Every placement + receipt + the coverage status + the sha256 of each reviewed
PDF are written to
markup_manifest.json(portable — no key, no absolute path).
Every finding — inked or not — is also written to findings.csv, one row per
finding with every field flattened (qc_id first; provenance in the sources
column; citation columns, then the absence cross-check's
three located_* columns, at the end), UTF-8 with a BOM and CRLF line endings so
Excel on Windows opens it cleanly. Model/drawing-controlled text cells are
formula-injection safe (Phase 26B, DA-031): a cell starting with =, +,
-, @, a tab, or a carriage return — even behind leading whitespace — is
apostrophe-prefixed so Excel shows it as text instead of executing it, while
host-owned numeric columns (pages, rectangles) keep their exact values and
findings.json keeps the canonical, un-prefixed strings.
The folder export itself is hardened (Phase 26B, DA-033): every artifact name
passes one containment allocator (traversal and drive prefixes dropped, Windows
reserved names and alternate-data-stream colons neutralized, collisions deduped),
every write target is proven to stay inside the export folder, evidence copies
never follow symlinks, and the whole folder is staged and atomically
published — an interrupted export leaves an explicitly *_INCOMPLETE-labeled
directory, never a final-looking one with silently missing files. In the GUI,
Export All… produces this complete folder in one action (see GUI export
options).
Two Windows realities are handled explicitly. Name collisions are deduped
case-insensitively, because M-101 and m-101 are one file on Windows and
macOS and the second sheet's text (or evidence directory) would otherwise
overwrite the first silently; the original case is still what gets written. And
every write goes through the \\?\ long-path form: an artifact name may be 120
characters, the operator picks the parent directory, and a real one
(…\OneDrive - <Company>\Projects\2026\<job>\Fire Protection\QC Reviews\ plus
the export folder, sheet_text\ and the file name) passes the 260-character
limit that applies unless the machine has LongPathsEnabled — off by default on
many images. The prefixed form stays internal: the path returned to the caller
and shown in the GUI is the plain one, and on POSIX the transform is an identity
function. The final publish rename also waits between retries now, since an
antivirus or search-indexer handle on the just-written folder does not clear
within the same microsecond three attempts used to span.
Every analysis run — standard, audit-only, exhaustive, even one where every
input was rejected — owns a run journal (ctx.run_journal): an append-only,
thread-safe event trace with a fresh opaque RUN-… id. Every export folder then
carries two artifacts built from it (Phase 26A, DA-024):
run.log— the human-readable per-run record: run id, start/end, environment identity (OS / Python / PyMuPDF / SDK versions, model, prompt + cache-schema + coordinate-space versions), the classified input inventory (SRC-####per accepted file, an explicit line per rejected one), the normalized configuration + profile snapshots, a per-sheet table (ok/failed, cache hit, raster/vector, text-layer length, omitted blank tiles, parser drift), a stage table (status, calls, items, duration) that includes the digest, per-family token/cost usage with derived totals — a stage that placed no call contributes no usage row at all — the ledger/receipt/coverage accounting, the resource-starvation check (aStarvation:header verdict plus a Resource pressure section — see below), the agent headroom check (aHeadroom:header verdict plus the Agent provisioning & headroom and Model calls by stage sections — see below), prose carry-through counts, the artifacts written, every run error, and the full event trace. UTF-8 + CRLF so Notepad reads it cleanly. This is per-run and ships in the export — distinct from the rotatingdrawing_analyzer.logdiagnostics file, which stays on the machine.run_manifest.json— the machine-readable counterpart (schema_version1): final status, configuration, source inventory, typed stage results, the usage ledger with derived totals, prose/evidence/markup-coverage summaries, theresource_pressurestarvation record, theagent_headroomblock (per-stage provisioning, headroom verdicts and the model map), sanitized errors, and the sha256 + size of every artifact in the export (run.logandmarkup_manifest.jsonincluded). Finalization is non-circular (§18.4): ordinary artifacts are written first, thenrun.log, then the manifest last — it hashes everything and excludes only itself.
Both are sanitized at the source: every journal field passes the shared
Phase 17 secret-redaction filter — which matches a secret's field name through any
underscore-joined prefix, so ANTHROPIC_API_KEY= and
DRAWING_ANALYZER_ANTHROPIC_API_KEY= are redacted and not only the values that
happen to look like Anthropic keys — plus an absolute-path scrubber at emit time
(/home/user/…/M-101.pdf → .../M-101.pdf), and the log/manifest carry counts
and identifiers — never prompts, image bytes, drawing text, long quotes, API
keys, or your directory layout. Set DRAWING_ANALYZER_BUILD to stamp a
build/commit identifier into the header.
The run's known private directories (input parents, work dir, home) are
additionally replaced with ... by literal match, which is what makes a spacey
Windows path scrub reliably — the bare regexes cannot tell where
C:\Users\Jane Smith\Job 4471\SET 01.pdf ends. That match is
case-insensitive and accepts either separator: Windows paths are
case-insensitive, and one lowercase drive letter in an exception string used to
be enough to leave the directory names in the file. Every renderer of both
artifacts receives that list, not just the errors section.
The same boundary applies to the exported Markdown and the HTML report.
00_index.md, each per-sheet file and report.html render host-generated status
and error strings — a rejected input's PermissionError, a failed API call's
exception repr, a stage note — and those now pass through the same
secret/path redaction. Digest prose deliberately does not: a sheet note reading
TOKEN: 12 at grid C:4 is drawing content, and rewriting it to protect a
credential that is not there would corrupt the review (I-2). Model findings are
left alone for the same reason.
A slow or partial run raises one question before any other: were the agents
starved while they worked? run.log and run_manifest.json answer it
directly. Every run carries a resource-pressure record
(ctx.resource_pressure, module resource_pressure.py) built from four legs:
- API capacity. Every transient retry in every stage — digest, critique,
verification, investigation, identity, review plan, focus, synthesis,
cross-QC, prose harvest, citation, plus the Files-API upload and the batch
transport's results read, status polling, harvest polling and resubmission
— is recorded: stage, kind (
rate_limited429 /overloaded529 /unavailable503 /server_error/timeout/connection), HTTP status, attempt, the backoff slept, the server'sretry-after, theanthropic-ratelimit-*-remainingheaders when the error carried them, the request id, and whether the loop finally gave up. Before this, every one of those loops slept and continued silently, so a run that spent forty minutes in rate-limit backoff looked like one that spent them reading. - Host resources. A daemon sampler ticks every 5 s
(
DRAWING_ANALYZER_RESOURCE_SAMPLE_SECONDS;0disables it) and records its own scheduling lag — how late it woke, the direct sign that this process could not get CPU, whatever the cause — plus process CPU time, resident and peak memory, system memory load, system CPU busy, free disk at the run's working locations (temp,work_dir,cache— by label, never by path) and the thread count. Windows reads come frompsapi/kernel32throughctypes, Linux from/proc; a metric a platform cannot read is listed as unavailable, never guessed. No new dependency. - Agent budgets. Allotments an agent exhausted: digests still cut off at
max_tokensafter the raised-cap retry, verifier replies truncated at the cap, investigations that hit the evidence-round cap or the per-run finding cap, citation references beyond the per-run cap, and batch attempts the stall watch or the time bound abandoned. - API spend limits. Two answers cannot be waited out inside a run: the
organization's monthly spend cap for its usage tier (HTTP 429 with
error_code: enforced_spend_limit_reachedand noretry-after; access resumes at the start of the next month or when the tier is raised) and a spend limit you set in the Claude Console, for the organization or a workspace (HTTP 400, "You have reached your specified … API usage limits"). Neither is retried. The first one is recorded — stage, HTTP status, request id, and the API's own message, which says when access resumes — and from then on the run sends no further paid requests: every later model call, batch submission and image upload is refused before it leaves the machine and fails with a short "not sent: the … spend limit was reached earlier in this run" error. Each stage handles that like any other failure, so the zero-API stages still run and everything finished before the limit still exports. A batch the API had already accepted is still collected. The record counts how many requests the limit answered and how many paid requests were refused. Setting a monthly spend limit on the workspace whose key drawing-analyzer uses (especially a key embedded in shared reports) is a cheap guard; with this leg, reaching it ends a run cleanly instead of failing every remaining sheet one by one.
The record derives starvation_status — DETECTED or NOT_DETECTED —
from named signals, each carrying its evidence: SPEND_LIMIT (an API spend
limit was reached; listed first, since it explains every failure after it),
API_THROTTLED (a give-up, three capacity answers, or 30 s+ spent in backoff),
CPU_STARVED (two late ticks, or one lag of 10 s+), MEMORY_PRESSURE (system
memory at 90 %+ load or under 512 MB available), LOW_DISK (under 1 GB free at
a watched location) and BUDGET_EXHAUSTED. A couple of recovered retries over
a long run are reported as counts but are not a verdict.
Where it shows: run.log gets a Starvation: header line and a Resource
pressure (starvation check) section; run_manifest.json a resource_pressure
block (verdict, signals, API counts with the bounded event list, host
aggregates, the budget list, the spend_limit block — the first hit, how many
answers said so, how many paid requests were refused — and the thresholds
used); the HTML report's Run record block and the GUI completion summary each
carry the one-line verdict, and the GUI adds its own red line naming a reached
spend limit with the API's message; and the journal trace shows the events as
they happened — API_RETRY, API_GIVE_UP, SPEND_LIMIT, RESOURCE_PRESSURE
(one per threshold breach, capped at 50), BUDGET_EXHAUSTED and the closing
RESOURCE_SUMMARY. Aggregates count
everything; only the listed events are bounded. The SDK's own in-client
retries (two per request by default) happen beneath these loops and are
visible only in the wire capture (DRAWING_ANALYZER_DEBUG).
The starvation check above asks whether the API or the computer held the run
back. The other half of "were the agents starved" is what each agent was
given: its output envelope (max_tokens, which adaptive thinking shares with
the reply), its effort level and thinking mode, the tools it could call and how
many uses each allowed, and the allotments the pipeline sets per work item (the
investigation's evidence rounds, the citation check's pause_turn resumes). A
thin result from an agent that ran out of room looks the same in the findings
as one from an agent that had nothing more to say. The per-call record tells
them apart.
Every model request is recorded where it is sent (module
call_telemetry.py): every real-time call at the one dispatch point all stages
share, and every Message Batch item at submit, completed when its results are
read. Each record holds what the call was given (model, effort and the highest
effort that model accepts, thinking mode, max_tokens, tools offered with
their per-call max_uses, tool_choice, task budget, structured outputs,
strict tool schemas, refusal fallback, image count) and what it used (stop
reason, input/output/cache tokens, share of max_tokens used, thinking blocks,
tool calls by name, the server's own web-search/web-fetch counts, tool errors
such as max_uses_exceeded, duration, message id). Each call also names its
stage, using the same family names as the usage table (digest, critique,
verify, investigate, citation, …), and its work item: SRC-0001:p0
for a sheet, a finding (joined to its QC-### in the export), a code reference
for the citation check, or set for one-per-run stages. A call made outside a
labeled stage is still recorded, as unattributed (<module>).
From those records each stage gets a headroom verdict:
| Verdict | Meaning |
|---|---|
STARVED |
The stage ran out of something it needed and its result shows it: a work item still cut off at max_tokens on its last call, a server tool that refused a use because max_uses was spent, a pause_turn still unresolved when resumes ran out, or an allotment the pipeline saw it exhaust (evidence rounds, the per-run investigation or citation cap, the digest/verifier output cap, a batch abandoned by the time bound). |
TIGHT |
It finished, but with no margin or with less than it was built for: a call at 90 %+ of max_tokens, a cap hit that a raised-cap retry recovered (a retry that fails, is stopped or is cut off again does not count as recovering it), a tool used up to its per-call limit, an item that asked for its whole allotment, a forced no-tools close, a recovered pause_turn, or an ability turned off mid-run (structured outputs, strict tool schemas, the refusal fallback or the task budget, latched off after an API rejection). |
ADEQUATE |
Every call finished inside its limits with room to spare. |
NOT_ASSESSED |
No call came back with a response this run can judge: everything was served from cache, failed or was stopped, or the only results are from a batch an earlier run submitted (its request, and so its limits, were never seen by this run). |
The run's verdict is its worst stage. Effort and thinking are reported as sent, next to the highest effort the model accepts; they are per-stage design choices, not inputs to the verdict. Output tokens include thinking, because the API reports them together.
Where it shows: a Headroom: header line in run.log plus an Agent
provisioning & headroom section (one block per stage: models, effort, thinking,
cap and peak use, tools and their limits, item allotments, abilities sent, and
the reasons behind a TIGHT or STARVED verdict) and a Model calls by stage
table (where each model ran: calls, real-time vs batch, work items, tokens);
run_manifest.json's agent_headroom block; one line in the GUI completion
summary and the report's Run record; and the diagnostics/ folder below.
Every export carries a diagnostics/ folder with the detail behind those
summaries, in forms Excel or a script can open. Start with 00_summary.md.
| File | What it holds |
|---|---|
00_summary.md |
The answers first (did the agents have room, were they starved by the API or the computer, did another model answer, which models ran), then the per-stage headroom table, the model map, the resource-pressure record and a guide to every file. |
api_calls.csv |
One row per model call: stage, work item and qc_id, transport, model asked for and model that answered, effort, thinking, max_tokens, output tokens and output_fraction, stop reason/outcome, tools offered, their limits, calls made and tools_at_limit, forced_close, task budget, item allotments, duration, batch/custom/message ids, error. |
api_calls.json |
The same calls with every field, for scripts. |
agent_headroom.json |
The per-stage provisioning and headroom counters and the model map (also in run_manifest.json). |
api_retries.csv |
Every transient API failure (429/529/503/5xx/timeout/connection) the run waited out or gave up on: stage, HTTP status, attempt, backoff, retry-after, rate-limit headers, request id. |
host_samples.csv |
The CPU / memory / disk / thread timeline the host sampler recorded during the run. lag_seconds is how late the sampler woke, the direct sign of CPU starvation. Bounded at 2,000 rows; a longer run keeps an evenly spaced series over its whole length. |
events.csv |
The run journal's event trace (the same events as run.log's Event trace), one per row. |
Same privacy boundary as run.log: counts, labels and identifiers only, never
a prompt, drawing text, a reply, an image or a key. Error strings and item
labels pass the same secret-redaction and path-scrubbing filter. The CSVs are
UTF-8 with a BOM and CRLF line endings, like findings.csv. run_manifest.json
hashes every file in the folder.
| Output | Where | Scope |
|---|---|---|
run.log |
export folder | One run, human-readable: inputs, configuration, stages, usage, starvation, headroom, model map, errors, full event trace. |
run_manifest.json |
export folder | The same run, machine-readable, plus the sha256 of every exported file. |
diagnostics/ |
export folder | Every model call, API retry, host sample and journal event (above). |
markup_manifest.json |
export folder (QC runs) | Every planned markup placement and its reconciled receipt. |
checklist_matrix.csv |
export folder (QC runs with a checklist) | Every checklist item against every sheet: its result and the findings against it. |
evidence/<QC-###>/investigation.json |
export folder (when investigation ran) | The full tool trace of each investigated finding: every crop/search requested and the verdict. |
drawing_analyzer.log |
%LOCALAPPDATA%\DrawingAnalyzer\logs\ on Windows (the app's settings folder elsewhere) |
The app session's rotating diagnostic log: request ids, batch ids, custom_id-to-sheet maps, retry detail. Stays on the computer; reset at each app launch. Set DRAWING_ANALYZER_DEBUG=1 to add the SDK's wire-level request/response lines. |
Opus 5.5 and Sonnet 5.5 run safety classifiers that can decline a request.
Every real-time call to them opts into Anthropic's server-side refusal fallback
(fallbacks: "default", beta server-side-fallback-2026-07-01), so a
false-positive refusal does not cost that sheet or finding its coverage: the API
re-runs the same request on a substitute it picks by refusal category inside the
same call. For Opus 5.5 that substitute is Opus 5 or Opus 4.8 (cyber-category
refusals go to Opus 4.8); for Sonnet 5.5 it is Sonnet 5. Message Batches calls
cannot carry the parameter. It is unlikely to fire on construction drawings,
but when it does the run says so:
- Pricing. The response's
usage.iterationsis read, and each billed attempt becomes its own usage record priced at the model that ran it: the declined attempt at the requested model (terminal_statusREFUSED), the rescue at the fallback's rate (modelis the model that ran it,requested_modelthe one the stage asked for).by_modelshows the fallback model's spend. - Not cached. A fallback answer came from a different model than the cache key names, so no stage stores it; the next run asks the requested model again.
- Visible.
run.loggets aFallback:header line and one Refusal fallback line per stage under Usage & estimated cost;run_manifest.jsongetsusage.model_fallbacks(stage family, requested model, serving model, responses served, attempts declined). The review plan'sreview_plan.mdnames the model that actually wrote it. - History stays valid. In the multi-turn investigation loop and the
citation check's pause resumes, the declined model's partial (its thinking
and tool requests before the
fallbackmarker) is neither executed nor sent back, as the API requires.
DRAWING_ANALYZER_REFUSAL_FALLBACK=0 turns the opt-in off, so a refusal
returns as one (that sheet or stage then reports the failure).
Findings often cite code sections (refs), and citations have a failure mode of
their own: a drawing citing 2016-era numbering under a 2019 basis (the
prototype found exactly that — NFPA 13's Table 13.2.1 became §4.3.1.7 in the 2019
renumbering). The drawing set can't validate its own citations, so
citation_check=True adds web-backed model calls (the API's server-side
web_search and web_fetch tools): does this section — in the edition the
set adopts and in the current edition — actually support the finding citing it?
Web fetch matters here more than the search does. With search alone the model answers from result snippets; with fetch it can open the publisher's page and read the section text before ruling. This stage runs on Sonnet 5.5 at medium effort. Opus 5 had no web fetch, so it could only read snippets; Opus 5.5 has it, so Sonnet 5.5 is now a cost choice, at half the per-token price. Both tools carry a shared source-quality blocklist, so a code verdict is never grounded on a forum post or another assistant's output. Fetch can only retrieve URLs a prior search surfaced in the same request, so the model cannot reach a page it invented.
References are normalized for case, whitespace, cosmetic punctuation and
§/Section/Sec. before grouping; each finding keeps its original spelling
for display. The pass admits at most 50 normalized refs per run, ordered by
the highest severity of their eligible citing findings (then QC id/finding id).
DRAWING_ANALYZER_CITATION_MAX_REFS overrides the cap; 0 checks none. Claims
whose citing findings are all REJECTED and refs beyond the cap receive
UNCHECKED assessments with explicit reasons, included in exports and stage
warnings. Skips happen before cache lookup or client creation and keep the stage
PARTIAL, consistent with the unchecked-claim gate.
The check is claim-complete: a verdict attaches to a finding only if that
finding's claim was in the request that produced it. Every eligible distinct claim
for an admitted reference is checked (chunked into claim-complete requests when
there are many), the model returns a per-claim verdict, and
each CitationAssessment is bound to exactly the findings whose claim it covered.
A finding that cites several references keeps one assessment per reference
(finding.citations), and finding.citation is a derived summary
(CHECKED_SUPPORTS / CHECKED_MISMATCH / UNRESOLVABLE / UNCHECKED) shown in
the markup popup, the CSV, and the report.
Each claim gets one of three verdicts. CHECKED_SUPPORTS means the model
found the section's text (or an authoritative excerpt) and it supports the
claim; it must name that page as its evidence link. CHECKED_MISMATCH means it
found concrete evidence the section does not. UNRESOLVABLE means it could not
find authoritative text to decide either way. That is common for codes that are
not freely published, such as the NFPA standards. Before this third answer
existed, the model had to pick a side, and the instruction to report a mismatch
only on concrete evidence pushed every gap toward "supports". A support with no
https evidence link is recorded as UNRESOLVABLE, with the reason in its note.
UNRESOLVABLE is a concluded check, not a failure: it is cached like any other
verdict, does not hold the stage at PARTIAL, and is counted in a stage
warning. On the drawing it reads Code ref not confirmed. In the derived
summary a mismatch outranks an unconfirmed reference, which outranks a support,
so a finding citing two references never reads "supports" while one of them
went unconfirmed.
The adopted editions are harvested offline from the sheet text layers (the
general-notes "NFPA 13, 2016 EDITION" claims), merged with the identity-detected
adopted codes (Phase A), and included in the prompt. A MISMATCH downgrades
nothing automatically — it is surfaced for the engineer, because sometimes the
stale citation is the finding; in the HTML report a mismatch renders with a
distinct warning style plus the structured provenance (which edition the verdict
was checked against, the current published edition, and the model-selected
https evidence link), and findings.csv carries four provenance columns.
Each fetch is capped at 4,000 tokens: enough for a clause and nearby
definitions/exceptions, without admitting 40,000 tokens of a whole code book.
The prompt asks for clause-level pages. Pause resumes cache the conversation
through the newest assistant turn, retaining the preceding breakpoint to reuse
long prefixes beyond the API's 20-block lookup window. The sliding pair stays
within the four-breakpoint limit. Resumes are sent at once, so every breakpoint
uses the 5-minute cache entry and usage prices writes at that rate.
Real-time only, billed by reported tokens and server search counts, additive,
and non-fatal: any failure leaves the affected claim UNCHECKED and holds the
stage at PARTIAL.
Warm runs are cheap (Phase B): complete verdicts cache content-addressed on
the normalized ref + claims + editions/jurisdiction context, expiring after
DRAWING_ANALYZER_CITATION_TTL_DAYS (default 30; 0 disables the cache). A
partial or failed check is never cached, so a cache hit can never hide an
unchecked claim. The pre-run cost estimate quotes the cold-run figure — warm
runs bill only cache-missed chunks.
The edition audit (Phase B) runs before any of this, zero-API and
pre-seal: when a ref cites an edition of a code the set adopts at a
different edition (a note citing NFPA 13 2013 on a set that adopts
2016 — the classic worldwide-set failure), it becomes a real ledger
finding that numbers, anchors to the stale-edition text itself, and gets
ink. When both the adopted and the cited edition are re-found verbatim in the
sheet text, the finding earns the deterministic tier (auto-ink, like the
offline auditors); a basis only the identity detected ships as a low-severity,
explicitly-labeled advisory that is crop-verified and web-checked downstream.
An edition the drawings only mention is not an adoption: when the identity
adopts NFPA 13 2025, a note reading "NFPA 13 2016 is not the governing edition"
does not make 2016 adopted, so a finding citing NFPA 13 2016 is still flagged.
(With no identity, as in the free battery, the text scan alone is the basis.)
Runs with the citation check or the free deterministic battery
(reference_audit=True gains it at zero cost, regex basis only).
Every QC item from every channel becomes an entry in one append-only per-run findings ledger — and everything downstream (anchoring, verification, the citation check, the markup writer, the CSV/JSON exports, the report's findings table, the index page) consumes the ledger and nothing else. If an item is not in the ledger it does not exist; if it is, the coverage assertion guarantees it is accounted for on the PDF.
Duplicates merge conservatively and losslessly. Two findings merge only when
they are semantically the same and their critical signatures agree: a shared
tile is a search hint, never identity, and geometric rectangle overlap alone is
never enough — two unrelated issues can share a table cell. A conflicting signature
blocks the merge even when the prose is similar (500 gpm vs 550 gpm, M-101 vs
M-102, shown vs not shown, or a different cross-sheet leg). A measurement is
compared by its value, not by how it was typed, so 1/2" is half an inch and
never the denominator — Provide 1/2" drain and Provide 2" drain are two pipe
sizes, not one finding twice — while 2 1/2", 2-1/2" and 2.5" are one
quantity. Feet-inches keeps both halves (12'-6" signs as 12 ft and 6 in,
neither negative), and a plural unit folds to its singular so 6 amps and 6 amp
are not read as a conflict. psig is deliberately not folded into psi: gauge
and absolute are different measurements, and collapsing them would hide a real
conflict rather than a formatting one. A measurement is
compared by its value, not by how it was typed, so 1/2" is half an inch and
never the denominator — Provide 1/2" drain and Provide 2" drain are two pipe
sizes, not one finding twice — while 2 1/2", 2-1/2" and 2.5" are one
quantity. Feet-inches keeps both halves (12'-6" signs as 12 ft and 6 in,
neither negative), and a plural unit folds to its singular so 6 amps and 6 amp
are not read as a conflict. psig is deliberately not folded into psi: gauge
and absolute are different measurements, and collapsing them would hide a real
conflict rather than a formatting one. A merge keeps
coherent grounding: the survivor's text and quote come from one member as an
atomic bundle — never one finding's text paired with another's quote — while the
loser's quote is kept as a supporting quote. The verdict rides that bundle,
for the same reason the rectangle does: DETERMINISTIC means "a host computed
this result from this quote", so it is only ever true of the text the host
computed. A member the auditors produced therefore wins the representative
outright — provenance outranks quote length, and the auditor normally holds the
shorter quote, because it quotes only the term it computed over while the model
quotes a whole schedule line. An auditor's pre-anchored DETERMINISTIC duplicate
still upgrades a model entry; what no longer happens is the reverse, where the
model's phrasing of the same issue inherited the host's label. A survivor's exact
rectangle is likewise never erased by an unanchored member. The merge unions the
provenance, keeps the most severe severity, and — when the merged provenance
spans two source families — marks the entry corroborated, raising both
reproduced and confidence so the two can never disagree. Multi-source
provenance doubles as a confidence signal, shown as chips in the report rows and
the markup popups (prose+json+critique×2).
The ledger runs an explicit lifecycle: it ingests while open, seals, then
anchors every finding, folds any duplicate the ingest pass couldn't see without
geometry, and only then assigns the sequential QC-### numbers — so they follow
visual order (source input order → page → top-to-bottom). Numbering strictly after
anchoring is what makes the numbers positional.
A finding that arrives after the seal is an orchestration failure, and the
ledger treats it as one whether or not it duplicates an existing entry. It is
counted, logged, and the run is marked incomplete — never numbered as
ordinary output. A post-seal duplicate is additionally dropped rather than
merged: a QC-### is a promise about specific content, and by then that number
may already be exported, inked on a reviewed PDF, and the name of an evidence
directory. The run still ships (I-3); it just says so.
Source-tag glossary (Finding.sources):
| Tag | Channel |
|---|---|
digest_json |
the digest's machine-read findings block |
digest_prose_coordination / digest_prose_conflict |
harvested digest prose sections |
critique_1 / critique_2 |
the critique's self-consistency reads (both = corroborated) |
cross_qc |
the cross-sheet conflict hunt |
synthesis_prose |
harvested synthesis conflict statements |
auditor_reference / auditor_arithmetic / auditor_naming / auditor_titleblock / auditor_sheet_index |
the deterministic auditors |
focus_prose |
per-sheet Focus sections (only with focus_findings_to_markups=True) |
The prose digest predates the structured findings and feeds a downstream consumer, so it is never modified — it is mirrored. Three layered mechanisms make the mirror a guarantee: (1) the digest prompt requires every prose Coordination/Conflict item to also appear in the JSON block; (2) the harvester splits those prose sections into items (using the same section grammar as the report's "⚠ Issues only" filter) and fuzzy-matches each against the same-sheet ledger entries — a match just tags provenance, free; (3) each unmatched straggler gets one small structuring call (item + the sheet's text layer → one finding with a verbatim quote), and if even that fails a degraded entry is ingested — the prose item verbatim, sheet-level — which still reaches the PDF as a margin callout. No prose QC item can fail to produce a ledger entry. Synthesis prose contributes its conflict statements the same way, anchored on the first sheet each names and dual-anchored when a second sheet is named.
Alongside the model reads, the analyzer runs a battery of deterministic,
zero-API auditors over the extracted vector text layers — no model call,
milliseconds of CPU. They catch the class of defect a vision model is unreliable
at but code is exact at: a stale cross-reference, a column that doesn't add up, a
tag spelled two ways, a title-block field that drifted, an index that disagrees
with the set. Every finding they emit is anchored to its own word rectangle and
marked DETERMINISTIC — trusted without a model re-check — so it is clouded
onto the reviewed PDFs by default when QC Markups is on.
They live in the drawing_analyzer.auditors package; run_auditors(rendered_sheets, claims=…) runs the whole battery and returns the combined findings plus a small
stats tally. Each auditor is isolated, so one failing never loses the others
(I-3). The battery wires into a run through reference_audit=True (or the GUI's
Reference audit checkbox); its findings arrive on ctx.reference_findings,
join ctx.all_findings, and the checks-passed tally lands on ctx.audit_stats.
| Auditor | Module | What it catches | Anchoring | Severity |
|---|---|---|---|---|
| References | auditors.references |
Stale / missing cross-references (SEE DRAWING X, detail bubbles NN/X, CSI spec sections) resolved against the set inventory |
the reference's own words | medium (miss), low (spec/malformed) |
| Arithmetic | auditors.arithmetic |
Numbers that don't add up — column totals, density × area = demand, base area × 1.3 = design area — computed by the host, never the model | the claim's verbatim quote | graded by magnitude |
| Naming | auditors.naming |
The same thing tagged two ways across the set (C1R vs C1-R — same letters and digits, only formatting differs). A changed number (A1-2 vs A2) is meaning-bearing and is not flagged, and neither is a regrouped one (VAV-2-1 vs VAV-21) unless the set's own usage establishes which spelling is the convention |
each drifting occurrence | low (question) |
| Title-block | auditors.titleblock |
A project-number / package/project name (incl. multiword) / date that differs on one sheet from the set consensus — found by its field label, so a substantially different value is caught, not just a one-character typo | the drifting value | low (coordination) |
| Sheet-index | auditors.sheet_index |
A drawing index that lists a sheet not in the set (or a malformed / out-of-convention entry), or omits one that is | the index entry / header | medium / low |
All the auditors share one host-owned sheet-ID grammar (auditors.sheet_ids):
it learns the set's numbering convention from each sheet's title-block ID —
hyphenated (M-101), compact (FP101), or dotted (M1.01) — so it works across
offices without a hardcoded scheme, and it resolves a reference as present,
well-formed but not present in the provided set (with the closest in-set ID
suggested), or malformed. A negative corpus keeps code/standard citations
(NFPA 13), transmittal numbers (RFI-123), drawing annotations (REV-2,
DET-3, DWG-4, TYP-2, SIM-1, NTS-1), voltages (480V), room numbers, and
dimensions from ever becoming a sheet finding. The annotation prefixes matter more
than they look — they are among the most common words on a sheet, so
INSTALL PER REV-2 was being reported as a stale reference ("did you mean
E-2?") with deterministic ink behind it. Paper sizes are deliberately not in
the corpus: A1–A4 are ISO sizes and perfectly good architectural sheet ids,
and the corpus never consults the set, so a blanket veto would drop real
references. A strict subset of the corpus also vetoes a candidate while the
sheet's own id is being detected, so a code citation or a drawing annotation
sitting further into the bottom-right corner than the title block can no longer
become the sheet's id — which matters because the set's grammar is learned from
exactly those ids. Only a strict subset, because the corpus answers "does this
point at a sheet in the set?", which is safely no for things a sheet can still
be named: SK-1 is a sketch issued as a sheet, and so are PR-04 and
ADD-2. It never claims a sheet doesn't
exist — only that it isn't in the set you provided — because a partial set
legitimately omits sheets. On a real 8-sheet fire-protection set this alone caught
three genuine coordination errors. A one-sheet set is treated as low-confidence:
only strong trigger phrases run, and the finding reports the limitation.
The arithmetic auditor embodies the tool's core principle — coverage proposes,
precision disposes — for the one thing a vision model is worst at: mental
arithmetic on a table it just transcribed (the prototype watched one misread a
flow-test total, 540 → 660). So the model never calculates. The critique and
cross-sheet QC passes emit, alongside their findings, a claims array — the
numbers they read and how those should relate:
{
"sheet_id": "F-D-01-1",
"quote": "20 20 20 TOTAL 540",
"kind": "sum", // "sum" | "product" | "factor"
"terms": [20, 20, 20],
"expected": 540,
"note": "flow-test column total"
}The host then does the arithmetic itself — parsing every term to an exact
decimal (tolerant of commas, units, and fractions like 2 1/2), adding or
multiplying with the standard library, never eval, never the model's answer
— and raises a finding only when the numbers genuinely don't add up (base area × 1.3 = 1950, but the DIPA row still states 1500). Relationships that check out
are counted, not flagged, and surfaced in the report as "N numeric relationships
checked ✓" — the balance column of a real review.
The operation is always host-deterministic, but the numbers it operated on may
have been misread. So a mismatch is trusted DETERMINISTIC (and clouded
automatically, even in verified-only mode) only when the claim's own quote
independently carries every operand; when the operands were merely transcribed by
the model — the common case for a column sum whose addends live in a table — the
mismatch is left UNCERTAIN and sent to the crop verifier before it inks as
ground truth. The popup states which ("host-computed from model-transcribed
terms"), and it keeps saying so after the crop check: verification and the
investigation loop replace the verdict, never the provenance behind it. A
VERIFIED crop means a verifier looked at the drawing — it does not turn numbers
the model transcribed into numbers the sheet printed, and the caveat a reviewer
reads must not quietly become "AI-verified against the drawing". The match tolerance is relative (magnitude-aware), so a small-value error
like 0.2 + 0.2 printed as 0.5 is caught rather than swallowed by a fixed slack.
A term the host cannot read as one value is refused outright rather than
truncated to its leading run, and its claim is then reported as unchecked
instead of as a mismatch. 12,5 is not 12, 12'-6" is twelve feet six, and a
percent is a ratio, not the quantity — so 1500 SF + 30% = 1950 SF is not
"the sum of 1500 and 30". That last one mattered most: because the bare 30
appeared literally in the quote, the claim cleared the independent-validation
gate and inked a HIGH-severity "the product of 1500, 30 is 45000" wearing the
host's label. A separator only disqualifies when it is tight, so an ordinary
operand list (0.5, 1.5, TOTAL 2.0) keeps every term while the compact malformed
forms (12,5, 10,20) stay rejected. The quote scanner applies the identical
rule, so the two can never disagree about what a number is — and one claim the
host cannot compute or print no longer costs the run every other claim's
arithmetic.
The analyzer's built-in goals are fixed (a spec-reviewer-oriented digest). A per-run focus lets you add your own, for one run, at your discretion — it is never required, and the standard output is always produced unchanged. With a focus set:
- Each sheet is read with your question in mind — the vision prompt asks for
one extra, final
Focus findingssection per sheet (the standard digest sections are unaffected). - A set-level Focus Report is assembled in one extra text-only pass (Sonnet
5.5 at high effort by default): a direct, cross-sheet answer to your focus,
citing the sheets that carry each fact. It leads the combined Markdown, is written as
00_focus.mdin folder exports, and gets a pinned card (plus a Focus filter chip) in the HTML report.
Cache interplay: the focus is folded into the per-sheet cache key. Re-running with the same focus is served from cache; changing or adding a focus re-analyzes the sheets (the model must re-read the drawings with the new question), while your existing no-focus cache entries remain valid for ordinary runs.
claude-opus-5-5 and claude-sonnet-5-5 are registered model overrides.
Switching the review model invalidates the digest and critique caches once:
their keys include the resolved model, so the first run with the new model
rebuilds them and later runs reuse its entries. Model selection remains a
quality decision.
| Variable | Default | Effect |
|---|---|---|
ANTHROPIC_API_KEY |
— | Required (or paste the key into the GUI). |
DRAWING_ANALYZER_MODEL |
Opus 5.5 | Vision model for per-sheet digests (high effort). Also the default for critique, cross-sheet QC and the review plan, which each have their own override below. |
DRAWING_ANALYZER_SYNTHESIS_MODEL |
Sonnet 5.5 | Cross-sheet synthesis model (text-only, high effort). It reconciles digests the vision passes already wrote, so Sonnet is a cost choice at half Opus 5.5's per-token price. Does not follow DRAWING_ANALYZER_MODEL. |
DRAWING_ANALYZER_FOCUS_MODEL |
Sonnet 5.5 | Focus-report model (text-only, high effort). Same reasoning as synthesis. Does not follow DRAWING_ANALYZER_MODEL. |
DRAWING_ANALYZER_VERIFICATION_MODEL |
Sonnet 5.5 | Per-finding verification model (crop + short prompt, medium effort). The older DRAWING_ANALYZER_VERIFY_MODEL wins when set. |
DRAWING_ANALYZER_INVESTIGATION_MODEL |
escalation model (Opus 5.5) | The Phase C investigation loop's model (multi-turn, vision + tools, high effort). |
DRAWING_ANALYZER_INVESTIGATION_MAX_ROUNDS |
6 |
Evidence requests per investigation before the forced text-only close (rides the verdict-cache key). |
DRAWING_ANALYZER_INVESTIGATION_TASK_BUDGET |
40000 |
Advisory token budget handed to the model per investigation, so it paces itself instead of being cut off at the round cap. Anthropic's 20,000 floor is enforced; rides the verdict-cache key. |
DRAWING_ANALYZER_INVESTIGATION_MAX_FINDINGS |
scales with the set | UNCERTAIN findings investigated per run (severity-first; the excess is disclosed as a stage warning). Defaults to 10 + one per 4 sheets, capped at 40; setting this pins an absolute number at any set size. |
DRAWING_ANALYZER_CRITIQUE_MODEL |
Opus 5.5 | Critique-pass vision model (critique=True, high effort). |
DRAWING_ANALYZER_CROSS_QC_MODEL |
Opus 5.5 | Cross-sheet QC model, text-only (cross_qc=True, high effort). |
DRAWING_ANALYZER_CITATION_MODEL |
Sonnet 5.5 | Citation-check model, with web search and web fetch (citation_check=True, medium effort). Sonnet rather than the review flagship is a cost choice: Opus 5.5 also has web fetch (Opus 5 did not). A model without web fetch degrades to search-only. |
DRAWING_ANALYZER_CITATION_MAX_REFS |
50 |
Per-run normalized reference cap, severity-first after skipping rejected-only claims. Cache hits share the cap. 0 checks none; invalid/negative values use the default. Every skipped claim is explicitly unchecked. |
DRAWING_ANALYZER_IDENTITY_MODEL |
Haiku 5.5 | Set-identity model, text-only (Phase A), at medium effort. Advisory-only, with the regex edition harvest as a backstop. |
DRAWING_ANALYZER_REVIEW_PLAN_MODEL |
Opus 5.5 | Review-plan authoring model, text-only (Phase A, high effort). |
DRAWING_ANALYZER_MAX_PLAN_ITEMS |
60 |
Total item cap on the model-authored review plan. When the model writes more than this, the overage is taken from the longest discipline each round, so the loss is shared: five disciplines of 20 items leave 12 each. (It used to trim the last plan's tail until that plan was gone, and plans sort alphabetically — so mechanical and plumbing were deleted outright while architectural kept all 20.) |
DRAWING_ANALYZER_HARVEST_MODEL |
Haiku 5.5 | Prose-harvest structuring model (one small call per straggler, at medium effort on Haiku 5.5; existing model overrides retain low effort). |
DRAWING_ANALYZER_ABSENCE_CHECK_MODEL |
Haiku 5.5 | Absence cross-check model, text-only, at medium effort: one call per 25 sheet-level absences. Every hit it reports is re-checked by the host before it changes anything. |
DRAWING_ANALYZER_CHAT_MODEL |
Sonnet 5.5 | The HTML report's in-browser Ask AI assistant (billed to the reader's key). Needs adaptive thinking plus the web-search and web-fetch server tools; a model without web fetch (such as Opus 5) degrades the widget to search-only. |
DRAWING_ANALYZER_WEB_SEARCH_TOOL_TYPE |
web_search_20260209 |
Server-side web-search tool type string (survives an API rename). |
DRAWING_ANALYZER_WEB_SEARCH_MAX_USES |
10 |
Per-request web-search budget for citation checks (rides the verdict-cache key). |
DRAWING_ANALYZER_WEB_FETCH_MAX_USES |
4 |
Per-request web-fetch budget for citation checks. Each fetch pulls at most 4k tokens of clause text and nearby context into the request. Rides the verdict-cache key. |
DRAWING_ANALYZER_CITATION_TTL_DAYS |
30 |
Citation verdict-cache TTL; 0 disables the cache (no read, no write). |
DRAWING_ANALYZER_MARKUP_APPENDIX |
off | Append the "checked and consistent" page to reviewed PDFs. |
DRAWING_ANALYZER_CRITIQUE_RUNS |
2 |
Critique self-consistency reads to merge (1 disables it). |
DRAWING_ANALYZER_CRITIQUE_STRUCTURED_OUTPUTS |
off | Constrain the critique's reply with Anthropic structured outputs (output_config.format + a JSON schema) instead of asking for a fenced ```json block in prose. The critique is the only high-volume call whose reply is JSON and nothing else, which is what makes it the one stage a whole-response schema actually fits (the digest writes a prose digest then a findings block, so a schema cannot describe it). Opt-in on purpose: Anthropic documents citations and prefill as the only incompatibilities and says nothing either way about image inputs — and every critique request carries an overview plus a full tile grid. Prove it on your own account with pytest -m network tests/test_live_api_canary.py -k output_config_format before turning it on. Ignored on the Message-Batches transport (a batch item's shape is fixed at submit, so it cannot degrade). Structured and fenced reads cache under separate keys, so flipping this costs one re-read per sheet each way and discards nothing. |
DRAWING_ANALYZER_HARVEST_STRUCTURED_OUTPUTS |
off | The same structured-outputs contract on the prose harvest's per-straggler structuring call: text-only, Haiku 5.5, one flat finding object whose schema mirrors the prompt's field list. Its own self-healing latch and its own cache-key fold-in (structured and fenced items never collide). Prove it with pytest -m network tests/test_live_api_canary.py -k harvest_under_output_config_format. |
DRAWING_ANALYZER_VERIFY_STRUCTURED_OUTPUTS |
off | The same contract on the verifier's two-field verdict (verdict as a three-way enum + note), on the single-crop and cross-sheet calls alike. Vision-involved, so it carries the critique's caveat; the prompt is unchanged (it already asks for a bare object), so the requests differ only by the schema. Structured and plain verdicts cache under separate keys. Prove it with -k verify_under_output_config_format. Whether it is worth turning on is answered by the parse-loss line the verification stage now writes (see Verification pass). |
DRAWING_ANALYZER_ARITHMETIC_REL_TOL |
0.01 |
Arithmetic auditor's relative match tolerance (drawings round). |
DRAWING_ANALYZER_NAMING_DOMINANT_MIN_FREQ |
2 |
Naming auditor: occurrences that make a tag "established" vocabulary. |
DRAWING_ANALYZER_NAMING_DRIFT_MAX_FREQ |
2 |
Naming auditor: a tag is only flagged as drift when this rare. |
DRAWING_ANALYZER_PROFILES_DIR |
~/.drawing_analyzer/profiles |
User review-profile directory (wins over packaged profiles on name). |
DRAWING_ANALYZER_SAVE_TILES |
off | Keep every rendered tile image and export a tiles/ folder with per-tile notes when save_tile_artifacts is not passed. In the GUI it only sets the starting state of the Save tile images checkbox. |
DRAWING_ANALYZER_USE_BATCH |
off | Opt every run into the Message Batches transport (~50% token-rate discount with the same model/prompt/review contract) without editing call sites. An explicit use_batch= argument still wins. |
DRAWING_ANALYZER_BATCH_STALL_TIMEOUT_MIN |
25 first watch, 60 after |
Minutes of completely frozen batch request counts before the batch is abandoned and its sheets resubmitted. Setting this applies one value to every watch (see Stuck batches). |
DRAWING_ANALYZER_BATCH_HARVEST_TIMEOUT_MIN |
request-derived (~112 min at 64k) | Maximum minutes to wait for an abandoned batch to settle before harvesting its completed items. Default: largest submitted max_tokens / 10 tokens per second + 5 minutes of cancellation grace, including raised retry caps. A positive finite override is floored at one minute; invalid values use the default. Harvest time is additional to the collection budget. Shorter overrides can cause duplicate billing if in-flight generation outlasts the wait. |
DRAWING_ANALYZER_MAX_BATCH_RESUBMIT_ROUNDS |
4 |
Fresh batches recovery will submit for the sheets a stuck batch left unresolved, before the run keeps a clean retriable batch error. |
DRAWING_ANALYZER_BATCH_MAX_ELAPSED_HOURS |
24 |
Hours a batch run will wait before detaching from the remote batch. The default is the Batches API's own SLA. Lower it to cap wall clock; a malformed or non-positive value falls back to the default, and any override is floored at one minute. The cost dialog quotes whatever this resolves to. |
DRAWING_ANALYZER_MAX_WORKERS |
6 for digest/verification; 4 for critique |
Real-time stage concurrency (1 = sequential). When set, overrides all three stage defaults. |
DRAWING_ANALYZER_STAGE_OVERLAP |
auto | Overlap independent set-level calls for the real SDK client; 0 disables it and 1 explicitly opts a thread-safe custom client in. DRAWING_ANALYZER_MAX_WORKERS=1 remains fully sequential. |
DRAWING_ANALYZER_UPLOAD_WORKERS |
6 |
Files-API image-upload concurrency per sheet (1 = sequential). |
DRAWING_ANALYZER_CROSS_QC_WORKERS |
3 |
Independent cross-QC map/reconcile calls; results still fold deterministically. |
DRAWING_ANALYZER_HARVEST_WORKERS |
4 |
Independent prose-straggler structuring calls. |
DRAWING_ANALYZER_ANNOTATE_WORKERS |
2 |
Independent source-PDF markup processes (1 disables process fan-out; hard cap 4). |
DRAWING_ANALYZER_SUPPRESS_NEAR_BLANK |
off | Also drop near-blank tiles (PNG-byte threshold), not just pixel-uniform ones. |
DRAWING_ANALYZER_NEAR_BLANK_MAX_BYTES |
3072 |
Near-blank PNG-byte threshold (only when the above is on). |
DRAWING_ANALYZER_FIXED_GRID |
off | Set 1 to restore fixed 6×6 tiling. Otherwise choose each page's grid from displayed size, minimizing image tokens while preserving the matching vector/raster ANSI E DPI floor. Explicit rows/cols still win. |
DRAWING_ANALYZER_TILE_TARGET_PX |
1560 |
Measurement knob. Vector-sheet tile long edge, in pixels. Image tokens scale with the square of this, and the tiles ride the digest plus both critique reads, so it is the single highest-leverage number in the bill: 1400 ≈ −20% image tokens per sheet, 1240 ≈ −36%, 1100 ≈ −50%. Whether a lower value still reads the drawing is a quality question — sweep it against a real set before changing anything, and note that a changed target re-renders (it invalidates the digest/critique caches by design). Raster sheets are unaffected: with no text layer the pixels are the only channel. Clamped to [400, 1992] so no value can breach the API's hard 2000 px many-image cap. |
DRAWING_ANALYZER_CACHE_PATH |
~/.drawing_analyzer/drawing_digest_cache.json |
On-disk SQLite/WAL cache (legacy filename retained; old JSON migrates automatically). |
DRAWING_ANALYZER_CACHE_PERSIST |
on | Disable to keep the cache in-memory only. |
DRAWING_ANALYZER_REFUSAL_FALLBACK |
on | Real-time Opus 5.5 / Sonnet 5.5 calls opt into the server-side refusal fallback. Set 0/false to let a refusal return as one (see Did another model answer?). |
DRAWING_ANALYZER_DIAGNOSTICS |
on | Set 0/false to disable the rotating drawing_analyzer.log diagnostics file the GUI writes. |
DRAWING_ANALYZER_SHOW_THINKING |
on | Whether a run with a live view (monitor=; every GUI run) asks the models for reasoning summaries for the Live view's Thinking tab. 0/off sends the requests without thinking.display. Display only: the summaries are never exported, logged or cached, and no cache key depends on the setting. |
DRAWING_ANALYZER_RESOURCE_SAMPLE_SECONDS |
5 |
Cadence of the host sampler behind the run's resource-starvation record (scheduling lag, CPU, memory, disk; see Was the run starved of resources?). Clamped to 1–300; 0/off disables the sampler, while API retries and exhausted agent budgets are still recorded. |
DRAWING_ANALYZER_DISABLE_UPDATE_CHECK |
off | Set truthy to turn off the desktop app's daily update check and "Check for Updates" button (locked-down deployments). |
DRAWING_ANALYZER_UPDATE_URL |
GitHub releases latest.json |
Override the update-manifest URL (testing, or self-hosting a fork's releases). |
DRAWING_ANALYZER_DEBUG |
off | Also route the Anthropic SDK / httpx wire-level logs (status codes, request-ids, retries) into the diagnostics file. Long base64 runs are elided with their size and each record is capped, so a sheet's ~3 MB of image data cannot rotate the log ring away — what stays is the request shape, which is what wire capture is for. |
DRAWING_ANALYZER_CACHE_DIAGNOSTICS |
off | Request the prompt-cache diagnostics beta on API calls (operator debugging only). |
DRAWING_ANALYZER_MAX_SHEETS |
2000 |
Sheet count above which a run needs explicit confirmation (confirm_large_set=True) rather than silent truncation. |
DRAWING_ANALYZER_MAX_FILES |
500 |
Input-file count above which a run needs explicit confirmation. |
DRAWING_ANALYZER_MAX_PAGE_PT |
20000 |
Largest sane page dimension (points); a larger/NaN/infinite box is treated as pathological and its page is skipped before rendering. |
DRAWING_ANALYZER_EST_BYTES_PER_SHEET |
3145728 |
Per-sheet temp/output estimate for the QC-run disk preflight (evidence crops, reviewed PDFs). |
DRAWING_ANALYZER_WORKDIR_MAX_AGE_HOURS |
24 |
How long a leftover drawing_qc_* work directory may sit in the system temp dir before a later run reaps it. These hold the high-DPI evidence crops and are created lazily by the verify / investigate / markup stages, so on a repeatedly-reviewed set they are the largest thing the tool leaves behind. Pruned on the way in, never at run end: extract_drawing_context returns before the caller exports, and the export copies the evidence out — deleting at run end would destroy the crops before anything saved them. The current run's own directory is never touched, and neither is one with anything recent anywhere inside it (a concurrent run writes crops two levels down, where the top-level mtime cannot see them). 0 disables pruning; a malformed value falls back to the default. |
Every selected PDF is classified up front (Phase 18B): a corrupt, missing,
password-protected, zero-page, or not-actually-a-PDF file is recorded and
skipped — it never aborts the rest of the set — and the same file selected twice
is processed once. The not-a-PDF case is worth naming: the PDF engine also opens
images, XPS, EPUB and CBZ, so a genuine PNG or EPUB renamed .pdf used to be
accepted and pushed through a pipeline that assumes a drawing set throughout. (A
text file renamed .pdf was always rejected, which is why the gap was easy to
miss.) The reason names the format that was detected — "not a PDF (opened as
Image)" — because that is what tells you which file to look at.
The run's report and error list name exactly what was dropped and why (without
leaking absolute paths), so a mixed good/bad drop still produces a partial
deliverable for the good files. A single unreadable page inside an otherwise
good PDF is likewise skipped rather than failing the whole file, and a
pathological (oversized/NaN) page box is caught before it can exhaust memory.
Each source is also fingerprinted at the start of the run, and re-checked just before its reviewed PDF is written. If a source file changes on disk mid-run (e.g. it's re-exported while a long QC pass is running), it is left un-marked — its anchors were computed from the earlier revision and would land on the wrong content — and the run tells you which file changed and that a re-run is needed. The other files are marked up normally.
Accepted digest and critique batches, including follow-up and recovery rounds, are recorded in the digest cache before polling. On the next run, finished batches are collected into the cache before new work is submitted. Sheets owned by a batch that is still processing are left with a retriable status instead of being submitted again. This works after an application exit or crash when cache persistence is enabled.
Recovery also runs when ordinary result caching is disabled, including after switching from Economy to real-time analysis. If the submission receipt cannot be saved, the accepted batch is canceled. Its uploads are released only after the API confirms the batch has ended; otherwise they remain retained.
Interrupted results downloads retry from the beginning using the normal transient-error backoff. If collection remains unavailable, the run returns retriable errors for the affected sheets and keeps the batch record for the next run. A failed download never triggers another paid batch round. Records are removed after results reach the durable cache, or after the API's results retention window expires (29 days from batch creation).
The Message Batches API is asynchronous and occasionally sticks: a submitted
batch sits at processing=N with zero completions, sometimes for hours,
while every upload and request in hand is perfectly valid. A healthy 39-sheet
drawing batch lands in ~10 minutes, so a batch that has not completed a single
item is the signal.
The poll therefore watches for completely frozen request counts — any
completion resets the timer — and, when the caller opted into recovery, gives up
on a frozen batch, best-effort cancels it (an abandoned batch left running only
burns quota and pins the uploaded files), and resubmits its sheets as a fresh
batch on the same still-uploaded file_ids. Recovery never silently drops to
full-rate real-time calls, so a stuck run keeps the ~50% batch discount.
A sheet the batch already finished is never resubmitted. Before anything is
sent again, the run reads the abandoned batch's completed items back and fills
those sheets from them — cancellation on the Batches API is asynchronous, so a
canceled batch reaches ended only after its in-flight requests finish, and
those requests are still billed. Their finished items stay readable. Without
that step a stalled batch was paid for twice: results are
filled only from a terminal read, so on a batch that never terminated every
sheet looked unresolved, including the ones already produced and billed. A real
40-sheet run detached with 11 sheets in hand, resubmitted all 40, and returned
nothing after paying for 12 digests. The harvest allows one full generation at
a conservative 10 tokens per second, using the largest actual submitted
max_tokens (including raised retry caps), plus five minutes for cancellation
to settle: about 112 minutes at 64k, or 218 minutes at 128k. It is not
shortened by a small collection budget. Set
DRAWING_ANALYZER_BATCH_HARVEST_TIMEOUT_MIN to override this finite ceiling.
Harvest time is added back to the caller's start mark, so it never spends the
recovery's budget. If the batch will not settle within that ceiling, the run
resubmits unresolved sheets as before; an in-flight generation that outlasts the
ceiling can still be billed twice.
The critique batch shares the main poller, but has no stall watch, harvest or automatic resubmission. It waits for the full collection bound and reports uncollected critique reads if it must detach; it does not enter this recovery loop.
The run waits up to 24 hours for a batch — the Batches API's own SLA, and
what makes the app's "can run overnight" wording true rather than aspirational.
Set DRAWING_ANALYZER_BATCH_MAX_ELAPSED_HOURS to cap the wall clock instead.
Raising the bound is only safe because of the harvest above: a longer wait
would otherwise just be a longer window in which completed sheets get re-billed.
A batch that is still completing items when the bound arrives is recorded
distinctly from one that froze — it is handled the same way, since the cancel is
what makes its finished sheets readable at all, but the log, the per-sheet error
and the usage ledger say which happened, so "stuck backend" and "slower than my
bound" are not the same line to read.
The window is tiered, because the first watch and the later ones ask different questions:
| Watch | Window | The question |
|---|---|---|
| The primary batch | 25 min | Is this batch moving at all? |
| Every resubmission after it | 60 min | Is the queue deep, or is the backend sick? |
A flat hour on the first watch is expensive: one real 39-sheet run burned two
consecutive frozen hours before its third batch completed in 589 s — 2 h 16 m of
wall clock for ~25 min of work. Set DRAWING_ANALYZER_BATCH_STALL_TIMEOUT_MIN
to apply one window to every watch (an operator who names a threshold means it
for the whole run); DRAWING_ANALYZER_MAX_BATCH_RESUBMIT_ROUNDS bounds how many
fresh batches recovery will try before the sheets keep a clean retriable error.
While a batch is queued the run is not silent: a heartbeat every five
minutes reports elapsed time, items done, and how long the watch will keep
waiting, in both the diagnostics log and the GUI activity list, and the progress
line carries the elapsed clock — so a deep queue reads as a queue rather than a
hung app. Every abandoned batch is recorded in the usage ledger as a
non-billable attempt (ABANDONED_*, zero tokens), so run_manifest.json
accounts for the attempts a run made and threw away, and the collect log names
both the batch that was submitted and the batch that actually served the
digests.
python -m pytestThe suite is hermetic — no API key, no network. Tests that render real PDFs are
skipped when PyMuPDF is unavailable. tests/test_drawing_acceptance.py encodes
the QC acceptance script end to end (a fresh both-checkbox run, the reviewed-PDF
appearance streams, the stale-reference suggestion, a zero-digest-API cached
re-run, and the raster fallback) against fake clients, so the whole findings →
verify → cloud chain is exercised without a key.
The same file also carries the Phase 27 trust gauntlet: one deterministic
synthetic oracle set (tests/fixtures/gauntlet.py) containing duplicate
basenames, every page rotation plus a reduced CropBox, vector/raster/hybrid
pages, a pre-existing reviewer annotation, unrelated same-tile findings,
repeated text, prose stragglers with a forced structuring failure, a
reproduced-vs-singleton critique pair, a deterministic arithmetic mismatch, a
dual-leg cross-sheet conflict, a rejected finding, an unanchored finding, a
set-level synthesis conflict, two claims sharing one code reference, and a
corrupt input — driven cold (fifteen first-run guarantees, from wrong-source
isolation to byte-exact verifier evidence), warm (zero digest/critique calls,
zero rasterization, stable QC numbering), and mutated (only the changed source
re-analyzed), plus per-stage failure injection proving the run degrades to
PARTIAL/FAILED honestly while the standard digest survives. The §19.2
large-set tests prove the >40-sheet map/reconcile topology (44 sheets, two
shards) and the 84-sheet three-shard reduction find a conflict no single shard
can see.
The report's security boundary (see SECURITY.md) is proven
end to end in real headless Chromium — a DOM emulator can't verify CSP
enforcement, file:// behavior, browser URL normalization, or event execution.
These tests carry the browser marker and skip automatically when Playwright
or its browser binary is absent, so the default python -m pytest is unaffected.
To run them locally:
pip install -e ".[dev,browsertest]"
python -m playwright install chromium # one-time browser download
python -m pytest -m browserThe tests are still hermetic: the report is loaded over file:// and the
Anthropic fetch is stubbed with a canned (malicious) stream, so there is no
network and no key. A global execution sentinel must stay unset while a hostile
payload streams through the assistant and while hostile filenames / findings /
errors sit in the report body.
.github/workflows/ci.yml runs the hermetic suite on Windows + Ubuntu across
Python 3.11 and 3.12 (byte-compile, the PyMuPDF import-isolation check, then the
full suite), plus the headless-Chromium report-security suite on Linux, plus two
release-gate jobs (Phase 27 §19.8): security-gates (repo secret scan,
correctness-class static analysis, the dependency license/AGPL audit, and a
pip-audit vulnerability scan with a documented dated-exception policy) and
build (wheel/sdist build under the committed requirements-release.lock
constraints, twine check, and a clean-venv install smoke test proving the
profile mechanism works in the installed wheel — the loader tolerates the
absent packaged directory and the parser round-trips a checklist item — and that
the installed version matches drawing_analyzer.__version__). No built-in
profiles ship: the packaged profiles/ directory is empty by design and review
plans are model-authored per set (Phase A), so there is nothing in the wheel for
that smoke to find. Actions are pinned to immutable commit SHAs,
permissions are read-only, and the workflow never uses pull_request_target.
Making these checks required is a one-time branch-protection setting an
owner/admin configures in repository settings.
Three additions worth knowing about:
- The workflow also runs on a weekly schedule (Monday 06:00 UTC) and on
workflow_dispatch, so the pip-audit gate catches a CVE disclosed against an already-pinned dependency instead of waiting for someone to commit..github/dependabot.ymlopens grouped weekly updates for both the SHA-pinned actions andrequirements-release.lock. The Windows installer build is constrained by that lock, and the Inno Setup compiler — the one tool whose output is the shipped artifact — has its exact version recorded in the build summary and in the acceptance record. It is deliberately not fetched: the runner image already ships it, and a build that downloads no compiler cannot be served a bad package. The compatibility contract is theInno Setup 6directoryinstaller.isstargets, not a version comparison —ISCC.exereportsProductVersion 0.0.0.0, so a floor check on it fails good compilers. - The browser job now writes a JUnit report and runs
scripts/check_browser_suite.pyagainst it. Every test in that suite skips itself when Chromium will not launch and pytest exits 0 on an all-skipped run, so the required check was green over a suite that executed nothing — measured on one commit, one environment variable apart:98 passedand98 skipped, 2180 deselected, both exit 0. The guard fails the job below a floor of genuinely executed tests. A skip is not a pass. release.yml'spublishjob now needs two tag-gated jobs in its ownneedschain:gates(the fullscripts/run_acceptance.pywith Chromium installed, plus static analysis and the license and CVE audits) andgates-windows(the hermetic suite on Windows). Branch protection does not apply to a tag push and a tag can name any commit, so a release used to be gated only on the installer compiling. CI also triggers onv*tags, but that is visibility only — a separate workflow run started by the same tag is not a dependency of the release workflow, so it cannot gate the publish.
A release candidate is not done when the test suite is green — it is done when the recorded acceptance evidence says so (Phase 27, §19.9). The pieces:
-
Automated gates, one command:
python scripts/run_acceptance.pyruns byte-compile, import isolation, the full hermetic suite (including the trust gauntlet), the secret scan, the browser exploit suite (when Playwright is installed), and a wheel/sdist build + clean-venv install smoke — and prints a PASS/FAIL table. CI runs the same gates per push. Every pytest gate deselects thenetworkmarker in the child it spawns, so the run stays hermetic even on a machine with a realANTHROPIC_API_KEYexported — the billable canary below only ever runs when you ask for it by name. -
Live API canary (opt-in, billable):
ANTHROPIC_API_KEY=... python -m pytest -m network -rs -s tests/test_live_api_canary.pyverifies the live request schemas, critique structured-output compliance, the pinned web-search tool type, the Files API upload→delete lifecycle, and that exported logs/manifests carry no key. Never runs in CI. -
Host evidence vs. prompt text: a sheet's text layer exists in three forms and they are not interchangeable.
sheet_textis capped at 15,000 characters and is what the model is shown;full_sheet_textis the uncapped text kept for host-side checks and never sent anywhere;wordsis the coordinate stream the anchor resolver uses. All three are extracted from the page's own content, with annotations excluded. That matters most on a re-review: the PDF engine folds annotation text into ordinary text extraction, so a set marked up by an earlier run used to feed its own prior QC callouts back to the model as sheet text — the analyzer reading its previous output as if the engineer had drawn it — and the inflated word count also decided whether the sheet was treated as raster, so a scanned sheet carrying nothing but a prior callout was rendered at the cheaper vector target. Cross-sheet grounding asksmodels.sheet_evidence_text(), so a quote the model read off the drawing past the cap is still recognised as real source text instead of being discarded as a hallucination. -
Evidence the host cannot check: a scanned sheet has no text layer, and a hybrid sheet's pasted detail is invisible to a text search even though the sheet has words. A cross-sheet quote the host cannot check is admitted at reduced trust rather than discarded — labelled on the drawing with a plain-words reason and flagged beside the quote in the report. The label says which case it is:
[NO TEXT TO CHECK]where the region carried no searchable text,[NO QUOTE TO CHECK]where the AI named no quote to look for. A quote that fails to match text the sheet's own region does have is unchanged: that stays the hallucination signal.Grounding therefore has three outcomes, not two, and the third is the one a boolean used to swallow:
outcome meaning what happens TEXT_GROUNDEDre-found in the sheet's source text full trust NOT_MATCHED_IN_TEXTshould have been findable, was not dropped — the hallucination signal TEXT_EVIDENCE_UNAVAILABLEno text existed to check, or no quote was given admitted at reduced trust, labelled, still verified Collapsing the last two into "not grounded" is what made a scanned sheet — and the pasted raster region of a hybrid sheet, which has words and so cannot be identified by
is_raster— look like a sheet full of invented quotes. The question is asked of the reported tile, not the whole sheet: one selectable title block would otherwise answer "yes, there was text" for a detail pasted in as pixels, and the recovery would never fire on a real page. -
The cross-sheet pass reports no tile. It reads digests and text layers only, never a tile image or the sheet's grid, so a tile it named was a guess. A guess could rescue a quote the text did not support (a wordless tile answered "no text here") and put the cloud on the wrong part of the sheet, so its prompts no longer ask for one. With no tile, a scanned sheet's quote is still admitted at reduced trust; it is placed as a margin callout instead of on a tile. A quote from a hybrid sheet's pasted raster detail is now dropped as unmatched, because without a location nothing shows that the region had no text. The host still bounds-checks a tile if a model volunteers one.
-
Recovered evidence costs more downstream, by design. Findings that used to be dropped now survive into the later stages, and those stages bill. A reduced-trust finding with a reported tile is anchored to it so the crop re-check and the investigation loop can still look at it — so a set with scanned or hybrid sheets can show more verification calls, more investigation rounds, and a higher total than the same set produced before, with nothing having gone wrong. That is the trade: the alternative was dropping real findings silently.
-
What each evidence change did to the cross-QC cache, and why they differ. Recovering quotes past the prompt cap added the uncapped text's digest to the cache key only for a sheet that is actually truncated — for every other sheet the evidence equals the text already in the key, so those keys stayed byte-identical and no stored result was thrown away. Admitting visual evidence changed what a cached entry means, so every entry had to go; that invalidation comes from the edited map prompt, which rides every cross-QC key, rather than from a second mechanism bolted on beside it. Note that
_CROSS_QC_CACHE_CONTRACTsits at 2 for an unrelated reason — entries written before the findings cap became loss-aware were stored ascompleteafter a silent truncation — so that number is not the record of either evidence change. -
Evidence coverage (zero API calls):
python scripts/measure_evidence_coverage.py --pdf SET.pdf [--export-dir DIR]scans a set without rendering or calling the API and reports how much evidence the host can actually check: sheets whose text layer runs past the 15,000-character cap (and by how much), the textless/hybrid population, the cross-QC 4,000-character budget spend, and — with--export-dir— the anchor tiers of cross-sheet findings a previous run kept. It states plainly what it cannot see: the grounding discard rate is a property of what a model returned, and the survivors in an export are not a substitute for it. -
Benchmarks:
python scripts/benchmark_drawing_analyzer.py --checkmeasures the §19.7 scenarios (cold/warm/mutated/exhaustive/corrupt-partial) on medians and enforces the mechanical gates (a warm run makes zero digest/critique calls and rasterizes nothing; sources hash once per run; usage totals reconcile). Seedocs/PERFORMANCE_AND_COST_VALIDATION.md. -
Manual records:
docs/WINDOWS_ACCEPTANCE.md(real-Windows path/input matrix) anddocs/RELEASE_ACCEPTANCE_TEMPLATE.md(the master §19.9 checklist, including the Bluebeam Revu / Acrobat / Chromium viewer script and the Excel/Notepad output script) are completed per release candidate and kept with the release. -
Reproducible builds:
requirements-release.lockpins the dependency set release artifacts are built and smoke-tested against (pip install -c requirements-release.lock ".[dev]"); regenerate it deliberately and note it in the changelog when bumping dependencies.
See CHANGELOG.md for a consolidated record of changes,
including the QC findings / verification / markup integration and the periodic
re-verification of the Anthropic vision / Files / Batches API facts these
constants depend on.
Drawing Analyzer is free software. Copyright © 2026 Abraham Borg; distributed
under the GNU Affero General Public License, version 3 or later
(AGPL-3.0-or-later — see LICENSE). Anyone may use, study, modify,
and redistribute it, commercially or otherwise, under that license's terms; there
are no restrictions on distribution beyond the AGPL's own conditions (share the
source of modified versions, including when they are offered over a network).
The copyleft license is not optional here: the project depends on
PyMuPDF, which is dual-licensed
AGPL-3.0 / Artifex commercial, so a combined work distributed with PyMuPDF
must itself be AGPL (unless you obtain Artifex's commercial license).
Commercial use and AGPL-compliant redistribution are both allowed as is; a
proprietary (closed-source) distribution is not, unless PyMuPDF's licensing
is resolved with Artifex or PyMuPDF is replaced.
All PyMuPDF usage is isolated to exactly two modules —
src/drawing_analyzer/render.py (rasterizing sheets and crops) and
src/drawing_analyzer/annotate.py (writing cloud annotations onto reviewed
PDFs) — so the PDF backend can be swapped for a permissively-licensed one by
rewriting just those two files, should you want to relicense.
For the rasterization side, pypdfium2 + Pillow is a drop-in permissive
replacement. The markup writer is the harder swap: pypdf can build /Square
annotations with a cloud border-effect dict (/BE {/S /C /I 2}), but it does
not generate the appearance stream (/AP) that PyMuPDF's annot.update()
produces, so some viewers render those annotations blank — which is why PyMuPDF
is used here. That trade-off is documented in annotate.py's module docstring.
The Windows installer carries the same notices as the source: its license page
shows the AGPL text under the copyright line (on a fresh install and on every
in-app update, which runs the full wizard). The page is read-only — there is no
"I accept" step, since the AGPL does not require accepting it to run the
program. The license is installed next to the program as LICENSE.txt with a
Start-menu shortcut, and the Setup exe's version resource and the Add/Remove
Programs entry carry the copyright and license.