diff options
Diffstat (limited to 'cmds/retrostar.go')
| -rw-r--r-- | cmds/retrostar.go | 127 |
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 +} |
