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:
2026-09-26 19:52:09 -07:00
co-authored by ornith-1.5-35b-a3b
parent 7c40531891
commit 3a3a470cd7
6 changed files with 218 additions and 3 deletions
+46
View File
@@ -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)
}
}
+158
View File
@@ -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()
}
}
}
+3
View File
@@ -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