aboutsummaryrefslogtreecommitdiff
path: root/cmds/retrostar.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/retrostar.go
downloadfluxrec-96ab2fcb2ff442698465389d75390afa91629165.tar.gz
fluxrec-96ab2fcb2ff442698465389d75390afa91629165.zip
fluxrec: personal Miniflux article recommender
Diffstat (limited to 'cmds/retrostar.go')
-rw-r--r--cmds/retrostar.go127
1 files changed, 127 insertions, 0 deletions
diff --git a/cmds/retrostar.go b/cmds/retrostar.go
new file mode 100644
index 0000000..37f9313
--- /dev/null
+++ b/cmds/retrostar.go
@@ -0,0 +1,127 @@
+// Retrostar command: retroactive-starring aid for cold start. Scores the
+// recent read+unstarred entry pool with the current model and prints the
+// highest-ranked candidates so they can be starred by hand in the Miniflux
+// UI. Entries already in labels.jsonl are excluded: an exported negative
+// can never be re-labeled (labels never change) and an exported positive
+// is already known. Read-only — it never writes to Miniflux; starring
+// stays a human action in the UI.
+package cmds
+
+import (
+ "flag"
+ "fmt"
+ "io"
+ "sort"
+ "time"
+
+ "fluxrec/core"
+ "fluxrec/miniflux"
+)
+
+type RetrostarCommand struct {
+ ModelPath string
+ Labels string
+ Days int
+ Limit int
+}
+
+func (c *RetrostarCommand) Name() string { return "retrostar" }
+
+func (c *RetrostarCommand) Init(args []string) error {
+ fs := flag.NewFlagSet(c.Name(), flag.ContinueOnError)
+ fs.Usage = func() {
+ fmt.Fprint(fs.Output(), `Usage: fluxrec retrostar [flags]
+
+Print the highest-scored recent read+unstarred entries (model-ranked) as
+candidates for retroactive starring in the Miniflux UI. Entries already in
+labels.jsonl are skipped. Read-only: nothing is written to Miniflux.
+`)
+ fs.PrintDefaults()
+ }
+ fs.StringVar(&c.ModelPath, "model", "model.json", "Path to model.json")
+ fs.StringVar(&c.Labels, "labels", "labels.jsonl", "Path to labels.jsonl (already-labeled entries are excluded)")
+ fs.IntVar(&c.Days, "days", 60, "Only consider entries published within this many days (inside the deletion horizon)")
+ fs.IntVar(&c.Limit, "limit", 50, "How many top candidates to print")
+ if err := fs.Parse(args); err != nil {
+ return err
+ }
+ if c.Days < 1 {
+ return fmt.Errorf("--days must be >= 1, got %d", c.Days)
+ }
+ if c.Limit < 1 {
+ return fmt.Errorf("--limit must be >= 1, got %d", c.Limit)
+ }
+ return nil
+}
+
+func (c *RetrostarCommand) Run(stdin io.Reader, stdout io.Writer) error {
+ cfg, err := miniflux.ConfigFromEnv()
+ if err != nil {
+ return err
+ }
+ client := miniflux.NewClient(cfg)
+
+ model, err := loadModel(c.ModelPath)
+ if err != nil {
+ return err
+ }
+ vec := core.CreateVectorizerFromModel(model)
+
+ existing, err := core.ReadLabels(c.Labels)
+ if err != nil {
+ return err
+ }
+ labeled := make(map[int64]bool, len(existing))
+ for _, row := range existing {
+ labeled[row.EntryID] = true
+ }
+
+ cutoff := time.Now().Add(-time.Duration(c.Days) * 24 * time.Hour)
+ unstarred := false
+ f := miniflux.Filters{
+ Starred: &unstarred, Status: "read", Limit: 200,
+ PublishedAfter: cutoff.Unix(),
+ }
+ var scored []scoredEntry
+ for {
+ set, err := client.Entries(f)
+ if err != nil {
+ return err
+ }
+ if len(set.Entries) == 0 {
+ break
+ }
+ for _, e := range set.Entries {
+ if labeled[e.ID] {
+ continue
+ }
+ vecs := vec.Transform([]string{core.ScoringText(e.Title, e.Feed.Title)})
+ s, err := core.PredictScore(vecs[0], model.Weights)
+ if err != nil {
+ return fmt.Errorf("scoring entry %d: %w", e.ID, err)
+ }
+ scored = append(scored, scoredEntry{entry: e, score: s})
+ }
+ f.Offset += len(set.Entries)
+ if f.Offset >= set.Total {
+ break
+ }
+ }
+
+ sort.SliceStable(scored, func(i, j int) bool {
+ if scored[i].score == scored[j].score {
+ return scored[i].entry.ID < scored[j].entry.ID
+ }
+ return scored[i].score > scored[j].score
+ })
+
+ shown := min(c.Limit, len(scored))
+ for _, se := range scored[:shown] {
+ e := se.entry
+ fmt.Fprintf(stdout, "%.3f %s %s %s\n %s (entry %d)\n",
+ se.score, e.PublishedAt.Format("2006-01-02"), e.Feed.Title, e.Title, e.URL, e.ID)
+ }
+ fmt.Fprintf(stdout, "retrostar: scanned %d unlabeled candidates, showing %d — star in the Miniflux UI, then run export\n",
+ len(scored), shown)
+ return nil
+}