From 96ab2fcb2ff442698465389d75390afa91629165 Mon Sep 17 00:00:00 2001 From: Sam Scholten Date: Sun, 30 Aug 2026 12:50:24 +1000 Subject: fluxrec: personal Miniflux article recommender --- cmds/score.go | 103 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 103 insertions(+) create mode 100644 cmds/score.go (limited to 'cmds/score.go') 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 +} -- cgit v1.2.3