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
+163
View File
@@ -0,0 +1,163 @@
# P5a task 06: the PDF path through `/v1/fetch` and `/v2/scrape`
**Branch:** `p5a` (`git switch p5a`; `git status --short` must be empty, otherwise stop)
**Commit subject:** `api: serve PDFs — extraction in the child under the pool, full text or map, errors`
> This task is half of the original task 06, which proved too large for one session. Task 07
> adds the cache, `pages`, `grep` and the question limit. Do only what is here.
## Goal
A PDF URL fetched through `/v1/fetch` (mode `clean`) or `/v2/scrape` is extracted in the child
process under the pool and answered with the whole text (page markers) or the map. Its
failures get the errors below. **No cache in this task** (every request fetches and extracts);
**no `pages` or `grep`**.
## Files
- Copy: `internal/api/pdf_test.go` (the whole given file; 7 of its 10 tests must pass after
this task, listed in Done when)
- Create: `internal/api/pdf.go`: every function named below lives here
- Modify: `internal/api/api.go`, only the hooks in rule 6; `internal/fetch/fetch.go`, only
the `RetryAfter` field in rule 5; `docs/implementer-log.md`
## What already exists (do not re-read these packages; this is their API)
```go
// internal/clean
func Kind(contentType string, body []byte) string // "html" | "json" | "text" | "pdf" | "other"
// internal/pdftext
type OutlineItem struct{ Title string; Page, Level int }
type Meta struct{ Pages int; Title string; Outline []OutlineItem }
type Doc struct{ Meta; Text []string; Failed []int } // Text[i] = page i+1
var ErrEncrypted, ErrUnreadable, ErrNoText, ErrTimeout, ErrTooMuchText error
type Runner struct{ Exe string; Args, Env []string; Deadline time.Duration; MaxText int64; MaxPages int }
func (r *Runner) Run(ctx context.Context, pdf []byte) (*Doc, error) // a zero Runner runs /proc/self/exe pdftext
// internal/pdfpool
var ErrBusy, ErrCallerBusy error
func New(slots, queue int, wait time.Duration) *Pool
func (p *Pool) Do(ctx context.Context, key, caller string, fn func(context.Context) (any, error)) (any, error)
// internal/pdfdoc
type Options struct{ Pages, Grep string; OutCap int }
type Rendered struct{ Content string; Map bool; First, Last, GrepHits int; Note string }
func Render(doc *pdftext.Doc, opt Options) (Rendered, error)
func Full(doc *pdftext.Doc) string
```
## Interfaces added here
```go
package api
const (
DefaultPDFTTL = 24 * time.Hour // used by task 07; declare it now
DefaultPDFMaxQuestionChunks = 200 // used by task 07; declare it now
)
// in Server (after Prom):
PDFRunner *pdftext.Runner // nil = &pdftext.Runner{}
PDFPool *pdfpool.Pool // nil = pdfpool.New(2, 4, 20*time.Second), built once (sync.Once)
PDFTTL time.Duration // task 07
PDFMaxQuestionChunks int // task 07
// in v1Req:
Pages string `json:"pages"` // task 07
Grep string `json:"grep"` // task 07
```
## Rules
1. **`doFetch` and `cleanFetched` gain a fourth result, `doc *pdftext.Doc`,** which is nil unless
the resource is a PDF. Each uses `clean.Kind(res.ContentType, res.Body)` in place of today's
`MediaKind`/`Sniff` pair.
- `"other"` stays the 415 exactly as today.
- `"pdf"` calls `s.extractPDF(ctx, c, res)`; on success it returns
`cl = &clean.Result{Kind: "pdf", Title: doc.Title}` and the doc.
- `cleanFetched` has no caller at hand for the pool. For a PDF it returns the 415 as before:
the recover path (archive snapshots) does not serve PDFs.
2. **`func (s *Server) extractPDF(ctx context.Context, c *auth.Caller, res *fetch.Resource) (*pdftext.Doc, error)`**
- Calls `s.pool().Do(ctx, s.callerClass(c)+"|"+res.SHA256(), c.Name, fn)`, where `fn` returns
`s.runner().Run(jobCtx, res.Body)`.
- `pool()` and `runner()` apply the defaults.
- Type-assert the result with the two-value form.
3. **`func pdfError(err error) error`** maps every PDF-path error to a `*fetch.Error`:
| cause | Status | Msg | Hint | RetryAfter |
|---|---|---|---|---|
| `pdftext.ErrNoText` | 422 | `no text layer (scanned PDF?)` | `ocr` | |
| `pdftext.ErrEncrypted` | 422 | `encrypted PDF (password required)` | | |
| `pdftext.ErrUnreadable` (also when wrapped) | 422 | `unreadable PDF` | | |
| `pdftext.ErrTimeout` | 422 | `pdf extraction timed out` | | |
| `pdftext.ErrTooMuchText` | 422 | `pdf text over the output limit` | | |
| `pdfpool.ErrBusy` | 503 | `pdf workers busy` | | 10 |
| `pdfpool.ErrCallerBusy` | 429 | `busy` | | |
| anything else (a panic recovered by the pool) | 422 | `unreadable PDF` | | |
Use `errors.Is`. A PDF never produces a 500.
4. **Rendering** (`pdf.go`), `func (s *Server) renderPDF(doc *pdftext.Doc) (pdfdoc.Rendered, error)`:
`pdfdoc.Render(doc, pdfdoc.Options{OutCap: s.OutCap})`.
- **`/v1/fetch`**, mode `clean` with a doc:
- `content` is `Rendered.Content`;
- the envelope gains
`"pdf": {"pages": doc.Pages, "title": doc.Title, "outline": doc.Outline, "extracted": len(doc.Text), "first", "last", "map", "grep_hits", "note"}`,
the last five from `Rendered`;
- `sizes.raw_tokens` is the rune count of `pdfdoc.Full(doc)` / 4.
- Mode `raw` with a doc: `400 raw mode does not apply to PDFs`.
- Mode `digest` with a doc: run the digest exactly as today but with `pdfdoc.Full(doc)` as the
content. Task 07 adds the length limit before it.
- **`/v2/scrape`** with a doc:
- `markdown` is `Rendered.Content`; `html` is `""`;
- `metadata.title` is `doc.Title`;
- `metadata.numPages` is `doc.Pages`;
- `metadata.contentType` is the response's.
5. **`internal/fetch/fetch.go`:** add `RetryAfter int` to `fetch.Error` (seconds, 0 = none).
`writeFetchErr` sets the `Retry-After` header when it is non-zero, before `writeErr`. This is
the only change allowed in the fetch package.
6. **`api.go` hooks, and nothing else there:**
- the `Server` and `v1Req` fields;
- the two `Kind` switches (rule 1);
- the new result at the three call sites of `doFetch`/`cleanFetched`;
- one `if doc != nil { … }` block in `v1fetch` and in `scrape` that calls into `pdf.go`;
- the `Retry-After` line in `writeFetchErr`;
- `pdf_pages` (a count) added to the existing `fetch` and `scrape` log lines.
Never log a title, an outline or any text. Restricted callers go through the existing helpers.
## Facts
- The given test's child is the test binary itself (its `TestMain` turns into `pdftext` when
`SIFT_PDFTEXT_CHILD=1`). The rig builds `s.PDFRunner` with that environment; you never start
a child yourself outside `Runner.Run`.
- `setupAPI`, `postJSON` and `recHandler` come from `api_test.go` in the same package.
- `s.cache()` may be nil; this task never uses it.
## Steps
- [ ] **1.** `git switch p5a`; copy the given test.
- [ ] **2. See it fail:** `go test -count=1 ./internal/api/` fails to compile (undefined fields).
- [ ] **3.** `fetch.Error.RetryAfter`. **4.** `pdf.go`. **5.** The `api.go` hooks.
- [ ] **6.** `gofmt -w`; then
`go test -race -count=3 -run 'TestPDFCleanFetch|TestPDFMap|TestPDFErrors|TestPDFTimeout|TestPDFBusy|TestPDFRestrictedCaller|TestPDFScrape' ./internal/api/` → `ok`,
and `go test -race -count=1 ./internal/api/ -run 'Test[^P]'` (every existing api test) → `ok`.
- [ ] **7.** Row `p5a/06-api-pdf`. Its Gate column says `pass (7 of 10 PDF tests; 3 are task 07's)`.
Commit.
```sh
git add internal/api internal/fetch/fetch.go docs/implementer-log.md
git commit -F .state/commit-msg.txt
```
## Done when
- The seven named tests pass under `-race -count=3`, and every non-PDF api test passes.
- The three remaining given tests fail only on `pages`, `grep`, the cache or the question
limit: `TestPDFPagesAndGrep`, `TestPDFCache`, `TestPDFQuestionTooLong`. That is task 07.
`make gate` is expected red on exactly those three and nothing else. Say so in the log row.
- The given file is byte-identical.
## Stop and report if
- Any of the seven fails for a reason that needs the cache, `pages` or `grep`.