From c153ff35cee7407f3d9411329293d3880d9d4033 Mon Sep 17 00:00:00 2001 From: Mathias Date: Tue, 2 Jun 2026 20:58:04 +0200 Subject: [PATCH 1/2] feat(store): add read methods for stored summaries MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ListSummaries (recent-first, user-scoped, limit) and GetSummaryByVideo LEFT JOIN videos for title/url/published_at, null-safe when no videos row exists. Channel mirrors provider for now — channel_title lives on the not-yet-migrated subscriptions table (data-model.md). New file so it does not collide with Worker F's concurrent edits to store.go. Co-Authored-By: Claude Opus 4.8 (1M context) --- internal/adapters/store/reads.go | 168 ++++++++++++++++++++++++++ internal/adapters/store/reads_test.go | 148 +++++++++++++++++++++++ 2 files changed, 316 insertions(+) create mode 100644 internal/adapters/store/reads.go create mode 100644 internal/adapters/store/reads_test.go diff --git a/internal/adapters/store/reads.go b/internal/adapters/store/reads.go new file mode 100644 index 0000000..d488996 --- /dev/null +++ b/internal/adapters/store/reads.go @@ -0,0 +1,168 @@ +package store + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "time" + + "github.com/jackc/pgx/v5" +) + +// ErrNotFound is returned by GetSummaryByVideo when no summary exists for the +// (userID, videoID) pair scoped to that user. +var ErrNotFound = errors.New("store: summary not found") + +// SummaryRow is a read-side view of a stored summary, enriched with the video's +// metadata when a matching videos row exists. It is deliberately separate from +// domain.Summary: it carries display fields (Title, Channel, URL, PublishedAt) +// that the engine never sees, sourced via a LEFT JOIN so a summary with no +// videos row still renders (Title/URL empty, PublishedAt zero). +// +// Channel currently mirrors the video provider ("youtube"): the per-channel +// channel_title lives on the subscriptions table (docs/data-model.md), which is +// not part of the Stage-0 store slice yet. When that table is migrated, swap the +// JOIN source — callers already fall back gracefully on an empty Channel. +type SummaryRow struct { + VideoID string + Title string // videos.title; empty when no videos row + Channel string // videos.provider for now; empty when no videos row + URL string // videos.url; empty when no videos row + PublishedAt time.Time // videos.published_at; zero when absent + Summary string + Highlights []string + Takeaways []string + AIProvider string + AIModel string + FallbackUsed bool + CreatedAt time.Time +} + +// selectSummary is the shared projection for both reads. videos is LEFT JOINed +// on its UUID id (= summaries.video_id) and the same user_id, so the join never +// crosses users and a missing videos row yields nulls, not a dropped summary. +const selectSummary = ` + SELECT s.video_id, + COALESCE(v.title, ''), + COALESCE(v.provider, ''), + COALESCE(v.url, ''), + v.published_at, + s.summary, + s.highlights, + s.takeaways, + COALESCE(s.ai_provider, ''), + COALESCE(s.ai_model, ''), + s.fallback_used, + s.created_at + FROM summaries s + LEFT JOIN videos v ON v.id = s.video_id AND v.user_id = s.user_id` + +// ListSummaries returns the user's summaries, most recent first, capped at limit. +// A non-positive limit defaults to 50. Scoped by user_id: one user never sees +// another's summaries (per-user isolation, docs/data-model.md). +func (s *Store) ListSummaries(ctx context.Context, userID string, limit int) ([]SummaryRow, error) { + if limit <= 0 { + limit = 50 + } + rows, err := s.pool.Query(ctx, + selectSummary+` + WHERE s.user_id = $1 + ORDER BY s.created_at DESC + LIMIT $2`, + userID, limit) + if err != nil { + return nil, fmt.Errorf("store: list summaries: %w", err) + } + defer rows.Close() + + var out []SummaryRow + for rows.Next() { + row, err := scanSummaryRow(rows) + if err != nil { + return nil, err + } + out = append(out, row) + } + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("store: iterate summaries: %w", err) + } + return out, nil +} + +// GetSummaryByVideo returns the full summary for (userID, videoID), including +// highlights and takeaways. Returns ErrNotFound when the user has no such +// summary. Scoped by user_id. +func (s *Store) GetSummaryByVideo(ctx context.Context, userID, videoID string) (*SummaryRow, error) { + rows, err := s.pool.Query(ctx, + selectSummary+` + WHERE s.user_id = $1 AND s.video_id = $2`, + userID, videoID) + if err != nil { + return nil, fmt.Errorf("store: get summary: %w", err) + } + defer rows.Close() + + if !rows.Next() { + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("store: get summary: %w", err) + } + return nil, ErrNotFound + } + row, err := scanSummaryRow(rows) + if err != nil { + return nil, err + } + return &row, nil +} + +// scanSummaryRow reads one row in the selectSummary column order. published_at is +// nullable (no videos row, or an unset date) so it scans through a pointer. +func scanSummaryRow(rows pgx.Row) (SummaryRow, error) { + var ( + row SummaryRow + highlights []byte + takeaways []byte + publishedAt *time.Time + ) + if err := rows.Scan( + &row.VideoID, + &row.Title, + &row.Channel, + &row.URL, + &publishedAt, + &row.Summary, + &highlights, + &takeaways, + &row.AIProvider, + &row.AIModel, + &row.FallbackUsed, + &row.CreatedAt, + ); err != nil { + return SummaryRow{}, fmt.Errorf("store: scan summary: %w", err) + } + if publishedAt != nil { + row.PublishedAt = *publishedAt + } + var err error + if row.Highlights, err = unmarshalList(highlights); err != nil { + return SummaryRow{}, fmt.Errorf("store: unmarshal highlights: %w", err) + } + if row.Takeaways, err = unmarshalList(takeaways); err != nil { + return SummaryRow{}, fmt.Errorf("store: unmarshal takeaways: %w", err) + } + return row, nil +} + +// unmarshalList decodes a jsonb array column into a string slice, mirroring +// marshalList in store.go. Empty/NULL bytes decode to a nil slice. +func unmarshalList(b []byte) ([]string, error) { + if len(b) == 0 { + return nil, nil + } + var xs []string + if err := json.Unmarshal(b, &xs); err != nil { + return nil, err + } + return xs, nil +} diff --git a/internal/adapters/store/reads_test.go b/internal/adapters/store/reads_test.go new file mode 100644 index 0000000..550c9f0 --- /dev/null +++ b/internal/adapters/store/reads_test.go @@ -0,0 +1,148 @@ +package store_test + +import ( + "context" + "testing" + "time" + + "github.com/jackc/pgx/v5/pgxpool" + "github.com/stretchr/testify/require" + + "gitea.d-ma.be/mathias/tapir/internal/adapters/store" +) + +// seedVideo inserts a videos row whose id matches a summary's video_id, so the +// read-side LEFT JOIN has metadata to attach. published_at may be the zero time +// to exercise the NULL path. +func seedVideo(t *testing.T, p *pgxpool.Pool, userID, videoID, title, provider, url string, published time.Time) { + t.Helper() + var pub any + if !published.IsZero() { + pub = published + } + _, err := p.Exec(context.Background(), + `INSERT INTO videos (id, user_id, provider, provider_video_id, title, url, published_at) + VALUES ($1, $2, $3, $4, $5, $6, $7)`, + videoID, userID, provider, "pv-"+videoID[:8], title, url, pub) + require.NoError(t, err) +} + +// setCreatedAt forces a summary's created_at so ordering is deterministic. +func setCreatedAt(t *testing.T, p *pgxpool.Pool, userID, videoID string, at time.Time) { + t.Helper() + _, err := p.Exec(context.Background(), + `UPDATE summaries SET created_at = $3 WHERE user_id = $1 AND video_id = $2`, + userID, videoID, at) + require.NoError(t, err) +} + +func TestListSummariesRecentFirstWithVideoMetadata(t *testing.T) { + ctx := context.Background() + s := newStore(t) + p := rawPool(t) + resetDB(t, p) + + require.NoError(t, s.Deliver(ctx, summary(userA, videoX, "older"))) + require.NoError(t, s.Deliver(ctx, summary(userA, videoY, "newer"))) + seedVideo(t, p, userA, videoX, "X Title", "youtube", "https://x", time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC)) + seedVideo(t, p, userA, videoY, "Y Title", "youtube", "https://y", time.Date(2026, 2, 1, 0, 0, 0, 0, time.UTC)) + // Force ordering: videoY is the most recent. + setCreatedAt(t, p, userA, videoX, time.Date(2026, 3, 1, 0, 0, 0, 0, time.UTC)) + setCreatedAt(t, p, userA, videoY, time.Date(2026, 3, 2, 0, 0, 0, 0, time.UTC)) + + rows, err := s.ListSummaries(ctx, userA, 50) + require.NoError(t, err) + require.Len(t, rows, 2) + + require.Equal(t, videoY, rows[0].VideoID, "most recent created_at first") + require.Equal(t, "Y Title", rows[0].Title) + require.Equal(t, "youtube", rows[0].Channel) + require.Equal(t, "https://y", rows[0].URL) + require.Equal(t, 2026, rows[0].PublishedAt.Year()) + + require.Equal(t, videoX, rows[1].VideoID) + require.Equal(t, "X Title", rows[1].Title) +} + +func TestListSummariesWithoutVideoRowIsNullSafe(t *testing.T) { + ctx := context.Background() + s := newStore(t) + resetDB(t, rawPool(t)) + + // Summary exists but no matching videos row (run loop hasn't populated it). + require.NoError(t, s.Deliver(ctx, summary(userA, videoX, "orphan"))) + + rows, err := s.ListSummaries(ctx, userA, 50) + require.NoError(t, err) + require.Len(t, rows, 1) + require.Empty(t, rows[0].Title, "no videos row -> empty title (caller falls back to id)") + require.Empty(t, rows[0].Channel) + require.Empty(t, rows[0].URL) + require.True(t, rows[0].PublishedAt.IsZero(), "absent published_at -> zero time") + require.Equal(t, "orphan", rows[0].Summary) +} + +func TestListSummariesRespectsLimit(t *testing.T) { + ctx := context.Background() + s := newStore(t) + p := rawPool(t) + resetDB(t, p) + + require.NoError(t, s.Deliver(ctx, summary(userA, videoX, "x"))) + require.NoError(t, s.Deliver(ctx, summary(userA, videoY, "y"))) + setCreatedAt(t, p, userA, videoX, time.Date(2026, 3, 1, 0, 0, 0, 0, time.UTC)) + setCreatedAt(t, p, userA, videoY, time.Date(2026, 3, 2, 0, 0, 0, 0, time.UTC)) + + rows, err := s.ListSummaries(ctx, userA, 1) + require.NoError(t, err) + require.Len(t, rows, 1) + require.Equal(t, videoY, rows[0].VideoID, "limit keeps the most recent") +} + +func TestListSummariesIsUserScoped(t *testing.T) { + ctx := context.Background() + s := newStore(t) + resetDB(t, rawPool(t)) + + require.NoError(t, s.Deliver(ctx, summary(userA, videoX, "a-only"))) + + rows, err := s.ListSummaries(ctx, userB, 50) + require.NoError(t, err) + require.Empty(t, rows, "user B must not see user A's summaries") +} + +func TestGetSummaryByVideoReturnsFullRow(t *testing.T) { + ctx := context.Background() + s := newStore(t) + resetDB(t, rawPool(t)) + + require.NoError(t, s.Deliver(ctx, summary(userA, videoX, "full body"))) + + row, err := s.GetSummaryByVideo(ctx, userA, videoX) + require.NoError(t, err) + require.Equal(t, "full body", row.Summary) + require.Equal(t, []string{"h1", "h2"}, row.Highlights) + require.Equal(t, []string{"t1"}, row.Takeaways) + require.Equal(t, "local", row.AIProvider) + require.Equal(t, "qwen", row.AIModel) +} + +func TestGetSummaryByVideoNotFound(t *testing.T) { + ctx := context.Background() + s := newStore(t) + resetDB(t, rawPool(t)) + + _, err := s.GetSummaryByVideo(ctx, userA, videoX) + require.ErrorIs(t, err, store.ErrNotFound) +} + +func TestGetSummaryByVideoIsUserScoped(t *testing.T) { + ctx := context.Background() + s := newStore(t) + resetDB(t, rawPool(t)) + + require.NoError(t, s.Deliver(ctx, summary(userA, videoX, "a-owns"))) + + _, err := s.GetSummaryByVideo(ctx, userB, videoX) + require.ErrorIs(t, err, store.ErrNotFound, "the same video under another user is invisible") +} From d3498898c0ef74cfc3950dc2d115b3237d4455cb Mon Sep 17 00:00:00 2001 From: Mathias Date: Tue, 2 Jun 2026 20:58:09 +0200 Subject: [PATCH 2/2] feat(cli): add read-only list and show subcommands MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit tapir list — table of stored summaries (date, title|id, channel, AI, fallback), recent-first. tapir show — full summary with highlights and takeaways. DSN + user id from TAPIR_DB_DSN/TAPIR_USER_ID, never hardcoded. main.go gains a minimal os.Args[1] dispatcher kept flat so Worker F's auth/run cases union cleanly at merge. Co-Authored-By: Claude Opus 4.8 (1M context) --- cmd/tapir/env.go | 35 +++++++++++ cmd/tapir/format_test.go | 130 +++++++++++++++++++++++++++++++++++++++ cmd/tapir/list.go | 105 +++++++++++++++++++++++++++++++ cmd/tapir/main.go | 41 ++++++++++-- cmd/tapir/show.go | 86 ++++++++++++++++++++++++++ 5 files changed, 392 insertions(+), 5 deletions(-) create mode 100644 cmd/tapir/env.go create mode 100644 cmd/tapir/format_test.go create mode 100644 cmd/tapir/list.go create mode 100644 cmd/tapir/show.go diff --git a/cmd/tapir/env.go b/cmd/tapir/env.go new file mode 100644 index 0000000..0fbb22d --- /dev/null +++ b/cmd/tapir/env.go @@ -0,0 +1,35 @@ +package main + +import ( + "context" + "fmt" + "os" + + "gitea.d-ma.be/mathias/tapir/internal/adapters/store" +) + +// Env var names for the read-only CLI. DSN and user id are never hardcoded — the +// store holds confidential summaries, so the caller supplies both. +const ( + envDSN = "TAPIR_DB_DSN" + envUserID = "TAPIR_USER_ID" +) + +// openStoreFromEnv reads TAPIR_DB_DSN and TAPIR_USER_ID and opens a store. The +// returned close func releases the pool; callers defer it. Both vars are +// required: an empty value is a usage error, not a silent default. +func openStoreFromEnv(ctx context.Context) (s *store.Store, userID string, closeFn func(), err error) { + dsn := os.Getenv(envDSN) + if dsn == "" { + return nil, "", nil, fmt.Errorf("%s is required", envDSN) + } + userID = os.Getenv(envUserID) + if userID == "" { + return nil, "", nil, fmt.Errorf("%s is required", envUserID) + } + s, err = store.New(ctx, dsn) + if err != nil { + return nil, "", nil, err + } + return s, userID, s.Close, nil +} diff --git a/cmd/tapir/format_test.go b/cmd/tapir/format_test.go new file mode 100644 index 0000000..c45de52 --- /dev/null +++ b/cmd/tapir/format_test.go @@ -0,0 +1,130 @@ +package main + +import ( + "strings" + "testing" + "time" + + "github.com/stretchr/testify/require" + + "gitea.d-ma.be/mathias/tapir/internal/adapters/store" +) + +func TestFormatListColumnsAndOrdering(t *testing.T) { + rows := []store.SummaryRow{ + { + VideoID: "vid-newer", + Title: "Newer Title", + Channel: "youtube", + AIProvider: "anthropic", + PublishedAt: time.Date(2026, 2, 1, 0, 0, 0, 0, time.UTC), + }, + { + VideoID: "vid-older", + Title: "Older Title", + Channel: "youtube", + AIProvider: "local", + FallbackUsed: true, + PublishedAt: time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC), + }, + } + + var b strings.Builder + require.NoError(t, formatList(&b, rows)) + out := b.String() + + require.Contains(t, out, "DATE") + require.Contains(t, out, "TITLE") + require.Contains(t, out, "CHANNEL") + require.Contains(t, out, "FALLBACK") + require.Contains(t, out, "Newer Title") + require.Contains(t, out, "2026-02-01") + require.Contains(t, out, "anthropic") + + // formatList preserves caller ordering (the store query sorts recent-first). + require.Less(t, strings.Index(out, "Newer Title"), strings.Index(out, "Older Title")) + + // fallback flag rendered per row. + older := lineContaining(t, out, "Older Title") + require.Contains(t, older, "yes") + newer := lineContaining(t, out, "Newer Title") + require.Contains(t, newer, "no") +} + +func TestFormatListFallsBackToVideoIDWhenNoTitle(t *testing.T) { + rows := []store.SummaryRow{{VideoID: "raw-video-id", CreatedAt: time.Date(2026, 3, 1, 0, 0, 0, 0, time.UTC)}} + + var b strings.Builder + require.NoError(t, formatList(&b, rows)) + out := b.String() + + require.Contains(t, out, "raw-video-id", "missing title falls back to video id") + require.Contains(t, out, "2026-03-01", "missing publish date falls back to created_at") +} + +func TestFormatListEmpty(t *testing.T) { + var b strings.Builder + require.NoError(t, formatList(&b, nil)) + require.Contains(t, b.String(), "no summaries yet") +} + +func TestFormatShowFullSummary(t *testing.T) { + row := store.SummaryRow{ + VideoID: "vid-1", + Title: "Deep Dive", + Channel: "youtube", + URL: "https://example/watch", + PublishedAt: time.Date(2026, 5, 2, 0, 0, 0, 0, time.UTC), + Summary: "The core argument.", + Highlights: []string{"first point", "second point"}, + Takeaways: []string{"do this"}, + AIProvider: "local", + AIModel: "qwen", + FallbackUsed: true, + } + + var b strings.Builder + require.NoError(t, formatShow(&b, row)) + out := b.String() + + require.Contains(t, out, "Deep Dive") + require.Contains(t, out, "2026-05-02") + require.Contains(t, out, "https://example/watch") + require.Contains(t, out, "vid-1") + require.Contains(t, out, "local/qwen") + require.Contains(t, out, "fallback") + require.Contains(t, out, "The core argument.") + require.Contains(t, out, "first point") + require.Contains(t, out, "second point") + require.Contains(t, out, "do this") +} + +func TestFormatShowOmitsEmptySections(t *testing.T) { + row := store.SummaryRow{ + VideoID: "vid-2", + Summary: "Body only.", + AIProvider: "local", + } + + var b strings.Builder + require.NoError(t, formatShow(&b, row)) + out := b.String() + + require.Contains(t, out, "vid-2", "no title -> falls back to video id") + require.Contains(t, out, "Body only.") + require.NotContains(t, out, "Highlights:") + require.NotContains(t, out, "Takeaways:") + require.NotContains(t, out, "URL:") + require.NotContains(t, out, "Channel:") +} + +func lineContaining(t *testing.T, text, needle string) string { + t.Helper() + for _, ln := range strings.Split(text, "\n") { + if strings.Contains(ln, needle) { + return ln + } + } + t.Fatalf("no line containing %q in:\n%s", needle, text) + return "" +} diff --git a/cmd/tapir/list.go b/cmd/tapir/list.go new file mode 100644 index 0000000..eef4120 --- /dev/null +++ b/cmd/tapir/list.go @@ -0,0 +1,105 @@ +package main + +import ( + "context" + "flag" + "fmt" + "io" + "os" + "strings" + "text/tabwriter" + + "gitea.d-ma.be/mathias/tapir/internal/adapters/store" +) + +// runList prints the user's stored summaries as a table, most recent first. +// Read-only. Usage: tapir list [-limit N]. +func runList(ctx context.Context, args []string) error { + fs := flag.NewFlagSet("list", flag.ContinueOnError) + limit := fs.Int("limit", 50, "maximum number of summaries to show") + if err := fs.Parse(args); err != nil { + return err + } + + s, userID, closeFn, err := openStoreFromEnv(ctx) + if err != nil { + return err + } + defer closeFn() + + rows, err := s.ListSummaries(ctx, userID, *limit) + if err != nil { + return err + } + return formatList(os.Stdout, rows) +} + +// formatList renders rows as an aligned table. Pure: no DB, no env — so the +// column layout and fallbacks are unit-testable without Postgres. Title falls +// back to the video id and Channel to "-" when the videos row is absent. +func formatList(w io.Writer, rows []store.SummaryRow) error { + if len(rows) == 0 { + _, err := fmt.Fprintln(w, "no summaries yet") + return err + } + + // Writes to the tabwriter buffer; any error surfaces on Flush. + tw := tabwriter.NewWriter(w, 0, 4, 2, ' ', 0) + _, _ = fmt.Fprintln(tw, "DATE\tTITLE\tCHANNEL\tAI\tFALLBACK") + for _, r := range rows { + _, _ = fmt.Fprintf(tw, "%s\t%s\t%s\t%s\t%s\n", + listDate(r), + truncate(displayTitle(r), 60), + orDash(r.Channel), + orDash(r.AIProvider), + fallbackFlag(r.FallbackUsed), + ) + } + return tw.Flush() +} + +// listDate prefers the video's publish date; absent that, when the summary was +// created. Date only — the table is for scanning, not timestamps. +func listDate(r store.SummaryRow) string { + t := r.PublishedAt + if t.IsZero() { + t = r.CreatedAt + } + if t.IsZero() { + return "-" + } + return t.Format("2006-01-02") +} + +// displayTitle falls back to the video id when no title is stored, so a summary +// produced before the run loop populated videos is still identifiable. +func displayTitle(r store.SummaryRow) string { + if strings.TrimSpace(r.Title) != "" { + return r.Title + } + return r.VideoID +} + +func fallbackFlag(used bool) string { + if used { + return "yes" + } + return "no" +} + +func orDash(s string) string { + if strings.TrimSpace(s) == "" { + return "-" + } + return s +} + +func truncate(s string, max int) string { + if len(s) <= max { + return s + } + if max <= 1 { + return s[:max] + } + return s[:max-1] + "…" +} diff --git a/cmd/tapir/main.go b/cmd/tapir/main.go index dd1e7a6..7e878a9 100644 --- a/cmd/tapir/main.go +++ b/cmd/tapir/main.go @@ -1,10 +1,41 @@ -// Command tapir is the service entrypoint. Scaffold: it identifies itself so -// the CI smoke test has something to grep for, and exits. Wiring the HTTP -// server, watcher, adapters, and config is part of the build. +// Command tapir is the service entrypoint and CLI. Subcommands are dispatched off +// os.Args[1]; each lives in its own file (list.go, show.go, …). The switch is kept +// deliberately flat so concurrently-added subcommands union cleanly. package main -import "fmt" +import ( + "context" + "fmt" + "os" +) func main() { - fmt.Println("tapir: scaffold — not yet implemented") + if len(os.Args) < 2 { + usage() + os.Exit(2) + } + + ctx := context.Background() + var err error + switch os.Args[1] { + case "list": + err = runList(ctx, os.Args[2:]) + case "show": + err = runShow(ctx, os.Args[2:]) + default: + usage() + os.Exit(2) + } + + if err != nil { + fmt.Fprintln(os.Stderr, "tapir:", err) + os.Exit(1) + } +} + +func usage() { + fmt.Fprintln(os.Stderr, "usage: tapir [args]") + fmt.Fprintln(os.Stderr, "commands:") + fmt.Fprintln(os.Stderr, " list [-limit N] list stored summaries, recent first") + fmt.Fprintln(os.Stderr, " show show one summary in full") } diff --git a/cmd/tapir/show.go b/cmd/tapir/show.go new file mode 100644 index 0000000..870e76b --- /dev/null +++ b/cmd/tapir/show.go @@ -0,0 +1,86 @@ +package main + +import ( + "context" + "errors" + "fmt" + "io" + "os" + "strings" + + "gitea.d-ma.be/mathias/tapir/internal/adapters/store" +) + +// runShow prints the full summary for one video: text, highlights, takeaways, +// and provenance. Read-only. Usage: tapir show . +func runShow(ctx context.Context, args []string) error { + if len(args) != 1 || strings.TrimSpace(args[0]) == "" { + return errors.New("usage: tapir show ") + } + videoID := args[0] + + s, userID, closeFn, err := openStoreFromEnv(ctx) + if err != nil { + return err + } + defer closeFn() + + row, err := s.GetSummaryByVideo(ctx, userID, videoID) + if err != nil { + if errors.Is(err, store.ErrNotFound) { + return fmt.Errorf("no summary for video %q", videoID) + } + return err + } + return formatShow(os.Stdout, *row) +} + +// formatShow renders a single summary for terminal reading. Pure: no DB, no env, +// so it is unit-testable. Empty sections are omitted rather than printed blank. +func formatShow(w io.Writer, r store.SummaryRow) error { + var b strings.Builder + + fmt.Fprintf(&b, "%s\n", displayTitle(r)) + if d := listDate(r); d != "-" { + fmt.Fprintf(&b, "Date: %s\n", d) + } + if strings.TrimSpace(r.Channel) != "" { + fmt.Fprintf(&b, "Channel: %s\n", r.Channel) + } + if strings.TrimSpace(r.URL) != "" { + fmt.Fprintf(&b, "URL: %s\n", r.URL) + } + fmt.Fprintf(&b, "Video ID: %s\n", r.VideoID) + fmt.Fprintf(&b, "AI: %s\n", aiProvenance(r)) + + fmt.Fprintf(&b, "\n%s\n", strings.TrimSpace(r.Summary)) + + writeList(&b, "Highlights", r.Highlights) + writeList(&b, "Takeaways", r.Takeaways) + + _, err := io.WriteString(w, b.String()) + return err +} + +// aiProvenance summarises which model produced the summary and whether the local +// stack fell back to a cloud provider — the Stage-0 "is local good enough?" signal. +func aiProvenance(r store.SummaryRow) string { + parts := orDash(r.AIProvider) + if strings.TrimSpace(r.AIModel) != "" { + parts += "/" + r.AIModel + } + if r.FallbackUsed { + parts += " (fallback)" + } + return parts +} + +func writeList(b *strings.Builder, heading string, items []string) { + if len(items) == 0 { + return + } + fmt.Fprintf(b, "\n%s:\n", heading) + for _, it := range items { + fmt.Fprintf(b, " - %s\n", it) + } +}