// 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 }