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