api: PDF cache with revalidation; pages and grep; refuse questions on very long documents
Answer extracted PDFs from the document cache: fresh for PDFTTL, then a conditional GET (304 reuses the stored text, a 200 is extracted and stored without a second GET). Store after extraction with the response hash, ETag, fetch time and extracted pages. Route pages/grep through pdfdoc.Options with 400s for bad ranges and non-PDFs, and refuse a question when the document exceeds PDFMaxQuestionChunks with a pointer to grep and pages. Co-Authored-By: ornith-1.5-35b-a3b <ornith-1.5-35b-a3b@llama.cpp.invalid>
This commit is contained in:
@@ -10,3 +10,4 @@ One row per task session that ends in a commit. Newest last.
|
|||||||
| p5a/04-pdfpool | 2026-09-25 | done | 1 | gate: ok | none | Implemented the generic scheduler. `Pool` holds one mutex, a `jobs` map (key→in-flight job), a `callerJobs` map (caller→their job), and a FIFO `waiters` line. `Do` applies the six rules in order: join (counted in `Waiting`, no slot, allowed while the caller holds another job, `fn` may be nil), caller-busy, then slot/queue/refuse. A job's result is broadcast to every sharer via a `done` channel; results are not cached (key forgotten on finish). `fn` runs under `context.WithoutCancel` so the flight outlives a caller that leaves. Panic in `fn` is recovered into an error, freed the slot and caller. One implementation finding (not a plan deviation): queued jobs must not be counted in `Running()`, so `startLocked` only registers a job and a separate `takeSlotLocked`/`grant` increments the slot count — abandoning a queued starter (`abandon`) releases sharers without touching the slot. Given test byte-identical. |
|
| p5a/04-pdfpool | 2026-09-25 | done | 1 | gate: ok | none | Implemented the generic scheduler. `Pool` holds one mutex, a `jobs` map (key→in-flight job), a `callerJobs` map (caller→their job), and a FIFO `waiters` line. `Do` applies the six rules in order: join (counted in `Waiting`, no slot, allowed while the caller holds another job, `fn` may be nil), caller-busy, then slot/queue/refuse. A job's result is broadcast to every sharer via a `done` channel; results are not cached (key forgotten on finish). `fn` runs under `context.WithoutCancel` so the flight outlives a caller that leaves. Panic in `fn` is recovered into an error, freed the slot and caller. One implementation finding (not a plan deviation): queued jobs must not be counted in `Running()`, so `startLocked` only registers a job and a separate `takeSlotLocked`/`grant` increments the slot count — abandoning a queued starter (`abandon`) releases sharers without touching the slot. Given test byte-identical. |
|
||||||
| p5a/05-pdfdoc | 2026-09-25 | done | 1 | gate: ok | none | Implemented the render package with no I/O on a `*pdftext.Doc`. `Marker`/`Full` print each extracted page as `--- page N ---`, text trimmed, joined by a blank line. `Render` dispatches grep → page range → whole. Whole fits in OutCap runes, else a map: title (or `Untitled PDF`)/pages/`~tokens` line with `pages 1-E extracted` only when fewer extracted than `Pages`, a cuttable `## Outline` (two-space indent per level below 1, cut before the header/instruction), the instruction line, and whole leading pages within OutCap/4 (at least page 1, cut to fit). Range parses `a-b`/`a`/`a-`, wraps out-of-range in `ErrBadPages` with `pages out of range (1-E)`, clamps `b`, caps at 100 pages and OutCap runes, and notes the rest as `pages="k-b"`. Grep is RE2 with smart-case `(?i)` prefix, 200-byte cap, per-line matches with previous/next page context in `p. P-`/`p. P:` form, first 100 shown, total counted; RE2 keeps the `(a+)+$` test linear. Every length is in runes. Given test byte-identical; make gate ok. |
|
| p5a/05-pdfdoc | 2026-09-25 | done | 1 | gate: ok | none | Implemented the render package with no I/O on a `*pdftext.Doc`. `Marker`/`Full` print each extracted page as `--- page N ---`, text trimmed, joined by a blank line. `Render` dispatches grep → page range → whole. Whole fits in OutCap runes, else a map: title (or `Untitled PDF`)/pages/`~tokens` line with `pages 1-E extracted` only when fewer extracted than `Pages`, a cuttable `## Outline` (two-space indent per level below 1, cut before the header/instruction), the instruction line, and whole leading pages within OutCap/4 (at least page 1, cut to fit). Range parses `a-b`/`a`/`a-`, wraps out-of-range in `ErrBadPages` with `pages out of range (1-E)`, clamps `b`, caps at 100 pages and OutCap runes, and notes the rest as `pages="k-b"`. Grep is RE2 with smart-case `(?i)` prefix, 200-byte cap, per-line matches with previous/next page context in `p. P-`/`p. P:` form, first 100 shown, total counted; RE2 keeps the `(a+)+$` test linear. Every length is in runes. Given test byte-identical; make gate ok. |
|
||||||
| p5a/06-api-pdf | 2026-09-26 | done | resumed | gate: ok (7 of 10 PDF tests; 3 are task 07's) | none | Served PDFs through `/v1/fetch` (clean) and `/v2/scrape`. Added `RetryAfter int` to `fetch.Error` and a `Retry-After` header line guarded by `> 0`. Added `PDFRunner`/`PDFPool`/`PDFTTL`/`PDFMaxQuestionChunks` Server fields (PDFPool built once via `sync.Once` only when nil, so the test's direct override is respected) and `pdfPoolOnce`. Routed `clean.Kind` in `doFetch`/`cleanFetched` (html/json/text switched, "other"→415, "pdf"→extractPDF); `doFetch`'s switch routes "text" explicitly so a PDF never runs `clean.Text` first. New `internal/api/pdf.go`: `DefaultPDFTTL`/`DefaultPDFMaxQuestionChunks` (task 07), `runner()`/`pool()` defaults, `extractPDF` (pool key `callerClass|SHA256`, two-value assert, `fmt.Errorf("pdf: bad result %T")`), `pdfError` (422 for the five pdftext errors + panic default, 503+RetryAfter 10 for ErrBusy, 429 for ErrCallerBusy), `renderPDF` (`pdfdoc.Render`, whole text or map). `doFetch` returns the 4th `doc *pdftext.Doc`; digest uses `pdfdoc.Full(doc)` (computed once) as content, clean uses `renderPDF.Content`, envelope `pdf` object has pages/title/outline/extracted/first/last/map/grep_hits/note, raw mode→400. Scrape markdown=`renderPDF.Content`, html="", `metadata.numPages`=doc.Pages. 7 of 10 given tests pass under -race -count=3; the 3 failures (`TestPDFPagesAndGrep`, `TestPDFCache`, `TestPDFQuestionTooLong`) are task 07 tests that reference fields not in this task, exactly as the task doc (lines 145, 157-158) predicts; nothing else fails. Given files byte-identical. |
|
| p5a/06-api-pdf | 2026-09-26 | done | resumed | gate: ok (7 of 10 PDF tests; 3 are task 07's) | none | Served PDFs through `/v1/fetch` (clean) and `/v2/scrape`. Added `RetryAfter int` to `fetch.Error` and a `Retry-After` header line guarded by `> 0`. Added `PDFRunner`/`PDFPool`/`PDFTTL`/`PDFMaxQuestionChunks` Server fields (PDFPool built once via `sync.Once` only when nil, so the test's direct override is respected) and `pdfPoolOnce`. Routed `clean.Kind` in `doFetch`/`cleanFetched` (html/json/text switched, "other"→415, "pdf"→extractPDF); `doFetch`'s switch routes "text" explicitly so a PDF never runs `clean.Text` first. New `internal/api/pdf.go`: `DefaultPDFTTL`/`DefaultPDFMaxQuestionChunks` (task 07), `runner()`/`pool()` defaults, `extractPDF` (pool key `callerClass|SHA256`, two-value assert, `fmt.Errorf("pdf: bad result %T")`), `pdfError` (422 for the five pdftext errors + panic default, 503+RetryAfter 10 for ErrBusy, 429 for ErrCallerBusy), `renderPDF` (`pdfdoc.Render`, whole text or map). `doFetch` returns the 4th `doc *pdftext.Doc`; digest uses `pdfdoc.Full(doc)` (computed once) as content, clean uses `renderPDF.Content`, envelope `pdf` object has pages/title/outline/extracted/first/last/map/grep_hits/note, raw mode→400. Scrape markdown=`renderPDF.Content`, html="", `metadata.numPages`=doc.Pages. 7 of 10 given tests pass under -race -count=3; the 3 failures (`TestPDFPagesAndGrep`, `TestPDFCache`, `TestPDFQuestionTooLong`) are task 07 tests that reference fields not in this task, exactly as the task doc (lines 145, 157-158) predicts; nothing else fails. Given files byte-identical. |
|
||||||
|
| p5a/07-api-cache-pages | 2026-09-26 | done | 1 | gate: ok | none | Implemented the PDF document cache with revalidation in `doFetch`: `pdfFromCache` (miss / fresh within `PDFTTL` / stale → `FetchIf` with 304→`TouchPDF`+answer-fresh / 200→return response with ok=false and no second GET) and `storePDF` after `extractPDF` (`ContentHash=res.SHA256`, ETag/Last-Modified from headers, FetchedAt=now, Meta=JSON of `doc.Meta`, Pages=`doc.Text`). `pages`/`grep` passed into `pdfdoc.Options` in `renderPDF`; `ErrBadPages`/`ErrBadGrep`→400 with the error's own text, non-PDF→400 "pages and grep apply to PDFs only" before the doc block. Question limit in digest mode: `n=len(digest.Split(pdfdoc.Full(doc), ChunkTokens))`, `n>PDFMaxQuestionChunks`→422 "document too long for question (n chunks); use grep or pages" before any digest call. Envelope `cached` set true for cache answers via the synthetic cache res's empty body (`len(res.Body)==0`); the one `doFetch` change beyond the rule-1 call was reusing that 200 response instead of a second `Fetch`. Added `fmt` import to api.go. Given file byte-identical; make gate ok. |
|
||||||
|
|||||||
+35
-4
@@ -6,6 +6,7 @@ import (
|
|||||||
"encoding/hex"
|
"encoding/hex"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"net/http"
|
"net/http"
|
||||||
@@ -381,7 +382,7 @@ func (s *Server) scrape(w http.ResponseWriter, r *http.Request) {
|
|||||||
md, htmlOut := cl.Markdown, ""
|
md, htmlOut := cl.Markdown, ""
|
||||||
pages := 0
|
pages := 0
|
||||||
if doc != nil {
|
if doc != nil {
|
||||||
rp, err := s.renderPDF(doc)
|
rp, err := s.renderPDF(doc, pdfdoc.Options{OutCap: s.OutCap})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
s.writeFetchErr(w, c, req.URL, err)
|
s.writeFetchErr(w, c, req.URL, err)
|
||||||
return
|
return
|
||||||
@@ -472,6 +473,11 @@ func (s *Server) v1fetch(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
fetchMS := time.Since(t0).Milliseconds()
|
fetchMS := time.Since(t0).Milliseconds()
|
||||||
|
|
||||||
|
if doc == nil && (req.Pages != "" || req.Grep != "") {
|
||||||
|
s.writeFetchErr(w, c, req.URL, &fetch.Error{Status: 400, Msg: "pages and grep apply to PDFs only"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
var docLen, pdfPages int
|
var docLen, pdfPages int
|
||||||
var content string
|
var content string
|
||||||
var pdfMap map[string]any
|
var pdfMap map[string]any
|
||||||
@@ -481,8 +487,12 @@ func (s *Server) v1fetch(w http.ResponseWriter, r *http.Request) {
|
|||||||
writeErr(w, 400, "raw mode does not apply to PDFs", "")
|
writeErr(w, 400, "raw mode does not apply to PDFs", "")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
rp, err := s.renderPDF(doc)
|
rp, err := s.renderPDF(doc, pdfdoc.Options{Pages: req.Pages, Grep: req.Grep, OutCap: s.OutCap})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
if errors.Is(err, pdfdoc.ErrBadPages) || errors.Is(err, pdfdoc.ErrBadGrep) {
|
||||||
|
s.writeFetchErr(w, c, req.URL, &fetch.Error{Status: 400, Msg: err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
s.writeFetchErr(w, c, req.URL, err)
|
s.writeFetchErr(w, c, req.URL, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -526,6 +536,17 @@ func (s *Server) v1fetch(w http.ResponseWriter, r *http.Request) {
|
|||||||
var digMeta map[string]any
|
var digMeta map[string]any
|
||||||
var digestMS int64
|
var digestMS int64
|
||||||
if mode == "digest" {
|
if mode == "digest" {
|
||||||
|
if doc != nil {
|
||||||
|
n := len(digest.Split(pdfdoc.Full(doc), digest.ChunkTokens))
|
||||||
|
limit := s.PDFMaxQuestionChunks
|
||||||
|
if limit == 0 {
|
||||||
|
limit = DefaultPDFMaxQuestionChunks
|
||||||
|
}
|
||||||
|
if n > limit {
|
||||||
|
s.writeFetchErr(w, c, req.URL, &fetch.Error{Status: 422, Msg: fmt.Sprintf("document too long for question (%d chunks); use grep or pages", n)})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
if s.Digest == nil {
|
if s.Digest == nil {
|
||||||
warn = append(warn, "digest fell back to clean: unset")
|
warn = append(warn, "digest fell back to clean: unset")
|
||||||
mode = "clean"
|
mode = "clean"
|
||||||
@@ -590,7 +611,7 @@ func (s *Server) v1fetch(w http.ResponseWriter, r *http.Request) {
|
|||||||
"fetched_at": res.FetchedAt.Format(time.RFC3339),
|
"fetched_at": res.FetchedAt.Format(time.RFC3339),
|
||||||
"hash": res.SHA256(),
|
"hash": res.SHA256(),
|
||||||
"bytes": len(res.Body),
|
"bytes": len(res.Body),
|
||||||
"cached": false,
|
"cached": len(res.Body) == 0,
|
||||||
"kind": "live",
|
"kind": "live",
|
||||||
}
|
}
|
||||||
if snap != nil {
|
if snap != nil {
|
||||||
@@ -673,15 +694,24 @@ func (s *Server) cleanFetched(res *fetch.Resource, jq string, onlyMain bool) (*f
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) doFetch(r *http.Request, c *auth.Caller, rawURL, jq string, onlyMain bool) (*fetch.Resource, *clean.Result, *pdftext.Doc, error) {
|
func (s *Server) doFetch(r *http.Request, c *auth.Caller, rawURL, jq string, onlyMain bool) (*fetch.Resource, *clean.Result, *pdftext.Doc, error) {
|
||||||
|
res, hitDoc, ok, err := s.pdfFromCache(r.Context(), c, rawURL)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, nil, err
|
||||||
|
}
|
||||||
|
if ok {
|
||||||
|
return res, &clean.Result{Kind: "pdf", Title: hitDoc.Title}, hitDoc, nil
|
||||||
|
}
|
||||||
|
if res == nil {
|
||||||
if err := s.acquire(c.Name, rawURL); err != nil {
|
if err := s.acquire(c.Name, rawURL); err != nil {
|
||||||
return nil, nil, nil, err
|
return nil, nil, nil, err
|
||||||
}
|
}
|
||||||
defer s.release(c.Name, rawURL)
|
defer s.release(c.Name, rawURL)
|
||||||
|
|
||||||
res, err := s.Fetch.Fetch(r.Context(), rawURL)
|
res, err = s.Fetch.Fetch(r.Context(), rawURL)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, nil, nil, err
|
return nil, nil, nil, err
|
||||||
}
|
}
|
||||||
|
}
|
||||||
kind := clean.Kind(res.ContentType, res.Body)
|
kind := clean.Kind(res.ContentType, res.Body)
|
||||||
if kind == "other" {
|
if kind == "other" {
|
||||||
ct := res.ContentType
|
ct := res.ContentType
|
||||||
@@ -713,6 +743,7 @@ func (s *Server) doFetch(r *http.Request, c *auth.Caller, rawURL, jq string, onl
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, nil, nil, err
|
return nil, nil, nil, err
|
||||||
}
|
}
|
||||||
|
s.storePDF(dcache.PDFKey(rawURL, s.callerClass(c)), res, doc)
|
||||||
cl = &clean.Result{Kind: "pdf", Title: doc.Title}
|
cl = &clean.Result{Kind: "pdf", Title: doc.Title}
|
||||||
}
|
}
|
||||||
return res, cl, doc, nil
|
return res, cl, doc, nil
|
||||||
|
|||||||
+76
-3
@@ -2,11 +2,14 @@ package api
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"net/http"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"git.wntrmute.dev/kyle/sift/internal/auth"
|
"git.wntrmute.dev/kyle/sift/internal/auth"
|
||||||
|
"git.wntrmute.dev/kyle/sift/internal/dcache"
|
||||||
"git.wntrmute.dev/kyle/sift/internal/fetch"
|
"git.wntrmute.dev/kyle/sift/internal/fetch"
|
||||||
"git.wntrmute.dev/kyle/sift/internal/pdfdoc"
|
"git.wntrmute.dev/kyle/sift/internal/pdfdoc"
|
||||||
"git.wntrmute.dev/kyle/sift/internal/pdfpool"
|
"git.wntrmute.dev/kyle/sift/internal/pdfpool"
|
||||||
@@ -80,7 +83,77 @@ func pdfError(err error) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// renderPDF draws the document for an agent: the whole text when it fits the
|
// renderPDF draws the document for an agent: the whole text when it fits the
|
||||||
// output cap, otherwise the map.
|
// output cap, otherwise the map, a page range, or grep results.
|
||||||
func (s *Server) renderPDF(doc *pdftext.Doc) (pdfdoc.Rendered, error) {
|
func (s *Server) renderPDF(doc *pdftext.Doc, opts pdfdoc.Options) (pdfdoc.Rendered, error) {
|
||||||
return pdfdoc.Render(doc, pdfdoc.Options{OutCap: s.OutCap})
|
return pdfdoc.Render(doc, opts)
|
||||||
|
}
|
||||||
|
|
||||||
|
// pdfFromCache answers a PDF from the document cache before any fetch: a fresh
|
||||||
|
// entry directly, a stale one after a conditional GET (a 304 reuses the stored
|
||||||
|
// text), or a miss. A 200 from the revalidation is returned with ok=false so the
|
||||||
|
// caller extracts it; a nil cache is always a miss.
|
||||||
|
func (s *Server) pdfFromCache(ctx context.Context, c *auth.Caller, rawURL string) (*fetch.Resource, *pdftext.Doc, bool, error) {
|
||||||
|
cache := s.cache()
|
||||||
|
if cache == nil {
|
||||||
|
return nil, nil, false, nil
|
||||||
|
}
|
||||||
|
key := dcache.PDFKey(rawURL, s.callerClass(c))
|
||||||
|
e, ok := cache.GetPDF(key)
|
||||||
|
if !ok {
|
||||||
|
return nil, nil, false, nil
|
||||||
|
}
|
||||||
|
ttl := s.PDFTTL
|
||||||
|
if ttl == 0 {
|
||||||
|
ttl = DefaultPDFTTL
|
||||||
|
}
|
||||||
|
if time.Since(e.FetchedAt) < ttl {
|
||||||
|
res, doc := s.pdfFromEntry(rawURL, e)
|
||||||
|
return res, doc, true, nil
|
||||||
|
}
|
||||||
|
res, err := s.Fetch.FetchIf(ctx, rawURL, e.ETag, e.LastModified)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, false, err
|
||||||
|
}
|
||||||
|
if res.Status == http.StatusNotModified {
|
||||||
|
cache.TouchPDF(key, time.Now().UTC())
|
||||||
|
res, doc := s.pdfFromEntry(rawURL, e)
|
||||||
|
return res, doc, true, nil
|
||||||
|
}
|
||||||
|
return res, nil, false, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// pdfFromEntry rebuilds the synthetic response and doc for a cached entry: the
|
||||||
|
// request's URL, a 200 PDF with the stored fetch time and hash, and no body.
|
||||||
|
func (s *Server) pdfFromEntry(rawURL string, e dcache.PDFEntry) (*fetch.Resource, *pdftext.Doc) {
|
||||||
|
var doc pdftext.Doc
|
||||||
|
_ = json.Unmarshal(e.Meta, &doc.Meta)
|
||||||
|
doc.Text = e.Pages
|
||||||
|
res := &fetch.Resource{
|
||||||
|
URL: rawURL,
|
||||||
|
FinalURL: rawURL,
|
||||||
|
Status: 200,
|
||||||
|
ContentType: "application/pdf",
|
||||||
|
FetchedAt: e.FetchedAt,
|
||||||
|
Hash: e.ContentHash,
|
||||||
|
}
|
||||||
|
return res, &doc
|
||||||
|
}
|
||||||
|
|
||||||
|
// storePDF writes an extracted PDF into the document cache for its TTL: its
|
||||||
|
// hash, the response's validators, the metadata as JSON and the per-page text.
|
||||||
|
// A nil cache is a no-op.
|
||||||
|
func (s *Server) storePDF(key string, res *fetch.Resource, doc *pdftext.Doc) {
|
||||||
|
cache := s.cache()
|
||||||
|
if cache == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
meta, _ := json.Marshal(doc.Meta)
|
||||||
|
cache.PutPDF(key, dcache.PDFEntry{
|
||||||
|
ContentHash: res.SHA256(),
|
||||||
|
ETag: res.Header.Get("ETag"),
|
||||||
|
LastModified: res.Header.Get("Last-Modified"),
|
||||||
|
FetchedAt: time.Now().UTC(),
|
||||||
|
Meta: meta,
|
||||||
|
Pages: doc.Text,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user