diff options
Diffstat (limited to 'cmds/export.go')
| -rw-r--r-- | cmds/export.go | 402 |
1 files changed, 402 insertions, 0 deletions
diff --git a/cmds/export.go b/cmds/export.go new file mode 100644 index 0000000..102c94a --- /dev/null +++ b/cmds/export.go @@ -0,0 +1,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 +} |
