cli: sift extract uploads a local PDF; README and skill
`sift extract <file.pdf>` stats the file (refusing over 100 MiB before the upload), streams it as the POST body with Content-Type application/pdf and the ContentLength set, and prints the /v1/extract answer exactly as `sift fetch` prints a PDF URL — same content output, same errors and exitFor codes. parseExtractOpts reuses splitAroundPositional; extractURL sends only the set options with mode=digest when a question is given. main.go gains the case and a help line; README and skill document the command. Co-Authored-By: ornith-1.5-35b-a3b <ornith-1.5-35b-a3b@llama.cpp.invalid>
This commit is contained in:
@@ -0,0 +1,46 @@
|
||||
package main
|
||||
|
||||
// P5c task 02: `sift extract <file>` uploads a PDF to /v1/extract.
|
||||
|
||||
import (
|
||||
"net/url"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestParseExtractOpts(t *testing.T) {
|
||||
o, err := parseExtractOpts([]string{"thesis.pdf", "--pages", "41-60", "--grep", "attention head"})
|
||||
if err != nil || o.File != "thesis.pdf" || o.Pages != "41-60" || o.Grep != "attention head" {
|
||||
t.Errorf("%+v %v", o, err)
|
||||
}
|
||||
o, err = parseExtractOpts([]string{"--question", "what is the method?", "--json", "paper.pdf"})
|
||||
if err != nil || o.File != "paper.pdf" || o.Question != "what is the method?" || !o.JSON {
|
||||
t.Errorf("flags first: %+v %v", o, err)
|
||||
}
|
||||
if _, err := parseExtractOpts(nil); err == nil {
|
||||
t.Error("no file: want an error")
|
||||
}
|
||||
if _, err := parseExtractOpts([]string{"a.pdf", "b.pdf"}); err == nil {
|
||||
t.Error("two files: want an error")
|
||||
}
|
||||
if !strings.Contains(extractUsage, "sift extract <file.pdf>") {
|
||||
t.Errorf("usage %q", extractUsage)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractURL(t *testing.T) {
|
||||
u, err := url.Parse(extractURL("https://sift.example:8443", extractOpts{File: "x.pdf", Pages: "2-3", Grep: "a b&c"}))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if u.Path != "/v1/extract" || u.Query().Get("pages") != "2-3" || u.Query().Get("grep") != "a b&c" {
|
||||
t.Errorf("url %s", u)
|
||||
}
|
||||
if u.Query().Has("question") || u.Query().Has("mode") {
|
||||
t.Errorf("unset options must not be sent: %s", u)
|
||||
}
|
||||
u, _ = url.Parse(extractURL("https://sift.example:8443", extractOpts{File: "x.pdf", Question: "why?"}))
|
||||
if u.Query().Get("question") != "why?" || u.Query().Get("mode") != "digest" {
|
||||
t.Errorf("question url %s (mode must be digest with a question)", u)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"flag"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// maxExtractUpload mirrors the server's /v1/extract cap (a file over 100 MiB is
|
||||
// refused on upload with 413), so reject it before uploading.
|
||||
const maxExtractUpload = 100 << 20
|
||||
|
||||
type extractOpts struct {
|
||||
File, Pages, Grep, Question, Mode string
|
||||
JSON bool
|
||||
}
|
||||
|
||||
const extractUsage = "usage: sift extract <file.pdf> [--pages a-b] [--grep RE] [--question Q] [--mode clean|digest] [--json]"
|
||||
|
||||
// parseExtractOpts parses `sift extract` args into extractOpts. It knows which
|
||||
// flags take a value; the PDF path is the one positional.
|
||||
func parseExtractOpts(args []string) (extractOpts, error) {
|
||||
flagArgs, pos, err := splitAroundPositional(args, extractValueFlags)
|
||||
if err != nil {
|
||||
return extractOpts{}, err
|
||||
}
|
||||
fs := flag.NewFlagSet("extract", flag.ContinueOnError)
|
||||
fs.SetOutput(io.Discard)
|
||||
modeF := fs.String("mode", "", "clean|digest (default digest if --question)")
|
||||
pagesF := fs.String("pages", "", "only these pages of a PDF (a-b or a)")
|
||||
grepF := fs.String("grep", "", "only lines matching RE in a PDF")
|
||||
questionF := fs.String("question", "", "focus the digest")
|
||||
asJSONF := fs.Bool("json", false, "print full /v1/extract envelope")
|
||||
if err := fs.Parse(flagArgs); err != nil {
|
||||
return extractOpts{}, err
|
||||
}
|
||||
if fs.NArg() != 0 {
|
||||
return extractOpts{}, fmt.Errorf("%s", extractUsage)
|
||||
}
|
||||
if len(pos) != 1 {
|
||||
return extractOpts{}, fmt.Errorf("%s", extractUsage)
|
||||
}
|
||||
return extractOpts{
|
||||
File: pos[0],
|
||||
Mode: *modeF,
|
||||
Pages: *pagesF,
|
||||
Grep: *grepF,
|
||||
Question: *questionF,
|
||||
JSON: *asJSONF,
|
||||
}, nil
|
||||
}
|
||||
|
||||
var extractValueFlags = map[string]bool{
|
||||
"mode": true, "pages": true, "grep": true, "question": true,
|
||||
}
|
||||
|
||||
// extractURL builds the /v1/extract URL from base plus only the options that are
|
||||
// set; mode defaults to digest when a question is given and no mode was.
|
||||
func extractURL(base string, o extractOpts) string {
|
||||
v := url.Values{}
|
||||
if o.Pages != "" {
|
||||
v.Set("pages", o.Pages)
|
||||
}
|
||||
if o.Grep != "" {
|
||||
v.Set("grep", o.Grep)
|
||||
}
|
||||
if o.Question != "" {
|
||||
v.Set("question", o.Question)
|
||||
}
|
||||
mode := o.Mode
|
||||
if mode == "" && o.Question != "" {
|
||||
mode = "digest"
|
||||
}
|
||||
if mode != "" {
|
||||
v.Set("mode", mode)
|
||||
}
|
||||
u, err := url.Parse(base)
|
||||
if err != nil {
|
||||
return base + "/v1/extract?" + v.Encode()
|
||||
}
|
||||
u.Path = "/v1/extract"
|
||||
u.RawQuery = v.Encode()
|
||||
return u.String()
|
||||
}
|
||||
|
||||
func extractMain() {
|
||||
o, err := parseExtractOpts(os.Args[1:])
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
os.Exit(2)
|
||||
}
|
||||
base, key, err := loadClient()
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
os.Exit(5)
|
||||
}
|
||||
f, err := os.Open(o.File)
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
os.Exit(5)
|
||||
}
|
||||
defer f.Close()
|
||||
st, err := f.Stat()
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
os.Exit(5)
|
||||
}
|
||||
if st.Size() > maxExtractUpload {
|
||||
fmt.Fprintf(os.Stderr, "extract: %s is %d bytes, over the %d MB limit\n", o.File, st.Size(), maxExtractUpload>>20)
|
||||
os.Exit(3)
|
||||
}
|
||||
req, err := http.NewRequest(http.MethodPost, extractURL(base, o), f)
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
os.Exit(5)
|
||||
}
|
||||
req.ContentLength = st.Size()
|
||||
req.Header.Set("Authorization", "Bearer "+key)
|
||||
req.Header.Set("Content-Type", "application/pdf")
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
os.Exit(5)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
var env map[string]any
|
||||
if err := json.NewDecoder(resp.Body).Decode(&env); err != nil {
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
os.Exit(5)
|
||||
}
|
||||
if resp.StatusCode != 200 {
|
||||
msg, _ := env["error"].(string)
|
||||
hint, _ := env["hint"].(string)
|
||||
if hint != "" {
|
||||
fmt.Fprintf(os.Stderr, "%s\nhint: %s\n", msg, hint)
|
||||
} else {
|
||||
fmt.Fprintln(os.Stderr, msg)
|
||||
}
|
||||
os.Exit(exitFor(resp.StatusCode, msg))
|
||||
}
|
||||
if o.JSON {
|
||||
enc := json.NewEncoder(os.Stdout)
|
||||
enc.SetIndent("", " ")
|
||||
_ = enc.Encode(env)
|
||||
return
|
||||
}
|
||||
if c, ok := env["content"].(string); ok {
|
||||
fmt.Print(c)
|
||||
if !strings.HasSuffix(c, "\n") {
|
||||
fmt.Println()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -22,6 +22,8 @@ func main() {
|
||||
serveMain()
|
||||
case "fetch":
|
||||
fetchMain()
|
||||
case "extract":
|
||||
extractMain()
|
||||
case "search":
|
||||
searchMain()
|
||||
case "keys":
|
||||
@@ -45,6 +47,7 @@ func usage() {
|
||||
Commands:
|
||||
sift serve [flags] run the service
|
||||
sift fetch <url> fetch via the service
|
||||
sift extract <file.pdf> extract a local PDF via the service
|
||||
sift search "<query>" search via the service
|
||||
sift keys add|list|revoke
|
||||
sift version
|
||||
|
||||
Reference in New Issue
Block a user