api: serve PDFs — extraction in the child under the pool, full text or map, errors
Route "pdf" through clean.Kind in doFetch/cleanFetched: extract under the pool (keyed by caller class + document hash, one per caller), map the five pdftext errors plus a pool panic to 422, ErrBusy to 503+Retry-After 10, ErrCallerBusy to 429, and answer with the whole text or the map. Adds RetryAfter to fetch.Error and the matching header. No cache, pages or grep — those are task 07. Co-Authored-By: ornith-1.5-35b-a3b <ornith-1.5-35b-a3b@llama.cpp.invalid>
This commit is contained in:
+110
-39
@@ -10,10 +10,12 @@ import (
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
"unicode/utf8"
|
||||
|
||||
"git.wntrmute.dev/kyle/sift/internal/auth"
|
||||
"git.wntrmute.dev/kyle/sift/internal/clean"
|
||||
@@ -23,6 +25,9 @@ import (
|
||||
"git.wntrmute.dev/kyle/sift/internal/fleetctx"
|
||||
"git.wntrmute.dev/kyle/sift/internal/guard"
|
||||
"git.wntrmute.dev/kyle/sift/internal/metrics"
|
||||
"git.wntrmute.dev/kyle/sift/internal/pdfdoc"
|
||||
"git.wntrmute.dev/kyle/sift/internal/pdfpool"
|
||||
"git.wntrmute.dev/kyle/sift/internal/pdftext"
|
||||
"git.wntrmute.dev/kyle/sift/internal/recover"
|
||||
"git.wntrmute.dev/kyle/sift/internal/search"
|
||||
"git.wntrmute.dev/kyle/sift/internal/selfip"
|
||||
@@ -44,8 +49,13 @@ type Server struct {
|
||||
OutCap int
|
||||
SelfIP *selfip.Discoverer
|
||||
Digest *digest.Engine
|
||||
Search *search.Engine
|
||||
Ctx *fleetctx.Client
|
||||
|
||||
PDFRunner *pdftext.Runner
|
||||
PDFPool *pdfpool.Pool
|
||||
PDFTTL time.Duration
|
||||
PDFMaxQuestionChunks int
|
||||
Search *search.Engine
|
||||
Ctx *fleetctx.Client
|
||||
// PermitRestricted is context.permit_restricted: those callers never
|
||||
// have a fetch host written to the log (host=-). Independent of Honcho.
|
||||
PermitRestricted []string
|
||||
@@ -56,6 +66,8 @@ type Server struct {
|
||||
per map[string]chan struct{}
|
||||
|
||||
reqN, blockN, scrapeN atomic.Uint64
|
||||
|
||||
pdfPoolOnce sync.Once
|
||||
}
|
||||
|
||||
func New(version string, g *guard.Guard, f *fetch.Fetcher, keys *auth.Store, log *slog.Logger) *Server {
|
||||
@@ -300,6 +312,10 @@ type v1Req struct {
|
||||
Context *bool `json:"context"`
|
||||
JQ string `json:"jq"`
|
||||
Recover bool `json:"recover"`
|
||||
|
||||
// Pages narrows a PDF digest to "2-3"; Grep matches lines in it. Optional; task-07.
|
||||
Pages string `json:"pages"`
|
||||
Grep string `json:"grep"`
|
||||
}
|
||||
|
||||
func (s *Server) caller(w http.ResponseWriter, r *http.Request) *auth.Caller {
|
||||
@@ -357,12 +373,23 @@ func (s *Server) scrape(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
}
|
||||
s.scrapeN.Add(1)
|
||||
res, cl, ferr := s.doFetch(r, c, req.URL, "", onlyMain)
|
||||
res, cl, doc, ferr := s.doFetch(r, c, req.URL, "", onlyMain)
|
||||
if ferr != nil {
|
||||
s.writeFetchErr(w, c, req.URL, ferr)
|
||||
return
|
||||
}
|
||||
md, htmlOut := cl.Markdown, ""
|
||||
pages := 0
|
||||
if doc != nil {
|
||||
rp, err := s.renderPDF(doc)
|
||||
if err != nil {
|
||||
s.writeFetchErr(w, c, req.URL, err)
|
||||
return
|
||||
}
|
||||
md = rp.Content
|
||||
htmlOut = ""
|
||||
pages = doc.Pages
|
||||
}
|
||||
if wantHTML {
|
||||
htmlOut = cl.HTML
|
||||
}
|
||||
@@ -371,7 +398,7 @@ func (s *Server) scrape(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
rawTok := len(res.Body) / 4
|
||||
retTok := len(md) / 4
|
||||
s.Log.Info("scrape", "caller", c.Name, "host", s.logHost(c, res.FinalURL), "status", res.Status, "bytes", len(res.Body))
|
||||
s.Log.Info("scrape", "caller", c.Name, "host", s.logHost(c, res.FinalURL), "status", res.Status, "bytes", len(res.Body), "pdf_pages", pages)
|
||||
writeJSON(w, 200, map[string]any{
|
||||
"success": true,
|
||||
"data": map[string]any{
|
||||
@@ -382,6 +409,7 @@ func (s *Server) scrape(w http.ResponseWriter, r *http.Request) {
|
||||
"sourceURL": res.FinalURL,
|
||||
"statusCode": res.Status,
|
||||
"contentType": res.ContentType,
|
||||
"numPages": pages,
|
||||
"sift": map[string]any{
|
||||
"hash": res.SHA256(),
|
||||
"fetchedAt": res.FetchedAt.Format(time.RFC3339),
|
||||
@@ -429,12 +457,12 @@ func (s *Server) v1fetch(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
t0 := time.Now()
|
||||
onlyMain := true
|
||||
res, cl, ferr := s.doFetch(r, c, req.URL, req.JQ, onlyMain)
|
||||
res, cl, doc, ferr := s.doFetch(r, c, req.URL, req.JQ, onlyMain)
|
||||
var snap *recover.Result
|
||||
if ferr != nil && req.Recover && recoverWorthy(ferr) {
|
||||
sr, rerr := recover.Try(r.Context(), s.Fetch, req.URL)
|
||||
if rerr == nil && sr != nil && sr.Res != nil {
|
||||
res, cl, ferr = s.cleanFetched(sr.Res, req.JQ, onlyMain)
|
||||
res, cl, doc, ferr = s.cleanFetched(sr.Res, req.JQ, onlyMain)
|
||||
snap = sr
|
||||
}
|
||||
}
|
||||
@@ -444,16 +472,50 @@ func (s *Server) v1fetch(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
fetchMS := time.Since(t0).Milliseconds()
|
||||
|
||||
content := cl.Markdown
|
||||
if cl.Kind == "json" {
|
||||
content = cl.HTML
|
||||
}
|
||||
if mode == "raw" {
|
||||
content = string(res.Body)
|
||||
if len(content) > s.OutCap {
|
||||
content = clean.TruncateUTF8(content, s.OutCap)
|
||||
cl.Trunc = true
|
||||
cl.Warning = "truncated to output cap"
|
||||
var docLen, pdfPages int
|
||||
var content string
|
||||
var pdfMap map[string]any
|
||||
if doc != nil {
|
||||
pdfPages = doc.Pages
|
||||
if mode == "raw" {
|
||||
writeErr(w, 400, "raw mode does not apply to PDFs", "")
|
||||
return
|
||||
}
|
||||
rp, err := s.renderPDF(doc)
|
||||
if err != nil {
|
||||
s.writeFetchErr(w, c, req.URL, err)
|
||||
return
|
||||
}
|
||||
full := pdfdoc.Full(doc)
|
||||
docLen = utf8.RuneCountInString(full)
|
||||
if mode == "digest" {
|
||||
content = full
|
||||
} else {
|
||||
content = rp.Content
|
||||
}
|
||||
pdfMap = map[string]any{
|
||||
"pages": doc.Pages,
|
||||
"title": doc.Title,
|
||||
"outline": doc.Outline,
|
||||
"extracted": len(doc.Text),
|
||||
"first": rp.First,
|
||||
"last": rp.Last,
|
||||
"map": rp.Map,
|
||||
"grep_hits": rp.GrepHits,
|
||||
"note": rp.Note,
|
||||
}
|
||||
} else {
|
||||
content = cl.Markdown
|
||||
if cl.Kind == "json" {
|
||||
content = cl.HTML
|
||||
}
|
||||
if mode == "raw" {
|
||||
content = string(res.Body)
|
||||
if len(content) > s.OutCap {
|
||||
content = clean.TruncateUTF8(content, s.OutCap)
|
||||
cl.Trunc = true
|
||||
cl.Warning = "truncated to output cap"
|
||||
}
|
||||
}
|
||||
}
|
||||
warn := []string{}
|
||||
@@ -518,7 +580,7 @@ func (s *Server) v1fetch(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
s.Log.Info("fetch", "caller", c.Name, "host", s.logHost(c, res.FinalURL), "mode", mode,
|
||||
"q_len", qLen, "raw_tokens", len(res.Body)/4, "returned_tokens", len(content)/4,
|
||||
"digest_ms", digestMS, "fallback", fb)
|
||||
"pdf_pages", pdfPages, "digest_ms", digestMS, "fallback", fb)
|
||||
|
||||
src := map[string]any{
|
||||
"url": res.URL,
|
||||
@@ -549,12 +611,15 @@ func (s *Server) v1fetch(w http.ResponseWriter, r *http.Request) {
|
||||
"source": src,
|
||||
"warnings": warn,
|
||||
"sizes": map[string]int{
|
||||
"raw_tokens": len(res.Body) / 4,
|
||||
"raw_tokens": docLen / 4,
|
||||
"clean_tokens": digest.EstTokens(cl.Markdown),
|
||||
"returned_tokens": digest.EstTokens(content),
|
||||
},
|
||||
"timings": map[string]int64{"fetch_ms": fetchMS, "digest_ms": digestMS, "context_ms": ctxMS},
|
||||
}
|
||||
if pdfMap != nil {
|
||||
out["pdf"] = pdfMap
|
||||
}
|
||||
if digMeta != nil {
|
||||
out["digest"] = digMeta
|
||||
}
|
||||
@@ -585,13 +650,11 @@ func headings(md string) []string {
|
||||
return h
|
||||
}
|
||||
|
||||
func (s *Server) cleanFetched(res *fetch.Resource, jq string, onlyMain bool) (*fetch.Resource, *clean.Result, error) {
|
||||
kind := clean.MediaKind(res.ContentType)
|
||||
if kind == "sniff" {
|
||||
kind = clean.Sniff(res.Body)
|
||||
}
|
||||
if kind == "other" {
|
||||
return nil, nil, &fetch.Error{Status: 415, Hint: "browser", Msg: "unsupported content type"}
|
||||
func (s *Server) cleanFetched(res *fetch.Resource, jq string, onlyMain bool) (*fetch.Resource, *clean.Result, *pdftext.Doc, error) {
|
||||
kind := clean.Kind(res.ContentType, res.Body)
|
||||
if kind == "other" || kind == "pdf" {
|
||||
// The recover path (archive snapshots) never serves a PDF.
|
||||
return nil, nil, nil, &fetch.Error{Status: 415, Hint: "browser", Msg: "unsupported content type"}
|
||||
}
|
||||
var cl *clean.Result
|
||||
var cerr error
|
||||
@@ -604,31 +667,28 @@ func (s *Server) cleanFetched(res *fetch.Resource, jq string, onlyMain bool) (*f
|
||||
cl, cerr = clean.Text(res, s.OutCap)
|
||||
}
|
||||
if cerr != nil {
|
||||
return nil, nil, &fetch.Error{Status: 422, Msg: cerr.Error()}
|
||||
return nil, nil, nil, &fetch.Error{Status: 422, Msg: cerr.Error()}
|
||||
}
|
||||
return res, cl, nil
|
||||
return res, cl, nil, nil
|
||||
}
|
||||
|
||||
func (s *Server) doFetch(r *http.Request, c *auth.Caller, rawURL, jq string, onlyMain bool) (*fetch.Resource, *clean.Result, error) {
|
||||
func (s *Server) doFetch(r *http.Request, c *auth.Caller, rawURL, jq string, onlyMain bool) (*fetch.Resource, *clean.Result, *pdftext.Doc, error) {
|
||||
if err := s.acquire(c.Name, rawURL); err != nil {
|
||||
return nil, nil, err
|
||||
return nil, nil, nil, err
|
||||
}
|
||||
defer s.release(c.Name, rawURL)
|
||||
|
||||
res, err := s.Fetch.Fetch(r.Context(), rawURL)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
kind := clean.MediaKind(res.ContentType)
|
||||
if kind == "sniff" {
|
||||
kind = clean.Sniff(res.Body)
|
||||
return nil, nil, nil, err
|
||||
}
|
||||
kind := clean.Kind(res.ContentType, res.Body)
|
||||
if kind == "other" {
|
||||
ct := res.ContentType
|
||||
if ct == "" {
|
||||
ct = "unknown"
|
||||
}
|
||||
return nil, nil, &fetch.Error{Status: 415, Hint: "browser", Msg: "unsupported content type " + ct + "; hint: browser"}
|
||||
return nil, nil, nil, &fetch.Error{Status: 415, Hint: "browser", Msg: "unsupported content type " + ct + "; hint: browser"}
|
||||
}
|
||||
var cl *clean.Result
|
||||
var cerr error
|
||||
@@ -637,17 +697,25 @@ func (s *Server) doFetch(r *http.Request, c *auth.Caller, rawURL, jq string, onl
|
||||
cl, cerr = clean.HTML(res, onlyMain, s.OutCap)
|
||||
case "json":
|
||||
cl, cerr = clean.JSON(res, jq, s.OutCap)
|
||||
default:
|
||||
case "text":
|
||||
cl, cerr = clean.Text(res, s.OutCap)
|
||||
}
|
||||
if cerr != nil {
|
||||
var tl *clean.TooLargeError
|
||||
if errors.As(cerr, &tl) {
|
||||
return nil, nil, &fetch.Error{Status: 400, Msg: tl.Error()}
|
||||
return nil, nil, nil, &fetch.Error{Status: 400, Msg: tl.Error()}
|
||||
}
|
||||
return nil, nil, &fetch.Error{Status: 422, Msg: cerr.Error()}
|
||||
return nil, nil, nil, &fetch.Error{Status: 422, Msg: cerr.Error()}
|
||||
}
|
||||
return res, cl, nil
|
||||
var doc *pdftext.Doc
|
||||
if kind == "pdf" {
|
||||
doc, err = s.extractPDF(r.Context(), c, res)
|
||||
if err != nil {
|
||||
return nil, nil, nil, err
|
||||
}
|
||||
cl = &clean.Result{Kind: "pdf", Title: doc.Title}
|
||||
}
|
||||
return res, cl, doc, nil
|
||||
}
|
||||
|
||||
func (s *Server) writeFetchErr(w http.ResponseWriter, c *auth.Caller, rawURL string, err error) {
|
||||
@@ -670,6 +738,9 @@ func (s *Server) writeFetchErr(w http.ResponseWriter, c *auth.Caller, rawURL str
|
||||
} else {
|
||||
s.Log.Warn("fetch failed", "caller", name, "host", host, "reason", s.logReason(c, fe), "status", fe.Status)
|
||||
}
|
||||
if fe.RetryAfter > 0 {
|
||||
w.Header().Set("Retry-After", strconv.Itoa(fe.RetryAfter))
|
||||
}
|
||||
writeErr(w, fe.Status, fe.Msg, fe.Hint)
|
||||
return
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user