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
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
|
// Export command: pull stars and sampled negatives from Miniflux into
// labels.jsonl, the canonical label store.
//
// Positives: starred entries, incremental via a persisted changed_after
// cursor, deduped by ID then normalized URL. Negatives: seeded random
// sample of recent read+unstarred entries, topped up to a fixed ratio of
// the positive pool. Also carries --dump-raw fixture capture.
package cmds
import (
"encoding/json"
"flag"
"fmt"
"io"
"math/rand"
"os"
"path/filepath"
"time"
"fluxrec/core"
"fluxrec/miniflux"
)
type ExportCommand struct {
DumpRaw string
Labels string
State string
RunOut string
RecFeedID int64
NegDays int
NegRatio int
NegSeed int64
}
func (c *ExportCommand) Name() string { return "export" }
func (c *ExportCommand) Init(args []string) error {
fs := flag.NewFlagSet(c.Name(), flag.ContinueOnError)
fs.Usage = func() {
fmt.Fprint(fs.Output(), `Usage: fluxrec export [--labels FILE] [--state FILE] [--rec-feed-id ID]
fluxrec export --dump-raw DIR
Export labels from Miniflux into labels.jsonl: starred entries become
positives (incremental via --state cursor), plus a seeded random sample of
recent read+unstarred entries as negatives at --neg-ratio per positive.
Each run also writes a JSON summary to --run-out.
With --dump-raw, instead fetch ~25 starred and ~100 read unstarred entries
and write the raw API responses to DIR for offline development fixtures.
`)
fs.PrintDefaults()
}
fs.StringVar(&c.DumpRaw, "dump-raw", "", "Write raw API fixtures to DIR and exit")
fs.StringVar(&c.Labels, "labels", "labels.jsonl", "Path to labels.jsonl")
fs.StringVar(&c.State, "state", "export_state.json", "Path to the export cursor file")
fs.StringVar(&c.RunOut, "run-out", "export_run.json", "Path to write the per-run summary JSON")
fs.Int64Var(&c.RecFeedID, "rec-feed-id", 0, "Miniflux feed ID of the recommendations feed (0 = not deployed yet)")
fs.IntVar(&c.NegDays, "neg-days", 45, "Only sample negatives published within this many days")
fs.IntVar(&c.NegRatio, "neg-ratio", 20, "Target negatives per positive")
fs.Int64Var(&c.NegSeed, "neg-seed", 20240301, "Seed for negative sampling (same data + same seed = same sample)")
return fs.Parse(args)
}
func (c *ExportCommand) Run(stdin io.Reader, stdout io.Writer) error {
if c.DumpRaw != "" {
return c.runDumpRaw(stdout)
}
return c.runExport(stdout)
}
// exportState is the persisted incremental cursor: the largest changed_at
// (unix seconds) seen so far. Refetching starts one second before it so a
// star landing in the same second as the previous run's last entry can't
// slip through; the overlap is absorbed by dedup.
type exportState struct {
ChangedAfter int64 `json:"changed_after"`
}
func loadExportState(path string) (exportState, error) {
var st exportState
body, err := os.ReadFile(path)
if err != nil {
if os.IsNotExist(err) {
return st, nil
}
return st, fmt.Errorf("reading state %s: %w", path, err)
}
if err := json.Unmarshal(body, &st); err != nil {
return st, fmt.Errorf("parsing state %s: %w", path, err)
}
return st, nil
}
func saveExportState(path string, st exportState) error {
body, err := json.Marshal(st)
if err != nil {
return err
}
if err := os.WriteFile(path, body, 0o644); err != nil {
return fmt.Errorf("writing state %s: %w", path, err)
}
return nil
}
// exportCounts is what the run reports to stdout.
type exportCounts struct {
seen, appended, idDup, urlDup, recHit, recUnresolved int
}
func (c *ExportCommand) runExport(stdout io.Writer) error {
started := time.Now()
cfg, err := miniflux.ConfigFromEnv()
if err != nil {
return err
}
client := miniflux.NewClient(cfg)
state, err := loadExportState(c.State)
if err != nil {
return err
}
existing, err := core.ReadLabels(c.Labels)
if err != nil {
return err
}
seenIDs := make(map[int64]bool, len(existing))
seenURLs := make(map[string]bool, len(existing))
for _, row := range existing {
seenIDs[row.EntryID] = true
seenURLs[core.NormalizeURL(row.URL)] = true
}
starred := true
f := miniflux.Filters{Starred: &starred, Limit: 200}
if state.ChangedAfter > 0 {
f.ChangedAfter = state.ChangedAfter - 1 // boundary overlap, see exportState doc
}
var counts exportCounts
var newRows []core.LabelRow
maxChanged := state.ChangedAfter
now := time.Now().UTC()
for {
set, err := client.Entries(f)
if err != nil {
return err
}
if len(set.Entries) == 0 {
break
}
for _, e := range set.Entries {
counts.seen++
if ts := e.ChangedAt.Unix(); ts > maxChanged {
maxChanged = ts
}
// Stars on the recommendations feed are not original entries;
// resolve by URL against existing labels. If the original was
// never exported, there is nothing to attach the star to.
if c.RecFeedID != 0 && e.Feed.ID == c.RecFeedID {
if seenURLs[core.NormalizeURL(e.URL)] {
counts.recHit++
} else {
counts.recUnresolved++
}
continue
}
if seenIDs[e.ID] {
counts.idDup++
continue
}
nu := core.NormalizeURL(e.URL)
if seenURLs[nu] {
counts.urlDup++
continue
}
newRows = append(newRows, core.LabelRow{
EntryID: e.ID, URL: e.URL, Title: e.Title,
FeedID: e.Feed.ID, FeedTitle: e.Feed.Title,
Label: core.LabelPos, CapturedAt: now,
})
seenIDs[e.ID] = true
seenURLs[nu] = true
counts.appended++
}
f.Offset += len(set.Entries)
if f.Offset >= set.Total {
break
}
}
if err := core.AppendLabels(c.Labels, newRows); err != nil {
return err
}
if maxChanged > state.ChangedAfter {
if err := saveExportState(c.State, exportState{ChangedAfter: maxChanged}); err != nil {
return err
}
}
posTotal, negTotal := 0, 0
for _, row := range existing {
if row.Label == core.LabelPos {
posTotal++
} else {
negTotal++
}
}
posTotal += counts.appended
fmt.Fprintf(stdout, "export: seen=%d appended=%d (id-dup=%d url-dup=%d rec-hit=%d rec-unresolved=%d) pos-total=%d\n",
counts.seen, counts.appended, counts.idDup, counts.urlDup,
counts.recHit, counts.recUnresolved, posTotal)
if counts.recUnresolved > 0 {
fmt.Fprintln(stdout, "note: rec-feed stars without a matching labeled original were skipped")
}
nc, err := c.exportNegatives(client, seenIDs, seenURLs, posTotal, negTotal, now, stdout)
if err != nil {
return err
}
return writeRunSummary(c.RunOut, runSummary{
RanAt: now, DurationMs: time.Since(started).Milliseconds(),
PositivesSeen: counts.seen, PositivesNew: counts.appended,
PosTotal: posTotal, NegCandidates: nc.candidates, NegNew: nc.appended,
NegTotal: negTotal + nc.appended, IDDup: counts.idDup, URLDup: counts.urlDup,
RecHit: counts.recHit, RecUnresolved: counts.recUnresolved,
})
}
// negCounts is what the negatives sampler reports back.
type negCounts struct {
candidates, appended int
}
// exportNegatives samples read, unstarred entries published within the
// --neg-days window down to labels.jsonl as LabelNeg rows, topping the
// negative pool up to --neg-ratio × positives. Anything already labeled
// (either polarity) is excluded; sampling is seeded so the same candidate
// pool + seed reproduce the same sample.
//
// The window is enforced server-side via published_after (Miniflux >= 2.0.49,
// the same floor the rest of export requires): only in-window entries are
// fetched, so the deep archive is never walked and no client-side order
// assumption is needed (Miniflux's default order is published_at asc).
func (c *ExportCommand) exportNegatives(client *miniflux.Client, seenIDs map[int64]bool, seenURLs map[string]bool, posTotal, negTotal int, now time.Time, stdout io.Writer) (negCounts, error) {
var nc negCounts
need := c.NegRatio*posTotal - negTotal
if need <= 0 || posTotal == 0 {
fmt.Fprintf(stdout, "negatives: skipped (need=%d pos=%d)\n", need, posTotal)
return nc, nil
}
cutoff := now.Add(-time.Duration(c.NegDays) * 24 * time.Hour)
unstarred := false
f := miniflux.Filters{
Starred: &unstarred, Status: "read", Limit: 200,
PublishedAfter: cutoff.Unix(),
}
candIDs := make(map[int64]bool)
var cands []miniflux.Entry
for {
set, err := client.Entries(f)
if err != nil {
return nc, err
}
if len(set.Entries) == 0 {
break
}
for _, e := range set.Entries {
if seenIDs[e.ID] || candIDs[e.ID] || seenURLs[core.NormalizeURL(e.URL)] {
continue
}
candIDs[e.ID] = true
cands = append(cands, e)
}
f.Offset += len(set.Entries)
if f.Offset >= set.Total {
break
}
}
nc.candidates = len(cands)
rng := rand.New(rand.NewSource(c.NegSeed))
perm := rng.Perm(len(cands))
take := min(need, len(cands))
rows := make([]core.LabelRow, 0, take)
for i := range take {
e := cands[perm[i]]
rows = append(rows, core.LabelRow{
EntryID: e.ID, URL: e.URL, Title: e.Title,
FeedID: e.Feed.ID, FeedTitle: e.Feed.Title,
Label: core.LabelNeg, CapturedAt: now,
})
seenIDs[e.ID] = true
seenURLs[core.NormalizeURL(e.URL)] = true
}
if err := core.AppendLabels(c.Labels, rows); err != nil {
return nc, err
}
nc.appended = len(rows)
fmt.Fprintf(stdout, "negatives: candidates=%d appended=%d (target=%d:%d)\n",
nc.candidates, nc.appended, c.NegRatio, posTotal)
if nc.appended < need {
fmt.Fprintf(stdout, "note: only %d usable candidates in the last %d days; negatives short by %d\n",
nc.candidates, c.NegDays, need-nc.appended)
}
return nc, nil
}
// runSummary is the per-run JSON summary written for visibility; kept to
// the latest run (overwrite, not append).
type runSummary struct {
RanAt time.Time `json:"ran_at"`
DurationMs int64 `json:"duration_ms"`
PositivesSeen int `json:"positives_seen"`
PositivesNew int `json:"positives_new"`
PosTotal int `json:"pos_total"`
NegCandidates int `json:"neg_candidates"`
NegNew int `json:"neg_new"`
NegTotal int `json:"neg_total"`
IDDup int `json:"id_dup"`
URLDup int `json:"url_dup"`
RecHit int `json:"rec_hit"`
RecUnresolved int `json:"rec_unresolved"`
}
func writeRunSummary(path string, s runSummary) error {
body, err := json.MarshalIndent(s, "", " ")
if err != nil {
return err
}
if err := os.WriteFile(path, body, 0o644); err != nil {
return fmt.Errorf("writing run summary %s: %w", path, err)
}
return nil
}
// ── fixture capture (--dump-raw) ──
// dumpJob is one fixture to capture: output filename + API filters.
type dumpJob struct {
filename string
filters miniflux.Filters
}
func dumpJobs() []dumpJob {
starred, unstarred := true, false
return []dumpJob{
{"starred_sample.json", miniflux.Filters{Starred: &starred, Limit: 25}},
// Negatives fixture must NOT overlap stars; a plain status=read
// sample can be all-starred after a retroactive starring pass.
{"read_sample.json", miniflux.Filters{Starred: &unstarred, Status: "read", Limit: 100}},
}
}
func (c *ExportCommand) runDumpRaw(stdout io.Writer) error {
cfg, err := miniflux.ConfigFromEnv()
if err != nil {
return err
}
client := miniflux.NewClient(cfg)
if err := os.MkdirAll(c.DumpRaw, 0o755); err != nil {
return fmt.Errorf("creating %s: %w", c.DumpRaw, err)
}
for _, job := range dumpJobs() {
body, err := client.RawEntries(job.filters)
if err != nil {
return fmt.Errorf("fetching %s: %w", job.filename, err)
}
// Decode only to report a count and catch a malformed response
// before it becomes a silent bad fixture.
var set miniflux.EntrySet
if err := json.Unmarshal(body, &set); err != nil {
return fmt.Errorf("%s: response did not decode as entries: %w", job.filename, err)
}
path := filepath.Join(c.DumpRaw, job.filename)
if err := os.WriteFile(path, body, 0o644); err != nil {
return fmt.Errorf("writing %s: %w", path, err)
}
// Contamination guard: the negatives fixture must be star-free,
// or dev data for negative sampling teaches the wrong lesson.
starredN := 0
for _, e := range set.Entries {
if e.Starred {
starredN++
}
}
if job.filters.Starred != nil && !*job.filters.Starred && starredN > 0 {
fmt.Fprintf(stdout, "WARNING: %s contains %d starred entries (API ignored starred=false?)\n", job.filename, starredN)
}
fmt.Fprintf(stdout, "wrote %s (%d entries, %d bytes)\n", path, len(set.Entries), len(body))
}
return nil
}
|