feat(runner): manual summarization mode in the run loop

RunOnce now reads the user's mode (GetAutoSummarize) at the start of each pass:
- Auto (unchanged): summarize every unseen video.
- Manual: still UpsertVideo for every candidate (discovery — the user sees new
  videos in the list), but skip ProcessNewVideo unless the video is queued
  (RequestedVideoIDs). A queued video is summarized, then its flag is cleared
  (ClearSummarizeRequested) so it is not re-processed and the UI drops "Queued".

New Stats.SkippedManual counts discovered-but-unqueued videos. The VideoStore
port gains the three methods; the existing auto-mode tests set auto:true.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-03 19:28:20 +02:00
co-authored by Claude Opus 4.8
parent bdbdce7de1
commit a269d4a200
2 changed files with 109 additions and 8 deletions
+41 -2
View File
@@ -23,10 +23,15 @@ import (
)
// VideoStore is the durable persistence the run loop needs: assign a stable id +
// metadata, and read the already-summarized set. *store.Store satisfies it.
// metadata, read the already-summarized set, and (for manual summarization mode)
// read the user's mode + queued videos and clear a video's queue flag once it has
// been summarized. *store.Store satisfies it.
type VideoStore interface {
UpsertVideo(ctx context.Context, v domain.Video) (string, error)
SeenVideoIDs(ctx context.Context, userID string) (map[string]bool, error)
GetAutoSummarize(ctx context.Context, userID string) (bool, error)
RequestedVideoIDs(ctx context.Context, userID string) (map[string]bool, error)
ClearSummarizeRequested(ctx context.Context, userID, videoID string) error
}
// Processor runs the core use case for a single video. *usecase.Engine
@@ -59,6 +64,7 @@ type Stats struct {
Summarized int
SkippedSeen int
SkippedNoText int
SkippedManual int // discovered but not queued, in manual mode
Errors int
}
@@ -82,6 +88,22 @@ func (r *Runner) RunOnce(ctx context.Context) (Stats, error) {
return stats, fmt.Errorf("runner: load seen videos: %w", err)
}
// Summarization mode (per-user, ADR-012). Auto = summarize every unseen video
// (the original behavior). Manual = still discover/persist videos so the user
// sees them, but only summarize the ones explicitly queued via the web UI
// (summarize_requested). The queued set is loaded once per pass, like seen.
auto, err := r.store.GetAutoSummarize(ctx, r.userID)
if err != nil {
return stats, fmt.Errorf("runner: load summarize mode: %w", err)
}
var requested map[string]bool
if !auto {
requested, err = r.store.RequestedVideoIDs(ctx, r.userID)
if err != nil {
return stats, fmt.Errorf("runner: load requested videos: %w", err)
}
}
subs, err := r.src.ListSubscriptions(ctx, r.userID)
if err != nil {
return stats, fmt.Errorf("runner: list subscriptions: %w", err)
@@ -112,6 +134,14 @@ func (r *Runner) RunOnce(ctx context.Context) (Stats, error) {
}
seen[id] = true // also guard against the same video within this pass
// Manual mode: skip summarization for videos the user has not queued.
// Discovery already happened (UpsertVideo above), so the new video is
// visible in the list; it just isn't summarized until requested.
if !auto && !requested[id] {
stats.SkippedManual++
continue
}
if fetchDelay > 0 {
time.Sleep(fetchDelay)
}
@@ -127,6 +157,15 @@ func (r *Runner) RunOnce(ctx context.Context) (Stats, error) {
r.log.Info("skipped video (no transcript)", "video", v.ProviderVideoID, "title", v.Title)
case res.Summary != nil:
stats.Summarized++
// In manual mode the video was processed because it was queued;
// clear the flag so it is not re-summarized and the UI drops the
// "Queued" chip. (Auto mode never sets the flag.)
if !auto {
if err := r.store.ClearSummarizeRequested(ctx, r.userID, id); err != nil {
errs = append(errs, fmt.Errorf("clear summarize flag %q: %w", v.ProviderVideoID, err))
stats.Errors++
}
}
r.log.Info("summarized video", "video", v.ProviderVideoID, "title", v.Title,
"provider", res.Summary.AIProvider, "model", res.Summary.AIModel)
}
@@ -145,7 +184,7 @@ func (r *Runner) Loop(ctx context.Context, interval time.Duration) error {
r.log.Info("run pass complete",
"candidates", stats.Candidates, "summarized", stats.Summarized,
"skipped_seen", stats.SkippedSeen, "skipped_no_text", stats.SkippedNoText,
"errors", stats.Errors)
"skipped_manual", stats.SkippedManual, "errors", stats.Errors)
if err != nil {
r.log.Warn("run pass had errors", "err", err)
}
+66 -4
View File
@@ -40,9 +40,14 @@ func (f *fakeSource) FetchTranscript(_ context.Context, v domain.Video) (domain.
// fakeStore assigns deterministic ids ("id-"+provider video id) so a pre-seeded
// seen set lines up with UpsertVideo output, modelling cross-restart dedup.
// auto controls the summarization mode; requested is the manual-mode queue keyed
// by store id; cleared records the ids whose queue flag the runner reset.
type fakeStore struct {
seen map[string]bool
upserted []domain.Video
auto bool
requested map[string]bool
cleared []string
}
func (f *fakeStore) UpsertVideo(_ context.Context, v domain.Video) (string, error) {
@@ -58,6 +63,23 @@ func (f *fakeStore) SeenVideoIDs(_ context.Context, _ string) (map[string]bool,
return cp, nil
}
func (f *fakeStore) GetAutoSummarize(_ context.Context, _ string) (bool, error) {
return f.auto, nil
}
func (f *fakeStore) RequestedVideoIDs(_ context.Context, _ string) (map[string]bool, error) {
cp := make(map[string]bool, len(f.requested))
for k, v := range f.requested {
cp[k] = v
}
return cp, nil
}
func (f *fakeStore) ClearSummarizeRequested(_ context.Context, _, videoID string) error {
f.cleared = append(f.cleared, videoID)
return nil
}
type fakeSummarizer struct{}
func (fakeSummarizer) Summarize(_ context.Context, v domain.Video, _ domain.Transcript) (domain.Summary, error) {
@@ -91,7 +113,7 @@ func TestRunOnce_SummarizesNewVideos(t *testing.T) {
subs: []domain.Subscription{sub("chan1", "Channel One")},
videos: map[string][]domain.Video{"chan1": {vid("v1", "Video 1"), vid("v2", "Video 2")}},
}
st := &fakeStore{seen: map[string]bool{}}
st := &fakeStore{seen: map[string]bool{}, auto: true}
sink := &recordingSink{}
eng := usecase.NewEngine(src, fakeSummarizer{}, sink)
r := runner.New(src, st, eng, testUser, quietLogger())
@@ -114,7 +136,7 @@ func TestRunOnce_SkipsAlreadySummarized(t *testing.T) {
videos: map[string][]domain.Video{"chan1": {vid("v1", "Video 1"), vid("v2", "Video 2")}},
}
// v1 was summarized in a prior run (durable seen set).
st := &fakeStore{seen: map[string]bool{"id-v1": true}}
st := &fakeStore{seen: map[string]bool{"id-v1": true}, auto: true}
sink := &recordingSink{}
eng := usecase.NewEngine(src, fakeSummarizer{}, sink)
r := runner.New(src, st, eng, testUser, quietLogger())
@@ -133,7 +155,7 @@ func TestRunOnce_SkipsVideosWithoutTranscript(t *testing.T) {
videos: map[string][]domain.Video{"chan1": {vid("v1", "Video 1")}},
transcripts: map[string]domain.Transcript{"v1": {Source: domain.SourceNone}},
}
st := &fakeStore{seen: map[string]bool{}}
st := &fakeStore{seen: map[string]bool{}, auto: true}
sink := &recordingSink{}
eng := usecase.NewEngine(src, fakeSummarizer{}, sink)
r := runner.New(src, st, eng, testUser, quietLogger())
@@ -145,13 +167,53 @@ func TestRunOnce_SkipsVideosWithoutTranscript(t *testing.T) {
require.Empty(t, sink.delivered, "no summary delivered when there is no transcript")
}
func TestRunOnce_ManualMode_SkipsUnrequested(t *testing.T) {
src := &fakeSource{
subs: []domain.Subscription{sub("chan1", "Channel One")},
videos: map[string][]domain.Video{"chan1": {vid("v1", "Video 1"), vid("v2", "Video 2")}},
}
// Manual mode, nothing queued: discover (upsert) but summarize nothing.
st := &fakeStore{seen: map[string]bool{}, auto: false, requested: map[string]bool{}}
sink := &recordingSink{}
eng := usecase.NewEngine(src, fakeSummarizer{}, sink)
r := runner.New(src, st, eng, testUser, quietLogger())
stats, err := r.RunOnce(context.Background())
require.NoError(t, err)
require.Equal(t, 2, stats.Candidates)
require.Equal(t, 2, stats.SkippedManual, "manual mode skips unqueued videos")
require.Equal(t, 0, stats.Summarized)
require.Empty(t, sink.delivered, "no summary in manual mode without a request")
require.Len(t, st.upserted, 2, "discovery still persists every candidate")
}
func TestRunOnce_ManualMode_ProcessesRequested(t *testing.T) {
src := &fakeSource{
subs: []domain.Subscription{sub("chan1", "Channel One")},
videos: map[string][]domain.Video{"chan1": {vid("v1", "Video 1"), vid("v2", "Video 2")}},
}
// Manual mode, v1 queued (by store id). Only v1 is summarized; its flag clears.
st := &fakeStore{seen: map[string]bool{}, auto: false, requested: map[string]bool{"id-v1": true}}
sink := &recordingSink{}
eng := usecase.NewEngine(src, fakeSummarizer{}, sink)
r := runner.New(src, st, eng, testUser, quietLogger())
stats, err := r.RunOnce(context.Background())
require.NoError(t, err)
require.Equal(t, 1, stats.Summarized, "only the queued video is summarized")
require.Equal(t, 1, stats.SkippedManual, "the unqueued video is skipped")
require.Len(t, sink.delivered, 1)
require.Equal(t, "id-v1", sink.delivered[0].VideoID)
require.Equal(t, []string{"id-v1"}, st.cleared, "the queue flag is cleared after summarizing")
}
func TestRunOnce_UpsertsEveryCandidate(t *testing.T) {
src := &fakeSource{
subs: []domain.Subscription{sub("chan1", "Channel One")},
videos: map[string][]domain.Video{"chan1": {vid("v1", "Video 1"), vid("v2", "Video 2")}},
}
// Even an already-seen video gets upserted so its metadata stays fresh.
st := &fakeStore{seen: map[string]bool{"id-v1": true}}
st := &fakeStore{seen: map[string]bool{"id-v1": true}, auto: true}
eng := usecase.NewEngine(src, fakeSummarizer{}, &recordingSink{})
r := runner.New(src, st, eng, testUser, quietLogger())