merge: CLI reader — tapir list/show (Worker E, agent/cli-reader)

This commit is contained in:
2026-06-02 21:28:54 +02:00
7 changed files with 708 additions and 5 deletions
+35
View File
@@ -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
}
+130
View File
@@ -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 ""
}
+105
View File
@@ -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] + "…"
}
+36 -5
View File
@@ -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 <command> [args]")
fmt.Fprintln(os.Stderr, "commands:")
fmt.Fprintln(os.Stderr, " list [-limit N] list stored summaries, recent first")
fmt.Fprintln(os.Stderr, " show <video-id> show one summary in full")
}
+86
View File
@@ -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 <video-id>.
func runShow(ctx context.Context, args []string) error {
if len(args) != 1 || strings.TrimSpace(args[0]) == "" {
return errors.New("usage: tapir show <video-id>")
}
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)
}
}
+168
View File
@@ -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
}
+148
View File
@@ -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")
}