Files
kyleandClaude Opus 5.5 cfde35397a P5a plan: PDF documents — seven Ornith tasks with acceptance tests first
docs/plans/p5a: 01 kind+fetch limits, 02 pdftext child + runner, 03 dcache PDF tables,
04 pdfpool, 05 pdfdoc render (map/pages/grep), 06 api, 07 digest citations, metrics, CLI,
skill, unit, README. Given tests compile against a panic-only skeleton and fail there for the
intended reasons; PDFs for tests are built by internal/pdftest (given), plus two qpdf-encrypted
fixtures.

Owner changes for the run: vendor go-pdfkit/extract v0.3.0 + reader v0.6.0 (pinned via
tools.go until task 02); AGENTS.md, tools/run-plan.sh (enforces a Co-Authored-By trailer for
the model), docs/implementer-log.md, `make gate`; gofmt (whitespace) on clean.go and guard;
TestUnsupportedType uses a zip now that PDFs are supported; requestDeadline 90→210 s and
writeTimeout 120→240 s for the PDF path's worst case.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 20:47:38 -07:00

253 lines
13 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Sift P5a — PDF documents
Drafted 2026-09-25 by Claude (Kyle: "support longer / large docs … for ingesting research papers /
thesis", thread `nhf5k9oiu3brzj6ejjbdo4o9te`). Reviewer: Claude. Implementer: Kyle's call.
## 1. Scope
In: PDFs through `/v1/fetch`, `/v2/scrape` and `sift fetch`. Text extraction in pure Go, in a
re-exec'd child of the sift binary. Per-page cache. A "map" answer for documents larger than the
output cap. `--pages` and `--grep` over the cached text. Page markers so digests can cite pages.
Bounded worker pool with single-flight.
Out:
- **P5b:** retrieval tuned for very long documents (page-aware chunks, background embedding,
BM25 while embeddings build).
- **P5c:** `sift extract <file>` for PDFs already on a host.
- OCR.
- Any other binary type (docx, epub).
The guard, auth and restricted-caller rules do not change: a PDF is fetched exactly like a page.
## 2. Why agents need this (facts, 2026-09-25)
`cleanFetched`/`doFetch` answer every non-html/json/text type with
`415 unsupported content type; hint: browser`. The skill says the same ("PDF in v1 → Browser tool").
An agent that hits a PDF therefore downloads it itself and assembles
`cp … ; file … ; pdfinfo … ; pdftotext -layout … | head -c 20000` in the shell. That is exactly the
command shape tirith flags and the approval gate then has to judge.
## 3. Engine: `github.com/go-pdfkit/extract` (pinned), chosen by bake-off
Bake-off harness: `~/src/pdfbench` on straylight. Data: `~/data/pdfbench`.
Setup:
- Run on orion. Each (engine, file) pair got its own `systemd-run --user` unit with sift's
hardening: `MemoryDenyWriteExecute`, `MemoryMax=512M`, the same syscall filter,
`RestrictNamespaces`, `PrivateNetwork`.
- Corpus: 18 public PDFs plus 6 synthetic ones (scanned, AES-256/RC4 owner-only, user-password,
2,100 pages, truncated, broken xref). No PDF came from a restricted caller.
- Text scored against `pdftotext -layout`.
- 200 byte-level mutants for robustness.
| engine | recall / precision / word order | median ms | median/max MB | fuzz: panics or hangs | salvage |
|---|---|---|---|---|---|
| **go-pdfkit/extract** | **0.937 / 0.943 / 0.915** | 128 | 16 / 66 | **0 / 0** (195 of 200 ok) | **0.79** |
| Detective-XH/gopdf | 0.898 / 0.867 / 0.827 | 122 | 12 / 59 | 0 / 0 (68 of 200 ok) | 0.29 |
| go-pdfium (wasm) | 0.941 / 0.945 / 0.915 **only with MDWE off** | 475 + 2.4 s compile | ~300 / OOM at 512 | 0 / 0 | 0.74 |
| ledongthuc/pdf | 0.47 / 0.60 / 0.43 (glues words) | 31 | 8 / 47 | — | — |
Notes:
- go-pdfium in compiled mode panics under `MemoryDenyWriteExecute` (all 24 files). In
interpreter mode it takes 7.5 s per PDF and runs out of memory twice.
- Whole documents with extract (no page cap), same sandbox:
| document | pages | time | memory |
|---|---|---|---|
| Karpathy thesis (14.7 MB) | 95 | 0.5 s | 39 MB |
| Japanese white paper | 307 | 1.2 s | 83 MB |
| RP2040 datasheet | 642 | 2.0 s | 34 MB |
| synthetic | 2,100 | 10 s | 59 MB |
**Known extract defect:** the Russian UDHR (Acrobat Distiller 5, 2002) comes out with words glued
together and digits dropped (recall 0.03; gopdf and pdfium read it cleanly).
- File the upstream issue. No fixture is committed: the document is not ours to redistribute.
- Pin `extract v0.3.0` / `reader v0.6.0` in `go.mod` and vendor them, as for every other dependency.
## 4. Detection and fetch
- **Kind `pdf`:**
- `Content-Type` of `application/pdf` or `application/x-pdf`; or
- `clean.Sniff` finds `%PDF-` within the first 1024 bytes. The spec allows leading junk, and
many servers send `application/octet-stream`.
- **Accept header:** add `application/pdf;q=0.8` to the fetch `Accept`.
- **Size and time limits.** For a response that is a PDF (by header, or by sniffing the first
bytes of the stream), the body limit is **100 MB** instead of `DefaultMaxBody` (5 MB), and the
fetch timeout is **120 s**. Other kinds keep today's limits. A body over the limit is
`413 too large (>100 MB)`.
## 5. Extraction in a child process: `sift pdftext`
A hidden subcommand of the same binary, so there is no poppler, no new package, and no change to
the unit's `MemoryDenyWriteExecute`.
- **Parent:**
- `exec.CommandContext(ctx, "/proc/self/exe", "pdftext", "-max-pages", N)`;
- empty environment; the PDF bytes on stdin, then close stdin;
- reads stdout with a cap of 64 MiB of text, and stderr with a cap of 4 KiB;
- deadline **60 s**, after which the child is killed and the result is
`422 pdf extraction timed out`.
- **Child:**
- sets `debug.SetMemoryLimit(768 MiB)` and `RLIMIT_AS` 2 GiB before reading anything;
- `recover()` around each page;
- writes JSON Lines:
```json
{"meta":{"pages":212,"title":"…","outline":[{"title":"2 Background","page":14,"level":1}]}}
{"page":1,"text":"…"}
{"page":2,"text":"…"}
```
- `outline` comes from `/Outlines` through `go-pdfkit/reader` (title, destination page,
depth). It is optional and may be `[]`. A page that fails yields
`{"page":n,"error":"…"}` and extraction continues.
- **Exit codes:** 0 = ok; 3 = encrypted and needs a user password; 4 = unreadable (not a PDF, or
no page tree); anything else is `422 unreadable PDF`.
- **Page limit:** `-max-pages` 2000. Past it, the map says `pages 1–2000 of N extracted`.
- **Empty text:** if every page's text is blank after trimming, the answer is
`422 no text layer (scanned PDF?)` with hint `ocr`.
- **Logging:** the child never logs text. The parent logs pages, bytes, ms and the outcome, and
for restricted callers only the outcome (P2.4 rules).
## 6. Worker pool, single-flight, fairness
Kyle, 2026-09-25: "one or two workers seems tenable".
- **Pool:** 2 extraction slots and 4 waiting places. A request that waits more than 20 s, or
finds the queue full, gets `503 {"error":"pdf workers busy"}` with `Retry-After: 10`.
- **Single-flight:** keyed by (caller class, content hash). Concurrent requests for the same
bytes share one extraction. Downloads may still happen twice: a URL is not known to be a PDF
until it has been fetched.
- **Per caller:** one PDF job in flight. A second concurrent one gets the existing
`429 busy` path.
- **Cache first:** a request answered from the cache (§7) never touches the pool.
## 7. Cache (dcache)
Two tables, partitioned by caller class like the digest cache:
- `pdf_doc(url_key, caller_class, content_hash, etag, last_modified, meta_json, fetched_at)`
- TTL 24 h. After that, a conditional GET (`If-None-Match` / `If-Modified-Since`); on 304,
refresh `fetched_at`.
- `pdf_page(content_hash, caller_class, page, text)`
Both count toward `dcache` `maxBytes` and its eviction, whole documents at a time (oldest
`fetched_at` first). The digest and embedding caches already key on the content hash, so
`--question` on a cached PDF reuses its embeddings.
## 8. What callers get back
Text is assembled with a marker before **every** page, so both agents and digests can cite pages:
```
--- page 1 ---
<page 1 text>
--- page 2 ---
<page 2 text>
```
**`/v1/fetch`, mode `clean`** (new optional request fields `pages`, `grep`):
| request | content |
|---|---|
| whole text ≤ `OutCap` | the whole text with page markers |
| whole text > `OutCap` | **the map**: `# <title>`, `pages N · ~T tokens`, the outline as `- 2 Background (p. 14)` lines, a line saying `use pages="a-b" or grep=…`, then as many leading pages as fit in `OutCap/4` |
| `pages: "41-60"` | those pages with markers; 1-based and inclusive; `a-b`, `a`, or `a-`; capped at 100 pages or `OutCap`, whichever is smaller, with a note when cut; out of range is `400 pages out of range (1–N)` |
| `grep: "<RE2>"` | up to 100 matches as `p. N: <line>` with one line of context each side; Go RE2 (linear time); pattern at most 200 bytes; case-insensitive unless the pattern has upper case |
- `pages` and `grep` together means grep within those pages.
- The JSON envelope gains:
```
"pdf": {"pages", "title", "outline", "extracted", "first", "last", "map", "grep_hits", "note"}
```
**`/v1/fetch`, mode `digest` (`question`):**
- The existing engine runs over the text with page markers.
- The system prompt gains one line: "When the document has `--- page N ---` markers, cite
pages as (p. N)."
- If `Split` yields more than 200 chunks (about 240k tokens), P5a answers
`422 document too long for question; use grep or pages` and P5b removes that limit.
**`/v2/scrape` (Hermes `web_extract`):** `markdown` is what clean mode would return (the whole
text or the map). `metadata` gains `contentType: application/pdf` and `numPages`. There is no new
request field: Hermes cannot send one.
**Errors**, all under the existing error shape:
- `413` too large;
- `422` no text layer / encrypted / unreadable / timed out / too long for question;
- `503` workers busy.
## 9. CLI and skill
- `sift fetch <url> --pages 41-60`
- `sift fetch <url> --grep 'attention head'`
- The fetch help text lists both.
- `skill/SKILL.md`:
- remove the 415/PDF row;
- add "PDFs work; a long one answers with a map first — then use `--pages` / `--grep`, or
`--question` for a digest that cites pages".
## 10. Unit and metrics
- `nix/module.nix`: `MemoryMax = "1G"` (was 512M; orion has 13 GB available). Nothing else in
the hardening changes: the child inherits it.
- Metrics:
- `sift_pdf_extract_total{outcome}`
- `sift_pdf_extract_seconds` (histogram)
- `sift_pdf_pages_total`
- `sift_pdf_pool_wait_seconds`
- `sift_pdf_cache_hits_total`
## 11. Acceptance (contract) tests
All run offline against `tests/testdata/pdf/` fixtures served by an httptest server:
- small public-domain PDFs only;
- the synthetic scanned, encrypted, truncated and 2,100-page files are regenerated by a script,
not committed.
1. `application/pdf` and `application/octet-stream` + `%PDF-` both extract; `text/html` never
takes the PDF path.
2. A 3-page text PDF returns all three pages with `--- page 2 ---` / `--- page 3 ---` markers
and the envelope `pdf.pages == 3`.
3. A document over `OutCap` returns `pdf.map == true`, the outline lines with page numbers, and
no more than `OutCap` characters.
4. `pages: "2-3"` returns exactly pages 2–3. `"0-1"`, `"5-4"` and `"999"` are 400.
5. `grep` finds a phrase known to be on page 2 as `p. 2: …`. A 201-byte pattern is 400. The
pathological regex `(a+)+$` on 1 MB of `a` finishes in under 1 s (RE2).
6. Scanned fixture → `422 no text layer (scanned PDF?)`, hint `ocr`. User-password fixture →
`422` encrypted. Truncated fixture → 200 with whatever extract salvages, or 422; never 500.
7. **The child is killed on deadline:** a test hook makes `pdftext` sleep. With a 1 s deadline
the parent answers `422 … timed out` within 2 s and no child process remains.
8. **Pool:** with 2 slots, 4 waiting places and a slow child, the seventh concurrent request
gets 503 with `Retry-After`. Two concurrent requests for the same URL cause **one**
upstream GET and one child.
9. **Cache:** a second `pages` request for a fetched document makes **no** upstream GET and
starts no child. After the TTL, the conditional GET returns 304 and still starts no child.
10. **Restricted caller:** the log lines for a restricted caller's PDF fetch carry no host,
title or text; its pages land in its own cache partition, invisible to a fleet caller.
11. **Body limit:** 100 MB + 1 byte → 413 for a PDF; an HTML body over 5 MB → 413 (unchanged).
12. **`/v2/scrape`** with the Hermes 0.21 body `{"url","formats":["markdown"]}` on a PDF returns
`success: true`, markdown with page markers, and `metadata.numPages`.
13. **Digest:** `question` on the 3-page fixture returns a digest containing `(p. ` for a fact
known to be on one page (the model is stubbed, as in the existing digest tests; the check is
that the page markers reach the prompt and the prompt line is present).
14. (Dropped: no third-party fixture is committed; the extract defect is tracked upstream.)
## 12. Rollout
1. Implementer builds P5a on a branch; the reviewer (Claude) checks §11, the child-process code,
and every new limit.
2. Orion pin bump (straylight's lane, as before) and `MemoryMax=1G`.
3. The skill update ships with the pin bump to talos, straylight and titan.
4. Watch `sift_pdf_*` and tirith for a week. The success measure is that the
`cp … pdftotext …` shell pattern disappears from agents' commands.
## 13. Changes after the plan was written (2026-09-25)
- The implementation plan is `docs/plans/p5a/` (seven tasks, Ornith implementing, acceptance tests
first). Where this doc and a task file differ, the task file and its given tests win.
- `requestDeadline` 90 s → 210 s and `writeTimeout` 120 s → 240 s, so the PDF path's worst case
(120 s download + 20 s queue + 60 s extraction) fits; other paths keep their own timeouts.
- The map line reads `pages 1-E extracted` (ASCII hyphen) when the child stopped at its page cap.