feat(runner): bound auto-summarize to a recency window

In automatic mode the scheduler now only summarizes videos published within
TAPIR_AUTO_SUMMARIZE_WINDOW (default ~7d). Older videos are still discovered
and listed — they keep the manual "Summarize" affordance — but are not
auto-processed, so a large back-catalogue (the maintainer's ~256-deep queue)
stops self-inflicting 429s against the per-IP caption gate each cycle (UX
review B1, recency design).

- runner.WithAutoWindow + Stats.SkippedTooOld; tooOld() treats a zero window
  as disabled and an undated video as never-aged-out (processed, not stranded).
- An explicit manual request bypasses the bound even in auto mode (requested
  videos are loaded in auto mode when a window is active).
- Wired through cmdRun, the scheduler's per-user runner, sumStats, and pass
  logging. config: TAPIR_AUTO_SUMMARIZE_WINDOW (default 168h), .env.example.
- Account copy (A7) updated to match: "Automatic summarizes new videos from
  about the last week; older videos stay browsable — summarize on demand."

The rate gate is untouched; the manual path still serialises through it. This
bounds auto LOAD, it does not fetch harder.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-08 13:48:04 +02:00
co-authored by Claude Opus 4.8
parent 4a0a56e152
commit 2384c47b81
9 changed files with 199 additions and 42 deletions
+90
View File
@@ -234,6 +234,96 @@ func TestRunOnce_ManualMode_ProcessesRequested(t *testing.T) {
require.Equal(t, []string{"id-v1"}, st.cleared, "the queue flag is cleared after summarizing")
}
// --- recency window (B1) ---------------------------------------------------
// TestRunOnce_AutoMode_SkipsOldVideos: with a recency window set, auto mode
// summarizes only videos published within the window; older ones are discovered
// (upserted) but not auto-summarized — they wait for a manual request.
func TestRunOnce_AutoMode_SkipsOldVideos(t *testing.T) {
base := time.Date(2026, 6, 8, 12, 0, 0, 0, time.UTC)
src := &fakeSource{
subs: []domain.Subscription{sub("chan1", "Channel One")},
videos: map[string][]domain.Video{"chan1": {
vidAt("recent", "Recent", base.Add(-24*time.Hour)), // 1d old → in window
vidAt("old", "Old", base.Add(-30*24*time.Hour)), // 30d old → out of window
}},
}
st := &fakeStore{seen: map[string]bool{}, auto: true}
sink := &recordingSink{}
eng := usecase.NewEngine(src, fakeSummarizer{}, sink)
r := runner.New(src, st, eng, testUser, quietLogger(),
runner.WithAutoWindow(7*24*time.Hour), runner.WithClock(func() time.Time { return base }))
stats, err := r.RunOnce(context.Background())
require.NoError(t, err)
require.Equal(t, 1, stats.Summarized, "only the recent video is auto-summarized")
require.Equal(t, 1, stats.SkippedTooOld, "the old video is skipped by the recency bound")
require.Len(t, sink.delivered, 1)
require.Equal(t, "id-recent", sink.delivered[0].VideoID)
require.Len(t, st.upserted, 2, "both videos are still discovered and listed")
}
// TestRunOnce_AutoMode_OldVideoRequestedBypassesWindow: an explicit manual
// request (summarize_requested) overrides the recency bound even in auto mode.
func TestRunOnce_AutoMode_OldVideoRequestedBypassesWindow(t *testing.T) {
base := time.Date(2026, 6, 8, 12, 0, 0, 0, time.UTC)
src := &fakeSource{
subs: []domain.Subscription{sub("chan1", "Channel One")},
videos: map[string][]domain.Video{"chan1": {vidAt("old", "Old", base.Add(-30*24*time.Hour))}},
}
st := &fakeStore{seen: map[string]bool{}, auto: true, requested: map[string]bool{"id-old": true}}
sink := &recordingSink{}
eng := usecase.NewEngine(src, fakeSummarizer{}, sink)
r := runner.New(src, st, eng, testUser, quietLogger(),
runner.WithAutoWindow(7*24*time.Hour), runner.WithClock(func() time.Time { return base }))
stats, err := r.RunOnce(context.Background())
require.NoError(t, err)
require.Equal(t, 1, stats.Summarized, "a requested old video is summarized despite the window")
require.Equal(t, 0, stats.SkippedTooOld)
require.Len(t, sink.delivered, 1)
}
// TestRunOnce_AutoWindowZero_SummarizesOld: a zero window disables the bound —
// the pre-recency behaviour (summarize every unseen video) is preserved.
func TestRunOnce_AutoWindowZero_SummarizesOld(t *testing.T) {
base := time.Date(2026, 6, 8, 12, 0, 0, 0, time.UTC)
src := &fakeSource{
subs: []domain.Subscription{sub("chan1", "Channel One")},
videos: map[string][]domain.Video{"chan1": {vidAt("old", "Old", base.Add(-365*24*time.Hour))}},
}
st := &fakeStore{seen: map[string]bool{}, auto: true}
sink := &recordingSink{}
eng := usecase.NewEngine(src, fakeSummarizer{}, sink)
r := runner.New(src, st, eng, testUser, quietLogger(),
runner.WithClock(func() time.Time { return base })) // no WithAutoWindow → 0
stats, err := r.RunOnce(context.Background())
require.NoError(t, err)
require.Equal(t, 1, stats.Summarized, "window disabled → old video summarized")
require.Equal(t, 0, stats.SkippedTooOld)
}
// TestRunOnce_AutoMode_UndatedVideoSummarized: a video with no published_at
// cannot be aged out — it is processed, not silently stranded.
func TestRunOnce_AutoMode_UndatedVideoSummarized(t *testing.T) {
base := time.Date(2026, 6, 8, 12, 0, 0, 0, time.UTC)
src := &fakeSource{
subs: []domain.Subscription{sub("chan1", "Channel One")},
videos: map[string][]domain.Video{"chan1": {vid("undated", "Undated")}}, // zero PublishedAt
}
st := &fakeStore{seen: map[string]bool{}, auto: true}
sink := &recordingSink{}
eng := usecase.NewEngine(src, fakeSummarizer{}, sink)
r := runner.New(src, st, eng, testUser, quietLogger(),
runner.WithAutoWindow(7*24*time.Hour), runner.WithClock(func() time.Time { return base }))
stats, err := r.RunOnce(context.Background())
require.NoError(t, err)
require.Equal(t, 1, stats.Summarized, "an undated video is processed, not aged out")
require.Equal(t, 0, stats.SkippedTooOld)
}
// noFetchSource fails the test if a transcript fetch happens — used to prove the
// runner skips a rate-limited video before touching the caption endpoint.
type noFetchSource struct{ *fakeSource }