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>
This commit is contained in:
2026-09-25 20:47:38 -07:00
co-authored by Claude Opus 5.5
parent 3f2b78d4aa
commit cfde35397a
115 changed files with 26422 additions and 43 deletions
+108
View File
@@ -0,0 +1,108 @@
# P5a task 07: page citations, metrics, CLI flags, skill, unit, README
**Branch:** `p5a` (`git switch p5a`; `git status --short` must be empty, otherwise stop)
**Commit subject:** `P5a: digests cite pages; sift_pdf_* metrics; sift fetch --pages/--grep; skill, unit and README`
## Goal
Finish P5a:
- digests of PDFs cite `(p. N)`;
- the PDF path is visible in `/metrics`;
- `sift fetch` can ask for pages and grep;
- the agents' skill tells them PDFs work;
- the unit gets the memory the child needs;
- the README describes all of it.
## Files
- Copy: `internal/digest/pages_test.go`, `internal/metrics/pdf_test.go`, `cmd/sift/fetchpdf_test.go`
- Modify:
- `internal/digest/digest.go` (prompt line, `PromptVersion`)
- `internal/metrics/metrics.go`
- `internal/api/pdf.go` (the metric calls)
- `cmd/sift/fetchcmd.go`
- `skill/SKILL.md`
- `nix/module.nix` (**only** `MemoryMax`)
- `README.md`
- `docs/implementer-log.md`
## Rules
1. **Digest.**
- Add one sentence to `systemPrompt`: `When the document has "--- page N ---" markers, cite
the page of each fact as (p. N).`
- Set `PromptVersion = "3"`. The digest cache keys on it, so digests made with the old
prompt are not served.
- `Split` already keeps markers whole; the given test pins that.
2. **Metrics** (`metrics.go`):
- `func (s *Set) ObservePDF(outcome string, d time.Duration, pages int)`. It feeds:
- the counter `sift_pdf_extract_total{outcome}`;
- the histogram `sift_pdf_extract_seconds`;
- the counter `sift_pdf_pages_total`.
`outcome` must be one of `ok`, `no_text`, `encrypted`, `unreadable`, `timeout`,
`too_large`, `busy`; anything else is counted as `other`. The label never carries raw
text: the given test sends `blocked: evil.example`.
- `func (s *Set) ObservePDFWait(d time.Duration)` feeds the histogram
`sift_pdf_pool_wait_seconds`.
- `func (s *Set) PDFCacheHit()` feeds the counter `sift_pdf_cache_hits_total`.
- Register them on the Set's own registry, like `sift_requests_total`.
- In `internal/api/pdf.go`:
- call `ObservePDF` once per extraction attempt, with the outcome and the pages extracted;
- call `ObservePDFWait` with the time spent waiting in `PDFPool.Do` before the job
started (measure around `Do`: total minus the job's own time);
- call `PDFCacheHit` for every answer served from the cache, fresh or after a 304.
3. **CLI** (`fetchcmd.go`).
- Parsing becomes a struct, and today's tuple function stays as a wrapper so the existing
tests keep compiling:
```go
type fetchOpts struct {
URL, Mode, JQ, Question, Pages, Grep string
NoContext, Recover, JSON bool
}
func parseFetchOpts(args []string) (fetchOpts, error) // the parser; knows --pages and --grep (both take a value)
func parseFetchArgs(args []string) (…the same 8 results as today…) // calls parseFetchOpts
func fetchBody(o fetchOpts) map[string]any // the /v1/fetch request body
```
- `fetchBody` builds today's body: url, mode (defaulting to digest when there is a question,
else clean), jq, question, context (question set and not `--no-context`), recover. It adds
`"pages"` and `"grep"` **only when set**.
- The command uses `parseFetchOpts` and `fetchBody`.
- Add `[--pages a-b] [--grep RE]` to `fetchUsage`.
- The given `cmd/sift/fetchpdf_test.go` and the existing flag tests must pass.
4. **Skill** (`skill/SKILL.md`):
- Remove the `415 unsupported type` row's mention of PDF.
- Add a short "PDFs" section:
- PDFs work;
- a long one answers with a map (outline with page numbers, first pages);
- then use `--pages 41-60` or `--grep 'pattern'`, or `--question` for a digest that cites
pages;
- scanned PDFs return `no text layer`;
- never download a PDF and run `pdftotext` yourself.
5. **Unit** (`nix/module.nix`): `MemoryMax = "1G";` (was `"512M"`). Nothing else in the file
changes.
6. **README:**
- a "PDF documents" section covering: detection, limits (100 MB, 120 s download, 60 s
extraction, 2,000 pages), the child process, the pool (2 slots, 4 waiting, 20 s), the
cache (24 h, conditional GET), the answers (full / map / pages / grep / digest with
citations), the errors table from task 06, and the metrics;
- note that the engine is `go-pdfkit/extract` v0.3.0, chosen by the bake-off in `docs/P5a.md` §3.
## Steps
- [ ] **1.** `git switch p5a`; copy the three given tests. **2. See them fail.**
- [ ] **3.** digest, **4.** metrics and the calls in `pdf.go`, **5.** CLI, **6.** skill, unit,
README.
- [ ] **7.** `gofmt -w`; `make gate`.
- [ ] **8.** `make build && bin/sift fetch 2>&1 | grep -c -- '--pages'` prints `1`.
- [ ] **9.** Row `p5a/07-digest-cli-docs`; commit.
```sh
git add internal/digest internal/metrics internal/api/pdf.go cmd/sift skill nix/module.nix README.md docs/implementer-log.md
git commit -F .state/commit-msg.txt
```
## Done when
- All tests pass; `make gate` ok; given files byte-identical; the `nix/module.nix` diff is the
one line.