7.8 KiB
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,grepand 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 theRetryAfterfield in rule 5;docs/implementer-log.md
What already exists (do not re-read these packages; this is their API)
// 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
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
-
doFetchandcleanFetchedgain a fourth result,doc *pdftext.Doc, which is nil unless the resource is a PDF. Each usesclean.Kind(res.ContentType, res.Body)in place of today'sMediaKind/Sniffpair."other"stays the 415 exactly as today."pdf"callss.extractPDF(ctx, c, res); on success it returnscl = &clean.Result{Kind: "pdf", Title: doc.Title}and the doc.cleanFetchedhas 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.
-
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), wherefnreturnss.runner().Run(jobCtx, res.Body). pool()andrunner()apply the defaults.- Type-assert the result with the two-value form.
- Calls
-
func pdfError(err error) errormaps every PDF-path error to a*fetch.Error:cause Status Msg Hint RetryAfter pdftext.ErrNoText422 no text layer (scanned PDF?)ocrpdftext.ErrEncrypted422 encrypted PDF (password required)pdftext.ErrUnreadable(also when wrapped)422 unreadable PDFpdftext.ErrTimeout422 pdf extraction timed outpdftext.ErrTooMuchText422 pdf text over the output limitpdfpool.ErrBusy503 pdf workers busy10 pdfpool.ErrCallerBusy429 busyanything else (a panic recovered by the pool) 422 unreadable PDFUse
errors.Is. A PDF never produces a 500. -
Rendering (
pdf.go),func (s *Server) renderPDF(doc *pdftext.Doc) (pdfdoc.Rendered, error):pdfdoc.Render(doc, pdfdoc.Options{OutCap: s.OutCap})./v1/fetch, modecleanwith a doc:contentisRendered.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 fromRendered; sizes.raw_tokensis the rune count ofpdfdoc.Full(doc)/ 4.
- Mode
rawwith a doc:400 raw mode does not apply to PDFs. - Mode
digestwith a doc: run the digest exactly as today but withpdfdoc.Full(doc)as the content. Task 07 adds the length limit before it. /v2/scrapewith a doc:markdownisRendered.Content;htmlis"";metadata.titleisdoc.Title;metadata.numPagesisdoc.Pages;metadata.contentTypeis the response's.
-
internal/fetch/fetch.go: addRetryAfter inttofetch.Error(seconds, 0 = none).writeFetchErrsets theRetry-Afterheader when it is non-zero, beforewriteErr. This is the only change allowed in the fetch package. -
api.gohooks, and nothing else there:- the
Serverandv1Reqfields; - the two
Kindswitches (rule 1); - the new result at the three call sites of
doFetch/cleanFetched; - one
if doc != nil { … }block inv1fetchand inscrapethat calls intopdf.go; - the
Retry-Afterline inwriteFetchErr; pdf_pages(a count) added to the existingfetchandscrapelog lines.
Never log a title, an outline or any text. Restricted callers go through the existing helpers.
- the
Facts
- The given test's child is the test binary itself (its
TestMainturns intopdftextwhenSIFT_PDFTEXT_CHILD=1). The rig buildss.PDFRunnerwith that environment; you never start a child yourself outsideRunner.Run. setupAPI,postJSONandrecHandlercome fromapi_test.goin 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. Theapi.gohooks. - 6.
gofmt -w; thengo test -race -count=3 -run 'TestPDFCleanFetch|TestPDFMap|TestPDFErrors|TestPDFTimeout|TestPDFBusy|TestPDFRestrictedCaller|TestPDFScrape' ./internal/api/→ok, andgo test -race -count=1 ./internal/api/ -run 'Test[^P]'(every existing api test) →ok. - 7. Row
p5a/06-api-pdf. Its Gate column sayspass (7 of 10 PDF tests; 3 are task 07's). Commit.
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 gateis 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,
pagesorgrep.