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>
7.0 KiB
P5a task 06: PDFs through /v1/fetch and /v2/scrape
Branch: p5a (git switch p5a; git status --short must be empty, otherwise stop)
Commit subject: api: serve PDFs — child extraction via the pool, document cache, map, pages, grep
Goal
Wire tasks 01–05 into the API. A PDF URL fetched through /v1/fetch or /v2/scrape is:
- extracted once in the child, under the pool;
- cached per page;
- answered with the whole text, the map, a page range, or grep results.
Later requests for the same document are served from the cache, revalidated after 24 h with a
conditional GET. question digests the document's text, page markers included, unless the
document is too long.
Files
- Copy:
internal/api/pdf_test.go - Create:
internal/api/pdf.go— everything PDF lives here - Modify:
internal/api/api.go— only the hooks listed in rule 7.api.gois already 840 lines; do not move code into it. - Modify:
docs/implementer-log.md
Interfaces
package api
const (
DefaultPDFTTL = 24 * time.Hour
DefaultPDFMaxQuestionChunks = 200
)
// in Server:
PDFRunner *pdftext.Runner // nil = &pdftext.Runner{} (the child is /proc/self/exe pdftext)
PDFPool *pdfpool.Pool // nil = pdfpool.New(2, 4, 20*time.Second)
PDFTTL time.Duration // 0 = DefaultPDFTTL
PDFMaxQuestionChunks int // 0 = DefaultPDFMaxQuestionChunks
// in v1Req:
Pages string `json:"pages"`
Grep string `json:"grep"`
Defaults are applied where the field is read: a zero Server from New must work. Build the
default pool once, with a sync.Once — never one per request.
Rules
-
Cache first.
- Before fetching, look up
dcache.PDFKey(rawURL, s.callerClass(c))ins.cache()(which may be nil: then there is no cache). - Fresh (now −
FetchedAt< TTL): build the*pdftext.Docfrom the entry (unmarshalMeta;Text=Pages). Answer with no network call and no child. The source is marked"cached": true. - Stale:
s.Fetch.FetchIf(ctx, rawURL, e.ETag, e.LastModified).- 304 →
TouchPDF(key, now)and answer from the entry, with no child. - 200 → go on as for a new document.
- 304 →
- Not cached: the usual
s.Fetch.Fetch, under the existingacquire/release.
- Before fetching, look up
-
A PDF is
clean.Kind(res.ContentType, res.Body) == "pdf". Every other kind goes through today's path unchanged. Useclean.Kindin place of theMediaKind/Sniffpair in bothdoFetchandcleanFetched."other"stays415. -
Extraction.
- Call
s.PDFPool.Do(ctx, class+"|"+res.SHA256(), c.Name, fn), wherefnrunss.PDFRunner.Run(jobCtx, res.Body). - The key is the content hash, so identical bytes are extracted once however many callers or URLs ask. The key includes the caller class, so a restricted caller never shares a job with the fleet.
- Then
PutPDFunder the URL key with: the content hash; the response'sETagandLast-Modified;FetchedAt= now;Meta= the JSON ofdoc.Meta;Pages=doc.Text.
- Call
-
Errors, all through the existing
writeErrshape ({"success":false,"error":…,"hint":…}):cause status error hint pdftext.ErrNoText422 no text layer (scanned PDF?)ocrpdftext.ErrEncrypted422 encrypted PDF (password required)pdftext.ErrUnreadable422 unreadable PDFpdftext.ErrTimeout422 pdf extraction timed outpdftext.ErrTooMuchText422 pdf text over the output limitpdfpool.ErrBusy503 pdf workers busy, headerRetry-After: 10pdfpool.ErrCallerBusy429 busypdfdoc.ErrBadPages/ErrBadGrep400 the error's text pagesorgrepon a non-PDF400 pages and grep apply to PDFs onlyquestionon a document overPDFMaxQuestionChunkschunks422 document too long for question (N chunks); use grep or pagesA PDF path never answers 500 for anything the document did.
-
/v1/fetch.- Mode
clean:contentispdfdoc.Render(doc, {Pages, Grep, OutCap: s.OutCap}).Content. - The envelope gains:
"pdf": {"pages", "title", "outline", "extracted", "first", "last", "map", "grep_hits", "note"}extractedislen(doc.Text);outlineisdoc.Outline; the rest come fromRendered. - Mode
digest: the text ispdfdoc.Full(doc). First countlen(digest.Split(full, digest.ChunkTokens)); if it is overPDFMaxQuestionChunks, answer the 422 above before any model call. Otherwise run the digest as for any page, withfullas the content. - Mode
rawon a PDF: 400raw mode does not apply to PDFs. sizes.raw_tokensis the rune count ofFull(doc)/ 4 for a PDF (there is no body on a cache hit).
- Mode
-
/v2/scrape.markdownisRender(doc, {OutCap: s.OutCap}).Content;htmlis"".metadata.titleisdoc.Title;metadata.numPagesisdoc.Pages;metadata.contentTypeis the response's (application/pdfon a cache hit).
-
api.gohooks (the only edits allowed there):- the four
Serverfields and twov1Reqfields; - the
Kindswitch indoFetch/cleanFetched, now returning the doc; - one call into
pdf.goinv1fetchand inscrape; Retry-AfterinwriteFetchErrfor a 503.
doFetchandcleanFetchedmay gain a result,doc *pdftext.Doc(nil unless a PDF). - the four
-
Logging:
- Keep today's
fetch/scrapelog lines and addpdf_pages(a count) to them. - Never log the title, the outline or any text.
- A restricted caller's lines go through the existing helpers (host blanked); the given test reads every log record.
- Keep today's
Facts
- The tests set
s.Digest = &digest.Engine{Cache: db}only to gives.cache()a database: no model is configured. - The child in the tests is the test binary (
TestMainin the given file). setupAPI,postJSONandrecHandlercome fromapi_test.goin the same package.requestDeadline(210 s) andwriteTimeout(240 s) were raised by the owner for this path: download ≤ 120 s + queue ≤ 20 s + extraction ≤ 60 s.
Steps
- 1.
git switch p5a; copy the given test. 2. See it fail (undefined fields). - 3.
pdf.go(cache, extraction, errors, render), then theapi.gohooks. - 4.
gofmt -w;go test -race -count=3 ./internal/api/→ok: the new tests and every existing api test (search, scrape, restricted logging, write timeout). - 5.
make gate. 6. Rowp5a/06-api; commit.
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 gateok; given file byte-identical;api.gogrew by the hooks only (the owner will diff it).
Stop and report if
- A rule here contradicts an existing api test.
- The pool's default wait (20 s) makes a test take longer than the test allows. The given tests set their own pool where timing matters.