p5a: split task 06 (too large: looped 3h) into 06-api-pdf and 07-api-cache-pages; old 07 is 08

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-09-26 03:12:07 -07:00
co-authored by Claude Opus 5.5
parent 6a3b6dab71
commit 5ce625be33
5 changed files with 271 additions and 155 deletions
+89
View File
@@ -0,0 +1,89 @@
# P5a task 07: the PDF document cache, `pages`, `grep`, and the question limit
**Branch:** `p5a` (`git switch p5a`; `git status --short` must be empty, otherwise stop)
**Commit subject:** `api: PDF cache with revalidation; pages and grep; refuse questions on very long documents`
## Goal
Task 06 serves PDFs but fetches and extracts every time. After this task, a PDF already
extracted is answered from the cache: fresh for 24 h, then revalidated with a conditional GET.
An agent can ask for `pages` or `grep`. A question on a document too long for the digest is
refused with a pointer to those two.
## Files
- Copy: nothing (the given `internal/api/pdf_test.go` came in task 06)
- Modify: `internal/api/pdf.go` (all new logic), `internal/api/api.go` (only the call in rule 1
and the two request fields' use in rule 3), `docs/implementer-log.md`
## What already exists
- Task 06's `extractPDF`, `pdfError`, `renderPDF`, the `Server` fields (`PDFTTL`,
`PDFMaxQuestionChunks`), the `v1Req` fields (`Pages`, `Grep`), and `DefaultPDFTTL` /
`DefaultPDFMaxQuestionChunks`.
- `internal/dcache`:
```go
type PDFEntry struct{ ContentHash, ETag, LastModified string; FetchedAt time.Time; Meta []byte; Pages []string }
func PDFKey(url, callerClass string) string
func (d *DB) PutPDF(key string, e PDFEntry)
func (d *DB) GetPDF(key string) (PDFEntry, bool)
func (d *DB) TouchPDF(key string, fetchedAt time.Time)
```
A nil `*DB` is a no-op.
- `internal/fetch`: `func (f *Fetcher) FetchIf(ctx, rawURL, etag, lastModified string) (*Resource, error)`.
A 304 returns `Status 304`, an empty body and a nil error.
- `internal/digest`: `Split(text string, maxTok int) []string` and `ChunkTokens`.
## Rules
1. **Cache first, in `doFetch`**, before `acquire` and `s.Fetch.Fetch`: call
`s.pdfFromCache(ctx, c, rawURL) (res *fetch.Resource, doc *pdftext.Doc, ok bool, err error)`.
The key is `dcache.PDFKey(rawURL, s.callerClass(c))`; the cache is `s.cache()` (may be nil).
- **Miss:** `ok == false`; go on as today.
- **Fresh** (`time.Since(e.FetchedAt) < ttl`, where `ttl` is `s.PDFTTL` or `DefaultPDFTTL`):
build the doc from the entry (unmarshal `Meta` into `doc.Meta`; `doc.Text = e.Pages`), and a
synthetic `res`: `URL` and `FinalURL` = rawURL, `Status` 200,
`ContentType "application/pdf"`, `FetchedAt` = `e.FetchedAt`, `Hash` = `e.ContentHash`, no
body. No network call and no child.
- **Stale:** `s.Fetch.FetchIf(ctx, rawURL, e.ETag, e.LastModified)`.
- 304 → `TouchPDF(key, now)`, then answer as fresh.
- 200 → return that response with `ok == false`, so `doFetch` goes on without a second
GET: the new document is extracted and stored.
- An error → return it.
- The source in the `/v1/fetch` envelope says `"cached": true` for an answer from the cache
(fresh or after a 304).
2. **Store after extraction:** after `extractPDF` succeeds, `PutPDF` under the URL key with:
- `ContentHash: res.SHA256()`;
- `ETag` and `Last-Modified` from the response headers;
- `FetchedAt: now`;
- `Meta`: the JSON of `doc.Meta`;
- `Pages: doc.Text`.
3. **`pages` and `grep` (`/v1/fetch`):**
- Pass `req.Pages` and `req.Grep` into `pdfdoc.Options` in `renderPDF`.
- `pdfdoc.ErrBadPages` or `pdfdoc.ErrBadGrep` (use `errors.Is`) → 400, with the error's own
text as the message.
- `pages` or `grep` on a non-PDF → `400 pages and grep apply to PDFs only`.
4. **Question limit:** in mode `digest` with a doc, compute
`n := len(digest.Split(pdfdoc.Full(doc), digest.ChunkTokens))`. If `n` exceeds
`s.PDFMaxQuestionChunks` (or its default), answer
`422 document too long for question (<n> chunks); use grep or pages` **before** any digest
call.
## Steps
- [ ] **1.** `git switch p5a`. **2. See the three tests fail:**
`go test -count=1 -run 'TestPDFPagesAndGrep|TestPDFCache|TestPDFQuestionTooLong' ./internal/api/`.
- [ ] **3.** `pdfFromCache` and the store (rules 1–2), **4.** pages and grep, **5.** the question limit.
- [ ] **6.** `gofmt -w`; `go test -race -count=3 ./internal/api/` → `ok` (all ten PDF tests and
every existing test).
- [ ] **7.** `make gate` → `gate: ok`. **8.** Row `p5a/07-api-cache-pages`; commit.
```sh
git add internal/api docs/implementer-log.md
git commit -F .state/commit-msg.txt
```
## Done when
- `go test -race -count=3 ./internal/api/` is ok; `make gate` ok; the given file is still
byte-identical.