aboutsummaryrefslogtreecommitdiff
path: root/cmds/retrostar.go
blob: 37f9313419ef3bcebd5cd43df8d8e2ec0737d47b (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
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
}