diff --git a/cmd/tapir/chat_test.go b/cmd/tapir/chat_test.go new file mode 100644 index 0000000..c492bdb --- /dev/null +++ b/cmd/tapir/chat_test.go @@ -0,0 +1,64 @@ +package main + +import ( + "strings" + "testing" + + "gitea.d-ma.be/mathias/tapir/internal/config" +) + +// TestChatModelsReuseTheChainLocalFirst: the switcher offers the ADR-022 chain in +// order, deduped — primary, local fallback, cloud. +func TestChatModelsReuseTheChainLocalFirst(t *testing.T) { + got := chatModels(config.Config{ + SummarizerModel: "koala/phi4-mini", + FallbackModel: "iguana/gemma4-26b", + CloudFallbackModel: "berget/mistral-small", + }) + want := []string{"koala/phi4-mini", "iguana/gemma4-26b", "berget/mistral-small"} + if strings.Join(got, ",") != strings.Join(want, ",") { + t.Fatalf("chatModels = %v, want %v", got, want) + } +} + +// TestChatCloudModelAbsentWhenDisabled: the local-first / NDA lever — with the +// cloud fallback empty (TAPIR_CLOUD_FALLBACK_MODEL=""), no external model is +// offered in the switcher, so chat content never leaves the local stack (ADR-027, +// honouring ADR-022's "content stays local" guarantee). +func TestChatCloudModelAbsentWhenDisabled(t *testing.T) { + got := chatModels(config.Config{ + SummarizerModel: "koala/phi4-mini", + FallbackModel: "iguana/gemma4-26b", + CloudFallbackModel: "", + }) + for _, m := range got { + if strings.HasPrefix(m, "berget/") || strings.Contains(m, "mistral") { + t.Fatalf("cloud model %q offered though the cloud fallback is disabled", m) + } + } + want := []string{"koala/phi4-mini", "iguana/gemma4-26b"} + if strings.Join(got, ",") != strings.Join(want, ",") { + t.Fatalf("chatModels = %v, want %v", got, want) + } +} + +// TestChatModelsDedup: a config that reuses one alias across slots collapses to a +// single switcher entry (no duplicate options). +func TestChatModelsDedup(t *testing.T) { + got := chatModels(config.Config{ + SummarizerModel: "koala/phi4-mini", + FallbackModel: "koala/phi4-mini", + CloudFallbackModel: "", + }) + if len(got) != 1 || got[0] != "koala/phi4-mini" { + t.Fatalf("chatModels = %v, want a single deduped entry", got) + } +} + +// TestBuildChatNilWithoutGateway: no gateway → no chat backend (routes unmounted, +// read path unaffected). +func TestBuildChatNilWithoutGateway(t *testing.T) { + if c := buildChat(config.Config{GatewayURL: ""}); c != nil { + t.Fatal("buildChat must return nil without a gateway URL") + } +} diff --git a/cmd/tapir/main.go b/cmd/tapir/main.go index 2e01fc3..3a5ff65 100644 --- a/cmd/tapir/main.go +++ b/cmd/tapir/main.go @@ -197,6 +197,15 @@ func cmdServe(ctx context.Context, log *slog.Logger) error { secretStore := secrets.NewFileStore(cfg.SecretsFile) app := &web.App{Store: st, Identity: st, Auth: authn, Secrets: secretStore, Log: log, RecencyWindow: cfg.AutoSummarizeWindow} + // Per-video deeper-dive chat over the STORED transcript (ADR-027). Enabled + // whenever a gateway is configured — it needs no YouTube credentials because it + // never fetches. Guarded so a typed-nil never lands in the interface field + // (which would mount the routes over a nil backend). + if c := buildChat(cfg); c != nil { + app.Chat = c + log.Info("web chat enabled (stored-transcript only)", "models", chatModels(cfg)) + } + // User onboarding is handled by the IdP (Authentik invite flow), not Tapir — // the Dex local-password provisioning path was removed (ADR-019). An // authenticated subject with no Tapir user is routed to /register. diff --git a/cmd/tapir/processor.go b/cmd/tapir/processor.go index 40f0377..2c82ddf 100644 --- a/cmd/tapir/processor.go +++ b/cmd/tapir/processor.go @@ -5,6 +5,7 @@ import ( "fmt" "strings" + "gitea.d-ma.be/mathias/tapir/internal/adapters/chat" "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" @@ -68,6 +69,49 @@ func buildSummarizer(cfg config.Config) *summarizer.Summarizer { return summarizer.NewChain(eps, cfg.MaxTranscriptChars) } +// chatModels is the ordered, local-first set of models offered in the chat +// switcher (ADR-027), reusing the ADR-022 chain: primary → local fallback → +// cloud. Empty entries are dropped and duplicates collapsed, so a client/NDA +// deployment that sets the cloud fallback empty simply has no external model in +// the switcher — the same local-first lever the summarizer honours. +func chatModels(cfg config.Config) []string { + var models []string + add := func(m string) { + if m == "" { + return + } + for _, e := range models { + if e == m { + return + } + } + models = append(models, m) + } + add(cfg.SummarizerModel) + add(cfg.FallbackModel) + add(cfg.CloudFallbackModel) + return models +} + +// buildChat wires the per-video chat service (ADR-027): a Completer factory over +// the SAME LiteLLM gateway the summarizer uses (a different alias per model, not a +// second client config) and the same transcript-truncation budget. It returns nil +// when no gateway is configured — chat is simply not mounted, the read path is +// unaffected. It deliberately takes NO YouTube source: chat is stored-only. +func buildChat(cfg config.Config) *chat.Service { + if cfg.GatewayURL == "" { + return nil + } + models := chatModels(cfg) + if len(models) == 0 { + return nil + } + newClient := func(model string) chat.Completer { + return llm.New(cfg.GatewayURL, cfg.GatewayKey, model, cfg.SummarizerTimeout, llm.WithMaxTokens(cfg.SummaryMaxTokens)) + } + return chat.New(newClient, models, cfg.MaxTranscriptChars) +} + // providerOf maps a model alias to the domain AIProvider recorded on summaries. // A "berget/" alias is an external provider; everything else is the local stack. func providerOf(model string) string { diff --git a/docs/use-cases/chat_transcript.feature b/docs/use-cases/chat_transcript.feature new file mode 100644 index 0000000..47f4d0f --- /dev/null +++ b/docs/use-cases/chat_transcript.feature @@ -0,0 +1,63 @@ +Feature: Chat with a video's stored transcript + As a reader whose summary made me want to dig deeper + I want to ask questions about the video without watching it + So that I can go further on the ones worth it, without leaving the reader + + # ADR-027. The load-bearing constraint is safety-by-construction: chat runs + # ONLY against an already-stored transcript (ADR-021) and never fetches captions, + # never touches the rate gate, never reaches YouTube. Entry is from the summary + # view of one's OWN video; the conversation is ephemeral (no persisted history). + + Background: + Given I have a summarized video with a stored transcript + + Scenario: A summary view offers a deeper-dive into the video + When I view the summary + Then I see a "dig deeper" affordance that opens a chat about this video + + Scenario: Ask a question answered from the stored transcript + When I ask a question in the chat + Then the answer is produced from the stored transcript + And no caption fetch and no YouTube call occurs + + Scenario: Chat never fetches captions or reaches YouTube + When I ask a question in the chat + Then Tapir reads only the stored transcript + And the caption-fetch and video-fetch paths are never invoked + + Scenario: A video with no stored transcript offers no chat + Given a video that has no stored transcript + When I open the chat for it + Then I am told chat is not available + And no fetch is attempted and no model is called + + Scenario: The default model is the summary's model and is switchable + When I open the chat + Then the model defaults to the model that produced the summary + And I can switch among the offered chain models + + Scenario: Switching models re-runs against the same transcript + When I ask a question with a different chain model selected + Then the chosen model answers + And it answers against the same stored transcript + + Scenario: The cloud model is hidden when cloud is disabled + Given the cloud fallback model is disabled + Then the chat switcher offers only local models + + Scenario: A long transcript is bounded and the chat says so + Given the stored transcript is longer than the model budget + When I ask a question + Then the answer is produced from a bounded portion + And the chat notes that it worked from a bounded portion + + Scenario: A multi-turn conversation is ephemeral + When I ask a follow-up question + Then the prior turn is carried into the answer + And nothing about the conversation is written to the database + + Scenario: Chat is reachable only from my own summary view + Given another user has a summarized video with a stored transcript + When I try to open the chat for their video + Then I get a not-found response + And no model is called diff --git a/internal/web/chat.go b/internal/web/chat.go new file mode 100644 index 0000000..164004c --- /dev/null +++ b/internal/web/chat.go @@ -0,0 +1,218 @@ +package web + +import ( + "context" + "errors" + "net/http" + "strings" + + "gitea.d-ma.be/mathias/tapir/internal/adapters/chat" + "gitea.d-ma.be/mathias/tapir/internal/adapters/store" +) + +// Chatter is the per-video chat backend (ADR-027). *chat.Service satisfies it; +// tests substitute a fake. It carries NO caption-fetch dependency — the chat +// handlers reach it only after reading an already-stored transcript, so an +// enabled chat cannot trigger a fetch, touch the rate gate, or reach YouTube. +type Chatter interface { + // Models returns the offerable models, local-first (cloud absent when disabled). + Models() []string + // DefaultModel resolves the model a fresh chat opens with given the summary's + // model (the ADR-027 default), falling back to the first offered model. + DefaultModel(summaryModel string) string + // Answer runs one chat turn against the supplied stored transcript text. + Answer(ctx context.Context, req chat.Request) (chat.Reply, error) +} + +// maxHistoryTurns bounds the ephemeral conversation carried per request, so a long +// back-and-forth cannot grow the prompt without limit (the transcript already +// dominates the budget). Older turns drop off the front. +const maxHistoryTurns = 8 + +// chatView is everything the chat templates render: the video identity for links +// and titles, the model switcher state, the running (ephemeral) conversation, and +// the honest flags — Available is false when no usable transcript is stored +// (ADR-027: honest "not available", never a fetch), Truncated when the transcript +// was bounded to fit the model, Error for a transient model failure. +type chatView struct { + VideoID string + Title string + Available bool + Models []string + Selected string + History []chat.Turn + Truncated bool + Error string +} + +// handleChat renders the chat page for a summarized video (GET). Entry is scoped +// through GetSummaryByVideo, which is RLS/user-scoped: a video that is not the +// requesting user's own resolves to ErrNotFound → 404, so chat is reachable only +// from the user's own summary view (ADR-027 isolation). The transcript is read +// from the SHARED store (ADR-021) — a pure DB read, never a caption fetch; an +// absent/text-less transcript renders the honest "not available" state, no fetch. +func (a *App) handleChat(w http.ResponseWriter, r *http.Request) { + userID, ok := a.currentUserID(w, r) + if !ok { + return + } + row, ok := a.loadOwnedSummary(w, r, userID) + if !ok { + return + } + _, hasText, ok := a.readTranscript(w, r, *row) + if !ok { + return + } + a.render(w, r, ChatPage(chatView{ + VideoID: row.VideoID, + Title: displayTitle(*row), + Available: hasText, + Models: a.Chat.Models(), + Selected: a.Chat.DefaultModel(row.AIModel), + })) +} + +// handleChatMessage answers one question against the stored transcript (POST). +// It reads the transcript from the store (no fetch), runs the chosen model over +// it plus the prior turns, appends the answer, and returns the refreshed chat +// panel (HTMX) or the whole page (no-JS). A model failure is surfaced inline, +// not as a 500 — the conversation and the question are preserved for a retry. +func (a *App) handleChatMessage(w http.ResponseWriter, r *http.Request) { + userID, ok := a.currentUserID(w, r) + if !ok { + return + } + row, ok := a.loadOwnedSummary(w, r, userID) + if !ok { + return + } + if err := r.ParseForm(); err != nil { + http.Error(w, "bad form", http.StatusBadRequest) + return + } + transcript, hasText, ok := a.readTranscript(w, r, *row) + if !ok { + return + } + + model := a.resolveModel(r.FormValue("model"), row.AIModel) + history := parseHistory(r.Form["hq"], r.Form["ha"]) + question := strings.TrimSpace(r.FormValue("question")) + + view := chatView{ + VideoID: row.VideoID, + Title: displayTitle(*row), + Available: hasText, + Models: a.Chat.Models(), + Selected: model, + History: history, + } + + switch { + case !hasText: + // Honest "not available" — no fetch, no model call (ADR-027). + case question == "": + // A model switch with no text just re-renders — no wasted round-trip. + default: + reply, err := a.Chat.Answer(r.Context(), chat.Request{ + Model: model, + Transcript: transcript, + History: history, + Question: question, + }) + if err != nil { + a.logger().Error("chat answer", "video", row.VideoID, "model", model, "err", err) + view.Error = "That model couldn't answer just now. Try again, or switch models." + } else { + view.History = appendTurn(history, chat.Turn{Question: question, Answer: reply.Answer}) + view.Truncated = reply.Truncated + } + } + a.renderChat(w, r, view) +} + +// loadOwnedSummary fetches the summary for the path's video scoped to userID, or +// writes the right response (404 on not-found/not-owned, 500 on error) and reports +// false. It is the single isolation gate for both chat handlers. +func (a *App) loadOwnedSummary(w http.ResponseWriter, r *http.Request, userID string) (*store.SummaryRow, bool) { + videoID := r.PathValue("videoId") + row, err := a.Store.GetSummaryByVideo(r.Context(), userID, videoID) + if errors.Is(err, store.ErrNotFound) { + http.NotFound(w, r) + return nil, false + } + if err != nil { + a.serverError(w, r, "chat get summary", err) + return nil, false + } + return row, true +} + +// readTranscript reads the shared stored transcript for a row (ADR-021) and +// reports whether it carries usable text. It is a pure DB read — NO caption fetch, +// the property the whole feature's safety rests on. ok is false only on a store +// error (after a 500 is written); a missing/text-less transcript is (",", false, +// true) — the honest "not available" case, handled by the caller, not an error. +func (a *App) readTranscript(w http.ResponseWriter, r *http.Request, row store.SummaryRow) (content string, hasText, ok bool) { + t, found, err := a.Store.GetTranscript(r.Context(), row.Channel, row.ProviderVideoID) + if err != nil { + a.serverError(w, r, "chat get transcript", err) + return "", false, false + } + if !found || !t.HasText() { + return "", false, true + } + return t.Content, true, true +} + +// renderChat returns the chat panel fragment for an HTMX request, or the full +// chat page otherwise (no-JS POST re-renders the whole page). +func (a *App) renderChat(w http.ResponseWriter, r *http.Request, v chatView) { + if isHTMX(r) { + a.render(w, r, chatPanel(v)) + return + } + a.render(w, r, ChatPage(v)) +} + +// resolveModel keeps the posted model only when it is an offered option; anything +// else (a forged value, or a model dropped because cloud is disabled) falls back +// to the default. The chat.Service enforces the same guard before the gateway; +// this keeps the rendered switcher honest too. +func (a *App) resolveModel(posted, summaryModel string) string { + for _, m := range a.Chat.Models() { + if m == posted { + return posted + } + } + return a.Chat.DefaultModel(summaryModel) +} + +// parseHistory zips the parallel hidden hq/ha fields back into ordered turns, +// keeping only the most recent maxHistoryTurns. net/url preserves the submission +// order of repeated fields, so the pairing is stable. +func parseHistory(qs, as []string) []chat.Turn { + n := len(qs) + if len(as) < n { + n = len(as) + } + turns := make([]chat.Turn, 0, n) + for i := 0; i < n; i++ { + turns = append(turns, chat.Turn{Question: qs[i], Answer: as[i]}) + } + return capHistory(turns) +} + +// appendTurn adds a completed exchange and re-bounds the conversation. +func appendTurn(history []chat.Turn, t chat.Turn) []chat.Turn { + return capHistory(append(history, t)) +} + +// capHistory keeps the last maxHistoryTurns turns (drops the oldest). +func capHistory(turns []chat.Turn) []chat.Turn { + if len(turns) <= maxHistoryTurns { + return turns + } + return turns[len(turns)-maxHistoryTurns:] +} diff --git a/internal/web/chat_handler_test.go b/internal/web/chat_handler_test.go new file mode 100644 index 0000000..7fea6aa --- /dev/null +++ b/internal/web/chat_handler_test.go @@ -0,0 +1,340 @@ +package web_test + +import ( + "context" + "net/http" + "net/http/httptest" + "net/url" + "strings" + "testing" + "time" + + "github.com/jackc/pgx/v5/pgxpool" + "github.com/stretchr/testify/require" + + "gitea.d-ma.be/mathias/tapir/internal/adapters/chat" + "gitea.d-ma.be/mathias/tapir/internal/adapters/store" + "gitea.d-ma.be/mathias/tapir/internal/domain" + "gitea.d-ma.be/mathias/tapir/internal/web" +) + +// videoZ is a video id used by the isolation test for a DIFFERENT user's video. +const videoZ = "33333333-3333-3333-3333-333333333333" + +// --- chat test doubles ----------------------------------------------------- + +// fakeChatter is a web.Chatter that records the request it received and returns a +// canned reply. It performs NO network and NO fetch — it stands in for the real +// chat.Service so the handler behaviour is what's under test. +type fakeChatter struct { + models []string + reply chat.Reply + err error + gotReq chat.Request + callCount int +} + +func (f *fakeChatter) Models() []string { return f.models } + +func (f *fakeChatter) DefaultModel(summaryModel string) string { + for _, m := range f.models { + if m == summaryModel { + return summaryModel + } + } + if len(f.models) > 0 { + return f.models[0] + } + return "" +} + +func (f *fakeChatter) Answer(_ context.Context, req chat.Request) (chat.Reply, error) { + f.callCount++ + f.gotReq = req + if f.err != nil { + return chat.Reply{}, f.err + } + return f.reply, nil +} + +// tripwireProcessor and tripwireFetcher are the YouTube-reaching collaborators +// (summarize → caption fetch, and the paste metadata fetch). Wired into the App +// for the safety test, they fail it the instant chat routes into either — the +// behavioural proof that chat never triggers a fetch (ADR-027). +type tripwireProcessor struct{ t *testing.T } + +func (p tripwireProcessor) ProcessVideo(context.Context, string, string) error { + p.t.Fatal("chat triggered summarization (→ caption fetch) — must never happen (ADR-027)") + return nil +} + +type tripwireFetcher struct{ t *testing.T } + +func (f tripwireFetcher) FetchVideo(context.Context, string, string) (domain.Video, error) { + f.t.Fatal("chat triggered a YouTube video fetch — must never happen (ADR-027)") + return domain.Video{}, nil +} + +// --- helpers --------------------------------------------------------------- + +// newChatApp builds the App under test as the registered stub user, with a Chat +// backend wired. Mirrors newApp but adds chat (and any extra wiring via mutate). +func newChatApp(t *testing.T, chatter web.Chatter, mutate func(*web.App)) *web.App { + t.Helper() + s := newStore(t) + app := &web.App{ + Store: s, + Identity: s, + Auth: web.StubAuth{U: web.User{Subject: stubSubject}}, + Chat: chatter, + } + if mutate != nil { + mutate(app) + } + return app +} + +// seededProviderVideoID mirrors seedVideo's derivation so the chat path's +// (provider, providerVideoID) transcript key matches the seeded video row. +func seededProviderVideoID(videoID string) string { return "pv-" + videoID[:8] } + +// seedTranscript stores a shared (provider, providerVideoID) transcript — the +// ADR-021 stored content the chat reads. Captions source = usable text. +func seedTranscript(t *testing.T, s *store.Store, videoID, content string) { + t.Helper() + err := s.SaveTranscript(context.Background(), "youtube", seededProviderVideoID(videoID), + domain.Transcript{Source: domain.SourceCaptions, Language: "en", Content: content}) + require.NoError(t, err) +} + +func getChat(t *testing.T, app *web.App, videoID string) *httptest.ResponseRecorder { + t.Helper() + return do(t, app, httptest.NewRequest(http.MethodGet, "/v/"+videoID+"/chat", nil)) +} + +func postChat(t *testing.T, app *web.App, videoID string, form url.Values, htmx bool) *httptest.ResponseRecorder { + t.Helper() + req := httptest.NewRequest(http.MethodPost, "/v/"+videoID+"/chat", strings.NewReader(form.Encode())) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + if htmx { + req.Header.Set("HX-Request", "true") + } + return do(t, app, req) +} + +// --- scenarios ------------------------------------------------------------- + +// The "dig deeper" affordance appears on a summary detail view only when chat is +// enabled, and links to that video's chat — the single entry point (ADR-027 §1). +func TestChatEntryAffordanceOnSummaryView(t *testing.T) { + ctx := context.Background() + withChat := newChatApp(t, &fakeChatter{models: []string{"phi4-mini"}}, nil) + p := rawPool(t) + resetDB(t, p) + require.NoError(t, deliver(ctx, withChat, videoX, "body x")) + seedVideo(t, p, videoX, "X Title", "https://x", time.Time{}) + + html := body(t, do(t, withChat, httptest.NewRequest(http.MethodGet, "/v/"+videoX, nil))) + require.Contains(t, html, "Dig deeper", "the deeper-dive affordance is shown when chat is enabled") + require.Contains(t, html, "/v/"+videoX+"/chat", "it links to this video's chat") + + // With no chat backend wired the affordance is absent (routes unmounted). + noChat := newApp(t) + require.NoError(t, deliver(ctx, noChat, videoX, "body x")) + html = body(t, do(t, noChat, httptest.NewRequest(http.MethodGet, "/v/"+videoX, nil))) + require.NotContains(t, html, "Dig deeper", "no affordance when chat is disabled") +} + +// THE KEY SAFETY ASSERTION (ADR-027): a chat answer is produced entirely from the +// stored transcript — the model receives the stored text, and neither the +// summarize→fetch path nor the YouTube fetch path is ever touched. The tripwire +// collaborators t.Fatal the test if chat reaches them. +func TestChatAnswersFromStoredTranscriptWithoutAnyFetch(t *testing.T) { + ctx := context.Background() + const transcript = "STORED-TRANSCRIPT-MARKER: the host explains attention budgets." + chatter := &fakeChatter{ + models: []string{"phi4-mini"}, + reply: chat.Reply{Answer: "It is about attention budgets."}, + } + app := newChatApp(t, chatter, func(a *web.App) { + a.Processor = tripwireProcessor{t} // fails the test if chat summarizes/fetches + a.Fetcher = tripwireFetcher{t} // fails the test if chat fetches video metadata + }) + p := rawPool(t) + resetDB(t, p) + require.NoError(t, deliver(ctx, app, videoX, "summary body")) + seedVideo(t, p, videoX, "X Title", "https://x", time.Time{}) + seedTranscript(t, app.Store.(*store.Store), videoX, transcript) + + form := url.Values{"question": {"what is it about?"}, "model": {"phi4-mini"}} + rec := postChat(t, app, videoX, form, true) + + require.Equal(t, http.StatusOK, rec.Code) + require.Contains(t, body(t, rec), "It is about attention budgets.", "the answer is rendered") + require.Equal(t, 1, chatter.callCount, "the model was asked exactly once") + require.Equal(t, transcript, chatter.gotReq.Transcript, + "the model answered from the STORED transcript, not a fetched one") + // The tripwires never firing IS the no-fetch / no-YouTube proof. +} + +// A video with no stored transcript yields an honest "not available" — and never +// a fetch, never a model call (ADR-027 §2: do not add an on-demand-fetch path). +func TestChatUnavailableWhenNoStoredTranscript(t *testing.T) { + ctx := context.Background() + chatter := &fakeChatter{models: []string{"phi4-mini"}} + app := newChatApp(t, chatter, func(a *web.App) { + a.Processor = tripwireProcessor{t} + a.Fetcher = tripwireFetcher{t} + }) + p := rawPool(t) + resetDB(t, p) + require.NoError(t, deliver(ctx, app, videoX, "summary body")) + seedVideo(t, p, videoX, "X Title", "https://x", time.Time{}) + // NB: no seedTranscript — the transcript is absent. + + rec := getChat(t, app, videoX) + require.Equal(t, http.StatusOK, rec.Code) + require.Contains(t, body(t, rec), "isn't available", "honest not-available copy") + + // Asking anyway still triggers nothing: no model call, no fetch. + rec = postChat(t, app, videoX, url.Values{"question": {"hi"}, "model": {"phi4-mini"}}, true) + require.Equal(t, http.StatusOK, rec.Code) + require.Equal(t, 0, chatter.callCount, "no model call without a stored transcript") +} + +// Chat is reachable ONLY from the user's own summary view: opening chat for a +// video that belongs to another user 404s (the summary read is RLS-scoped), so a +// guessed/arbitrary video id is not a chat surface (ADR-027 §5 isolation). +func TestChatOnlyReachableForOwnVideo(t *testing.T) { + chatter := &fakeChatter{models: []string{"phi4-mini"}} + app := newChatApp(t, chatter, func(a *web.App) { + a.Processor = tripwireProcessor{t} + a.Fetcher = tripwireFetcher{t} + }) + p := rawPool(t) + resetDB(t, p) + + // A summarized video with a stored transcript owned by ANOTHER user. + const other = "22222222-2222-2222-2222-222222222222" + seedForeignSummaryWithTranscript(t, p, other, videoZ, "Foreign Title") + + rec := getChat(t, app, videoZ) + require.Equal(t, http.StatusNotFound, rec.Code, "cannot open chat for another user's video") + require.Equal(t, 0, chatter.callCount, "no model call for a non-owned video") + + rec = postChat(t, app, videoZ, url.Values{"question": {"hi"}, "model": {"phi4-mini"}}, true) + require.Equal(t, http.StatusNotFound, rec.Code, "cannot post chat to another user's video") +} + +// The switcher offers exactly the backend's models, defaults to the summary's own +// model, and a switch re-runs against the SAME transcript with the chosen model +// (ADR-027 §3 — model-comparison instrumentation). A model dropped from the offer +// set (e.g. cloud disabled) is not rendered. +func TestChatModelSwitcherDefaultAndSwitch(t *testing.T) { + ctx := context.Background() + // The seeded summary's model is "phi4-mini" (see handlers_test summary()). + chatter := &fakeChatter{ + models: []string{"phi4-mini", "gemma4-26b"}, // note: no cloud model offered + reply: chat.Reply{Answer: "answer"}, + } + app := newChatApp(t, chatter, nil) + p := rawPool(t) + resetDB(t, p) + require.NoError(t, deliver(ctx, app, videoX, "summary body")) + seedVideo(t, p, videoX, "X Title", "https://x", time.Time{}) + seedTranscript(t, app.Store.(*store.Store), videoX, "the transcript text") + + html := body(t, getChat(t, app, videoX)) + require.Contains(t, html, "gemma4-26b", "every offered model is in the switcher") + require.NotContains(t, html, "mistral", "a non-offered (cloud-disabled) model is absent") + require.Contains(t, html, `value="phi4-mini" selected`, "defaults to the summary's own model") + + // Switching to gemma re-runs against the same stored transcript. + form := url.Values{"question": {"q"}, "model": {"gemma4-26b"}} + rec := postChat(t, app, videoX, form, true) + require.Equal(t, http.StatusOK, rec.Code) + require.Equal(t, "gemma4-26b", chatter.gotReq.Model, "the chosen model answers") + require.Equal(t, "the transcript text", chatter.gotReq.Transcript, "against the same transcript") +} + +// A truncated transcript surfaces the honest bounded-context note (ADR-027 §2). +func TestChatTruncationNoteShown(t *testing.T) { + ctx := context.Background() + chatter := &fakeChatter{ + models: []string{"phi4-mini"}, + reply: chat.Reply{Answer: "answer", Truncated: true}, + } + app := newChatApp(t, chatter, nil) + p := rawPool(t) + resetDB(t, p) + require.NoError(t, deliver(ctx, app, videoX, "summary body")) + seedVideo(t, p, videoX, "X Title", "https://x", time.Time{}) + seedTranscript(t, app.Store.(*store.Store), videoX, "long transcript") + + rec := postChat(t, app, videoX, url.Values{"question": {"q"}, "model": {"phi4-mini"}}, true) + require.Contains(t, body(t, rec), "bounded portion", "the truncation note is shown") +} + +// A multi-turn conversation is carried in the request (hidden fields), not the DB: +// prior turns ride back into the next answer, and nothing is persisted (ADR-027 §4). +func TestChatMultiTurnHistoryIsEphemeral(t *testing.T) { + ctx := context.Background() + chatter := &fakeChatter{models: []string{"phi4-mini"}, reply: chat.Reply{Answer: "second answer"}} + app := newChatApp(t, chatter, nil) + p := rawPool(t) + resetDB(t, p) + require.NoError(t, deliver(ctx, app, videoX, "summary body")) + seedVideo(t, p, videoX, "X Title", "https://x", time.Time{}) + seedTranscript(t, app.Store.(*store.Store), videoX, "the transcript") + + // Second turn posts the prior exchange as hidden history fields. + form := url.Values{ + "question": {"follow-up question"}, + "model": {"phi4-mini"}, + "hq": {"first question"}, + "ha": {"first answer"}, + } + rec := postChat(t, app, videoX, form, true) + require.Equal(t, http.StatusOK, rec.Code) + + require.Len(t, chatter.gotReq.History, 1, "the prior turn was carried into the request") + require.Equal(t, "first question", chatter.gotReq.History[0].Question) + require.Equal(t, "first answer", chatter.gotReq.History[0].Answer) + + html := body(t, rec) + require.Contains(t, html, "second answer", "the new answer renders") + require.Contains(t, html, "first question", "the conversation persists in page state") + + // Nothing was written to the DB — there is no chat table; the summaries/actions + // are unchanged by a chat turn. + row, err := app.Store.GetSummaryByVideo(ctx, userID, videoX) + require.NoError(t, err) + require.Equal(t, "summary body", row.Summary, "chat never mutates stored data") +} + +// --- foreign-user seeding (isolation) -------------------------------------- + +// seedForeignSummaryWithTranscript creates a fully-summarized, transcript-backed +// video owned by a DIFFERENT user via the raw pool (which bypasses RLS), so the +// isolation test can confirm the requesting user cannot open chat for it. +func seedForeignSummaryWithTranscript(t *testing.T, p *pgxpool.Pool, otherUserID, videoID, title string) { + t.Helper() + ctx := context.Background() + _, err := p.Exec(ctx, `INSERT INTO users (id) VALUES ($1) ON CONFLICT (id) DO NOTHING`, otherUserID) + require.NoError(t, err) + _, err = p.Exec(ctx, + `INSERT INTO videos (id, user_id, provider, provider_video_id, title, url) + VALUES ($1, $2, 'youtube', $3, $4, 'https://z')`, + videoID, otherUserID, seededProviderVideoID(videoID), title) + require.NoError(t, err) + _, err = p.Exec(ctx, + `INSERT INTO summaries (user_id, video_id, summary, ai_provider, ai_model) + VALUES ($1, $2, 'foreign summary', 'local', 'phi4-mini')`, + otherUserID, videoID) + require.NoError(t, err) + _, err = p.Exec(ctx, + `INSERT INTO transcripts (provider, provider_video_id, source, language, content) + VALUES ('youtube', $1, 'captions', 'en', 'foreign transcript')`, + seededProviderVideoID(videoID)) + require.NoError(t, err) +} diff --git a/internal/web/handlers.go b/internal/web/handlers.go index 48041e6..6d20c37 100644 --- a/internal/web/handlers.go +++ b/internal/web/handlers.go @@ -26,6 +26,10 @@ type Store interface { DistinctChannels(ctx context.Context, userID string) ([]string, error) GetSummaryByVideo(ctx context.Context, userID, videoID string) (*store.SummaryRow, error) GetVideoRow(ctx context.Context, userID, videoID string) (*store.SummaryRow, error) + // GetTranscript reads the shared, stored transcript keyed by (provider, + // providerVideoID) — ADR-021. It is a pure DB read: it never fetches captions, + // so the chat path (ADR-027) reaches it without any caption-fetch surface. + GetTranscript(ctx context.Context, provider, providerVideoID string) (domain.Transcript, bool, error) ActionsFor(ctx context.Context, userID string, videoIDs []string) (map[string][]string, error) SetAction(ctx context.Context, userID, videoID, action string) error ClearAction(ctx context.Context, userID, videoID, action string) error @@ -91,6 +95,11 @@ type App struct { // Fetcher, when non-nil, resolves an arbitrary YouTube video id to metadata for // the paste-a-URL flow (Feature 2). Nil = the /paste route is not mounted. Fetcher VideoFetcher + // Chat, when non-nil, answers per-video questions against a video's STORED + // transcript (ADR-027). Nil = the /v/{id}/chat routes are not mounted and the + // summary view shows no "dig deeper" affordance. It holds no caption-fetch + // dependency, so an enabled chat cannot reach YouTube or the rate gate. + Chat Chatter // 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 @@ -149,6 +158,12 @@ func (a *App) Router() http.Handler { app.HandleFunc("POST /paste", a.handlePaste) } app.HandleFunc("GET /v/{videoId}/status", a.handleStatus) + // Per-video deeper-dive chat over the STORED transcript (ADR-027). Mounted only + // when a Chat backend is wired; it never fetches captions. + if a.Chat != nil { + app.HandleFunc("GET /v/{videoId}/chat", a.handleChat) + app.HandleFunc("POST /v/{videoId}/chat", a.handleChatMessage) + } app.HandleFunc("GET /register", a.handleRegisterForm) app.HandleFunc("POST /register", a.handleRegister) @@ -265,7 +280,7 @@ func (a *App) handleDetail(w http.ResponseWriter, r *http.Request) { a.serverError(w, r, "get summary", err) return } - a.render(w, r, DetailPage(*row)) + a.render(w, r, DetailPage(*row, a.Chat != nil)) } // handleAction toggles one action: re-clicking an active verb clears it, else it diff --git a/internal/web/view.go b/internal/web/view.go index e053c46..9507036 100644 --- a/internal/web/view.go +++ b/internal/web/view.go @@ -184,6 +184,12 @@ func statusURL(videoID string) templ.SafeURL { return templ.SafeURL("/v/" + videoID + "/status") } +// chatURL builds the per-video chat path (GET renders the page, POST answers) — +// the deeper-dive over the stored transcript (ADR-027). +func chatURL(videoID string) templ.SafeURL { + return templ.SafeURL("/v/" + videoID + "/chat") +} + // 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. @@ -725,6 +731,28 @@ a.btn, a.btn:visited { color: var(--accent-fg); } .detail ul { margin: 0; padding-left: 1.2rem; line-height: 1.6; } .detail li { margin-bottom: var(--s1); } +/* deeper-dive chat (ADR-027) */ +.dig-deeper { margin: var(--s3) 0 0; } +.chat .chat-scope { margin: 0 0 var(--s4); font-size: .9rem; } +.chat-panel { display: flex; flex-direction: column; gap: var(--s3); } +.chat-log { display: flex; flex-direction: column; gap: var(--s3); } +.chat-turn { border-radius: var(--radius); padding: var(--s2) var(--s3); } +.chat-turn p { margin: 0; } +.chat-q { background: var(--accent-weak); color: var(--fg); align-self: flex-end; max-width: 85%; } +.chat-a { background: var(--card); border: 1px solid var(--line); } +.chat-a .body { white-space: pre-wrap; line-height: 1.6; } +.chat-note { margin: 0; font-size: .82rem; font-style: italic; } +.chat-error { margin: 0; color: #8a1c10; font-size: .9rem; } +@media (prefers-color-scheme: dark) { .chat-error { color: #f3b5ae; } } +.chat-form { display: flex; flex-direction: column; gap: var(--s2); margin: var(--s2) 0 0; } +.chat-model { flex-direction: column; display: flex; gap: var(--s1); font-size: .78rem; text-transform: uppercase; letter-spacing: .04em; color: var(--muted); align-items: flex-start; } +.chat-model select { font: inherit; text-transform: none; letter-spacing: 0; padding: .4rem .55rem; border: 1px solid var(--line); border-radius: var(--radius); background: var(--card); color: var(--fg); } +.chat-model-hint { text-transform: none; letter-spacing: 0; font-size: .78rem; } +.chat-form textarea { font: inherit; padding: .55rem; border: 1px solid var(--line); border-radius: var(--radius); background: var(--card); color: var(--fg); resize: vertical; } +.chat-form textarea:focus-visible { outline: 2px solid var(--accent); outline-offset: 1px; border-color: var(--accent); } +.chat-form .btn { align-self: flex-start; } +.chat-thinking { font-style: italic; } + /* action toggles — watched|skipped form one segmented control (they are mutually exclusive), "saved" sits apart as an independent toggle */ .actions { display: flex; gap: var(--s3); margin: var(--s4) 0; flex-wrap: wrap; align-items: center; } diff --git a/internal/web/views.templ b/internal/web/views.templ index 4d82cd6..97ebd21 100644 --- a/internal/web/views.templ +++ b/internal/web/views.templ @@ -411,8 +411,9 @@ templ noCaptionsCard(r store.SummaryRow) { } // DetailPage is the full summary view: text, highlights, takeaways, metadata, -// and the action button group. -templ DetailPage(r store.SummaryRow) { +// and the action button group. chatEnabled adds the "dig deeper" affordance +// (ADR-027) — a quiet link into the per-video chat over the stored transcript. +templ DetailPage(r store.SummaryRow, chatEnabled bool) { @Layout("Tapir — " + displayTitle(r)) {

← Summaries

@@ -440,6 +441,11 @@ templ DetailPage(r store.SummaryRow) { if r.URL != "" {

watch on source ↗

} + if chatEnabled { + // Where the "I want more" reaction goes (ADR-027). A quiet link, not a + // loud CTA — it deepens value for a reader already here, never nudges. +

Dig deeper — ask about this video →

+ } @ActionButtons(r.VideoID, actionSet(r.Actions)) // Lead with the attention-saving payload: Takeaways ("is this worth my // time?") first, then Highlights, then the full Summary last (UX review @@ -473,6 +479,74 @@ templ DetailPage(r store.SummaryRow) { } } +// ChatPage is the per-video deeper-dive chat (ADR-027): a question/answer surface +// over the video's ALREADY-STORED transcript. The back link returns to the +// summary it was entered from. All the interaction lives in chatPanel so the HTMX +// answer-swap and the no-JS full-page render share one component. +templ ChatPage(v chatView) { + @Layout("Tapir — Ask — " + v.Title) { +
+

← { v.Title }

+

Ask about this video

+

Answers come only from this video's stored transcript — Tapir never fetches anything new here.

+ @chatPanel(v) +
+ } +} + +// chatPanel is the conversation + ask form, swapped in place on each answer +// (HTMX targets #chat-panel, outerHTML). When no transcript is stored it shows the +// honest "not available" state and no form (ADR-027: never a fetch). The prior +// turns ride as hidden hq/ha fields so the ephemeral conversation survives the +// round-trip without any persisted state. +templ chatPanel(v chatView) { +
+ if !v.Available { +

Chat isn't available for this video — its transcript isn't stored, and chat never fetches new captions. Summarize the video first to store its transcript.

+ } else { + if len(v.History) > 0 { +
+ for _, t := range v.History { +

{ t.Question }

+

{ t.Answer }

+ } +
+ } + if v.Truncated { +

Working from a bounded portion of a long transcript — answers about the end of the video may be incomplete.

+ } + if v.Error != "" { + + } +
+ for _, t := range v.History { + + + } + + + + thinking… +
+ } +
+} + // RegisterPage is the explicit registration step (ADR-012): an authenticated Dex // subject with no tapir user picks a display name to create their account. // errMsg, when set, reports a validation problem on the prior POST. diff --git a/internal/web/views_templ.go b/internal/web/views_templ.go index 54d1027..bb47b81 100644 --- a/internal/web/views_templ.go +++ b/internal/web/views_templ.go @@ -1453,8 +1453,9 @@ func noCaptionsCard(r store.SummaryRow) templ.Component { } // DetailPage is the full summary view: text, highlights, takeaways, metadata, -// and the action button group. -func DetailPage(r store.SummaryRow) templ.Component { +// and the action button group. chatEnabled adds the "dig deeper" affordance +// (ADR-027) — a quiet link into the per-video chat over the stored transcript. +func DetailPage(r store.SummaryRow, chatEnabled bool) templ.Component { return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil { @@ -1494,7 +1495,7 @@ func DetailPage(r store.SummaryRow) templ.Component { var templ_7745c5c3_Var61 string templ_7745c5c3_Var61, templ_7745c5c3_Err = templ.JoinStringErrs(displayTitle(r)) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views.templ`, Line: 419, Col: 24} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views.templ`, Line: 420, Col: 24} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var61)) if templ_7745c5c3_Err != nil { @@ -1512,7 +1513,7 @@ func DetailPage(r store.SummaryRow) templ.Component { var templ_7745c5c3_Var62 string templ_7745c5c3_Var62, templ_7745c5c3_Err = templ.JoinStringErrs(detailMeta(r)) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views.templ`, Line: 422, Col: 26} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views.templ`, Line: 423, Col: 26} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var62)) if templ_7745c5c3_Err != nil { @@ -1541,7 +1542,7 @@ func DetailPage(r store.SummaryRow) templ.Component { var templ_7745c5c3_Var63 string templ_7745c5c3_Var63, templ_7745c5c3_Err = templ.ResolveAttributeValue(url) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views.templ`, Line: 431, Col: 15} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views.templ`, Line: 432, Col: 15} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var63) if templ_7745c5c3_Err != nil { @@ -1554,7 +1555,7 @@ func DetailPage(r store.SummaryRow) templ.Component { var templ_7745c5c3_Var64 string templ_7745c5c3_Var64, templ_7745c5c3_Err = templ.ResolveAttributeValue(displayTitle(r)) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views.templ`, Line: 432, Col: 29} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views.templ`, Line: 433, Col: 29} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var64) if templ_7745c5c3_Err != nil { @@ -1573,7 +1574,7 @@ func DetailPage(r store.SummaryRow) templ.Component { var templ_7745c5c3_Var65 templ.SafeURL templ_7745c5c3_Var65, templ_7745c5c3_Err = templ.JoinURLErrs(externalURL(r.URL)) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views.templ`, Line: 441, Col: 50} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views.templ`, Line: 442, Col: 50} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var65)) if templ_7745c5c3_Err != nil { @@ -1584,82 +1585,101 @@ func DetailPage(r store.SummaryRow) templ.Component { return templ_7745c5c3_Err } } + if chatEnabled { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 132, "

Dig deeper — ask about this video →

") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } templ_7745c5c3_Err = ActionButtons(r.VideoID, actionSet(r.Actions)).Render(ctx, templ_7745c5c3_Buffer) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } if len(r.Takeaways) > 0 { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 132, "

Takeaways

") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 140, "

Summary

") + if len(r.Highlights) > 0 { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 138, "

Highlights

    ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + for _, h := range r.Highlights { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 139, "
  • ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var68 string + templ_7745c5c3_Var68, templ_7745c5c3_Err = templ.JoinStringErrs(h) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views.templ`, Line: 469, Col: 14} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var68)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 140, "
  • ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 141, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 142, "

Summary

") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - var templ_7745c5c3_Var68 string - templ_7745c5c3_Var68, templ_7745c5c3_Err = templ.JoinStringErrs(r.Summary) + var templ_7745c5c3_Var69 string + templ_7745c5c3_Var69, templ_7745c5c3_Err = templ.JoinStringErrs(r.Summary) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views.templ`, Line: 470, Col: 31} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views.templ`, Line: 476, Col: 31} } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var68)) + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var69)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 141, "

") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 143, "

") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -1673,6 +1693,323 @@ func DetailPage(r store.SummaryRow) templ.Component { }) } +// ChatPage is the per-video deeper-dive chat (ADR-027): a question/answer surface +// over the video's ALREADY-STORED transcript. The back link returns to the +// summary it was entered from. All the interaction lives in chatPanel so the HTMX +// answer-swap and the no-JS full-page render share one component. +func ChatPage(v chatView) templ.Component { + return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { + templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context + if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil { + return templ_7745c5c3_CtxErr + } + templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) + if !templ_7745c5c3_IsBuffer { + defer func() { + templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) + if templ_7745c5c3_Err == nil { + templ_7745c5c3_Err = templ_7745c5c3_BufErr + } + }() + } + ctx = templ.InitializeContext(ctx) + templ_7745c5c3_Var70 := templ.GetChildren(ctx) + if templ_7745c5c3_Var70 == nil { + templ_7745c5c3_Var70 = templ.NopComponent + } + ctx = templ.ClearChildren(ctx) + templ_7745c5c3_Var71 := templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { + templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context + templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) + if !templ_7745c5c3_IsBuffer { + defer func() { + templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) + if templ_7745c5c3_Err == nil { + templ_7745c5c3_Err = templ_7745c5c3_BufErr + } + }() + } + ctx = templ.InitializeContext(ctx) + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 144, "

← ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var73 string + templ_7745c5c3_Var73, templ_7745c5c3_Err = templ.JoinStringErrs(v.Title) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views.templ`, Line: 489, Col: 64} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var73)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 146, "

Ask about this video

Answers come only from this video's stored transcript — Tapir never fetches anything new here.

") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = chatPanel(v).Render(ctx, templ_7745c5c3_Buffer) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 147, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + return nil + }) + templ_7745c5c3_Err = Layout("Tapir — Ask — "+v.Title).Render(templ.WithChildren(ctx, templ_7745c5c3_Var71), templ_7745c5c3_Buffer) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + return nil + }) +} + +// chatPanel is the conversation + ask form, swapped in place on each answer +// (HTMX targets #chat-panel, outerHTML). When no transcript is stored it shows the +// honest "not available" state and no form (ADR-027: never a fetch). The prior +// turns ride as hidden hq/ha fields so the ephemeral conversation survives the +// round-trip without any persisted state. +func chatPanel(v chatView) templ.Component { + return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { + templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context + if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil { + return templ_7745c5c3_CtxErr + } + templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) + if !templ_7745c5c3_IsBuffer { + defer func() { + templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) + if templ_7745c5c3_Err == nil { + templ_7745c5c3_Err = templ_7745c5c3_BufErr + } + }() + } + ctx = templ.InitializeContext(ctx) + templ_7745c5c3_Var74 := templ.GetChildren(ctx) + if templ_7745c5c3_Var74 == nil { + templ_7745c5c3_Var74 = templ.NopComponent + } + ctx = templ.ClearChildren(ctx) + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 148, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + if !v.Available { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 149, "

Chat isn't available for this video — its transcript isn't stored, and chat never fetches new captions. Summarize the video first to store its transcript.

") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } else { + if len(v.History) > 0 { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 150, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + for _, t := range v.History { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 151, "

") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var75 string + templ_7745c5c3_Var75, templ_7745c5c3_Err = templ.JoinStringErrs(t.Question) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views.templ`, Line: 510, Col: 51} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var75)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 152, "

") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var76 string + templ_7745c5c3_Var76, templ_7745c5c3_Err = templ.JoinStringErrs(t.Answer) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views.templ`, Line: 511, Col: 62} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var76)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 153, "

") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 154, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 155, " ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + if v.Truncated { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 156, "

Working from a bounded portion of a long transcript — answers about the end of the video may be incomplete.

") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 157, " ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + if v.Error != "" { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 158, "

") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var77 string + templ_7745c5c3_Var77, templ_7745c5c3_Err = templ.JoinStringErrs(v.Error) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views.templ`, Line: 519, Col: 48} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var77)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 159, "

") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 160, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + for _, t := range v.History { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 163, " ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 166, " thinking…
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 173, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + return nil + }) +} + // RegisterPage is the explicit registration step (ADR-012): an authenticated Dex // subject with no tapir user picks a display name to create their account. // errMsg, when set, reports a validation problem on the prior POST. @@ -1692,12 +2029,12 @@ func RegisterPage(email, errMsg string) templ.Component { }() } ctx = templ.InitializeContext(ctx) - templ_7745c5c3_Var69 := templ.GetChildren(ctx) - if templ_7745c5c3_Var69 == nil { - templ_7745c5c3_Var69 = templ.NopComponent + templ_7745c5c3_Var84 := templ.GetChildren(ctx) + if templ_7745c5c3_Var84 == nil { + templ_7745c5c3_Var84 = templ.NopComponent } ctx = templ.ClearChildren(ctx) - templ_7745c5c3_Var70 := templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { + templ_7745c5c3_Var85 := templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) if !templ_7745c5c3_IsBuffer { @@ -1709,59 +2046,59 @@ func RegisterPage(email, errMsg string) templ.Component { }() } ctx = templ.InitializeContext(ctx) - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 142, "

Complete your registration

") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 174, "

Complete your registration

") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } if email != "" { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 143, "

Signed in as ") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 175, "

Signed in as ") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - var templ_7745c5c3_Var71 string - templ_7745c5c3_Var71, templ_7745c5c3_Err = templ.JoinStringErrs(email) + var templ_7745c5c3_Var86 string + templ_7745c5c3_Var86, templ_7745c5c3_Err = templ.JoinStringErrs(email) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views.templ`, Line: 484, Col: 40} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views.templ`, Line: 558, Col: 40} } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var71)) + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var86)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 144, ".

") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 176, ".

") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 145, "

Choose a display name to finish setting up your Tapir account.

") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 177, "

Choose a display name to finish setting up your Tapir account.

") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } if errMsg != "" { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 146, "

") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 178, "

") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - var templ_7745c5c3_Var72 string - templ_7745c5c3_Var72, templ_7745c5c3_Err = templ.JoinStringErrs(errMsg) + var templ_7745c5c3_Var87 string + templ_7745c5c3_Var87, templ_7745c5c3_Err = templ.JoinStringErrs(errMsg) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views.templ`, Line: 488, Col: 42} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views.templ`, Line: 562, Col: 42} } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var72)) + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var87)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 147, "

") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 179, "

") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 148, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 180, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } return nil }) - templ_7745c5c3_Err = Layout("Tapir — Register").Render(templ.WithChildren(ctx, templ_7745c5c3_Var70), templ_7745c5c3_Buffer) + templ_7745c5c3_Err = Layout("Tapir — Register").Render(templ.WithChildren(ctx, templ_7745c5c3_Var85), templ_7745c5c3_Buffer) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -1789,12 +2126,12 @@ func AccountPage(displayName, email string, conns []store.Connection, autoSummar }() } ctx = templ.InitializeContext(ctx) - templ_7745c5c3_Var73 := templ.GetChildren(ctx) - if templ_7745c5c3_Var73 == nil { - templ_7745c5c3_Var73 = templ.NopComponent + templ_7745c5c3_Var88 := templ.GetChildren(ctx) + if templ_7745c5c3_Var88 == nil { + templ_7745c5c3_Var88 = templ.NopComponent } ctx = templ.ClearChildren(ctx) - templ_7745c5c3_Var74 := templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { + templ_7745c5c3_Var89 := templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) if !templ_7745c5c3_IsBuffer { @@ -1810,43 +2147,43 @@ func AccountPage(displayName, email string, conns []store.Connection, autoSummar if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 149, "

Account

Display name
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 181, "

Account

Display name
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - var templ_7745c5c3_Var75 string - templ_7745c5c3_Var75, templ_7745c5c3_Err = templ.JoinStringErrs(displayNameOr(displayName)) + var templ_7745c5c3_Var90 string + templ_7745c5c3_Var90, templ_7745c5c3_Err = templ.JoinStringErrs(displayNameOr(displayName)) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views.templ`, Line: 512, Col: 36} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views.templ`, Line: 586, Col: 36} } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var75)) + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var90)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 150, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 182, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } if email != "" { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 151, "
Signed in as
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 183, "
Signed in as
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - var templ_7745c5c3_Var76 string - templ_7745c5c3_Var76, templ_7745c5c3_Err = templ.JoinStringErrs(email) + var templ_7745c5c3_Var91 string + templ_7745c5c3_Var91, templ_7745c5c3_Err = templ.JoinStringErrs(email) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views.templ`, Line: 515, Col: 16} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views.templ`, Line: 589, Col: 16} } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var76)) + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var91)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 152, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 184, "") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 153, "

Summarization

Automatic summarizes new videos from about the last week as they are discovered. Older videos stay browsable — summarize them on demand. Manual lets you pick which videos to summarize — every new video appears in your list with a Summarize button.

") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 185, "

Summarization

Automatic summarizes new videos from about the last week as they are discovered. Older videos stay browsable — summarize them on demand. Manual lets you pick which videos to summarize — every new video appears in your list with a Summarize button.

") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -1854,178 +2191,178 @@ func AccountPage(displayName, email string, conns []store.Connection, autoSummar if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 154, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 186, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } if len(channelErrors) > 0 { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 155, "

Unavailable channels

") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 187, "

Unavailable channels

") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - var templ_7745c5c3_Var77 string - templ_7745c5c3_Var77, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%d channel(s) returned errors on the last discovery pass.", len(channelErrors))) + var templ_7745c5c3_Var92 string + templ_7745c5c3_Var92, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%d channel(s) returned errors on the last discovery pass.", len(channelErrors))) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views.templ`, Line: 532, Col: 100} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views.templ`, Line: 606, Col: 100} } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var77)) + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var92)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 156, " These may have been deleted or made private on YouTube.

    ") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 188, " These may have been deleted or made private on YouTube.

      ") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } for _, ce := range channelErrors { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 157, "
    • ") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 189, "
    • ") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - var templ_7745c5c3_Var78 string - templ_7745c5c3_Var78, templ_7745c5c3_Err = templ.JoinStringErrs(ce.ChannelName) + var templ_7745c5c3_Var93 string + templ_7745c5c3_Var93, templ_7745c5c3_Err = templ.JoinStringErrs(ce.ChannelName) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views.templ`, Line: 538, Col: 57} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views.templ`, Line: 612, Col: 57} } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var78)) + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var93)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 158, " unavailable since ") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 190, " unavailable since ") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - var templ_7745c5c3_Var79 string - templ_7745c5c3_Var79, templ_7745c5c3_Err = templ.JoinStringErrs(ce.FirstSeen.Format("2006-01-02")) + var templ_7745c5c3_Var94 string + templ_7745c5c3_Var94, templ_7745c5c3_Err = templ.JoinStringErrs(ce.FirstSeen.Format("2006-01-02")) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views.templ`, Line: 540, Col: 89} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views.templ`, Line: 614, Col: 89} } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var79)) + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var94)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 159, "
    • ") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 191, "") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 160, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 192, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 161, "

Connected accounts

") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 193, "

Connected accounts

") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } if len(conns) == 0 { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 162, "

No connected video accounts yet.

") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 194, "

No connected video accounts yet.

") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } else { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 163, "
    ") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 195, "
      ") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } for _, c := range conns { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 164, "
    • ") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 196, "
    • ") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - var templ_7745c5c3_Var80 string - templ_7745c5c3_Var80, templ_7745c5c3_Err = templ.JoinStringErrs(providerLabel(c.Provider)) + var templ_7745c5c3_Var95 string + templ_7745c5c3_Var95, templ_7745c5c3_Err = templ.JoinStringErrs(providerLabel(c.Provider)) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views.templ`, Line: 555, Col: 64} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views.templ`, Line: 629, Col: 64} } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var80)) + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var95)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 165, " ") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 197, " ") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } if c.ProviderAccount != "" { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 166, "") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 198, "") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - var templ_7745c5c3_Var81 string - templ_7745c5c3_Var81, templ_7745c5c3_Err = templ.JoinStringErrs(c.ProviderAccount) + var templ_7745c5c3_Var96 string + templ_7745c5c3_Var96, templ_7745c5c3_Err = templ.JoinStringErrs(c.ProviderAccount) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views.templ`, Line: 557, Col: 49} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views.templ`, Line: 631, Col: 49} } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var81)) + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var96)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 167, " ") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 199, " ") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 168, "") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 200, "") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - var templ_7745c5c3_Var82 string - templ_7745c5c3_Var82, templ_7745c5c3_Err = templ.JoinStringErrs(c.Status) + var templ_7745c5c3_Var97 string + templ_7745c5c3_Var97, templ_7745c5c3_Err = templ.JoinStringErrs(c.Status) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views.templ`, Line: 559, Col: 38} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views.templ`, Line: 633, Col: 38} } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var82)) + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var97)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 169, "
      connected ") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 201, "
      connected ") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - var templ_7745c5c3_Var83 string - templ_7745c5c3_Var83, templ_7745c5c3_Err = templ.JoinStringErrs(c.ConnectedAt.Format("2006-01-02")) + var templ_7745c5c3_Var98 string + templ_7745c5c3_Var98, templ_7745c5c3_Err = templ.JoinStringErrs(c.ConnectedAt.Format("2006-01-02")) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views.templ`, Line: 561, Col: 83} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views.templ`, Line: 635, Col: 83} } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var83)) + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var98)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 170, "
    • ") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 203, "\">") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 172, "
    ") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 204, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } if !hasYouTube(conns) { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 173, "

Connect YouTube

") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 205, "

Connect YouTube

") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 174, "

Delete account

Permanently remove your Tapir account and all of its data — summaries, watch/skip/save actions, and connected accounts. This cannot be undone.

Delete account…

This permanently deletes your account and all data. Are you sure?

") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 206, "

Delete account

Permanently remove your Tapir account and all of its data — summaries, watch/skip/save actions, and connected accounts. This cannot be undone.

Delete account…

This permanently deletes your account and all data. Are you sure?

") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } return nil }) - templ_7745c5c3_Err = Layout("Tapir — Account").Render(templ.WithChildren(ctx, templ_7745c5c3_Var74), templ_7745c5c3_Buffer) + templ_7745c5c3_Err = Layout("Tapir — Account").Render(templ.WithChildren(ctx, templ_7745c5c3_Var89), templ_7745c5c3_Buffer) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -2053,51 +2390,51 @@ func summarizeModeControl(auto bool) templ.Component { }() } ctx = templ.InitializeContext(ctx) - templ_7745c5c3_Var85 := templ.GetChildren(ctx) - if templ_7745c5c3_Var85 == nil { - templ_7745c5c3_Var85 = templ.NopComponent + templ_7745c5c3_Var100 := templ.GetChildren(ctx) + if templ_7745c5c3_Var100 == nil { + templ_7745c5c3_Var100 = templ.NopComponent } ctx = templ.ClearChildren(ctx) - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 175, "

Current mode: ") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 207, "

Current mode: ") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - var templ_7745c5c3_Var86 string - templ_7745c5c3_Var86, templ_7745c5c3_Err = templ.JoinStringErrs(summarizeModeLabel(auto)) + var templ_7745c5c3_Var101 string + templ_7745c5c3_Var101, templ_7745c5c3_Err = templ.JoinStringErrs(summarizeModeLabel(auto)) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views.templ`, Line: 599, Col: 53} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views.templ`, Line: 673, Col: 53} } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var86)) + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var101)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 176, "

") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 210, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -2125,38 +2462,38 @@ func ActionButtons(videoID string, active map[string]bool) templ.Component { }() } ctx = templ.InitializeContext(ctx) - templ_7745c5c3_Var89 := templ.GetChildren(ctx) - if templ_7745c5c3_Var89 == nil { - templ_7745c5c3_Var89 = templ.NopComponent + templ_7745c5c3_Var104 := templ.GetChildren(ctx) + if templ_7745c5c3_Var104 == nil { + templ_7745c5c3_Var104 = templ.NopComponent } ctx = templ.ClearChildren(ctx) - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 179, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 213, "\" hx-target=\"#action-buttons\" hx-swap=\"outerHTML\">") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -2168,7 +2505,7 @@ func ActionButtons(videoID string, active map[string]bool) templ.Component { if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 182, "") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 214, "") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -2176,7 +2513,7 @@ func ActionButtons(videoID string, active map[string]bool) templ.Component { if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 183, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 215, "") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -2202,81 +2539,81 @@ func actionButton(verb string, isActive bool) templ.Component { }() } ctx = templ.InitializeContext(ctx) - templ_7745c5c3_Var92 := templ.GetChildren(ctx) - if templ_7745c5c3_Var92 == nil { - templ_7745c5c3_Var92 = templ.NopComponent + templ_7745c5c3_Var107 := templ.GetChildren(ctx) + if templ_7745c5c3_Var107 == nil { + templ_7745c5c3_Var107 = templ.NopComponent } ctx = templ.ClearChildren(ctx) - var templ_7745c5c3_Var93 = []any{"action", templ.KV("active", isActive)} - templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var93...) + var templ_7745c5c3_Var108 = []any{"action", templ.KV("active", isActive)} + templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var108...) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 184, "") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 220, "") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } diff --git a/test/acceptance/scenario_coverage_test.go b/test/acceptance/scenario_coverage_test.go index 141131f..099f78b 100644 --- a/test/acceptance/scenario_coverage_test.go +++ b/test/acceptance/scenario_coverage_test.go @@ -63,6 +63,18 @@ var scenarioCoverage = map[string]string{ "A returning subject passes straight through": "TestRegisteredSubjectPassesThrough", "Deleting an account removes only my data and leaves other users untouched": "TestDeleteAccountWipesDataAndSecretsAndLogsOut", + // chat_transcript.feature (ADR-027) + "A summary view offers a deeper-dive into the video": "TestChatEntryAffordanceOnSummaryView", + "Ask a question answered from the stored transcript": "TestChatAnswersFromStoredTranscriptWithoutAnyFetch", + "Chat never fetches captions or reaches YouTube": "TestChatAnswersFromStoredTranscriptWithoutAnyFetch", + "A video with no stored transcript offers no chat": "TestChatUnavailableWhenNoStoredTranscript", + "The default model is the summary's model and is switchable": "TestChatModelSwitcherDefaultAndSwitch", + "Switching models re-runs against the same transcript": "TestChatModelSwitcherDefaultAndSwitch", + "The cloud model is hidden when cloud is disabled": "TestChatCloudModelAbsentWhenDisabled", + "A long transcript is bounded and the chat says so": "TestChatTruncationNoteShown", + "A multi-turn conversation is ephemeral": "TestChatMultiTurnHistoryIsEphemeral", + "Chat is reachable only from my own summary view": "TestChatOnlyReachableForOwnVideo", + // summarize_new_video.feature "A subscribed channel posts a video that has captions": "TestSubscribedVideoWithCaptionsIsSummarizedAndDelivered", "A subscribed channel posts a video with no usable transcript": "TestVideoWithNoTranscriptIsSkipped",