Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a4aeb5efcd | ||
|
|
8c6c7ca947 | ||
|
|
25215cbcbd | ||
|
|
404f74c55c | ||
|
|
3014ee0d60 | ||
|
|
a269d4a200 | ||
|
|
bdbdce7de1 | ||
|
|
748d5eb0bd | ||
|
|
fa57ee0532 |
+24
-21
@@ -22,15 +22,11 @@ import (
|
|||||||
"os/signal"
|
"os/signal"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"gitea.d-ma.be/mathias/tapir/internal/adapters/llm"
|
|
||||||
"gitea.d-ma.be/mathias/tapir/internal/adapters/secrets"
|
"gitea.d-ma.be/mathias/tapir/internal/adapters/secrets"
|
||||||
"gitea.d-ma.be/mathias/tapir/internal/adapters/store"
|
"gitea.d-ma.be/mathias/tapir/internal/adapters/store"
|
||||||
"gitea.d-ma.be/mathias/tapir/internal/adapters/summarizer"
|
|
||||||
"gitea.d-ma.be/mathias/tapir/internal/adapters/youtube"
|
|
||||||
"gitea.d-ma.be/mathias/tapir/internal/auth"
|
"gitea.d-ma.be/mathias/tapir/internal/auth"
|
||||||
"gitea.d-ma.be/mathias/tapir/internal/config"
|
"gitea.d-ma.be/mathias/tapir/internal/config"
|
||||||
"gitea.d-ma.be/mathias/tapir/internal/runner"
|
"gitea.d-ma.be/mathias/tapir/internal/runner"
|
||||||
"gitea.d-ma.be/mathias/tapir/internal/usecase"
|
|
||||||
"gitea.d-ma.be/mathias/tapir/internal/web"
|
"gitea.d-ma.be/mathias/tapir/internal/web"
|
||||||
"gitea.d-ma.be/mathias/tapir/internal/web/oidc"
|
"gitea.d-ma.be/mathias/tapir/internal/web/oidc"
|
||||||
)
|
)
|
||||||
@@ -120,24 +116,16 @@ func cmdRun(ctx context.Context, log *slog.Logger) error {
|
|||||||
}
|
}
|
||||||
defer st.Close()
|
defer st.Close()
|
||||||
|
|
||||||
secretStore := secrets.NewFileStore(cfg.SecretsFile)
|
// Same wiring the web serve path uses (buildProcessor). ValidateForRun above
|
||||||
src := youtube.New(youtube.Config{
|
// already required the engine's inputs, so a nil here is a genuine config gap.
|
||||||
ClientID: cfg.YTClientID,
|
engine, err := buildProcessor(cfg, st)
|
||||||
ClientSecret: cfg.YTClientSecret,
|
if err != nil {
|
||||||
TokenSecretRef: cfg.YTTokenRef,
|
return err
|
||||||
PreferredLanguages: []string{"en"},
|
|
||||||
}, secretStore)
|
|
||||||
|
|
||||||
// Local Primary only; no BYO fallback for the demo (fallback nil).
|
|
||||||
primary := summarizer.Endpoint{
|
|
||||||
Client: llm.New(cfg.GatewayURL, cfg.GatewayKey, cfg.SummarizerModel, cfg.SummarizerTimeout),
|
|
||||||
Provider: "local",
|
|
||||||
Model: cfg.SummarizerModel,
|
|
||||||
}
|
}
|
||||||
sum := summarizer.New(primary, nil)
|
if engine == nil {
|
||||||
|
return fmt.Errorf("run: incomplete summarization config (gateway, youtube credentials, secrets file)")
|
||||||
engine := usecase.NewEngine(src, sum, st)
|
}
|
||||||
r := runner.New(src, st, engine, cfg.UserID, log)
|
r := runner.New(engine.Source, st, engine, cfg.UserID, log)
|
||||||
|
|
||||||
log.Info("starting run", "user", cfg.UserID, "model", cfg.SummarizerModel,
|
log.Info("starting run", "user", cfg.UserID, "model", cfg.SummarizerModel,
|
||||||
"gateway", cfg.GatewayURL, "poll_interval", cfg.PollInterval)
|
"gateway", cfg.GatewayURL, "poll_interval", cfg.PollInterval)
|
||||||
@@ -204,6 +192,21 @@ func cmdServe(ctx context.Context, log *slog.Logger) error {
|
|||||||
log.Info("web youtube connect enabled", "redirect", cfg.YTConnectRedirectURL)
|
log.Info("web youtube connect enabled", "redirect", cfg.YTConnectRedirectURL)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Immediate summarization for the web "Summarize" button. When the engine can
|
||||||
|
// be built (gateway + YouTube credentials + secrets present), a click runs the
|
||||||
|
// summary now in the background; otherwise the button stays queue-only and the
|
||||||
|
// next `tapir run` does the work (buildProcessor returns nil — never an error).
|
||||||
|
engine, err := buildProcessor(cfg, st)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if engine != nil {
|
||||||
|
app.Processor = &engineProcessor{engine: engine, store: st}
|
||||||
|
log.Info("web immediate summarization enabled", "model", cfg.SummarizerModel)
|
||||||
|
} else {
|
||||||
|
log.Info("web summarization is queue-only (incomplete engine config)")
|
||||||
|
}
|
||||||
|
|
||||||
srv := &http.Server{
|
srv := &http.Server{
|
||||||
Addr: cfg.HTTPAddr,
|
Addr: cfg.HTTPAddr,
|
||||||
Handler: app.Router(),
|
Handler: app.Router(),
|
||||||
|
|||||||
@@ -0,0 +1,86 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"gitea.d-ma.be/mathias/tapir/internal/adapters/llm"
|
||||||
|
"gitea.d-ma.be/mathias/tapir/internal/adapters/secrets"
|
||||||
|
"gitea.d-ma.be/mathias/tapir/internal/adapters/store"
|
||||||
|
"gitea.d-ma.be/mathias/tapir/internal/adapters/summarizer"
|
||||||
|
"gitea.d-ma.be/mathias/tapir/internal/adapters/youtube"
|
||||||
|
"gitea.d-ma.be/mathias/tapir/internal/config"
|
||||||
|
"gitea.d-ma.be/mathias/tapir/internal/domain"
|
||||||
|
"gitea.d-ma.be/mathias/tapir/internal/usecase"
|
||||||
|
)
|
||||||
|
|
||||||
|
// buildProcessor wires the summarization engine — YouTube source (captions-first),
|
||||||
|
// AI-router summarizer, store sink — shared by `tapir run` and the web
|
||||||
|
// "Summarize now" path so the wiring lives in one place. It returns (nil, nil) —
|
||||||
|
// not an error — when the config cannot support live summarization (no gateway
|
||||||
|
// URL, no YouTube client credentials, or no secrets file). That nil is the
|
||||||
|
// queue-only fallback: the web UI keeps working (the button just queues) and
|
||||||
|
// `tapir run` reports the gap via its own ValidateForRun. Missing engine config
|
||||||
|
// is never an error here.
|
||||||
|
func buildProcessor(cfg config.Config, st *store.Store) (*usecase.Engine, error) {
|
||||||
|
if cfg.GatewayURL == "" || cfg.YTClientID == "" || cfg.YTClientSecret == "" || cfg.SecretsFile == "" {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
secretStore := secrets.NewFileStore(cfg.SecretsFile)
|
||||||
|
src := youtube.New(youtube.Config{
|
||||||
|
ClientID: cfg.YTClientID,
|
||||||
|
ClientSecret: cfg.YTClientSecret,
|
||||||
|
TokenSecretRef: cfg.YTTokenRef,
|
||||||
|
PreferredLanguages: []string{"en"},
|
||||||
|
}, secretStore)
|
||||||
|
|
||||||
|
// Local Primary only; no BYO fallback for the demo (fallback nil).
|
||||||
|
primary := summarizer.Endpoint{
|
||||||
|
Client: llm.New(cfg.GatewayURL, cfg.GatewayKey, cfg.SummarizerModel, cfg.SummarizerTimeout),
|
||||||
|
Provider: "local",
|
||||||
|
Model: cfg.SummarizerModel,
|
||||||
|
}
|
||||||
|
sum := summarizer.New(primary, nil)
|
||||||
|
|
||||||
|
return usecase.NewEngine(src, sum, st), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// engineProcessor adapts the engine (which works in terms of a domain.Video) to
|
||||||
|
// the web.Processor port (which works in terms of a stored video id): it loads the
|
||||||
|
// video row, runs the engine, and — on a produced summary — clears the manual
|
||||||
|
// queue flag, mirroring the runner so the video is not re-summarized on the next
|
||||||
|
// `tapir run` and the UI drops the "Queued" chip. A skip (no transcript) leaves
|
||||||
|
// the flag set so a later run can retry.
|
||||||
|
type engineProcessor struct {
|
||||||
|
engine *usecase.Engine
|
||||||
|
store *store.Store
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *engineProcessor) ProcessVideo(ctx context.Context, userID, videoID string) error {
|
||||||
|
row, err := p.store.GetVideoRow(ctx, userID, videoID)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("load video %q: %w", videoID, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
v := domain.Video{
|
||||||
|
ID: row.VideoID,
|
||||||
|
UserID: userID,
|
||||||
|
Provider: domain.Provider(row.Channel),
|
||||||
|
ProviderVideoID: row.ProviderVideoID,
|
||||||
|
Title: row.Title,
|
||||||
|
URL: row.URL,
|
||||||
|
PublishedAt: row.PublishedAt,
|
||||||
|
}
|
||||||
|
|
||||||
|
res, err := p.engine.ProcessNewVideo(ctx, v)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("process video %q: %w", videoID, err)
|
||||||
|
}
|
||||||
|
if res.Summary != nil {
|
||||||
|
if err := p.store.ClearSummarizeRequested(ctx, userID, videoID); err != nil {
|
||||||
|
return fmt.Errorf("clear summarize flag %q: %w", videoID, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"gitea.d-ma.be/mathias/tapir/internal/config"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestBuildProcessorNilOnIncompleteConfig asserts the queue-only fallback: when a
|
||||||
|
// required input is missing, buildProcessor returns (nil, nil) — never an error —
|
||||||
|
// so the web UI degrades to queue-only instead of failing to start.
|
||||||
|
func TestBuildProcessorNilOnIncompleteConfig(t *testing.T) {
|
||||||
|
// A complete config (the fields buildProcessor gates on). The store is nil:
|
||||||
|
// buildProcessor must not touch it on the incomplete paths, and the complete
|
||||||
|
// path only stores the pointer (no connection), so nil is fine for this test.
|
||||||
|
complete := config.Config{
|
||||||
|
GatewayURL: "http://gw/v1",
|
||||||
|
YTClientID: "id",
|
||||||
|
YTClientSecret: "secret",
|
||||||
|
SecretsFile: "/tmp/secrets.json",
|
||||||
|
}
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
mutate func(config.Config) config.Config
|
||||||
|
wantNil bool
|
||||||
|
}{
|
||||||
|
{"complete", func(c config.Config) config.Config { return c }, false},
|
||||||
|
{"no gateway url", func(c config.Config) config.Config { c.GatewayURL = ""; return c }, true},
|
||||||
|
{"no yt client id", func(c config.Config) config.Config { c.YTClientID = ""; return c }, true},
|
||||||
|
{"no yt client secret", func(c config.Config) config.Config { c.YTClientSecret = ""; return c }, true},
|
||||||
|
{"no secrets file", func(c config.Config) config.Config { c.SecretsFile = ""; return c }, true},
|
||||||
|
{"empty config", func(config.Config) config.Config { return config.Config{} }, true},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
engine, err := buildProcessor(tt.mutate(complete), nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("buildProcessor returned an error, want nil: %v", err)
|
||||||
|
}
|
||||||
|
if (engine == nil) != tt.wantNil {
|
||||||
|
t.Fatalf("engine == nil is %v, want %v", engine == nil, tt.wantNil)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -162,3 +162,32 @@ allow per-provider when a user connects one.
|
|||||||
|
|
||||||
_Snapshot date 2026-06-02. Items marked **confirm** were not verified to a pinned source at
|
_Snapshot date 2026-06-02. Items marked **confirm** were not verified to a pinned source at
|
||||||
snapshot time — check brain or the live cluster before depending on them._
|
snapshot time — check brain or the live cluster before depending on them._
|
||||||
|
|
||||||
|
## Stage 1 — multi-user facts (verified 2026-06-03)
|
||||||
|
|
||||||
|
### Postgres RLS (ADR-012)
|
||||||
|
- **The deployed DSN MUST connect as a non-superuser, non-BYPASSRLS role.** The
|
||||||
|
app uses the `tapir` role (table owner, non-superuser). `FORCE ROW LEVEL
|
||||||
|
SECURITY` is applied on all user-owned tables; a superuser DSN silently bypasses
|
||||||
|
FORCE and isolation is dead in prod. Verify: `SELECT rolsuper FROM pg_roles
|
||||||
|
WHERE rolname = 'tapir'` must return `f`.
|
||||||
|
- Scoping is via `set_config('tapir.current_user_id', $userID, true)` (transaction-
|
||||||
|
local, auto-resets on commit — never leaks across a pooled connection).
|
||||||
|
|
||||||
|
### Per-user YouTube token persistence
|
||||||
|
- Stage-1 uses the **file-backed SecretStore** at `TAPIR_SECRETS_FILE=/data/secrets.json`
|
||||||
|
mounted from a **PVC** (`tapir-secrets`, 64Mi, RWO). Tokens survive pod restarts.
|
||||||
|
Upgrading to an ESO-backed per-user SecretStore is backlog (infra#86).
|
||||||
|
- Per-user token ref scheme: `youtube/<userID>/refresh_token` (Worker C, ADR-006).
|
||||||
|
The Stage-0 single ref `youtube/refresh_token` is no longer used by `serve`; it
|
||||||
|
remains valid for the CLI `tapir run` (single-user, host-side).
|
||||||
|
|
||||||
|
### Web YouTube connect
|
||||||
|
- Redirect URI (registered in Google OAuth client, type Web): `https://tapir.d-ma.be/oauth/youtube/callback`.
|
||||||
|
- Config env: `TAPIR_YT_CONNECT_REDIRECT_URL=https://tapir.d-ma.be/oauth/youtube/callback`.
|
||||||
|
`TAPIR_YT_CLIENT_ID` / `TAPIR_YT_CLIENT_SECRET` from the Web client (not the Desktop client used for the CLI).
|
||||||
|
|
||||||
|
### Identity resolution
|
||||||
|
- `user_identities(dex_subject → user_id)` table is **intentionally NOT RLS-enabled**
|
||||||
|
(it's auth plumbing, holds no user data; data isolation is on the user-owned tables).
|
||||||
|
All data access after subject resolution goes through `withUser`.
|
||||||
|
|||||||
@@ -0,0 +1,2 @@
|
|||||||
|
ALTER TABLE videos DROP COLUMN IF EXISTS summarize_requested;
|
||||||
|
ALTER TABLE users DROP COLUMN IF EXISTS auto_summarize;
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
-- Migration 006: summarization mode (per-user auto/manual + per-video queue).
|
||||||
|
--
|
||||||
|
-- auto_summarize is a per-user setting (not a global one): multi-user ready per
|
||||||
|
-- ADR-012. FALSE default makes MANUAL the out-of-the-box behavior — `tapir run`
|
||||||
|
-- discovers new videos but only summarizes the ones the user explicitly queued.
|
||||||
|
--
|
||||||
|
-- summarize_requested is the per-video manual queue flag. The web "Summarize"
|
||||||
|
-- button sets it TRUE; the next `tapir run` picks it up, summarizes, and clears
|
||||||
|
-- it back to FALSE. In auto mode it is unused.
|
||||||
|
--
|
||||||
|
-- No RLS policy changes needed: both columns are added to tables that already
|
||||||
|
-- carry user_id and have ENABLE + FORCE ROW LEVEL SECURITY (migration 003). A new
|
||||||
|
-- column on an RLS-protected table inherits that protection automatically — the
|
||||||
|
-- existing users_isolation / videos_isolation policies gate every row, so these
|
||||||
|
-- columns are only ever readable/writable for the row's own user.
|
||||||
|
ALTER TABLE users ADD COLUMN auto_summarize BOOLEAN NOT NULL DEFAULT FALSE;
|
||||||
|
ALTER TABLE videos ADD COLUMN summarize_requested BOOLEAN NOT NULL DEFAULT FALSE;
|
||||||
@@ -39,6 +39,16 @@ type SummaryRow struct {
|
|||||||
FallbackUsed bool
|
FallbackUsed bool
|
||||||
CreatedAt time.Time
|
CreatedAt time.Time
|
||||||
Actions []string // current active actions for this video; nil when none
|
Actions []string // current active actions for this video; nil when none
|
||||||
|
|
||||||
|
// Summarized reports whether a summary exists for this video. The summary-only
|
||||||
|
// reads (ListSummaries/GetSummaryByVideo) always yield true; the all-videos
|
||||||
|
// read (ListVideos) yields false for a discovered-but-unsummarized video, whose
|
||||||
|
// Summary/Highlights/AIProvider fields are then empty.
|
||||||
|
Summarized bool
|
||||||
|
// SummarizeRequested reflects videos.summarize_requested: the manual queue flag
|
||||||
|
// set by the web "Summarize" button and cleared by the next `tapir run`. Only
|
||||||
|
// populated by ListVideos/GetVideoRow (summary-only reads leave it false).
|
||||||
|
SummarizeRequested bool
|
||||||
}
|
}
|
||||||
|
|
||||||
// selectSummary is the shared projection for both reads. videos is LEFT JOINed
|
// selectSummary is the shared projection for both reads. videos is LEFT JOINed
|
||||||
@@ -101,6 +111,156 @@ func (s *Store) ListSummaries(ctx context.Context, userID string, limit int) ([]
|
|||||||
return out, nil
|
return out, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// selectVideo is the all-videos projection: it drives from the videos table and
|
||||||
|
// LEFT JOINs the (at most one) summary, so a discovered-but-unsummarized video
|
||||||
|
// still appears with empty summary fields. The column order mirrors selectSummary
|
||||||
|
// for the shared fields, then appends summarized + summarize_requested. created_at
|
||||||
|
// falls back to the video's seen_at when there is no summary, so the read-side row
|
||||||
|
// always carries a sortable timestamp.
|
||||||
|
const selectVideo = `
|
||||||
|
SELECT v.id,
|
||||||
|
v.provider_video_id,
|
||||||
|
COALESCE(v.title, ''),
|
||||||
|
v.provider,
|
||||||
|
COALESCE(v.url, ''),
|
||||||
|
v.published_at,
|
||||||
|
COALESCE(s.summary, ''),
|
||||||
|
s.highlights,
|
||||||
|
s.takeaways,
|
||||||
|
COALESCE(s.ai_provider, ''),
|
||||||
|
COALESCE(s.ai_model, ''),
|
||||||
|
COALESCE(s.fallback_used, FALSE),
|
||||||
|
COALESCE(s.created_at, v.seen_at),
|
||||||
|
(s.id IS NOT NULL) AS summarized,
|
||||||
|
v.summarize_requested
|
||||||
|
FROM videos v
|
||||||
|
LEFT JOIN summaries s ON s.video_id = v.id AND s.user_id = v.user_id`
|
||||||
|
|
||||||
|
// ListVideos returns ALL of the user's videos — summarized and not — most recent
|
||||||
|
// first by seen_at, capped at limit (non-positive defaults to 50). Unsummarized
|
||||||
|
// videos come back with Summarized=false and empty summary fields, so the list
|
||||||
|
// view can render them with a "Summarize" affordance. Scoped by user_id.
|
||||||
|
func (s *Store) ListVideos(ctx context.Context, userID string, limit int) ([]SummaryRow, error) {
|
||||||
|
if limit <= 0 {
|
||||||
|
limit = 50
|
||||||
|
}
|
||||||
|
var out []SummaryRow
|
||||||
|
if err := s.withUser(ctx, userID, func(tx pgx.Tx) error {
|
||||||
|
rows, err := tx.Query(ctx,
|
||||||
|
selectVideo+`
|
||||||
|
WHERE v.user_id = $1
|
||||||
|
ORDER BY v.seen_at DESC
|
||||||
|
LIMIT $2`,
|
||||||
|
userID, limit)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("store: list videos: %w", err)
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
for rows.Next() {
|
||||||
|
row, err := scanVideoRow(rows)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
out = append(out, row)
|
||||||
|
}
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
return fmt.Errorf("store: iterate videos: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if err := s.attachActions(ctx, userID, out); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetVideoRow returns a single video row (summarized or not) for (userID,
|
||||||
|
// videoID), used to re-render one card after queuing it. Returns ErrNotFound when
|
||||||
|
// the user has no such video. Scoped by user_id.
|
||||||
|
func (s *Store) GetVideoRow(ctx context.Context, userID, videoID string) (*SummaryRow, error) {
|
||||||
|
var (
|
||||||
|
row SummaryRow
|
||||||
|
found bool
|
||||||
|
)
|
||||||
|
if err := s.withUser(ctx, userID, func(tx pgx.Tx) error {
|
||||||
|
rows, err := tx.Query(ctx,
|
||||||
|
selectVideo+`
|
||||||
|
WHERE v.user_id = $1 AND v.id = $2`,
|
||||||
|
userID, videoID)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("store: get video: %w", err)
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
if !rows.Next() {
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
return fmt.Errorf("store: get video: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
row, err = scanVideoRow(rows)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
found = true
|
||||||
|
return nil
|
||||||
|
}); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if !found {
|
||||||
|
return nil, ErrNotFound
|
||||||
|
}
|
||||||
|
holder := []SummaryRow{row}
|
||||||
|
if err := s.attachActions(ctx, userID, holder); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &holder[0], nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// scanVideoRow reads one row in the selectVideo column order. published_at is
|
||||||
|
// nullable so it scans through a pointer.
|
||||||
|
func scanVideoRow(rows pgx.Row) (SummaryRow, error) {
|
||||||
|
var (
|
||||||
|
row SummaryRow
|
||||||
|
highlights []byte
|
||||||
|
takeaways []byte
|
||||||
|
publishedAt *time.Time
|
||||||
|
)
|
||||||
|
if err := rows.Scan(
|
||||||
|
&row.VideoID,
|
||||||
|
&row.ProviderVideoID,
|
||||||
|
&row.Title,
|
||||||
|
&row.Channel,
|
||||||
|
&row.URL,
|
||||||
|
&publishedAt,
|
||||||
|
&row.Summary,
|
||||||
|
&highlights,
|
||||||
|
&takeaways,
|
||||||
|
&row.AIProvider,
|
||||||
|
&row.AIModel,
|
||||||
|
&row.FallbackUsed,
|
||||||
|
&row.CreatedAt,
|
||||||
|
&row.Summarized,
|
||||||
|
&row.SummarizeRequested,
|
||||||
|
); err != nil {
|
||||||
|
return SummaryRow{}, fmt.Errorf("store: scan video: %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
|
||||||
|
}
|
||||||
|
|
||||||
// GetSummaryByVideo returns the full summary for (userID, videoID), including
|
// GetSummaryByVideo returns the full summary for (userID, videoID), including
|
||||||
// highlights and takeaways. Returns ErrNotFound when the user has no such
|
// highlights and takeaways. Returns ErrNotFound when the user has no such
|
||||||
// summary. Scoped by user_id.
|
// summary. Scoped by user_id.
|
||||||
|
|||||||
@@ -181,6 +181,7 @@ func TestRLSEnforcesPerUserIsolation(t *testing.T) {
|
|||||||
}{
|
}{
|
||||||
{"update users", `UPDATE users SET display_name = 'hacked' WHERE id = $1`, b.userID},
|
{"update users", `UPDATE users SET display_name = 'hacked' WHERE id = $1`, b.userID},
|
||||||
{"update videos", `UPDATE videos SET title = 'hacked' WHERE user_id = $1`, b.userID},
|
{"update videos", `UPDATE videos SET title = 'hacked' WHERE user_id = $1`, b.userID},
|
||||||
|
{"queue videos summarize", `UPDATE videos SET summarize_requested = TRUE WHERE id = $1`, b.videoID},
|
||||||
{"update transcripts", `UPDATE transcripts SET content = 'hacked' WHERE user_id = $1`, b.userID},
|
{"update transcripts", `UPDATE transcripts SET content = 'hacked' WHERE user_id = $1`, b.userID},
|
||||||
{"update summaries", `UPDATE summaries SET summary = 'hacked' WHERE user_id = $1`, b.userID},
|
{"update summaries", `UPDATE summaries SET summary = 'hacked' WHERE user_id = $1`, b.userID},
|
||||||
{"update summary_actions", `UPDATE summary_actions SET action = 'skipped' WHERE user_id = $1`, b.userID},
|
{"update summary_actions", `UPDATE summary_actions SET action = 'skipped' WHERE user_id = $1`, b.userID},
|
||||||
@@ -218,5 +219,10 @@ func TestRLSEnforcesPerUserIsolation(t *testing.T) {
|
|||||||
require.Equal(t, 1, bDeliveries, "A's DELETE must not have removed B's delivery")
|
require.Equal(t, 1, bDeliveries, "A's DELETE must not have removed B's delivery")
|
||||||
require.Equal(t, 1, bConnections, "A's writes must not have touched B's connection")
|
require.Equal(t, 1, bConnections, "A's writes must not have touched B's connection")
|
||||||
|
|
||||||
|
var bRequested bool
|
||||||
|
require.NoError(t, super.QueryRow(ctx,
|
||||||
|
`SELECT summarize_requested FROM videos WHERE user_id = $1`, b.userID).Scan(&bRequested))
|
||||||
|
require.False(t, bRequested, "A scoped must not have queued B's video for summarization")
|
||||||
|
|
||||||
_ = a // a's ids are seeded for the symmetric read assertions above
|
_ = a // a's ids are seeded for the symmetric read assertions above
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,112 @@
|
|||||||
|
package store
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"github.com/jackc/pgx/v5"
|
||||||
|
)
|
||||||
|
|
||||||
|
// SetAutoSummarize sets the user's auto/manual summarization mode. TRUE =
|
||||||
|
// automatic (every new video is summarized by `tapir run`); FALSE = manual (the
|
||||||
|
// user queues videos individually). Per-user, not global (ADR-012). Scoped via
|
||||||
|
// withUser, so RLS confines the UPDATE to the calling user's own row.
|
||||||
|
func (s *Store) SetAutoSummarize(ctx context.Context, userID string, enabled bool) error {
|
||||||
|
return s.withUser(ctx, userID, func(tx pgx.Tx) error {
|
||||||
|
// Ensure the row exists (FK/identity target) before the UPDATE — mirrors
|
||||||
|
// the Deliver/UpsertVideo paths, so toggling mode works even before the
|
||||||
|
// first summary lands.
|
||||||
|
if _, err := tx.Exec(ctx,
|
||||||
|
`INSERT INTO users (id) VALUES ($1) ON CONFLICT (id) DO NOTHING`,
|
||||||
|
userID); err != nil {
|
||||||
|
return fmt.Errorf("store: upsert user: %w", err)
|
||||||
|
}
|
||||||
|
if _, err := tx.Exec(ctx,
|
||||||
|
`UPDATE users SET auto_summarize = $1 WHERE id = $2`, enabled, userID); err != nil {
|
||||||
|
return fmt.Errorf("store: set auto summarize: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetAutoSummarize reports the user's summarization mode (TRUE = automatic). An
|
||||||
|
// absent user row reads as FALSE (manual), the safe default. Scoped via withUser.
|
||||||
|
func (s *Store) GetAutoSummarize(ctx context.Context, userID string) (bool, error) {
|
||||||
|
var enabled bool
|
||||||
|
if err := s.withUser(ctx, userID, func(tx pgx.Tx) error {
|
||||||
|
err := tx.QueryRow(ctx,
|
||||||
|
`SELECT auto_summarize FROM users WHERE id = $1`, userID).Scan(&enabled)
|
||||||
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
|
enabled = false
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}); err != nil {
|
||||||
|
return false, fmt.Errorf("store: get auto summarize: %w", err)
|
||||||
|
}
|
||||||
|
return enabled, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// RequestSummarize queues a single video for manual summarization by setting its
|
||||||
|
// summarize_requested flag. The next `tapir run` picks it up and clears the flag.
|
||||||
|
// Returns ErrNotFound when the video does not exist or is not owned by the user
|
||||||
|
// (RLS hides another user's row, so the UPDATE matches zero rows). Scoped via
|
||||||
|
// withUser.
|
||||||
|
func (s *Store) RequestSummarize(ctx context.Context, userID, videoID string) error {
|
||||||
|
return s.withUser(ctx, userID, func(tx pgx.Tx) error {
|
||||||
|
ct, err := tx.Exec(ctx,
|
||||||
|
`UPDATE videos SET summarize_requested = TRUE WHERE id = $1`, videoID)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("store: request summarize: %w", err)
|
||||||
|
}
|
||||||
|
if ct.RowsAffected() == 0 {
|
||||||
|
return ErrNotFound
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// RequestedVideoIDs returns the set of the user's video ids currently flagged for
|
||||||
|
// manual summarization. The run loop loads it once per pass (mirroring
|
||||||
|
// SeenVideoIDs) to decide which discovered videos to process in manual mode.
|
||||||
|
// Scoped by user_id.
|
||||||
|
func (s *Store) RequestedVideoIDs(ctx context.Context, userID string) (map[string]bool, error) {
|
||||||
|
requested := make(map[string]bool)
|
||||||
|
if err := s.withUser(ctx, userID, func(tx pgx.Tx) error {
|
||||||
|
rows, err := tx.Query(ctx,
|
||||||
|
`SELECT id FROM videos WHERE user_id = $1 AND summarize_requested = TRUE`, userID)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("store: requested video ids: %w", err)
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
for rows.Next() {
|
||||||
|
var id string
|
||||||
|
if err := rows.Scan(&id); err != nil {
|
||||||
|
return fmt.Errorf("store: scan requested id: %w", err)
|
||||||
|
}
|
||||||
|
requested[id] = true
|
||||||
|
}
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
return fmt.Errorf("store: iterate requested ids: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return requested, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ClearSummarizeRequested resets a video's manual queue flag, called by the run
|
||||||
|
// loop after a queued video is successfully summarized so it is not re-processed
|
||||||
|
// and the list view drops the "Queued" chip. Scoped via withUser.
|
||||||
|
func (s *Store) ClearSummarizeRequested(ctx context.Context, userID, videoID string) error {
|
||||||
|
return s.withUser(ctx, userID, func(tx pgx.Tx) error {
|
||||||
|
if _, err := tx.Exec(ctx,
|
||||||
|
`UPDATE videos SET summarize_requested = FALSE WHERE id = $1`, videoID); err != nil {
|
||||||
|
return fmt.Errorf("store: clear summarize requested: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,126 @@
|
|||||||
|
package store_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/jackc/pgx/v5/pgxpool"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
|
||||||
|
"gitea.d-ma.be/mathias/tapir/internal/adapters/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
// seedBareVideo inserts a videos row with no summary, so the all-videos read and
|
||||||
|
// the manual-queue flag can be exercised without a delivered summary.
|
||||||
|
func seedBareVideo(t *testing.T, p *pgxpool.Pool, userID, videoID, title string) {
|
||||||
|
t.Helper()
|
||||||
|
_, err := p.Exec(context.Background(),
|
||||||
|
`INSERT INTO users (id) VALUES ($1) ON CONFLICT (id) DO NOTHING`, userID)
|
||||||
|
require.NoError(t, err)
|
||||||
|
_, err = p.Exec(context.Background(),
|
||||||
|
`INSERT INTO videos (id, user_id, provider, provider_video_id, title)
|
||||||
|
VALUES ($1, $2, 'youtube', $3, $4)`,
|
||||||
|
videoID, userID, "pv-"+videoID[:8], title)
|
||||||
|
require.NoError(t, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAutoSummarizeRoundTripDefaultsFalse(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
s := newStore(t)
|
||||||
|
resetDB(t, rawPool(t))
|
||||||
|
|
||||||
|
// Unknown / fresh user defaults to manual (false).
|
||||||
|
got, err := s.GetAutoSummarize(ctx, userA)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.False(t, got, "default mode is manual")
|
||||||
|
|
||||||
|
require.NoError(t, s.SetAutoSummarize(ctx, userA, true))
|
||||||
|
got, err = s.GetAutoSummarize(ctx, userA)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.True(t, got, "set to automatic round-trips")
|
||||||
|
|
||||||
|
require.NoError(t, s.SetAutoSummarize(ctx, userA, false))
|
||||||
|
got, err = s.GetAutoSummarize(ctx, userA)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.False(t, got, "set back to manual round-trips")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRequestSummarizeSetsFlag(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
s := newStore(t)
|
||||||
|
p := rawPool(t)
|
||||||
|
resetDB(t, p)
|
||||||
|
seedBareVideo(t, p, userA, videoX, "X Title")
|
||||||
|
|
||||||
|
require.NoError(t, s.RequestSummarize(ctx, userA, videoX))
|
||||||
|
|
||||||
|
requested, err := s.RequestedVideoIDs(ctx, userA)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Equal(t, map[string]bool{videoX: true}, requested)
|
||||||
|
|
||||||
|
// Clearing drops it from the requested set.
|
||||||
|
require.NoError(t, s.ClearSummarizeRequested(ctx, userA, videoX))
|
||||||
|
requested, err = s.RequestedVideoIDs(ctx, userA)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Empty(t, requested)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRequestSummarizeMissingVideo(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
s := newStore(t)
|
||||||
|
resetDB(t, rawPool(t))
|
||||||
|
|
||||||
|
err := s.RequestSummarize(ctx, userA, videoX)
|
||||||
|
require.ErrorIs(t, err, store.ErrNotFound, "queuing a non-existent video reports not found")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestListVideosReturnsSummarizedAndUnsummarized(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
s := newStore(t)
|
||||||
|
p := rawPool(t)
|
||||||
|
resetDB(t, p)
|
||||||
|
|
||||||
|
// videoX: discovered AND summarized. videoY: discovered, not yet summarized.
|
||||||
|
seedBareVideo(t, p, userA, videoX, "Summarized One")
|
||||||
|
seedBareVideo(t, p, userA, videoY, "Pending One")
|
||||||
|
require.NoError(t, s.Deliver(ctx, summary(userA, videoX, "body x")))
|
||||||
|
require.NoError(t, s.RequestSummarize(ctx, userA, videoY))
|
||||||
|
|
||||||
|
rows, err := s.ListVideos(ctx, userA, 50)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Len(t, rows, 2, "both summarized and unsummarized videos are listed")
|
||||||
|
|
||||||
|
byID := map[string]store.SummaryRow{}
|
||||||
|
for _, r := range rows {
|
||||||
|
byID[r.VideoID] = r
|
||||||
|
}
|
||||||
|
|
||||||
|
require.True(t, byID[videoX].Summarized)
|
||||||
|
require.Equal(t, "body x", byID[videoX].Summary)
|
||||||
|
require.False(t, byID[videoX].SummarizeRequested)
|
||||||
|
|
||||||
|
require.False(t, byID[videoY].Summarized, "no summary -> Summarized false")
|
||||||
|
require.Empty(t, byID[videoY].Summary, "unsummarized row has empty summary")
|
||||||
|
require.True(t, byID[videoY].SummarizeRequested, "queued video carries the flag")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestListVideosIsUserScoped(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
s := newStore(t)
|
||||||
|
p := rawPool(t)
|
||||||
|
resetDB(t, p)
|
||||||
|
seedBareVideo(t, p, userA, videoX, "A only")
|
||||||
|
|
||||||
|
rows, err := s.ListVideos(ctx, userB, 50)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Empty(t, rows, "user B must not see user A's videos")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetVideoRowNotFound(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
s := newStore(t)
|
||||||
|
resetDB(t, rawPool(t))
|
||||||
|
|
||||||
|
_, err := s.GetVideoRow(ctx, userA, videoX)
|
||||||
|
require.ErrorIs(t, err, store.ErrNotFound)
|
||||||
|
}
|
||||||
@@ -23,10 +23,15 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
// VideoStore is the durable persistence the run loop needs: assign a stable id +
|
// 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 {
|
type VideoStore interface {
|
||||||
UpsertVideo(ctx context.Context, v domain.Video) (string, error)
|
UpsertVideo(ctx context.Context, v domain.Video) (string, error)
|
||||||
SeenVideoIDs(ctx context.Context, userID string) (map[string]bool, 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
|
// Processor runs the core use case for a single video. *usecase.Engine
|
||||||
@@ -59,6 +64,7 @@ type Stats struct {
|
|||||||
Summarized int
|
Summarized int
|
||||||
SkippedSeen int
|
SkippedSeen int
|
||||||
SkippedNoText int
|
SkippedNoText int
|
||||||
|
SkippedManual int // discovered but not queued, in manual mode
|
||||||
Errors int
|
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)
|
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)
|
subs, err := r.src.ListSubscriptions(ctx, r.userID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return stats, fmt.Errorf("runner: list subscriptions: %w", err)
|
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
|
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 {
|
if fetchDelay > 0 {
|
||||||
time.Sleep(fetchDelay)
|
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)
|
r.log.Info("skipped video (no transcript)", "video", v.ProviderVideoID, "title", v.Title)
|
||||||
case res.Summary != nil:
|
case res.Summary != nil:
|
||||||
stats.Summarized++
|
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,
|
r.log.Info("summarized video", "video", v.ProviderVideoID, "title", v.Title,
|
||||||
"provider", res.Summary.AIProvider, "model", res.Summary.AIModel)
|
"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",
|
r.log.Info("run pass complete",
|
||||||
"candidates", stats.Candidates, "summarized", stats.Summarized,
|
"candidates", stats.Candidates, "summarized", stats.Summarized,
|
||||||
"skipped_seen", stats.SkippedSeen, "skipped_no_text", stats.SkippedNoText,
|
"skipped_seen", stats.SkippedSeen, "skipped_no_text", stats.SkippedNoText,
|
||||||
"errors", stats.Errors)
|
"skipped_manual", stats.SkippedManual, "errors", stats.Errors)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
r.log.Warn("run pass had errors", "err", err)
|
r.log.Warn("run pass had errors", "err", err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
// fakeStore assigns deterministic ids ("id-"+provider video id) so a pre-seeded
|
||||||
// seen set lines up with UpsertVideo output, modelling cross-restart dedup.
|
// 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 {
|
type fakeStore struct {
|
||||||
seen map[string]bool
|
seen map[string]bool
|
||||||
upserted []domain.Video
|
upserted []domain.Video
|
||||||
|
auto bool
|
||||||
|
requested map[string]bool
|
||||||
|
cleared []string
|
||||||
}
|
}
|
||||||
|
|
||||||
func (f *fakeStore) UpsertVideo(_ context.Context, v domain.Video) (string, error) {
|
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
|
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{}
|
type fakeSummarizer struct{}
|
||||||
|
|
||||||
func (fakeSummarizer) Summarize(_ context.Context, v domain.Video, _ domain.Transcript) (domain.Summary, error) {
|
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")},
|
subs: []domain.Subscription{sub("chan1", "Channel One")},
|
||||||
videos: map[string][]domain.Video{"chan1": {vid("v1", "Video 1"), vid("v2", "Video 2")}},
|
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{}
|
sink := &recordingSink{}
|
||||||
eng := usecase.NewEngine(src, fakeSummarizer{}, sink)
|
eng := usecase.NewEngine(src, fakeSummarizer{}, sink)
|
||||||
r := runner.New(src, st, eng, testUser, quietLogger())
|
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")}},
|
videos: map[string][]domain.Video{"chan1": {vid("v1", "Video 1"), vid("v2", "Video 2")}},
|
||||||
}
|
}
|
||||||
// v1 was summarized in a prior run (durable seen set).
|
// 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{}
|
sink := &recordingSink{}
|
||||||
eng := usecase.NewEngine(src, fakeSummarizer{}, sink)
|
eng := usecase.NewEngine(src, fakeSummarizer{}, sink)
|
||||||
r := runner.New(src, st, eng, testUser, quietLogger())
|
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")}},
|
videos: map[string][]domain.Video{"chan1": {vid("v1", "Video 1")}},
|
||||||
transcripts: map[string]domain.Transcript{"v1": {Source: domain.SourceNone}},
|
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{}
|
sink := &recordingSink{}
|
||||||
eng := usecase.NewEngine(src, fakeSummarizer{}, sink)
|
eng := usecase.NewEngine(src, fakeSummarizer{}, sink)
|
||||||
r := runner.New(src, st, eng, testUser, quietLogger())
|
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")
|
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) {
|
func TestRunOnce_UpsertsEveryCandidate(t *testing.T) {
|
||||||
src := &fakeSource{
|
src := &fakeSource{
|
||||||
subs: []domain.Subscription{sub("chan1", "Channel One")},
|
subs: []domain.Subscription{sub("chan1", "Channel One")},
|
||||||
videos: map[string][]domain.Video{"chan1": {vid("v1", "Video 1"), vid("v2", "Video 2")}},
|
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.
|
// 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{})
|
eng := usecase.NewEngine(src, fakeSummarizer{}, &recordingSink{})
|
||||||
r := runner.New(src, st, eng, testUser, quietLogger())
|
r := runner.New(src, st, eng, testUser, quietLogger())
|
||||||
|
|
||||||
|
|||||||
@@ -28,7 +28,12 @@ func (a *App) handleAccount(w http.ResponseWriter, r *http.Request) {
|
|||||||
if u, ok := a.Auth.CurrentUser(r); ok {
|
if u, ok := a.Auth.CurrentUser(r); ok {
|
||||||
email = u.Email
|
email = u.Email
|
||||||
}
|
}
|
||||||
a.render(w, r, AccountPage(name, email, conns, takeFlash(w, r)))
|
auto, err := a.Store.GetAutoSummarize(r.Context(), userID)
|
||||||
|
if err != nil {
|
||||||
|
a.serverError(w, r, "summarize mode", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
a.render(w, r, AccountPage(name, email, conns, auto, takeFlash(w, r)))
|
||||||
}
|
}
|
||||||
|
|
||||||
// handleDisconnect removes a provider connection: it deletes the OAuth token from
|
// handleDisconnect removes a provider connection: it deletes the OAuth token from
|
||||||
|
|||||||
+126
-3
@@ -16,12 +16,19 @@ import (
|
|||||||
// the concrete *store.Store). *store.Store satisfies it; tests can substitute a
|
// the concrete *store.Store). *store.Store satisfies it; tests can substitute a
|
||||||
// fake without a database.
|
// fake without a database.
|
||||||
type Store interface {
|
type Store interface {
|
||||||
ListSummaries(ctx context.Context, userID string, limit int) ([]store.SummaryRow, error)
|
ListVideos(ctx context.Context, userID string, limit int) ([]store.SummaryRow, error)
|
||||||
GetSummaryByVideo(ctx context.Context, userID, videoID string) (*store.SummaryRow, error)
|
GetSummaryByVideo(ctx context.Context, userID, videoID string) (*store.SummaryRow, error)
|
||||||
|
GetVideoRow(ctx context.Context, userID, videoID string) (*store.SummaryRow, error)
|
||||||
ActionsFor(ctx context.Context, userID string, videoIDs []string) (map[string][]string, error)
|
ActionsFor(ctx context.Context, userID string, videoIDs []string) (map[string][]string, error)
|
||||||
SetAction(ctx context.Context, userID, videoID, action string) error
|
SetAction(ctx context.Context, userID, videoID, action string) error
|
||||||
ClearAction(ctx context.Context, userID, videoID, action string) error
|
ClearAction(ctx context.Context, userID, videoID, action string) error
|
||||||
|
|
||||||
|
// Summarization mode: the per-user auto/manual toggle and the per-video
|
||||||
|
// manual queue (the "Summarize" button). The runner consumes the queue.
|
||||||
|
GetAutoSummarize(ctx context.Context, userID string) (bool, error)
|
||||||
|
SetAutoSummarize(ctx context.Context, userID string, enabled bool) error
|
||||||
|
RequestSummarize(ctx context.Context, userID, videoID string) error
|
||||||
|
|
||||||
// Account management (the /account page, disconnect, delete-account).
|
// Account management (the /account page, disconnect, delete-account).
|
||||||
ConnectionsForUser(ctx context.Context, userID string) ([]store.Connection, error)
|
ConnectionsForUser(ctx context.Context, userID string) ([]store.Connection, error)
|
||||||
DeleteConnection(ctx context.Context, userID, provider string) error
|
DeleteConnection(ctx context.Context, userID, provider string) error
|
||||||
@@ -54,6 +61,13 @@ type App struct {
|
|||||||
// Secrets removes a user's OAuth tokens on disconnect / delete-account. The
|
// Secrets removes a user's OAuth tokens on disconnect / delete-account. The
|
||||||
// account routes require it; cmd/tapir wires the file-backed store.
|
// account routes require it; cmd/tapir wires the file-backed store.
|
||||||
Secrets SecretRemover
|
Secrets SecretRemover
|
||||||
|
// Processor, when non-nil, summarizes a queued video immediately in a
|
||||||
|
// background goroutine (the "Summarize" button kicks it off). Nil = queue-only:
|
||||||
|
// the button flips the DB flag and the next `tapir run` does the work.
|
||||||
|
Processor Processor
|
||||||
|
// Processing tracks in-flight immediate summarizations so the status endpoint
|
||||||
|
// shows the animation until the summary lands. The zero value is ready to use.
|
||||||
|
Processing ProcessingSet
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *App) logger() *slog.Logger {
|
func (a *App) logger() *slog.Logger {
|
||||||
@@ -76,6 +90,8 @@ func (a *App) Router() http.Handler {
|
|||||||
app.HandleFunc("GET /{$}", a.handleList)
|
app.HandleFunc("GET /{$}", a.handleList)
|
||||||
app.HandleFunc("GET /v/{videoId}", a.handleDetail)
|
app.HandleFunc("GET /v/{videoId}", a.handleDetail)
|
||||||
app.HandleFunc("POST /v/{videoId}/action", a.handleAction)
|
app.HandleFunc("POST /v/{videoId}/action", a.handleAction)
|
||||||
|
app.HandleFunc("POST /v/{videoId}/summarize", a.handleRequestSummarize)
|
||||||
|
app.HandleFunc("GET /v/{videoId}/status", a.handleStatus)
|
||||||
app.HandleFunc("GET /register", a.handleRegisterForm)
|
app.HandleFunc("GET /register", a.handleRegisterForm)
|
||||||
app.HandleFunc("POST /register", a.handleRegister)
|
app.HandleFunc("POST /register", a.handleRegister)
|
||||||
|
|
||||||
@@ -84,6 +100,7 @@ func (a *App) Router() http.Handler {
|
|||||||
app.HandleFunc("GET /account", a.handleAccount)
|
app.HandleFunc("GET /account", a.handleAccount)
|
||||||
app.HandleFunc("POST /account/disconnect/{provider}", a.handleDisconnect)
|
app.HandleFunc("POST /account/disconnect/{provider}", a.handleDisconnect)
|
||||||
app.HandleFunc("POST /account/delete", a.handleDeleteAccount)
|
app.HandleFunc("POST /account/delete", a.handleDeleteAccount)
|
||||||
|
app.HandleFunc("POST /account/summarize-mode", a.handleSummarizeMode)
|
||||||
|
|
||||||
// Web-initiated YouTube connect (ADR-006). Gated like every app route, so
|
// Web-initiated YouTube connect (ADR-006). Gated like every app route, so
|
||||||
// CurrentUserID is set and the connection binds to the authenticated user.
|
// CurrentUserID is set and the connection binds to the authenticated user.
|
||||||
@@ -121,9 +138,9 @@ func (a *App) handleList(w http.ResponseWriter, r *http.Request) {
|
|||||||
To: q.Get("to"),
|
To: q.Get("to"),
|
||||||
}
|
}
|
||||||
|
|
||||||
rows, err := a.Store.ListSummaries(r.Context(), userID, 0)
|
rows, err := a.Store.ListVideos(r.Context(), userID, 0)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
a.serverError(w, r, "list summaries", err)
|
a.serverError(w, r, "list videos", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
rows = f.apply(rows)
|
rows = f.apply(rows)
|
||||||
@@ -199,6 +216,112 @@ func (a *App) handleAction(w http.ResponseWriter, r *http.Request) {
|
|||||||
http.Redirect(w, r, "/v/"+videoID, http.StatusSeeOther)
|
http.Redirect(w, r, "/v/"+videoID, http.StatusSeeOther)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// handleRequestSummarize handles the "Summarize" button. It always flips the DB
|
||||||
|
// flag (summarize_requested) so the work is durable. With a Processor wired it
|
||||||
|
// then summarizes immediately in the background and answers with the animated
|
||||||
|
// processing card that polls /status until done; without one (queue-only) it
|
||||||
|
// answers with the "Queued" card — the next `tapir run` does the work. Without
|
||||||
|
// JS it redirects back to the list (POST→redirect→GET).
|
||||||
|
func (a *App) handleRequestSummarize(w http.ResponseWriter, r *http.Request) {
|
||||||
|
userID, ok := a.currentUserID(w, r)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
videoID := r.PathValue("videoId")
|
||||||
|
|
||||||
|
err := a.Store.RequestSummarize(r.Context(), userID, videoID)
|
||||||
|
if errors.Is(err, store.ErrNotFound) {
|
||||||
|
http.NotFound(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
a.serverError(w, r, "request summarize", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if !isHTMX(r) {
|
||||||
|
http.Redirect(w, r, "/", http.StatusSeeOther)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
row, err := a.Store.GetVideoRow(r.Context(), userID, videoID)
|
||||||
|
if err != nil {
|
||||||
|
a.serverError(w, r, "get video", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if a.Processor != nil {
|
||||||
|
a.startProcessing(userID, videoID)
|
||||||
|
a.render(w, r, processingCard(*row))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
a.render(w, r, VideoCard(*row))
|
||||||
|
}
|
||||||
|
|
||||||
|
// startProcessing marks a video in-flight and summarizes it in the background.
|
||||||
|
// The goroutine uses a detached context — not the request's, which is cancelled
|
||||||
|
// when the handler returns — and clears the in-flight mark on completion. On
|
||||||
|
// error the DB flag stays set, so the video remains queued for the next
|
||||||
|
// `tapir run`; a successful Processor.ProcessVideo clears it itself.
|
||||||
|
func (a *App) startProcessing(userID, videoID string) {
|
||||||
|
key := processingKey(userID, videoID)
|
||||||
|
a.Processing.Add(key)
|
||||||
|
go func() {
|
||||||
|
defer a.Processing.Remove(key)
|
||||||
|
if err := a.Processor.ProcessVideo(context.Background(), userID, videoID); err != nil {
|
||||||
|
a.logger().Error("background summarize", "user", userID, "video", videoID, "err", err)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleStatus is the HTMX poll target for an in-flight summarization. It returns
|
||||||
|
// the card in its current state: the full summary card once the summary exists,
|
||||||
|
// otherwise the animated processing card while still in-flight (which keeps
|
||||||
|
// polling), or the queued/button card when neither holds. VideoCard carries no
|
||||||
|
// polling attributes, so HTMX stops polling once it swaps in.
|
||||||
|
func (a *App) handleStatus(w http.ResponseWriter, r *http.Request) {
|
||||||
|
userID, ok := a.currentUserID(w, r)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
videoID := r.PathValue("videoId")
|
||||||
|
|
||||||
|
row, err := a.Store.GetVideoRow(r.Context(), userID, videoID)
|
||||||
|
if errors.Is(err, store.ErrNotFound) {
|
||||||
|
http.NotFound(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
a.serverError(w, r, "get video", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if row.Summarized || !a.Processing.Has(processingKey(userID, videoID)) {
|
||||||
|
a.render(w, r, VideoCard(*row))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
a.render(w, r, processingCard(*row))
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleSummarizeMode toggles the user's auto/manual summarization mode. The form
|
||||||
|
// submits the desired new value (enabled=true|false). For HTMX it returns the
|
||||||
|
// refreshed mode control; without JS it redirects back to the account page.
|
||||||
|
func (a *App) handleSummarizeMode(w http.ResponseWriter, r *http.Request) {
|
||||||
|
userID, ok := a.currentUserID(w, r)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
enabled := r.FormValue("enabled") == "true"
|
||||||
|
if err := a.Store.SetAutoSummarize(r.Context(), userID, enabled); err != nil {
|
||||||
|
a.serverError(w, r, "set summarize mode", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !isHTMX(r) {
|
||||||
|
http.Redirect(w, r, "/account", http.StatusSeeOther)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
a.render(w, r, summarizeModeControl(enabled))
|
||||||
|
}
|
||||||
|
|
||||||
// currentUserID returns the tapir user_id the registration gate resolved for this
|
// currentUserID returns the tapir user_id the registration gate resolved for this
|
||||||
// request. Behind the gate it is always present; a miss means a handler was
|
// request. Behind the gate it is always present; a miss means a handler was
|
||||||
// reached without scoping (a wiring bug), so it answers 500 and reports false.
|
// reached without scoping (a wiring bug), so it answers 500 and reports false.
|
||||||
|
|||||||
@@ -185,8 +185,10 @@ func TestListRendersRowsAndActionState(t *testing.T) {
|
|||||||
func TestListHTMXReturnsFragment(t *testing.T) {
|
func TestListHTMXReturnsFragment(t *testing.T) {
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
app := newApp(t)
|
app := newApp(t)
|
||||||
resetDB(t, rawPool(t))
|
p := rawPool(t)
|
||||||
|
resetDB(t, p)
|
||||||
require.NoError(t, deliver(ctx, app, videoX, "body x"))
|
require.NoError(t, deliver(ctx, app, videoX, "body x"))
|
||||||
|
seedVideo(t, p, videoX, "X Title", "https://x", time.Time{})
|
||||||
|
|
||||||
req := httptest.NewRequest(http.MethodGet, "/", nil)
|
req := httptest.NewRequest(http.MethodGet, "/", nil)
|
||||||
req.Header.Set("HX-Request", "true")
|
req.Header.Set("HX-Request", "true")
|
||||||
@@ -290,6 +292,103 @@ func TestActionRejectsUnknownVerb(t *testing.T) {
|
|||||||
require.Equal(t, http.StatusBadRequest, rec.Code)
|
require.Equal(t, http.StatusBadRequest, rec.Code)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestListShowsSummarizeButtonForUnsummarized(t *testing.T) {
|
||||||
|
app := newApp(t)
|
||||||
|
p := rawPool(t)
|
||||||
|
resetDB(t, p)
|
||||||
|
// A discovered-but-unsummarized video (no summary delivered).
|
||||||
|
seedVideo(t, p, videoX, "Pending Title", "https://x", time.Time{})
|
||||||
|
|
||||||
|
rec := do(t, app, httptest.NewRequest(http.MethodGet, "/", nil))
|
||||||
|
require.Equal(t, http.StatusOK, rec.Code)
|
||||||
|
html := body(t, rec)
|
||||||
|
|
||||||
|
require.Contains(t, html, "Pending Title", "unsummarized videos are listed too")
|
||||||
|
require.Contains(t, html, "Summarize", "a Summarize button is offered")
|
||||||
|
require.Contains(t, html, "/v/"+videoX+"/summarize", "button posts to the queue endpoint")
|
||||||
|
require.Contains(t, html, "card-pending", "muted pending treatment")
|
||||||
|
require.NotContains(t, html, "Queued", "not queued yet")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRequestSummarizeQueuesAndRendersCard(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
app := newApp(t)
|
||||||
|
p := rawPool(t)
|
||||||
|
resetDB(t, p)
|
||||||
|
seedVideo(t, p, videoX, "Pending Title", "https://x", time.Time{})
|
||||||
|
|
||||||
|
rec := postSummarize(t, app, videoX, true)
|
||||||
|
require.Equal(t, http.StatusOK, rec.Code)
|
||||||
|
html := body(t, rec)
|
||||||
|
require.Contains(t, html, "Queued", "card now shows the queued state")
|
||||||
|
require.NotContains(t, html, ">Summarize<", "the Summarize button is gone once queued")
|
||||||
|
|
||||||
|
// The flag is persisted, so the next run picks it up.
|
||||||
|
row, err := app.Store.GetVideoRow(ctx, userID, videoX)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.True(t, row.SummarizeRequested)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRequestSummarizeNonHTMXRedirects(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
app := newApp(t)
|
||||||
|
p := rawPool(t)
|
||||||
|
resetDB(t, p)
|
||||||
|
seedVideo(t, p, videoX, "Pending Title", "https://x", time.Time{})
|
||||||
|
|
||||||
|
rec := postSummarize(t, app, videoX, false)
|
||||||
|
require.Equal(t, http.StatusSeeOther, rec.Code)
|
||||||
|
require.Equal(t, "/", rec.Header().Get("Location"))
|
||||||
|
|
||||||
|
row, err := app.Store.GetVideoRow(ctx, userID, videoX)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.True(t, row.SummarizeRequested, "queued on the no-JS path too")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRequestSummarizeNotFound(t *testing.T) {
|
||||||
|
app := newApp(t)
|
||||||
|
resetDB(t, rawPool(t))
|
||||||
|
rec := postSummarize(t, app, videoX, true)
|
||||||
|
require.Equal(t, http.StatusNotFound, rec.Code, "queuing an unknown video is a 404")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSummarizeModeToggle(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
app := newApp(t)
|
||||||
|
resetDB(t, rawPool(t))
|
||||||
|
|
||||||
|
// Account page defaults to manual.
|
||||||
|
rec := do(t, app, httptest.NewRequest(http.MethodGet, "/account", nil))
|
||||||
|
require.Equal(t, http.StatusOK, rec.Code)
|
||||||
|
html := body(t, rec)
|
||||||
|
require.Contains(t, html, "Manual", "default mode shown")
|
||||||
|
require.Contains(t, html, "Switch to automatic")
|
||||||
|
|
||||||
|
// Toggle to automatic via HTMX returns the refreshed control.
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/account/summarize-mode",
|
||||||
|
strings.NewReader("enabled=true"))
|
||||||
|
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||||
|
req.Header.Set("HX-Request", "true")
|
||||||
|
rec = do(t, app, req)
|
||||||
|
require.Equal(t, http.StatusOK, rec.Code)
|
||||||
|
html = body(t, rec)
|
||||||
|
require.Contains(t, html, "Automatic")
|
||||||
|
require.Contains(t, html, "Switch to manual")
|
||||||
|
|
||||||
|
got, err := app.Store.GetAutoSummarize(ctx, userID)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.True(t, got, "mode persisted")
|
||||||
|
}
|
||||||
|
|
||||||
|
func postSummarize(t *testing.T, app *web.App, videoID string, htmx bool) *httptest.ResponseRecorder {
|
||||||
|
t.Helper()
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/v/"+videoID+"/summarize", nil)
|
||||||
|
if htmx {
|
||||||
|
req.Header.Set("HX-Request", "true")
|
||||||
|
}
|
||||||
|
return do(t, app, req)
|
||||||
|
}
|
||||||
|
|
||||||
// deliver stores a summary through the App's store under test.
|
// deliver stores a summary through the App's store under test.
|
||||||
func deliver(ctx context.Context, app *web.App, videoID, text string) error {
|
func deliver(ctx context.Context, app *web.App, videoID, text string) error {
|
||||||
return app.Store.(*store.Store).Deliver(ctx, summary(videoID, text))
|
return app.Store.(*store.Store).Deliver(ctx, summary(videoID, text))
|
||||||
|
|||||||
@@ -0,0 +1,42 @@
|
|||||||
|
package web
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"sync"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Processor runs the core summarization use case for a single already-discovered
|
||||||
|
// video — resolve its transcript, summarize, deliver to the store. *usecase.Engine
|
||||||
|
// wrapped with the store satisfies it (wired in cmd/tapir). Optional on App: a nil
|
||||||
|
// Processor means queue-only — the "Summarize" button only flips the DB flag and
|
||||||
|
// the next `tapir run` does the work.
|
||||||
|
type Processor interface {
|
||||||
|
ProcessVideo(ctx context.Context, userID, videoID string) error
|
||||||
|
}
|
||||||
|
|
||||||
|
// ProcessingSet tracks the (user, video) ids currently being summarized in-process
|
||||||
|
// so the status endpoint can show the animation until the summary lands. It is
|
||||||
|
// ephemeral (single-instance Stage-1): a restart drops it, and the DB holds the
|
||||||
|
// durable state — the summary is present, or summarize_requested is still set so
|
||||||
|
// `tapir run` retries. The zero value is ready to use; methods are concurrency-safe.
|
||||||
|
type ProcessingSet struct {
|
||||||
|
m sync.Map
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add marks a key in-flight.
|
||||||
|
func (p *ProcessingSet) Add(key string) { p.m.Store(key, struct{}{}) }
|
||||||
|
|
||||||
|
// Remove clears a key once its summarization finishes (success or failure).
|
||||||
|
func (p *ProcessingSet) Remove(key string) { p.m.Delete(key) }
|
||||||
|
|
||||||
|
// Has reports whether a key is currently in-flight.
|
||||||
|
func (p *ProcessingSet) Has(key string) bool {
|
||||||
|
_, ok := p.m.Load(key)
|
||||||
|
return ok
|
||||||
|
}
|
||||||
|
|
||||||
|
// processingKey scopes the in-flight key by user so one user's summarization is
|
||||||
|
// never confused with another's for the same video id.
|
||||||
|
func processingKey(userID, videoID string) string {
|
||||||
|
return userID + "|" + videoID
|
||||||
|
}
|
||||||
@@ -0,0 +1,131 @@
|
|||||||
|
package web_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
|
||||||
|
"gitea.d-ma.be/mathias/tapir/internal/web"
|
||||||
|
)
|
||||||
|
|
||||||
|
// fakeProcessor records ProcessVideo calls. With block set it parks until the
|
||||||
|
// channel is closed, so a test can observe the handler return before the
|
||||||
|
// background work finishes (proving it ran in a goroutine).
|
||||||
|
type fakeProcessor struct {
|
||||||
|
block chan struct{}
|
||||||
|
done chan struct{}
|
||||||
|
calls []string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeProcessor) ProcessVideo(_ context.Context, _, videoID string) error {
|
||||||
|
if f.block != nil {
|
||||||
|
<-f.block
|
||||||
|
}
|
||||||
|
f.calls = append(f.calls, videoID)
|
||||||
|
if f.done != nil {
|
||||||
|
close(f.done)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRequestSummarizeImmediateProcessing(t *testing.T) {
|
||||||
|
app := newApp(t)
|
||||||
|
p := rawPool(t)
|
||||||
|
resetDB(t, p)
|
||||||
|
seedVideo(t, p, videoX, "Pending Title", "https://x", time.Time{})
|
||||||
|
|
||||||
|
fp := &fakeProcessor{block: make(chan struct{}), done: make(chan struct{})}
|
||||||
|
app.Processor = fp
|
||||||
|
|
||||||
|
rec := postSummarize(t, app, videoX, true)
|
||||||
|
require.Equal(t, http.StatusOK, rec.Code)
|
||||||
|
html := body(t, rec)
|
||||||
|
|
||||||
|
// The processing card came back while ProcessVideo is still parked on block:
|
||||||
|
// the work runs in a goroutine, the handler did not wait for it.
|
||||||
|
require.Contains(t, html, "Summarizing", "processing card returned")
|
||||||
|
require.Contains(t, html, "╭", "charm box rendered")
|
||||||
|
require.Contains(t, html, "▓", "tapir body block chars rendered")
|
||||||
|
require.Contains(t, html, "∩", "wiggling snout frame rendered")
|
||||||
|
require.Contains(t, html, web.CharmPurple, "charm palette applied to the border")
|
||||||
|
require.Contains(t, html, "/v/"+videoX+"/status", "card polls the status endpoint")
|
||||||
|
require.Contains(t, html, `hx-trigger="every 2s"`, "card auto-polls every 2s")
|
||||||
|
require.NotContains(t, html, "Queued", "not the queue-only card")
|
||||||
|
|
||||||
|
close(fp.block)
|
||||||
|
select {
|
||||||
|
case <-fp.done:
|
||||||
|
case <-time.After(2 * time.Second):
|
||||||
|
t.Fatal("ProcessVideo was not called in the background")
|
||||||
|
}
|
||||||
|
require.Equal(t, []string{videoX}, fp.calls)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStatusProcessingThenDone(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
app := newApp(t)
|
||||||
|
p := rawPool(t)
|
||||||
|
resetDB(t, p)
|
||||||
|
seedVideo(t, p, videoX, "Pending Title", "https://x", time.Time{})
|
||||||
|
|
||||||
|
// Park ProcessVideo so the video stays in-flight while we poll status.
|
||||||
|
fp := &fakeProcessor{block: make(chan struct{})}
|
||||||
|
app.Processor = fp
|
||||||
|
require.Equal(t, http.StatusOK, postSummarize(t, app, videoX, true).Code)
|
||||||
|
|
||||||
|
// Processing: status returns the animation card, still polling.
|
||||||
|
rec := getStatus(t, app, videoX)
|
||||||
|
require.Equal(t, http.StatusOK, rec.Code)
|
||||||
|
html := body(t, rec)
|
||||||
|
require.Contains(t, html, "Summarizing", "in-flight → animation card")
|
||||||
|
require.Contains(t, html, `hx-trigger="every 2s"`, "still polling")
|
||||||
|
|
||||||
|
close(fp.block)
|
||||||
|
|
||||||
|
// Done: once a summary exists, status returns the summary card with no poll.
|
||||||
|
require.NoError(t, deliver(ctx, app, videoX, "the summary body"))
|
||||||
|
rec = getStatus(t, app, videoX)
|
||||||
|
require.Equal(t, http.StatusOK, rec.Code)
|
||||||
|
html = body(t, rec)
|
||||||
|
require.NotContains(t, html, "Summarizing", "done → no animation")
|
||||||
|
require.NotContains(t, html, "every 2s", "done card does not poll (polling stops)")
|
||||||
|
require.Contains(t, html, "/v/"+videoX+"\"", "links to the detail page")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStatusQueuedWhenNotInFlight(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
app := newApp(t)
|
||||||
|
p := rawPool(t)
|
||||||
|
resetDB(t, p)
|
||||||
|
seedVideo(t, p, videoX, "Pending Title", "https://x", time.Time{})
|
||||||
|
|
||||||
|
// Flag set but nothing in-flight (e.g. queue-only, or after a restart).
|
||||||
|
require.NoError(t, app.Store.RequestSummarize(ctx, userID, videoX))
|
||||||
|
|
||||||
|
rec := getStatus(t, app, videoX)
|
||||||
|
require.Equal(t, http.StatusOK, rec.Code)
|
||||||
|
html := body(t, rec)
|
||||||
|
require.Contains(t, html, "Queued", "queued chip card")
|
||||||
|
require.NotContains(t, html, "Summarizing", "not processing")
|
||||||
|
require.NotContains(t, html, "every 2s", "queued card does not poll")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStatusNotFound(t *testing.T) {
|
||||||
|
app := newApp(t)
|
||||||
|
resetDB(t, rawPool(t))
|
||||||
|
rec := getStatus(t, app, videoX)
|
||||||
|
require.Equal(t, http.StatusNotFound, rec.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
func getStatus(t *testing.T, app *web.App, videoID string) *httptest.ResponseRecorder {
|
||||||
|
t.Helper()
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/v/"+videoID+"/status", nil)
|
||||||
|
req.Header.Set("HX-Request", "true")
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
app.Router().ServeHTTP(rec, req)
|
||||||
|
return rec
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
package web
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
func TestProcessingSetAddHasRemove(t *testing.T) {
|
||||||
|
var s ProcessingSet // zero value is usable
|
||||||
|
|
||||||
|
key := processingKey("user-1", "video-1")
|
||||||
|
if s.Has(key) {
|
||||||
|
t.Fatal("fresh set must not report a key as in-flight")
|
||||||
|
}
|
||||||
|
|
||||||
|
s.Add(key)
|
||||||
|
if !s.Has(key) {
|
||||||
|
t.Fatal("Add must mark the key in-flight")
|
||||||
|
}
|
||||||
|
|
||||||
|
// A different user with the same video id is a distinct key.
|
||||||
|
if s.Has(processingKey("user-2", "video-1")) {
|
||||||
|
t.Fatal("keys must be scoped by user")
|
||||||
|
}
|
||||||
|
|
||||||
|
s.Remove(key)
|
||||||
|
if s.Has(key) {
|
||||||
|
t.Fatal("Remove must clear the key")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,6 +4,7 @@ import (
|
|||||||
"regexp"
|
"regexp"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
"unicode/utf8"
|
||||||
|
|
||||||
"github.com/a-h/templ"
|
"github.com/a-h/templ"
|
||||||
|
|
||||||
@@ -171,6 +172,125 @@ func actionURL(videoID string) templ.SafeURL {
|
|||||||
return templ.SafeURL("/v/" + videoID + "/action")
|
return templ.SafeURL("/v/" + videoID + "/action")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// summarizeURL builds the manual-queue POST path for a video id.
|
||||||
|
func summarizeURL(videoID string) templ.SafeURL {
|
||||||
|
return templ.SafeURL("/v/" + videoID + "/summarize")
|
||||||
|
}
|
||||||
|
|
||||||
|
// statusURL builds the processing-status poll path (GET) for a video id — the
|
||||||
|
// HTMX poll target while an immediate summarization is in flight.
|
||||||
|
func statusURL(videoID string) templ.SafeURL {
|
||||||
|
return templ.SafeURL("/v/" + videoID + "/status")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Charmbracelet-inspired palette for the summarizing animation (TapirSpinner) —
|
||||||
|
// a charm purple box, pink tapir, mint snout/eyes/progress. Kept as named consts
|
||||||
|
// so the inline span colours and the CSS track/fill share one source of truth.
|
||||||
|
const (
|
||||||
|
CharmPurple = "#7653FC" // box border
|
||||||
|
CharmPink = "#FF6E9C" // tapir body
|
||||||
|
CharmMint = "#0EF9B6" // snout, eyes, progress fill
|
||||||
|
CharmCream = "#FFFDF5" // bright text
|
||||||
|
CharmDim = "#6C6C6C" // dim text
|
||||||
|
charmTrack = "#2D2D2D" // empty progress track (internal: dark char colour)
|
||||||
|
)
|
||||||
|
|
||||||
|
// tapirInteriorW is the fixed inner width of the Charm box, in monospace cells.
|
||||||
|
const tapirInteriorW = 34
|
||||||
|
|
||||||
|
// tapirBarFill is the mint progress fill (27 cells), revealed left→right by the
|
||||||
|
// CSS width/clip animation over the dim track drawn in each frame.
|
||||||
|
const tapirBarFill = "███████████████████████████"
|
||||||
|
|
||||||
|
// tapirRun is one coloured (or uncoloured) text segment of a box row.
|
||||||
|
type tapirRun struct {
|
||||||
|
s string
|
||||||
|
color string // "" = no span (plain text)
|
||||||
|
}
|
||||||
|
|
||||||
|
func tapirSpan(color, s string) string {
|
||||||
|
if color == "" {
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
return `<span style="color:` + color + `">` + s + `</span>`
|
||||||
|
}
|
||||||
|
|
||||||
|
// tapirLine renders one interior box row: concatenate the coloured runs, pad with
|
||||||
|
// spaces to the fixed interior width, then flank with the purple side borders.
|
||||||
|
// Padding is computed from the runs' rune counts, so every row's right border
|
||||||
|
// lines up no matter how many runs it has (assuming 1-cell monospace glyphs).
|
||||||
|
func tapirLine(runs ...tapirRun) string {
|
||||||
|
var b strings.Builder
|
||||||
|
width := 0
|
||||||
|
for _, r := range runs {
|
||||||
|
b.WriteString(tapirSpan(r.color, r.s))
|
||||||
|
width += utf8.RuneCountInString(r.s)
|
||||||
|
}
|
||||||
|
if width < tapirInteriorW {
|
||||||
|
b.WriteString(strings.Repeat(" ", tapirInteriorW-width))
|
||||||
|
}
|
||||||
|
bar := tapirSpan(CharmPurple, "│")
|
||||||
|
return bar + b.String() + bar
|
||||||
|
}
|
||||||
|
|
||||||
|
// tapirFrameHTML builds one animation frame: a rounded Charm box around a colored
|
||||||
|
// ASCII tapir, a dim progress track, and labels. snout is the wiggling nose glyph
|
||||||
|
// that differs between the three frames. Returned as raw HTML (coloured spans),
|
||||||
|
// emitted verbatim by the template via templ.Raw.
|
||||||
|
func tapirFrameHTML(snout string) string {
|
||||||
|
top := tapirSpan(CharmPurple, "╭"+strings.Repeat("─", tapirInteriorW)+"╮")
|
||||||
|
bottom := tapirSpan(CharmPurple, "╰"+strings.Repeat("─", tapirInteriorW)+"╯")
|
||||||
|
lines := []string{
|
||||||
|
top,
|
||||||
|
tapirLine(tapirRun{s: " "}, tapirRun{s: "◆", color: CharmMint}, tapirRun{s: " "}, tapirRun{s: "tapir", color: CharmCream}),
|
||||||
|
tapirLine(),
|
||||||
|
tapirLine(tapirRun{s: " "}, tapirRun{s: "▄▄▄▄▄", color: CharmPink}),
|
||||||
|
tapirLine(tapirRun{s: " "}, tapirRun{s: "▄█▓▓▓▓█▄", color: CharmPink}, tapirRun{s: " "}, tapirRun{s: snout, color: CharmMint}),
|
||||||
|
tapirLine(tapirRun{s: " "}, tapirRun{s: "█▓(", color: CharmPink}, tapirRun{s: " "}, tapirRun{s: "◕ ◕", color: CharmMint}, tapirRun{s: ")▓█", color: CharmPink}, tapirRun{s: "──┘", color: CharmMint}, tapirRun{s: " "}, tapirRun{s: "< thinking...", color: CharmDim}),
|
||||||
|
tapirLine(tapirRun{s: " "}, tapirRun{s: "▀█▓▓▓▓█▀", color: CharmPink}),
|
||||||
|
tapirLine(tapirRun{s: " "}, tapirRun{s: "██▄▄██", color: CharmPink}),
|
||||||
|
tapirLine(tapirRun{s: " "}, tapirRun{s: "▀▀", color: CharmPink}, tapirRun{s: " "}, tapirRun{s: "▀▀", color: CharmPink}),
|
||||||
|
tapirLine(),
|
||||||
|
tapirLine(tapirRun{s: " "}, tapirRun{s: "[", color: CharmDim}, tapirRun{s: strings.Repeat("░", 27), color: charmTrack}, tapirRun{s: "]", color: CharmDim}),
|
||||||
|
tapirLine(tapirRun{s: " "}, tapirRun{s: "summarizing", color: CharmDim}),
|
||||||
|
bottom,
|
||||||
|
}
|
||||||
|
return strings.Join(lines, "\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
// The three frames differ only in the snout glyph (∩ → ∪ → ~), cross-faded by CSS
|
||||||
|
// to read as a tapir wiggling its nose while it thinks.
|
||||||
|
var (
|
||||||
|
tapirFrameHTML1 = tapirFrameHTML("∩")
|
||||||
|
tapirFrameHTML2 = tapirFrameHTML("∪")
|
||||||
|
tapirFrameHTML3 = tapirFrameHTML("~")
|
||||||
|
)
|
||||||
|
|
||||||
|
// summarizeModeLabel names the current mode for display.
|
||||||
|
func summarizeModeLabel(auto bool) string {
|
||||||
|
if auto {
|
||||||
|
return "Automatic"
|
||||||
|
}
|
||||||
|
return "Manual"
|
||||||
|
}
|
||||||
|
|
||||||
|
// summarizeModeToggleLabel is the caption on the toggle button — it names the mode
|
||||||
|
// the click switches TO (the opposite of the current one).
|
||||||
|
func summarizeModeToggleLabel(auto bool) string {
|
||||||
|
if auto {
|
||||||
|
return "Switch to manual"
|
||||||
|
}
|
||||||
|
return "Switch to automatic"
|
||||||
|
}
|
||||||
|
|
||||||
|
// boolStr renders a bool as the "enabled" form value the toggle submits.
|
||||||
|
func boolStr(b bool) string {
|
||||||
|
if b {
|
||||||
|
return "true"
|
||||||
|
}
|
||||||
|
return "false"
|
||||||
|
}
|
||||||
|
|
||||||
// externalURL passes a stored source URL through templ's URL sanitiser.
|
// externalURL passes a stored source URL through templ's URL sanitiser.
|
||||||
func externalURL(u string) templ.SafeURL {
|
func externalURL(u string) templ.SafeURL {
|
||||||
return templ.URL(u)
|
return templ.URL(u)
|
||||||
@@ -347,6 +467,40 @@ main { max-width: 60rem; margin: 0 auto; padding: var(--s4) var(--s3); }
|
|||||||
.card-state { color: var(--muted); font-size: .8rem; }
|
.card-state { color: var(--muted); font-size: .8rem; }
|
||||||
.badge { display: inline-block; padding: .15rem .55rem; border-radius: 999px; background: var(--badge-bg); color: var(--badge-fg); font-size: .72rem; font-weight: 600; }
|
.badge { display: inline-block; padding: .15rem .55rem; border-radius: 999px; background: var(--badge-bg); color: var(--badge-fg); font-size: .72rem; font-weight: 600; }
|
||||||
|
|
||||||
|
/* pending (discovered-but-unsummarized) card: muted until summarized */
|
||||||
|
.card-pending { border-style: dashed; }
|
||||||
|
.card-pending .card-title { color: var(--muted); font-weight: 600; }
|
||||||
|
|
||||||
|
/* summarizing animation — a Charmbracelet-style TUI panel rendered in the
|
||||||
|
browser: a dark terminal card, a rounded purple box around a pink ASCII tapir,
|
||||||
|
and a lipgloss-style progress bar. Three frames are stacked and cross-faded by
|
||||||
|
a stepped keyframe (staggered delays) so the snout appears to wiggle; the
|
||||||
|
progress fill grows independently via a clip animation over the dim track. */
|
||||||
|
.card-processing { border-style: dashed; }
|
||||||
|
.tapir-charm { position: relative; display: inline-block; background: #0d0d12; border-radius: 10px; padding: .8em 1em; margin: var(--s2) 0; font: .82rem/1.15 ui-monospace, SFMono-Regular, Menlo, "Cascadia Code", monospace; box-shadow: 0 2px 14px rgba(118, 83, 252, .25); }
|
||||||
|
.tapir-charm pre { margin: 0; white-space: pre; opacity: 0; animation: tapir-cycle 1.2s steps(1, end) infinite; }
|
||||||
|
.tapir-charm .tapir-f1 { position: relative; animation-delay: 0s; }
|
||||||
|
.tapir-charm .tapir-f2 { position: absolute; top: .8em; left: 1em; animation-delay: .4s; }
|
||||||
|
.tapir-charm .tapir-f3 { position: absolute; top: .8em; left: 1em; animation-delay: .8s; }
|
||||||
|
@keyframes tapir-cycle { 0%, 33.32% { opacity: 1; } 33.33%, 100% { opacity: 0; } }
|
||||||
|
/* progress fill: 27 mint cells overlaying the dim track at box row 10, col 3,
|
||||||
|
revealed left→right over 8s, looping. */
|
||||||
|
.tapir-bar { position: absolute; top: calc(.8em + 11.5em); left: calc(1em + 3ch); height: 1.15em; line-height: 1.15; overflow: hidden; }
|
||||||
|
.tapir-bar-fill { animation: tapir-fill 8s linear infinite; text-shadow: 0 0 6px rgba(14, 249, 182, .7); }
|
||||||
|
@keyframes tapir-fill { 0% { clip-path: inset(0 100% 0 0); } 100% { clip-path: inset(0 0 0 0); } }
|
||||||
|
.tapir-label { color: var(--muted); font-size: .9rem; margin: 0; }
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.tapir-charm pre { animation: none; }
|
||||||
|
.tapir-charm .tapir-f2, .tapir-charm .tapir-f3 { display: none; }
|
||||||
|
.tapir-charm .tapir-f1 { opacity: 1; }
|
||||||
|
.tapir-bar-fill { animation: none; clip-path: inset(0 35% 0 0); }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* summarization mode toggle on the account page */
|
||||||
|
.summarize-mode { display: flex; gap: var(--s3); align-items: center; flex-wrap: wrap; }
|
||||||
|
.summarize-mode p { margin: 0; }
|
||||||
|
.summarize-mode form { margin: 0; }
|
||||||
|
|
||||||
/* empty state */
|
/* empty state */
|
||||||
.empty { text-align: center; color: var(--muted); padding: var(--s5) var(--s4); border: 1px dashed var(--line); border-radius: var(--radius); background: var(--card); }
|
.empty { text-align: center; color: var(--muted); padding: var(--s5) var(--s4); border: 1px dashed var(--line); border-radius: var(--radius); background: var(--card); }
|
||||||
.empty strong { display: block; color: var(--fg); font-size: 1.05rem; margin-bottom: var(--s2); }
|
.empty strong { display: block; color: var(--fg); font-size: 1.05rem; margin-bottom: var(--s2); }
|
||||||
|
|||||||
@@ -1,6 +1,11 @@
|
|||||||
package web
|
package web
|
||||||
|
|
||||||
import "testing"
|
import (
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"unicode/utf8"
|
||||||
|
)
|
||||||
|
|
||||||
func TestEmbedURL(t *testing.T) {
|
func TestEmbedURL(t *testing.T) {
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
@@ -27,3 +32,19 @@ func TestEmbedURL(t *testing.T) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestTapirFrameRowsAligned asserts every box row has the same cell width once
|
||||||
|
// the inline-colour spans are stripped, so the rounded border lines up on every
|
||||||
|
// line (the panel only looks right if the right │ is flush across all rows).
|
||||||
|
func TestTapirFrameRowsAligned(t *testing.T) {
|
||||||
|
stripSpan := regexp.MustCompile(`</?span[^>]*>`)
|
||||||
|
for name, frame := range map[string]string{"f1": tapirFrameHTML1, "f2": tapirFrameHTML2, "f3": tapirFrameHTML3} {
|
||||||
|
plain := stripSpan.ReplaceAllString(frame, "")
|
||||||
|
want := tapirInteriorW + 2 // both purple side borders
|
||||||
|
for i, line := range strings.Split(plain, "\n") {
|
||||||
|
if got := utf8.RuneCountInString(line); got != want {
|
||||||
|
t.Errorf("%s line %d width = %d, want %d: %q", name, i, got, want, line)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
+121
-27
@@ -73,44 +73,109 @@ templ filterForm(f Filter) {
|
|||||||
</form>
|
</form>
|
||||||
}
|
}
|
||||||
|
|
||||||
// summaryList is the swappable list fragment: one card per summary (title link,
|
// summaryList is the swappable list fragment: one card per video (summarized or
|
||||||
// channel · date meta, provider chip, fallback badge, action state). Cards
|
// not). Cards reflow to a single column on mobile; an empty list shows a friendly
|
||||||
// reflow to a single column on mobile; an empty list shows a friendly first-run
|
// first-run state instead of a blank table.
|
||||||
// state instead of a blank table.
|
|
||||||
templ summaryList(rows []store.SummaryRow) {
|
templ summaryList(rows []store.SummaryRow) {
|
||||||
if len(rows) == 0 {
|
if len(rows) == 0 {
|
||||||
<div class="empty">
|
<div class="empty">
|
||||||
<strong>No summaries yet</strong>
|
<strong>No videos yet</strong>
|
||||||
<span>Summaries appear here as your subscriptions are processed — run <code>tapir run</code> to fetch and summarize new videos.</span>
|
<span>Videos appear here as your subscriptions are processed — run <code>tapir run</code> to fetch them. In manual mode, use the Summarize button to queue one.</span>
|
||||||
</div>
|
</div>
|
||||||
} else {
|
} else {
|
||||||
<ul class="cards">
|
<ul class="cards">
|
||||||
for _, r := range rows {
|
for _, r := range rows {
|
||||||
<li class="card">
|
@VideoCard(r)
|
||||||
<div class="card-title"><a href={ videoURL(r.VideoID) }>{ displayTitle(r) }</a></div>
|
|
||||||
if cardMeta(r) != "" {
|
|
||||||
<div class="card-meta">{ cardMeta(r) }</div>
|
|
||||||
}
|
|
||||||
if p := previewText(r.Summary, 160); p != "" {
|
|
||||||
<div class="card-preview">{ p }</div>
|
|
||||||
}
|
|
||||||
<div class="card-foot">
|
|
||||||
if r.AIProvider != "" {
|
|
||||||
<span class="chip">{ r.AIProvider }</span>
|
|
||||||
}
|
|
||||||
if r.FallbackUsed {
|
|
||||||
<span class="badge" title="summarized with the fallback model" aria-label="summarized with the fallback model">fallback</span>
|
|
||||||
}
|
|
||||||
if len(r.Actions) > 0 {
|
|
||||||
<span class="card-state">{ strings.Join(r.Actions, ", ") }</span>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
</li>
|
|
||||||
}
|
}
|
||||||
</ul>
|
</ul>
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// VideoCard is one list card, also returned standalone by POST /v/{id}/summarize
|
||||||
|
// (HTMX swaps it in place via outerHTML). A summarized video links to its detail
|
||||||
|
// page and shows its provider chip / fallback badge / action state. An
|
||||||
|
// unsummarized video gets a muted "pending" treatment and either a "Summarize"
|
||||||
|
// button (to queue it) or a "Queued" chip when already requested.
|
||||||
|
templ VideoCard(r store.SummaryRow) {
|
||||||
|
<li class={ "card", templ.KV("card-pending", !r.Summarized) } id={ "video-" + r.VideoID }>
|
||||||
|
if r.Summarized {
|
||||||
|
<div class="card-title"><a href={ videoURL(r.VideoID) }>{ displayTitle(r) }</a></div>
|
||||||
|
} else {
|
||||||
|
<div class="card-title">{ displayTitle(r) }</div>
|
||||||
|
}
|
||||||
|
if cardMeta(r) != "" {
|
||||||
|
<div class="card-meta">{ cardMeta(r) }</div>
|
||||||
|
}
|
||||||
|
if r.Summarized {
|
||||||
|
if p := previewText(r.Summary, 160); p != "" {
|
||||||
|
<div class="card-preview">{ p }</div>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
<div class="card-foot">
|
||||||
|
if r.Summarized {
|
||||||
|
if r.AIProvider != "" {
|
||||||
|
<span class="chip">{ r.AIProvider }</span>
|
||||||
|
}
|
||||||
|
if r.FallbackUsed {
|
||||||
|
<span class="badge" title="summarized with the fallback model" aria-label="summarized with the fallback model">fallback</span>
|
||||||
|
}
|
||||||
|
if len(r.Actions) > 0 {
|
||||||
|
<span class="card-state">{ strings.Join(r.Actions, ", ") }</span>
|
||||||
|
}
|
||||||
|
} else if r.SummarizeRequested {
|
||||||
|
<span class="chip">Queued</span>
|
||||||
|
<span class="card-state muted">waiting for the next run</span>
|
||||||
|
} else {
|
||||||
|
<form
|
||||||
|
method="post"
|
||||||
|
action={ summarizeURL(r.VideoID) }
|
||||||
|
hx-post={ string(summarizeURL(r.VideoID)) }
|
||||||
|
hx-target={ "#video-" + r.VideoID }
|
||||||
|
hx-swap="outerHTML"
|
||||||
|
>
|
||||||
|
<button type="submit" class="btn-secondary">Summarize</button>
|
||||||
|
</form>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
}
|
||||||
|
|
||||||
|
// TapirSpinner is the summarizing animation: a Charmbracelet-style TUI panel —
|
||||||
|
// three richly coloured ASCII tapir frames (inline span colours, snout wiggling
|
||||||
|
// ∩→∪→~) cross-faded by CSS, plus a lipgloss-style progress bar whose mint fill
|
||||||
|
// grows over the dim track. The panel is aria-hidden (decorative); the
|
||||||
|
// "Summarizing…" label below carries the meaning for assistive tech.
|
||||||
|
templ TapirSpinner() {
|
||||||
|
<div class="tapir-charm" aria-hidden="true">
|
||||||
|
<pre class="tapir-f1">@templ.Raw(tapirFrameHTML1)</pre>
|
||||||
|
<pre class="tapir-f2">@templ.Raw(tapirFrameHTML2)</pre>
|
||||||
|
<pre class="tapir-f3">@templ.Raw(tapirFrameHTML3)</pre>
|
||||||
|
<div class="tapir-bar"><span class="tapir-bar-fill" style={ "color:" + CharmMint }>{ tapirBarFill }</span></div>
|
||||||
|
</div>
|
||||||
|
<p class="tapir-label" role="status" aria-live="polite"><em>Summarizing…</em></p>
|
||||||
|
}
|
||||||
|
|
||||||
|
// processingCard is the in-flight summarization card. It replaces the Summarize
|
||||||
|
// button card and polls /v/{id}/status every 2s, swapping itself (outerHTML, same
|
||||||
|
// id as VideoCard) for whatever state comes back: it keeps polling while still
|
||||||
|
// processing, and the summary/queued card it is eventually replaced by carries no
|
||||||
|
// poll, so polling stops on its own when the fragment changes.
|
||||||
|
templ processingCard(r store.SummaryRow) {
|
||||||
|
<li
|
||||||
|
class="card card-processing"
|
||||||
|
id={ "video-" + r.VideoID }
|
||||||
|
hx-get={ string(statusURL(r.VideoID)) }
|
||||||
|
hx-trigger="every 2s"
|
||||||
|
hx-swap="outerHTML"
|
||||||
|
>
|
||||||
|
<div class="card-title">{ displayTitle(r) }</div>
|
||||||
|
if cardMeta(r) != "" {
|
||||||
|
<div class="card-meta">{ cardMeta(r) }</div>
|
||||||
|
}
|
||||||
|
@TapirSpinner()
|
||||||
|
</li>
|
||||||
|
}
|
||||||
|
|
||||||
// DetailPage is the full summary view: text, highlights, takeaways, metadata,
|
// DetailPage is the full summary view: text, highlights, takeaways, metadata,
|
||||||
// and the action button group.
|
// and the action button group.
|
||||||
templ DetailPage(r store.SummaryRow) {
|
templ DetailPage(r store.SummaryRow) {
|
||||||
@@ -202,7 +267,7 @@ templ RegisterPage(email, errMsg string) {
|
|||||||
// signed-in email, the user's connected video accounts (each with a Disconnect
|
// signed-in email, the user's connected video accounts (each with a Disconnect
|
||||||
// control), a Connect-YouTube link when none is connected, and the delete-account
|
// control), a Connect-YouTube link when none is connected, and the delete-account
|
||||||
// danger zone. flash surfaces a one-shot notification (disconnect/connect).
|
// danger zone. flash surfaces a one-shot notification (disconnect/connect).
|
||||||
templ AccountPage(displayName, email string, conns []store.Connection, flash string) {
|
templ AccountPage(displayName, email string, conns []store.Connection, autoSummarize bool, flash string) {
|
||||||
@Layout("Tapir — Account") {
|
@Layout("Tapir — Account") {
|
||||||
@flashBanner(flash)
|
@flashBanner(flash)
|
||||||
<article class="account">
|
<article class="account">
|
||||||
@@ -215,6 +280,15 @@ templ AccountPage(displayName, email string, conns []store.Connection, flash str
|
|||||||
<dd>{ email }</dd>
|
<dd>{ email }</dd>
|
||||||
}
|
}
|
||||||
</dl>
|
</dl>
|
||||||
|
<section>
|
||||||
|
<h2>Summarization</h2>
|
||||||
|
<p class="muted">
|
||||||
|
Automatic summarizes every new video as it is discovered. Manual lets you
|
||||||
|
pick which videos to summarize — new videos appear in your list with a
|
||||||
|
Summarize button.
|
||||||
|
</p>
|
||||||
|
@summarizeModeControl(autoSummarize)
|
||||||
|
</section>
|
||||||
<section>
|
<section>
|
||||||
<h2>Connected accounts</h2>
|
<h2>Connected accounts</h2>
|
||||||
if len(conns) == 0 {
|
if len(conns) == 0 {
|
||||||
@@ -262,6 +336,26 @@ templ AccountPage(displayName, email string, conns []store.Connection, flash str
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// summarizeModeControl is the auto/manual toggle, also returned standalone by
|
||||||
|
// POST /account/summarize-mode (HTMX swaps it via outerHTML). The hidden field
|
||||||
|
// submits the desired NEW value, so a single submit flips the mode; without JS the
|
||||||
|
// form posts and the handler redirects back to /account.
|
||||||
|
templ summarizeModeControl(auto bool) {
|
||||||
|
<div id="summarize-mode" class="summarize-mode">
|
||||||
|
<p>Current mode: <strong>{ summarizeModeLabel(auto) }</strong></p>
|
||||||
|
<form
|
||||||
|
method="post"
|
||||||
|
action="/account/summarize-mode"
|
||||||
|
hx-post="/account/summarize-mode"
|
||||||
|
hx-target="#summarize-mode"
|
||||||
|
hx-swap="outerHTML"
|
||||||
|
>
|
||||||
|
<input type="hidden" name="enabled" value={ boolStr(!auto) }/>
|
||||||
|
<button type="submit" class="btn-secondary">{ summarizeModeToggleLabel(auto) }</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
// ActionButtons is the toggle group fragment returned by POST /v/{id}/action.
|
// ActionButtons is the toggle group fragment returned by POST /v/{id}/action.
|
||||||
// Each button submits its verb; HTMX swaps this element in place (outerHTML),
|
// Each button submits its verb; HTMX swaps this element in place (outerHTML),
|
||||||
// and without JS the form POSTs and the handler redirects back to the detail
|
// and without JS the form POSTs and the handler redirects back to the detail
|
||||||
|
|||||||
+708
-295
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user