package api import ( "context" "errors" "fmt" "time" "git.wntrmute.dev/kyle/sift/internal/auth" "git.wntrmute.dev/kyle/sift/internal/fetch" "git.wntrmute.dev/kyle/sift/internal/pdfdoc" "git.wntrmute.dev/kyle/sift/internal/pdfpool" "git.wntrmute.dev/kyle/sift/internal/pdftext" ) const ( // DefaultPDFTTL bounds a cached PDF's life. Task 07; declared now so the test compiles. DefaultPDFTTL = 24 * time.Hour // DefaultPDFMaxQuestionChunks caps a digest question's length. Task 07; declared now. DefaultPDFMaxQuestionChunks = 200 ) // runner returns the configured extraction child, or a zero Runner that runs // /proc/self/exe pdftext. func (s *Server) runner() *pdftext.Runner { if s.PDFRunner != nil { return s.PDFRunner } return &pdftext.Runner{} } // pool returns the extraction pool, built once and only when none is configured. func (s *Server) pool() *pdfpool.Pool { if s.PDFPool == nil { s.pdfPoolOnce.Do(func() { s.PDFPool = pdfpool.New(2, 4, 20*time.Second) }) } return s.PDFPool } // extractPDF runs one extraction under the pool, coalesced by document hash and // bounded to one per caller. func (s *Server) extractPDF(ctx context.Context, c *auth.Caller, res *fetch.Resource) (*pdftext.Doc, error) { fn := func(jobCtx context.Context) (any, error) { return s.runner().Run(jobCtx, res.Body) } v, err := s.pool().Do(ctx, s.callerClass(c)+"|"+res.SHA256(), c.Name, fn) if err != nil { return nil, pdfError(err) } doc, ok := v.(*pdftext.Doc) if !ok { return nil, fmt.Errorf("pdf: bad result %T", v) } return doc, nil } // pdfError maps every PDF-path error to the status sift returns to its caller; // a PDF never produces a 500. func pdfError(err error) error { switch { case errors.Is(err, pdftext.ErrNoText): return &fetch.Error{Status: 422, Msg: "no text layer (scanned PDF?)", Hint: "ocr"} case errors.Is(err, pdftext.ErrEncrypted): return &fetch.Error{Status: 422, Msg: "encrypted PDF (password required)"} case errors.Is(err, pdftext.ErrTimeout): return &fetch.Error{Status: 422, Msg: "pdf extraction timed out"} case errors.Is(err, pdftext.ErrTooMuchText): return &fetch.Error{Status: 422, Msg: "pdf text over the output limit"} case errors.Is(err, pdfpool.ErrBusy): return &fetch.Error{Status: 503, Msg: "pdf workers busy", RetryAfter: 10} case errors.Is(err, pdfpool.ErrCallerBusy): return &fetch.Error{Status: 429, Msg: "busy"} case errors.Is(err, pdftext.ErrUnreadable): return &fetch.Error{Status: 422, Msg: "unreadable PDF"} default: return &fetch.Error{Status: 422, Msg: "unreadable PDF"} } } // 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}) }