feat(web): per-video chat over the stored transcript (ADR-027)
A deeper-dive chat entered from the summary view: ask questions about a video against its already-stored transcript (ADR-021), no caption fetch, ever. Safety by construction — the load-bearing property. The chat handlers reach the chat.Service only after reading the SHARED stored transcript via Store.GetTranscript (a pure DB read); the service holds no VideoSource. So an enabled chat cannot trigger a caption fetch, touch the rate gate, or reach YouTube. A video with no stored transcript gets an honest "not available" — no fetch, no model call. The key web test wires the summarize/fetch collaborators as tripwires that fail the test if chat ever routes into them, and asserts the model answered from the stored text. Model defaults to the summary's own model and is switchable among the ADR-022 chain (phi4-mini → gemma4-26b → mistral-small); switching re-runs against the same transcript — deliberate model-comparison instrumentation. The cloud model is absent from the switcher when TAPIR_CLOUD_FALLBACK_MODEL="" (the local-first / NDA lever), honoured the same way the summarizer honours it. Reuses the existing LiteLLM gateway client (a chat is a different call, not a new integration) and the TAPIR_MAX_TRANSCRIPT_CHARS truncation, surfacing an honest bounded-context note when a long transcript is cut. Ephemeral v1: the multi-turn conversation rides in hidden request fields; no table, no migration, nothing persisted. Entry is RLS-scoped through GetSummaryByVideo, so chat is reachable only from the user's own summary view. Show-source verification and on-demand fetch are deferred (ADR-027). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
@@ -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:]
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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)) {
|
||||
<article class="detail">
|
||||
<p class="back"><a href="/">← Summaries</a></p>
|
||||
@@ -440,6 +441,11 @@ templ DetailPage(r store.SummaryRow) {
|
||||
if r.URL != "" {
|
||||
<p class="source"><a href={ externalURL(r.URL) } rel="noopener noreferrer">watch on source ↗</a></p>
|
||||
}
|
||||
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.
|
||||
<p class="dig-deeper"><a class="btn-secondary" href={ chatURL(r.VideoID) }>Dig deeper — ask about this video →</a></p>
|
||||
}
|
||||
@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) {
|
||||
<article class="detail chat">
|
||||
<p class="back"><a href={ videoURL(v.VideoID) }>← { v.Title }</a></p>
|
||||
<h1>Ask about this video</h1>
|
||||
<p class="chat-scope muted">Answers come only from this video's stored transcript — Tapir never fetches anything new here.</p>
|
||||
@chatPanel(v)
|
||||
</article>
|
||||
}
|
||||
}
|
||||
|
||||
// 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) {
|
||||
<div id="chat-panel" class="chat-panel">
|
||||
if !v.Available {
|
||||
<p class="chat-unavailable muted">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.</p>
|
||||
} else {
|
||||
if len(v.History) > 0 {
|
||||
<div class="chat-log">
|
||||
for _, t := range v.History {
|
||||
<div class="chat-turn chat-q"><p>{ t.Question }</p></div>
|
||||
<div class="chat-turn chat-a"><p class="body">{ t.Answer }</p></div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
if v.Truncated {
|
||||
<p class="chat-note muted">Working from a bounded portion of a long transcript — answers about the end of the video may be incomplete.</p>
|
||||
}
|
||||
if v.Error != "" {
|
||||
<p class="chat-error" role="alert">{ v.Error }</p>
|
||||
}
|
||||
<form
|
||||
class="chat-form"
|
||||
method="post"
|
||||
action={ chatURL(v.VideoID) }
|
||||
hx-post={ string(chatURL(v.VideoID)) }
|
||||
hx-target="#chat-panel"
|
||||
hx-swap="outerHTML"
|
||||
>
|
||||
for _, t := range v.History {
|
||||
<input type="hidden" name="hq" value={ t.Question }/>
|
||||
<input type="hidden" name="ha" value={ t.Answer }/>
|
||||
}
|
||||
<label class="chat-model">
|
||||
Model
|
||||
<select name="model">
|
||||
for _, m := range v.Models {
|
||||
<option value={ m } selected?={ m == v.Selected }>{ m }</option>
|
||||
}
|
||||
</select>
|
||||
<span class="chat-model-hint muted">Switch models to compare answers on the same transcript.</span>
|
||||
</label>
|
||||
<textarea name="question" rows="3" placeholder="Ask a question about this video…" required aria-label="Your question"></textarea>
|
||||
<button type="submit" class="btn">Ask</button>
|
||||
<span class="htmx-indicator chat-thinking">thinking…</span>
|
||||
</form>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
// 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.
|
||||
|
||||
+540
-203
File diff suppressed because it is too large
Load Diff
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user