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:
2026-09-26 04:23:21 -07:00
co-authored by ornith-1.5-35b-a3b
parent 93ec310113
commit 09f738bcac
3 changed files with 117 additions and 12 deletions
+76 -3
View File
@@ -2,11 +2,14 @@ package api
import (
"context"
"encoding/json"
"errors"
"fmt"
"net/http"
"time"
"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/pdfdoc"
"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
// output cap, otherwise the map.
func (s *Server) renderPDF(doc *pdftext.Doc) (pdfdoc.Rendered, error) {
return pdfdoc.Render(doc, pdfdoc.Options{OutCap: s.OutCap})
// output cap, otherwise the map, a page range, or grep results.
func (s *Server) renderPDF(doc *pdftext.Doc, opts pdfdoc.Options) (pdfdoc.Rendered, error) {
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,
})
}