docs: P5a — PDF documents (pure-Go extract in a re-exec'd child, per-page cache, map, pages/grep, 2-worker pool)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-09-25 20:19:39 -07:00
co-authored by Claude Opus 5.5
parent 4372673184
commit 3f2b78d4aa
+244
View File
@@ -0,0 +1,244 @@
# 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).
- Keep it as a regression fixture: `tests/testdata/pdf/udhr-ru.pdf`, expected to fail today and
marked as such.
- File the upstream issue.
- 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, final URL). Concurrent requests for the same
document share one download and one extraction.
- **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 page markers, so both agents and digests can cite pages:
```
<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": N, "title": "…", "outline": [...], "returned": [a, b], "map": bool, "grep_hits": n}
```
**`/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. **The `udhr-ru` regression fixture** is present and marked as a known failure (skip with a
reason naming the upstream issue).
## 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.