aboutsummaryrefslogtreecommitdiff
path: root/cmds/score.go
diff options
context:
space:
mode:
authorSam Scholten2026-08-30 12:50:24 +1000
committerSam Scholten2026-08-30 12:52:39 +1000
commit96ab2fcb2ff442698465389d75390afa91629165 (patch)
treeb36ef45dccaa791b0fce4dcad99d564635663c10 /cmds/score.go
downloadfluxrec-96ab2fcb2ff442698465389d75390afa91629165.tar.gz
fluxrec-96ab2fcb2ff442698465389d75390afa91629165.zip
fluxrec: personal Miniflux article recommender
Diffstat (limited to 'cmds/score.go')
-rw-r--r--cmds/score.go103
1 files changed, 103 insertions, 0 deletions
diff --git a/cmds/score.go b/cmds/score.go
new file mode 100644
index 0000000..da6167a
--- /dev/null
+++ b/cmds/score.go
@@ -0,0 +1,103 @@
+// Score command: debugging utility, one trained model vs. stdin titles.
+//
+// Reads one title per line on stdin, writes {"title", "score"} JSONL on
+// stdout. Exists to make the vendored ML core exercisable end-to-end before
+// the real commands (export/train/serve) land, and stays useful later for
+// poking at model behavior by hand.
+package cmds
+
+import (
+ "bufio"
+ "encoding/json"
+ "errors"
+ "flag"
+ "fmt"
+ "io"
+ "os"
+ "path/filepath"
+ "strings"
+
+ "fluxrec/core"
+)
+
+type ScoreCommand struct {
+ ModelPath string
+}
+
+func (c *ScoreCommand) Name() string { return "score" }
+
+func (c *ScoreCommand) Init(args []string) error {
+ fs := flag.NewFlagSet(c.Name(), flag.ContinueOnError)
+ fs.Usage = func() {
+ fmt.Fprint(fs.Output(), `Usage: fluxrec score --model MODEL
+
+Score stdin titles (one per line) with a trained model, JSONL to stdout.
+`)
+ fs.PrintDefaults()
+ }
+ fs.StringVar(&c.ModelPath, "model", "", "Path to model.json (required)")
+ if err := fs.Parse(args); err != nil {
+ return err
+ }
+ if c.ModelPath == "" {
+ return errors.New("--model flag is required")
+ }
+ clean := filepath.Clean(c.ModelPath)
+ if strings.Contains(clean, "..") {
+ return fmt.Errorf("invalid model path %q: must not contain '..'", c.ModelPath)
+ }
+ c.ModelPath = clean
+ return nil
+}
+
+type scoreResult struct {
+ Title string `json:"title"`
+ Score float64 `json:"score"`
+}
+
+func (c *ScoreCommand) Run(stdin io.Reader, stdout io.Writer) error {
+ model, err := loadModel(c.ModelPath)
+ if err != nil {
+ return err
+ }
+ vectorizer := core.CreateVectorizerFromModel(model)
+
+ enc := json.NewEncoder(stdout)
+ scanner := bufio.NewScanner(stdin)
+ scanner.Buffer(make([]byte, 64*1024), 1024*1024)
+ for scanner.Scan() {
+ title := strings.TrimSpace(scanner.Text())
+ if title == "" {
+ continue
+ }
+ if len(title) > 1000 {
+ return fmt.Errorf("title exceeds 1000 chars")
+ }
+ vec := vectorizer.Transform([]string{title})
+ score, err := core.PredictScore(vec[0], model.Weights)
+ if err != nil {
+ return fmt.Errorf("scoring %q: %w", title, err)
+ }
+ if err := enc.Encode(scoreResult{Title: title, Score: score}); err != nil {
+ return err
+ }
+ }
+ return scanner.Err()
+}
+
+func loadModel(path string) (*core.ModelEnvelope, error) {
+ f, err := os.Open(path)
+ if err != nil {
+ return nil, fmt.Errorf("opening model: %w", err)
+ }
+ defer func() { _ = f.Close() }()
+
+ var model core.ModelEnvelope
+ if err := json.NewDecoder(f).Decode(&model); err != nil {
+ return nil, fmt.Errorf("parsing model %s: %w", path, err)
+ }
+ if len(model.Weights) == 0 || len(model.OrderedVocab) == 0 {
+ return nil, fmt.Errorf("model %s is missing weights or vocabulary", path)
+ }
+ return &model, nil
+}