Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
71df696448 | ||
|
|
cc3cda4ab8 | ||
|
|
69a49bc603 | ||
|
|
137804b0b1 | ||
|
|
a9be5f285b | ||
|
|
fe56e2fe01 |
+76
-4
@@ -827,10 +827,18 @@ The fix is resilience around it, not replacing it.
|
|||||||
**Decision.**
|
**Decision.**
|
||||||
1. **Ordered endpoint chain (`summarizer.NewChain`).** Endpoints are tried in order; the first to
|
1. **Ordered endpoint chain (`summarizer.NewChain`).** Endpoints are tried in order; the first to
|
||||||
return a *parseable* summary wins. Default chain:
|
return a *parseable* summary wins. Default chain:
|
||||||
`koala/phi4-mini` (primary, local) → `koala/phi4-14b` (fallback, local) →
|
`koala/phi4-mini` (primary, local) → `iguana/gemma4-26b` (fallback, local on a
|
||||||
`berget/mistral-small` (worst-case, external). All three are reached through the **one** LiteLLM
|
*different host*) → `berget/mistral-small` (worst-case, external). All three are reached through
|
||||||
gateway by alias — the gateway already fronts both llama-swap and berget — so a fallback is a
|
the **one** LiteLLM gateway by alias — the gateway already fronts both llama-swap and berget — so
|
||||||
different alias, not a second client config.
|
a fallback is a different alias, not a second client config.
|
||||||
|
|
||||||
|
**Update 2026-06-11:** the local fallback moved from `koala/phi4-14b` to `iguana/gemma4-26b`.
|
||||||
|
koala now carries other GPU loads, so keeping the fallback on koala competed with them; iguana
|
||||||
|
(M2 Ultra) has the headroom, and a different host is also a different egress IP for the rare
|
||||||
|
fallback fetch. `gemma4-26b` is the brain-validated homelab general-purpose model (agentsquad
|
||||||
|
H2/H3 executor) and returned valid summary JSON on the real prompt in a smoke test
|
||||||
|
(~37s incl. cold-load — fine for a path hit only when the fast primary fails). Pure config:
|
||||||
|
`TAPIR_FALLBACK_MODEL`.
|
||||||
2. **A parse failure advances the chain, same as a transport error.** "Reliably summarized" means
|
2. **A parse failure advances the chain, same as a transport error.** "Reliably summarized" means
|
||||||
*parseable summary returned*, not *HTTP 200*. This is the behaviour the old Primary→Fallback
|
*parseable summary returned*, not *HTTP 200*. This is the behaviour the old Primary→Fallback
|
||||||
shape missed.
|
shape missed.
|
||||||
@@ -988,6 +996,70 @@ the prior equal-share behaviour. No schema or API change.
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## ADR-027 — Chat with a video's stored transcript (deeper-dive, on an already-summarized video)
|
||||||
|
|
||||||
|
**Status:** Accepted (2026-06-11). **Consumes ADR-021** (the shared, video-keyed transcript
|
||||||
|
store) for the first time beyond summarization; **uses the ADR-022 chain models**; relates to
|
||||||
|
ADR-012 (isolation) and ADR-016 (the Stage-0 gate).
|
||||||
|
|
||||||
|
**Context — observed demand, not hypothetical.** The maintainer read 10+ real pilot summaries
|
||||||
|
and reported the reactions: *many good; some he wanted to dig deeper into; some less useful*
|
||||||
|
(the "less useful" split between weak-*model* output and uninteresting-*video* content). The
|
||||||
|
middle reaction is the signal: a good summary that makes the reader want *more* is the summary
|
||||||
|
succeeding at triage and then hitting a wall — there is nowhere to go deeper short of watching
|
||||||
|
the video. That want is the feature. It is also the cheapest possible feature to satisfy
|
||||||
|
honestly, because ADR-021 already persists the transcript: the deeper-dive runs entirely on
|
||||||
|
stored public-content text + local models, touching **no** caption fetch and **no** YouTube.
|
||||||
|
|
||||||
|
**Decision.** Add a per-video chat that lets the user ask questions against a video's stored
|
||||||
|
transcript.
|
||||||
|
1. **Entry from the summary view only.** A "dig deeper / ask" affordance on a summarized video —
|
||||||
|
the chat lives exactly where the "I want more" reaction happens. No standalone chat surface.
|
||||||
|
2. **Stored-transcript-only (load-bearing constraint).** Chat is available **only** for videos
|
||||||
|
that already have a stored transcript. It never triggers a caption fetch, so it cannot touch
|
||||||
|
the rate gate, the 429 surface, or YouTube at all — the entire account-safety constraint that
|
||||||
|
governs the rest of Tapir is satisfied *by construction* here, not by careful gating. (Entry
|
||||||
|
being "from a summary" guarantees the transcript exists.) On-demand fetch for un-stored videos
|
||||||
|
is explicitly deferred.
|
||||||
|
3. **Model = the summary's model by default; user-switchable among the ADR-022 chain models**
|
||||||
|
(`phi4-mini` / `gemma4-26b` / `mistral-small` to start). This is deliberate: it doubles as
|
||||||
|
live model-comparison instrumentation — ask the same question of the same transcript under two
|
||||||
|
models and the difference is directly felt. This is the mechanism by which the maintainer
|
||||||
|
learns *which* model is worth defaulting to, and it is the multi-model-analysis direction
|
||||||
|
ADR-021 anticipated, arriving as a user-facing capability.
|
||||||
|
- Chat is a **read-bounded retrieval/QA task** (the user supplies the focus), which is
|
||||||
|
*easier* than summarization (the model must decide what matters). So a model that summarizes
|
||||||
|
mediocrely may chat well — chat is plausibly a partial remedy for the weak-summary case, not
|
||||||
|
an inheritor of it.
|
||||||
|
4. **Ephemeral chat (v1).** No persisted history; chat is per-session. Persisting per-user,
|
||||||
|
RLS-scoped history is deferred until there is evidence anyone wants to revisit a conversation.
|
||||||
|
5. **Chat-only, trust-the-model (v1) — with a recorded limitation.** The chat does not expose the
|
||||||
|
raw transcript for verification in v1 (kept simple). **Known limitation:** because some
|
||||||
|
summaries were weak-model output, the user has reason not to fully trust a chat answer's
|
||||||
|
fidelity to the transcript, and v1 gives no in-UI way to check. The model-switcher partially
|
||||||
|
compensates (two models disagreeing on the same question is itself a signal). A
|
||||||
|
"show source / view transcript" verification path is the natural **v2** and is *not*
|
||||||
|
foreclosed — ADR-021's stored transcript already makes it cheap. Recorded so v2 is a known
|
||||||
|
next step, not a rediscovery.
|
||||||
|
|
||||||
|
**Why this is safe and in-scope.** It adds no caption-fetch surface (stored-only), no new
|
||||||
|
non-RLS table (transcripts already shared per ADR-021; ephemeral chat stores nothing), and no
|
||||||
|
auth change. It is additive to the read path. The one genuine product expansion — Tapir becomes
|
||||||
|
an interactive transcript-QA tool, not only a summarizer — is justified by *observed* demand from
|
||||||
|
real reading, which is exactly the kind of evidence the Stage-0 discipline asks for before
|
||||||
|
building.
|
||||||
|
|
||||||
|
**Relation to the Stage-0 gate.** This is **not** a return-nudge (those stay deferred, ADR-020) —
|
||||||
|
it adds nothing that prompts the user to return; it deepens the value *once they are already
|
||||||
|
reading*. It does not contaminate the unprompted-return signal. If anything it strengthens the
|
||||||
|
"useful to me" case the gate measures, by giving a good summary somewhere to lead.
|
||||||
|
|
||||||
|
**Reversibility.** Additive read-path feature over the unchanged engine + the ADR-021 store.
|
||||||
|
Removing the summary-view affordance removes the feature; nothing else depends on it. Ephemeral =
|
||||||
|
no migration, no stored state to unwind. Spec: `docs/specs/chat-with-transcript.md`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Rejected alternatives
|
## Rejected alternatives
|
||||||
|
|
||||||
Approaches considered during the 2026-06-02 planning + grill session and **deliberately not
|
Approaches considered during the 2026-06-02 planning + grill session and **deliberately not
|
||||||
|
|||||||
@@ -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)
|
secretStore := secrets.NewFileStore(cfg.SecretsFile)
|
||||||
app := &web.App{Store: st, Identity: st, Auth: authn, Secrets: secretStore, Log: log, RecencyWindow: cfg.AutoSummarizeWindow}
|
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 —
|
// User onboarding is handled by the IdP (Authentik invite flow), not Tapir —
|
||||||
// the Dex local-password provisioning path was removed (ADR-019). An
|
// the Dex local-password provisioning path was removed (ADR-019). An
|
||||||
// authenticated subject with no Tapir user is routed to /register.
|
// authenticated subject with no Tapir user is routed to /register.
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"strings"
|
"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/llm"
|
||||||
"gitea.d-ma.be/mathias/tapir/internal/adapters/secrets"
|
"gitea.d-ma.be/mathias/tapir/internal/adapters/secrets"
|
||||||
"gitea.d-ma.be/mathias/tapir/internal/adapters/store"
|
"gitea.d-ma.be/mathias/tapir/internal/adapters/store"
|
||||||
@@ -68,6 +69,49 @@ func buildSummarizer(cfg config.Config) *summarizer.Summarizer {
|
|||||||
return summarizer.NewChain(eps, cfg.MaxTranscriptChars)
|
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.
|
// 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.
|
// A "berget/" alias is an external provider; everything else is the local stack.
|
||||||
func providerOf(model string) string {
|
func providerOf(model string) string {
|
||||||
|
|||||||
@@ -30,7 +30,9 @@ it** — endpoints and aliases drift, and this file is a snapshot (2026-06-06),
|
|||||||
- **Summarizer fallback chain (ADR-022).** The primary alias is the *first* of an ordered chain;
|
- **Summarizer fallback chain (ADR-022).** The primary alias is the *first* of an ordered chain;
|
||||||
on failure or unparseable output the summarizer advances to the next model. All reached through
|
on failure or unparseable output the summarizer advances to the next model. All reached through
|
||||||
the same gateway by alias.
|
the same gateway by alias.
|
||||||
- `TAPIR_FALLBACK_MODEL` — local fallback. **Default `koala/phi4-14b`.** Empty disables it.
|
- `TAPIR_FALLBACK_MODEL` — local fallback. **Default `iguana/gemma4-26b`** — on iguana, NOT
|
||||||
|
koala, so the fallback does not compete with koala's other GPU loads (and runs from a different
|
||||||
|
egress IP). Empty disables it.
|
||||||
- `TAPIR_CLOUD_FALLBACK_MODEL` — worst-case EXTERNAL fallback. **Default `berget/mistral-small`.**
|
- `TAPIR_CLOUD_FALLBACK_MODEL` — worst-case EXTERNAL fallback. **Default `berget/mistral-small`.**
|
||||||
**Set this empty (`""`) for any client/NDA deployment** so content never leaves the local
|
**Set this empty (`""`) for any client/NDA deployment** so content never leaves the local
|
||||||
stack — the chain then contains only local endpoints.
|
stack — the chain then contains only local endpoints.
|
||||||
|
|||||||
@@ -0,0 +1,89 @@
|
|||||||
|
# Spec — Chat with a video's stored transcript (ADR-027)
|
||||||
|
|
||||||
|
**Repo:** tapir · **Size:** medium · **Solo session.** Implements ADR-027. Read CLAUDE.md,
|
||||||
|
DECISIONS.md (ADR-021 transcript store, ADR-022 model chain, ADR-012 isolation, ADR-027), and
|
||||||
|
`docs/ui-spec.md` first. TBD, conventional commits, `task check` green per commit, `templ
|
||||||
|
generate` after view changes.
|
||||||
|
|
||||||
|
## FIRST: append ADR-027 to DECISIONS.md
|
||||||
|
ADR-027 text is provided separately (planning thread). Insert immediately before the
|
||||||
|
`## Rejected alternatives` heading, as the first commit, so the decision precedes the build.
|
||||||
|
|
||||||
|
## What this is
|
||||||
|
A per-video chat letting the user ask questions against a video's **already-stored** transcript,
|
||||||
|
entered from the summary view. Born from observed demand: the maintainer read real summaries and
|
||||||
|
some made him want to dig deeper — this gives that "I want more" reaction somewhere to go, without
|
||||||
|
watching the video.
|
||||||
|
|
||||||
|
## HARD CONSTRAINT — stored-transcript-only (the safety property)
|
||||||
|
Chat is available **ONLY** for videos that already have a stored transcript (ADR-021). It must
|
||||||
|
**never** trigger a caption fetch, never touch the rate gate, never reach YouTube. Entry being
|
||||||
|
"from a summarized video" guarantees the transcript exists. If somehow invoked on a video with no
|
||||||
|
stored transcript → show "transcript not available for chat", NO fetch. This is what makes the
|
||||||
|
feature safe by construction; do not add an on-demand-fetch path (explicitly deferred).
|
||||||
|
|
||||||
|
## 1. Entry point
|
||||||
|
- A "Dig deeper" / "Ask about this" affordance on the **summary view** of a summarized video
|
||||||
|
(not the list cards — the detail/summary page). Quiet, consistent with the existing card-state
|
||||||
|
styling.
|
||||||
|
- Opens a chat panel/view scoped to that one video, with its stored transcript as context.
|
||||||
|
|
||||||
|
## 2. The chat
|
||||||
|
- Read the stored transcript for the video (via the ADR-021 `TranscriptStore`, keyed by
|
||||||
|
`(provider, provider_video_id)`). No fetch.
|
||||||
|
- Send transcript + the user's question + minimal system framing to the chosen model via the
|
||||||
|
**existing LiteLLM gateway** (the same client the summarizer uses — a chat is a different
|
||||||
|
call, not a new integration).
|
||||||
|
- Stream or return the answer; render in the chat panel. HTMX/no-JS ethos — match the existing
|
||||||
|
app (the summarize status uses HTMX polling; chat can use a simple POST-and-render or HTMX
|
||||||
|
streaming if clean).
|
||||||
|
- **Transcript truncation:** reuse/respect `TAPIR_MAX_TRANSCRIPT_CHARS` (ADR-022) so a long
|
||||||
|
transcript fits the model context. If truncated, the chat should be honest that it's working
|
||||||
|
from a bounded portion (a quiet note), since answers about the tail of a long video may be
|
||||||
|
incomplete.
|
||||||
|
|
||||||
|
## 3. Model selection (the instrumentation win)
|
||||||
|
- **Default model = the model that produced this video's summary.** (Store/lookup which chain
|
||||||
|
model summarized it — if not already recorded, this is a small addition; if recording it is
|
||||||
|
non-trivial, default to the chain primary and note the gap.)
|
||||||
|
- **User can switch** among the ADR-022 chain models (`phi4-mini`, `gemma4-26b`,
|
||||||
|
`mistral-small` to start) via a simple selector in the chat panel. Switching re-runs against
|
||||||
|
the same transcript — this is deliberate model-comparison instrumentation.
|
||||||
|
- Respect the local-first / NDA posture: if `TAPIR_CLOUD_FALLBACK_MODEL=""` (cloud disabled),
|
||||||
|
the external model is NOT offered in the switcher — only local models. Chat must honor the same
|
||||||
|
"content stays local" guarantee as ADR-022.
|
||||||
|
|
||||||
|
## 4. Ephemeral (v1)
|
||||||
|
- No persisted chat history. Conversation lives for the session/page. No new table, no migration.
|
||||||
|
- (Multi-turn within a session is fine — keep the running messages in the request/page state —
|
||||||
|
but nothing is written to the DB.)
|
||||||
|
|
||||||
|
## 5. Isolation
|
||||||
|
- The transcript is shared/non-RLS (ADR-021) — fine, it's public content. But the chat is invoked
|
||||||
|
by a user about a video **in their feed**; confirm the entry path is reachable only for the
|
||||||
|
requesting user's own videos (the summary view is already RLS-scoped). Chat adds no new
|
||||||
|
user-data surface (ephemeral), so there's nothing new to RLS — but the test should confirm a
|
||||||
|
user can only open chat from their own summary view, not arbitrary video ids.
|
||||||
|
|
||||||
|
## Tests
|
||||||
|
- Chat on a video with a stored transcript → answer returned; assert NO caption-fetch / no
|
||||||
|
YouTube call occurs (the safety property — this is the key assertion).
|
||||||
|
- Chat invoked on a video with no stored transcript → honest "not available", NO fetch.
|
||||||
|
- Model switch → re-runs against the same transcript with the selected model; cloud model absent
|
||||||
|
from the switcher when `TAPIR_CLOUD_FALLBACK_MODEL=""`.
|
||||||
|
- Truncation honored for a long transcript; the bounded-context note shows.
|
||||||
|
- Entry is reachable only from the user's own summary view (isolation).
|
||||||
|
|
||||||
|
## Out of scope / deferred (record, don't build)
|
||||||
|
- **Persisted chat history** (per-user, RLS-scoped) — deferred until evidence anyone revisits a
|
||||||
|
conversation.
|
||||||
|
- **Show-source / transcript-verification UI** — the natural v2 (ADR-027 records it); v1 is
|
||||||
|
chat-only/trust-the-model. ADR-021's stored transcript makes v2 cheap when wanted.
|
||||||
|
- **On-demand fetch** for un-stored videos — would reintroduce the caption-fetch surface the
|
||||||
|
stored-only constraint removes. Not now.
|
||||||
|
- Anything that nudges the user to return (ADR-020 — gate contamination).
|
||||||
|
|
||||||
|
## Boundaries
|
||||||
|
Stored-transcript-only (HARD). No rate-gate/fetch surface. No auth changes. No new persisted
|
||||||
|
state in v1. Reuse the existing gateway client + truncation config; don't build a new model
|
||||||
|
integration.
|
||||||
@@ -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,171 @@
|
|||||||
|
// Package chat implements the per-video deeper-dive chat (ADR-027): a read-only
|
||||||
|
// QA over a video's ALREADY-STORED transcript (ADR-021). It is the enforcement
|
||||||
|
// point for the feature's load-bearing safety property — stored-transcript-only:
|
||||||
|
// the Service has NO VideoSource and NO caption-fetch dependency, only a
|
||||||
|
// Completer factory, so it CANNOT reach YouTube or the rate gate by construction.
|
||||||
|
// The caller supplies the stored transcript text; chat never fetches.
|
||||||
|
//
|
||||||
|
// It reuses the same LiteLLM gateway as the summarizer (a chat is a different
|
||||||
|
// call, not a new integration) and the same transcript-truncation discipline
|
||||||
|
// (TAPIR_MAX_TRANSCRIPT_CHARS) so a long transcript fits a small-context model.
|
||||||
|
package chat
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
"unicode/utf8"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Completer is the minimal LLM chat surface the Service needs. *llm.Client
|
||||||
|
// satisfies it; tests use a fake. It is the SAME surface the summarizer uses.
|
||||||
|
type Completer interface {
|
||||||
|
Complete(ctx context.Context, system, user string) (string, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Turn is one completed exchange in an ephemeral, session-only conversation
|
||||||
|
// (ADR-027 v1: nothing is persisted).
|
||||||
|
type Turn struct {
|
||||||
|
Question string
|
||||||
|
Answer string
|
||||||
|
}
|
||||||
|
|
||||||
|
// Request is one chat turn: the chosen model, the stored transcript text, the
|
||||||
|
// prior turns (for multi-turn context within the session), and the new question.
|
||||||
|
type Request struct {
|
||||||
|
Model string
|
||||||
|
Transcript string
|
||||||
|
History []Turn
|
||||||
|
Question string
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reply is the model's answer plus whether the transcript was bounded to fit the
|
||||||
|
// model context (so the UI can be honest that an answer about the tail of a long
|
||||||
|
// video may be incomplete).
|
||||||
|
type Reply struct {
|
||||||
|
Answer string
|
||||||
|
Truncated bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// Service answers questions against a stored transcript via a switchable set of
|
||||||
|
// models. models is the ordered, local-first list offered to the user (the cloud
|
||||||
|
// model is simply absent when disabled — see cmd wiring); maxChars bounds the
|
||||||
|
// transcript sent to any model (0 = unbounded). newClient builds a Completer for
|
||||||
|
// a chosen model alias (the same gateway, a different alias).
|
||||||
|
type Service struct {
|
||||||
|
newClient func(model string) Completer
|
||||||
|
models []string
|
||||||
|
maxChars int
|
||||||
|
}
|
||||||
|
|
||||||
|
// New constructs a Service. models must be non-empty and already filtered to the
|
||||||
|
// offerable set (cloud excluded when disabled) and de-duplicated by the caller.
|
||||||
|
func New(newClient func(model string) Completer, models []string, maxChars int) *Service {
|
||||||
|
return &Service{newClient: newClient, models: models, maxChars: maxChars}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Models returns a copy of the offerable model list (local-first order).
|
||||||
|
func (s *Service) Models() []string {
|
||||||
|
out := make([]string, len(s.models))
|
||||||
|
copy(out, s.models)
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// offers reports whether model is in the offerable set — the guard that keeps an
|
||||||
|
// arbitrary, un-offered alias (e.g. a forged form value) from reaching the gateway.
|
||||||
|
func (s *Service) offers(model string) bool {
|
||||||
|
for _, m := range s.models {
|
||||||
|
if m == model {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// DefaultModel resolves the model a fresh chat opens with: the summary's own
|
||||||
|
// model when it is still an offered option (the ADR-027 default — chat continues
|
||||||
|
// in the model that produced the summary), otherwise the first offered model.
|
||||||
|
// Returns "" only when no models are configured.
|
||||||
|
func (s *Service) DefaultModel(summaryModel string) string {
|
||||||
|
if summaryModel != "" && s.offers(summaryModel) {
|
||||||
|
return summaryModel
|
||||||
|
}
|
||||||
|
if len(s.models) > 0 {
|
||||||
|
return s.models[0]
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// Answer runs one chat turn. The model is forced back to a default if the request
|
||||||
|
// names an un-offered alias, so chat can never call the gateway with an arbitrary
|
||||||
|
// model. The transcript is truncated up front (reporting whether it was cut) and
|
||||||
|
// passed as system context; the running conversation is the user message.
|
||||||
|
func (s *Service) Answer(ctx context.Context, req Request) (Reply, error) {
|
||||||
|
if len(s.models) == 0 {
|
||||||
|
return Reply{}, fmt.Errorf("chat: no models configured")
|
||||||
|
}
|
||||||
|
model := req.Model
|
||||||
|
if !s.offers(model) {
|
||||||
|
model = s.DefaultModel("")
|
||||||
|
}
|
||||||
|
|
||||||
|
transcript, truncated := truncate(req.Transcript, s.maxChars)
|
||||||
|
system := buildSystem(transcript, truncated)
|
||||||
|
user := buildUser(req.History, req.Question)
|
||||||
|
|
||||||
|
out, err := s.newClient(model).Complete(ctx, system, user)
|
||||||
|
if err != nil {
|
||||||
|
return Reply{}, fmt.Errorf("chat: %s: %w", model, err)
|
||||||
|
}
|
||||||
|
answer := strings.TrimSpace(out)
|
||||||
|
if answer == "" {
|
||||||
|
return Reply{}, fmt.Errorf("chat: %s returned an empty answer", model)
|
||||||
|
}
|
||||||
|
return Reply{Answer: answer, Truncated: truncated}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
const systemPreamble = `You are Tapir, answering questions about ONE video using ONLY the transcript below.
|
||||||
|
Ground every answer in the transcript. If the transcript does not contain the answer, say so plainly rather than guessing.`
|
||||||
|
|
||||||
|
const truncatedNote = `
|
||||||
|
The transcript below is truncated to fit the model — if a question seems to concern something missing, note it may be beyond the available portion.`
|
||||||
|
|
||||||
|
// buildSystem frames the model as a transcript-grounded QA assistant and embeds
|
||||||
|
// the (possibly truncated) transcript as context.
|
||||||
|
func buildSystem(transcript string, truncated bool) string {
|
||||||
|
var b strings.Builder
|
||||||
|
b.WriteString(systemPreamble)
|
||||||
|
if truncated {
|
||||||
|
b.WriteString(truncatedNote)
|
||||||
|
}
|
||||||
|
b.WriteString("\n\nTranscript:\n")
|
||||||
|
b.WriteString(transcript)
|
||||||
|
return b.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
// buildUser renders the running conversation as the user message: prior turns as
|
||||||
|
// Q/A pairs followed by the new question. Folding history into one message keeps
|
||||||
|
// the Completer surface (a single system+user call) unchanged — no new llm method.
|
||||||
|
func buildUser(history []Turn, question string) string {
|
||||||
|
var b strings.Builder
|
||||||
|
for _, t := range history {
|
||||||
|
fmt.Fprintf(&b, "Q: %s\nA: %s\n\n", t.Question, t.Answer)
|
||||||
|
}
|
||||||
|
fmt.Fprintf(&b, "Q: %s", question)
|
||||||
|
return b.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
// truncate caps content to max bytes on a UTF-8 rune boundary, reporting whether
|
||||||
|
// it cut. It mirrors the summarizer's truncation discipline (ADR-022) but returns
|
||||||
|
// the cut flag so the chat UI can be honest about a bounded transcript. A
|
||||||
|
// non-positive max (or content already within budget) returns content unchanged.
|
||||||
|
func truncate(content string, max int) (string, bool) {
|
||||||
|
if max <= 0 || len(content) <= max {
|
||||||
|
return content, false
|
||||||
|
}
|
||||||
|
cut := max
|
||||||
|
for cut > 0 && !utf8.RuneStart(content[cut]) {
|
||||||
|
cut--
|
||||||
|
}
|
||||||
|
return content[:cut], true
|
||||||
|
}
|
||||||
@@ -0,0 +1,164 @@
|
|||||||
|
package chat
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// recordingCompleter captures the system+user it was asked with and returns a
|
||||||
|
// canned answer (or error). It also records which model alias built it.
|
||||||
|
type recordingCompleter struct {
|
||||||
|
model string
|
||||||
|
lastSystem string
|
||||||
|
lastUser string
|
||||||
|
answer string
|
||||||
|
err error
|
||||||
|
calls *int
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *recordingCompleter) Complete(_ context.Context, system, user string) (string, error) {
|
||||||
|
*c.calls++
|
||||||
|
c.lastSystem = system
|
||||||
|
c.lastUser = user
|
||||||
|
if c.err != nil {
|
||||||
|
return "", c.err
|
||||||
|
}
|
||||||
|
return c.answer, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// factory builds a recordingCompleter per model and records the last one built so
|
||||||
|
// the test can assert which model alias was actually used for the gateway call.
|
||||||
|
type factory struct {
|
||||||
|
answer string
|
||||||
|
err error
|
||||||
|
calls int
|
||||||
|
used *recordingCompleter
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *factory) make(model string) Completer {
|
||||||
|
c := &recordingCompleter{model: model, answer: f.answer, err: f.err, calls: &f.calls}
|
||||||
|
f.used = c
|
||||||
|
return c
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestModelsAreOfferedLocalFirstAndCopied(t *testing.T) {
|
||||||
|
f := &factory{answer: "ok"}
|
||||||
|
s := New(f.make, []string{"koala/phi4-mini", "iguana/gemma4-26b"}, 0)
|
||||||
|
|
||||||
|
got := s.Models()
|
||||||
|
want := []string{"koala/phi4-mini", "iguana/gemma4-26b"}
|
||||||
|
if len(got) != len(want) || got[0] != want[0] || got[1] != want[1] {
|
||||||
|
t.Fatalf("Models() = %v, want %v", got, want)
|
||||||
|
}
|
||||||
|
// Mutating the returned slice must not corrupt the Service's list.
|
||||||
|
got[0] = "tampered"
|
||||||
|
if s.Models()[0] != "koala/phi4-mini" {
|
||||||
|
t.Fatal("Models() leaked its backing slice")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDefaultModelIsTheSummarysModelWhenOffered(t *testing.T) {
|
||||||
|
f := &factory{answer: "ok"}
|
||||||
|
s := New(f.make, []string{"koala/phi4-mini", "iguana/gemma4-26b", "berget/mistral-small"}, 0)
|
||||||
|
|
||||||
|
if got := s.DefaultModel("iguana/gemma4-26b"); got != "iguana/gemma4-26b" {
|
||||||
|
t.Fatalf("DefaultModel(summary) = %q, want the summary's model", got)
|
||||||
|
}
|
||||||
|
// A summary model no longer offered (e.g. cloud disabled) falls back to first.
|
||||||
|
if got := s.DefaultModel("berget/old-model"); got != "koala/phi4-mini" {
|
||||||
|
t.Fatalf("DefaultModel(un-offered) = %q, want the first offered model", got)
|
||||||
|
}
|
||||||
|
// No summary model recorded → first offered.
|
||||||
|
if got := s.DefaultModel(""); got != "koala/phi4-mini" {
|
||||||
|
t.Fatalf("DefaultModel(\"\") = %q, want the first offered model", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAnswerGroundsOnTranscriptAndCarriesHistory(t *testing.T) {
|
||||||
|
f := &factory{answer: " The video is about attention. "}
|
||||||
|
s := New(f.make, []string{"koala/phi4-mini"}, 0)
|
||||||
|
|
||||||
|
reply, err := s.Answer(context.Background(), Request{
|
||||||
|
Model: "koala/phi4-mini",
|
||||||
|
Transcript: "ATTENTION-TRANSCRIPT-MARKER",
|
||||||
|
History: []Turn{{Question: "who", Answer: "the host"}},
|
||||||
|
Question: "what is it about",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Answer: %v", err)
|
||||||
|
}
|
||||||
|
if reply.Answer != "The video is about attention." {
|
||||||
|
t.Fatalf("answer not trimmed: %q", reply.Answer)
|
||||||
|
}
|
||||||
|
if reply.Truncated {
|
||||||
|
t.Fatal("short transcript must not report truncated")
|
||||||
|
}
|
||||||
|
// The transcript rides in the system prompt; the conversation in the user msg.
|
||||||
|
if !strings.Contains(f.used.lastSystem, "ATTENTION-TRANSCRIPT-MARKER") {
|
||||||
|
t.Fatal("transcript not grounded into the system prompt")
|
||||||
|
}
|
||||||
|
if !strings.Contains(f.used.lastUser, "Q: who") || !strings.Contains(f.used.lastUser, "A: the host") {
|
||||||
|
t.Fatalf("history not carried into the user message: %q", f.used.lastUser)
|
||||||
|
}
|
||||||
|
if !strings.Contains(f.used.lastUser, "what is it about") {
|
||||||
|
t.Fatal("new question missing from the user message")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAnswerTruncatesLongTranscriptAndReportsIt(t *testing.T) {
|
||||||
|
f := &factory{answer: "answer"}
|
||||||
|
s := New(f.make, []string{"koala/phi4-mini"}, 10)
|
||||||
|
|
||||||
|
reply, err := s.Answer(context.Background(), Request{
|
||||||
|
Model: "koala/phi4-mini",
|
||||||
|
Transcript: strings.Repeat("x", 500),
|
||||||
|
Question: "summarize",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Answer: %v", err)
|
||||||
|
}
|
||||||
|
if !reply.Truncated {
|
||||||
|
t.Fatal("a transcript past maxChars must report Truncated")
|
||||||
|
}
|
||||||
|
if strings.Count(f.used.lastSystem, "x") != 10 {
|
||||||
|
t.Fatalf("transcript not bounded to maxChars: got %d x's", strings.Count(f.used.lastSystem, "x"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAnswerForcesAnUnofferedModelBackToDefault(t *testing.T) {
|
||||||
|
f := &factory{answer: "answer"}
|
||||||
|
s := New(f.make, []string{"koala/phi4-mini", "iguana/gemma4-26b"}, 0)
|
||||||
|
|
||||||
|
// A forged/un-offered model must never reach the gateway as-is — it is forced
|
||||||
|
// to the default offered model (the cloud-absent guarantee depends on this).
|
||||||
|
_, err := s.Answer(context.Background(), Request{
|
||||||
|
Model: "berget/secret-cloud-model",
|
||||||
|
Transcript: "t",
|
||||||
|
Question: "q",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Answer: %v", err)
|
||||||
|
}
|
||||||
|
if f.used.model != "koala/phi4-mini" {
|
||||||
|
t.Fatalf("un-offered model reached the gateway as %q, want the default", f.used.model)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAnswerPropagatesCompleterError(t *testing.T) {
|
||||||
|
f := &factory{err: errors.New("gateway down")}
|
||||||
|
s := New(f.make, []string{"koala/phi4-mini"}, 0)
|
||||||
|
|
||||||
|
_, err := s.Answer(context.Background(), Request{Model: "koala/phi4-mini", Transcript: "t", Question: "q"})
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected the gateway error to propagate")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAnswerRejectsEmptyModelSet(t *testing.T) {
|
||||||
|
s := New(func(string) Completer { return nil }, nil, 0)
|
||||||
|
if _, err := s.Answer(context.Background(), Request{Question: "q"}); err == nil {
|
||||||
|
t.Fatal("expected an error when no models are configured")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,6 +4,7 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
|
"path/filepath"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
embeddedpostgres "github.com/fergusstrange/embedded-postgres"
|
embeddedpostgres "github.com/fergusstrange/embedded-postgres"
|
||||||
@@ -24,11 +25,22 @@ var _ ports.Sink = (*store.Store)(nil)
|
|||||||
var dsn string
|
var dsn string
|
||||||
|
|
||||||
func TestMain(m *testing.M) {
|
func TestMain(m *testing.M) {
|
||||||
const port = 54329
|
// Port + runtime/data dirs are per-process (PID-derived) so two concurrent
|
||||||
|
// `go test` invocations — e.g. a push-run and a tag-run firing together in CI —
|
||||||
|
// don't collide on a fixed port or a shared data dir (which silently failed
|
||||||
|
// both runs). CachePath is shared so the PG archive is downloaded once, not
|
||||||
|
// per process. Base 54000 keeps this package's range distinct from web's.
|
||||||
|
port := uint32(54000 + os.Getpid()%1000)
|
||||||
dsn = fmt.Sprintf("postgres://postgres:postgres@localhost:%d/postgres?sslmode=disable", port)
|
dsn = fmt.Sprintf("postgres://postgres:postgres@localhost:%d/postgres?sslmode=disable", port)
|
||||||
|
|
||||||
|
rt := filepath.Join(os.TempDir(), fmt.Sprintf("tapir-epg-store-%d", os.Getpid()))
|
||||||
pg := embeddedpostgres.NewDatabase(
|
pg := embeddedpostgres.NewDatabase(
|
||||||
embeddedpostgres.DefaultConfig().Port(port),
|
embeddedpostgres.DefaultConfig().
|
||||||
|
Port(port).
|
||||||
|
RuntimePath(rt).
|
||||||
|
DataPath(filepath.Join(rt, "data")).
|
||||||
|
BinariesPath(filepath.Join(rt, "bin")).
|
||||||
|
CachePath(filepath.Join(os.TempDir(), "tapir-epg-cache")),
|
||||||
)
|
)
|
||||||
if err := pg.Start(); err != nil {
|
if err := pg.Start(); err != nil {
|
||||||
fmt.Fprintf(os.Stderr, "embedded-postgres start: %v\n", err)
|
fmt.Fprintf(os.Stderr, "embedded-postgres start: %v\n", err)
|
||||||
@@ -40,6 +52,7 @@ func TestMain(m *testing.M) {
|
|||||||
if err := pg.Stop(); err != nil {
|
if err := pg.Stop(); err != nil {
|
||||||
fmt.Fprintf(os.Stderr, "embedded-postgres stop: %v\n", err)
|
fmt.Fprintf(os.Stderr, "embedded-postgres stop: %v\n", err)
|
||||||
}
|
}
|
||||||
|
_ = os.RemoveAll(rt)
|
||||||
os.Exit(code)
|
os.Exit(code)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -33,7 +33,10 @@ type Config struct {
|
|||||||
SummarizerModel string
|
SummarizerModel string
|
||||||
// FallbackModel is the LOCAL fallback alias tried when the primary fails or
|
// FallbackModel is the LOCAL fallback alias tried when the primary fails or
|
||||||
// returns unparseable output (ADR-022). Kept local so content stays on the
|
// returns unparseable output (ADR-022). Kept local so content stays on the
|
||||||
// homelab stack. Empty disables it. Default a bigger-context local model.
|
// homelab stack. Default is an IGUANA model (not koala) so the fallback runs
|
||||||
|
// on a different host than the koala primary — koala carries other loads, and
|
||||||
|
// a different host also means a different egress IP for the (rare) fallback.
|
||||||
|
// Empty disables it.
|
||||||
FallbackModel string
|
FallbackModel string
|
||||||
// CloudFallbackModel is the worst-case EXTERNAL fallback alias, tried only
|
// CloudFallbackModel is the worst-case EXTERNAL fallback alias, tried only
|
||||||
// after every local endpoint has failed (ADR-022). For client deployments set
|
// after every local endpoint has failed (ADR-022). For client deployments set
|
||||||
@@ -148,7 +151,7 @@ func (c Config) DexConfigured() bool { return strings.TrimSpace(c.OIDCIssuer) !=
|
|||||||
const (
|
const (
|
||||||
defaultGatewayURL = "http://koala:30401/v1"
|
defaultGatewayURL = "http://koala:30401/v1"
|
||||||
defaultSummarizerModel = "koala/phi4-mini"
|
defaultSummarizerModel = "koala/phi4-mini"
|
||||||
defaultFallbackModel = "koala/phi4-14b"
|
defaultFallbackModel = "iguana/gemma4-26b"
|
||||||
defaultCloudFallbackModel = "berget/mistral-small"
|
defaultCloudFallbackModel = "berget/mistral-small"
|
||||||
defaultSummaryMaxTokens = 1500
|
defaultSummaryMaxTokens = 1500
|
||||||
defaultMaxTranscriptChars = 18000
|
defaultMaxTranscriptChars = 18000
|
||||||
|
|||||||
@@ -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)
|
DistinctChannels(ctx context.Context, userID string) ([]string, error)
|
||||||
GetSummaryByVideo(ctx context.Context, userID, videoID string) (*store.SummaryRow, error)
|
GetSummaryByVideo(ctx context.Context, userID, videoID string) (*store.SummaryRow, error)
|
||||||
GetVideoRow(ctx context.Context, userID, videoID string) (*store.SummaryRow, error)
|
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)
|
ActionsFor(ctx context.Context, userID string, videoIDs []string) (map[string][]string, error)
|
||||||
SetAction(ctx context.Context, userID, videoID, action string) error
|
SetAction(ctx context.Context, userID, videoID, action string) error
|
||||||
ClearAction(ctx context.Context, userID, videoID, action string) error
|
ClearAction(ctx context.Context, userID, videoID, action string) error
|
||||||
@@ -91,6 +95,11 @@ type App struct {
|
|||||||
// Fetcher, when non-nil, resolves an arbitrary YouTube video id to metadata for
|
// 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.
|
// the paste-a-URL flow (Feature 2). Nil = the /paste route is not mounted.
|
||||||
Fetcher VideoFetcher
|
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
|
// Processing tracks in-flight immediate summarizations so the status endpoint
|
||||||
// shows the animation until the summary lands. The zero value is ready to use.
|
// shows the animation until the summary lands. The zero value is ready to use.
|
||||||
Processing ProcessingSet
|
Processing ProcessingSet
|
||||||
@@ -149,6 +158,12 @@ func (a *App) Router() http.Handler {
|
|||||||
app.HandleFunc("POST /paste", a.handlePaste)
|
app.HandleFunc("POST /paste", a.handlePaste)
|
||||||
}
|
}
|
||||||
app.HandleFunc("GET /v/{videoId}/status", a.handleStatus)
|
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("GET /register", a.handleRegisterForm)
|
||||||
app.HandleFunc("POST /register", a.handleRegister)
|
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)
|
a.serverError(w, r, "get summary", err)
|
||||||
return
|
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
|
// handleAction toggles one action: re-clicking an active verb clears it, else it
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import (
|
|||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
"os"
|
"os"
|
||||||
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
@@ -26,10 +27,22 @@ import (
|
|||||||
var dsn string
|
var dsn string
|
||||||
|
|
||||||
func TestMain(m *testing.M) {
|
func TestMain(m *testing.M) {
|
||||||
const port = 54330 // distinct from the store package's embedded PG (54329)
|
// Per-process port + dirs so concurrent `go test` runs (e.g. a push-run and a
|
||||||
|
// tag-run in CI) never collide on a fixed port or shared data dir. Base 55000
|
||||||
|
// keeps web's range distinct from the store package (54000). Shared CachePath
|
||||||
|
// downloads the PG archive once.
|
||||||
|
port := uint32(55000 + os.Getpid()%1000)
|
||||||
dsn = fmt.Sprintf("postgres://postgres:postgres@localhost:%d/postgres?sslmode=disable", port)
|
dsn = fmt.Sprintf("postgres://postgres:postgres@localhost:%d/postgres?sslmode=disable", port)
|
||||||
|
|
||||||
pg := embeddedpostgres.NewDatabase(embeddedpostgres.DefaultConfig().Port(port))
|
rt := filepath.Join(os.TempDir(), fmt.Sprintf("tapir-epg-web-%d", os.Getpid()))
|
||||||
|
pg := embeddedpostgres.NewDatabase(
|
||||||
|
embeddedpostgres.DefaultConfig().
|
||||||
|
Port(port).
|
||||||
|
RuntimePath(rt).
|
||||||
|
DataPath(filepath.Join(rt, "data")).
|
||||||
|
BinariesPath(filepath.Join(rt, "bin")).
|
||||||
|
CachePath(filepath.Join(os.TempDir(), "tapir-epg-cache")),
|
||||||
|
)
|
||||||
if err := pg.Start(); err != nil {
|
if err := pg.Start(); err != nil {
|
||||||
fmt.Fprintf(os.Stderr, "embedded-postgres start: %v\n", err)
|
fmt.Fprintf(os.Stderr, "embedded-postgres start: %v\n", err)
|
||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
@@ -38,6 +51,7 @@ func TestMain(m *testing.M) {
|
|||||||
if err := pg.Stop(); err != nil {
|
if err := pg.Stop(); err != nil {
|
||||||
fmt.Fprintf(os.Stderr, "embedded-postgres stop: %v\n", err)
|
fmt.Fprintf(os.Stderr, "embedded-postgres stop: %v\n", err)
|
||||||
}
|
}
|
||||||
|
_ = os.RemoveAll(rt)
|
||||||
os.Exit(code)
|
os.Exit(code)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -184,6 +184,12 @@ func statusURL(videoID string) templ.SafeURL {
|
|||||||
return templ.SafeURL("/v/" + videoID + "/status")
|
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) —
|
// Charmbracelet-inspired palette for the summarizing animation (TapirSpinner) —
|
||||||
// a charm purple box, pink tapir, mint snout/eyes/progress. Kept as named consts
|
// 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.
|
// 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 ul { margin: 0; padding-left: 1.2rem; line-height: 1.6; }
|
||||||
.detail li { margin-bottom: var(--s1); }
|
.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
|
/* action toggles — watched|skipped form one segmented control (they are mutually
|
||||||
exclusive), "saved" sits apart as an independent toggle */
|
exclusive), "saved" sits apart as an independent toggle */
|
||||||
.actions { display: flex; gap: var(--s3); margin: var(--s4) 0; flex-wrap: wrap; align-items: center; }
|
.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,
|
// DetailPage is the full summary view: text, highlights, takeaways, metadata,
|
||||||
// and the action button group.
|
// and the action button group. chatEnabled adds the "dig deeper" affordance
|
||||||
templ DetailPage(r store.SummaryRow) {
|
// (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)) {
|
@Layout("Tapir — " + displayTitle(r)) {
|
||||||
<article class="detail">
|
<article class="detail">
|
||||||
<p class="back"><a href="/">← Summaries</a></p>
|
<p class="back"><a href="/">← Summaries</a></p>
|
||||||
@@ -440,6 +441,11 @@ templ DetailPage(r store.SummaryRow) {
|
|||||||
if r.URL != "" {
|
if r.URL != "" {
|
||||||
<p class="source"><a href={ externalURL(r.URL) } rel="noopener noreferrer">watch on source ↗</a></p>
|
<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))
|
@ActionButtons(r.VideoID, actionSet(r.Actions))
|
||||||
// Lead with the attention-saving payload: Takeaways ("is this worth my
|
// Lead with the attention-saving payload: Takeaways ("is this worth my
|
||||||
// time?") first, then Highlights, then the full Summary last (UX review
|
// 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
|
// RegisterPage is the explicit registration step (ADR-012): an authenticated Dex
|
||||||
// subject with no tapir user picks a display name to create their account.
|
// subject with no tapir user picks a display name to create their account.
|
||||||
// errMsg, when set, reports a validation problem on the prior POST.
|
// errMsg, when set, reports a validation problem on the prior POST.
|
||||||
|
|||||||
+528
-191
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",
|
"A returning subject passes straight through": "TestRegisteredSubjectPassesThrough",
|
||||||
"Deleting an account removes only my data and leaves other users untouched": "TestDeleteAccountWipesDataAndSecretsAndLogsOut",
|
"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
|
// summarize_new_video.feature
|
||||||
"A subscribed channel posts a video that has captions": "TestSubscribedVideoWithCaptionsIsSummarizedAndDelivered",
|
"A subscribed channel posts a video that has captions": "TestSubscribedVideoWithCaptionsIsSummarizedAndDelivered",
|
||||||
"A subscribed channel posts a video with no usable transcript": "TestVideoWithNoTranscriptIsSkipped",
|
"A subscribed channel posts a video with no usable transcript": "TestVideoWithNoTranscriptIsSkipped",
|
||||||
|
|||||||
Reference in New Issue
Block a user