Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
19ca4282a8 | ||
|
|
71df696448 | ||
|
|
cc3cda4ab8 | ||
|
|
69a49bc603 | ||
|
|
137804b0b1 | ||
|
|
a9be5f285b | ||
|
|
fe56e2fe01 | ||
|
|
beeb5bc31b | ||
|
|
09eb31d1fe | ||
|
|
1665a1e7c4 | ||
|
|
5219561a91 |
+173
-4
@@ -827,10 +827,18 @@ The fix is resilience around it, not replacing it.
|
||||
**Decision.**
|
||||
1. **Ordered endpoint chain (`summarizer.NewChain`).** Endpoints are tried in order; the first to
|
||||
return a *parseable* summary wins. Default chain:
|
||||
`koala/phi4-mini` (primary, local) → `koala/phi4-14b` (fallback, local) →
|
||||
`berget/mistral-small` (worst-case, external). All three are reached through the **one** LiteLLM
|
||||
gateway by alias — the gateway already fronts both llama-swap and berget — so a fallback is a
|
||||
different alias, not a second client config.
|
||||
`koala/phi4-mini` (primary, local) → `iguana/gemma4-26b` (fallback, local on a
|
||||
*different host*) → `berget/mistral-small` (worst-case, external). All three are reached through
|
||||
the **one** LiteLLM gateway by alias — the gateway already fronts both llama-swap and berget — so
|
||||
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
|
||||
*parseable summary returned*, not *HTTP 200*. This is the behaviour the old Primary→Fallback
|
||||
shape missed.
|
||||
@@ -891,6 +899,167 @@ this is well under the 10k/day cap; at larger scale, batch `videos.list` across
|
||||
|
||||
---
|
||||
|
||||
## ADR-024 — Per-channel caption-availability memory
|
||||
|
||||
**Status:** Accepted (2026-06-10). **Builds on ADR-014** (per-IP caption budget), **ADR-021**
|
||||
(shared transcript cache), **ADR-023** (Shorts filter).
|
||||
|
||||
**Context.** After ADR-021 caches transcripts and ADR-023 drops Shorts, the remaining caption
|
||||
waste is the *first* fetch on every new video of a channel that never publishes English captions
|
||||
(foreign-language news, music, etc.). Each costs one rate-limited fetch to resolve to "none" —
|
||||
and on a throttled IP that fetch may 429 and churn the backoff machinery before it ever gets a
|
||||
verdict. A pilot user's feed had several such channels.
|
||||
|
||||
**Decision.** Remember, per `(user, channel)`, a streak of consecutive no-caption outcomes
|
||||
(`channel_caption_state`, migration 016, RLS-scoped like the rest of the user-owned schema).
|
||||
Once the streak reaches `TAPIR_CHANNEL_CAPTIONLESS_THRESHOLD` (default 5) the channel is
|
||||
suppressed — its videos are discovered/listed but not caption-fetched — for
|
||||
`TAPIR_CHANNEL_CAPTIONLESS_WINDOW` (default 14d), after which one video is re-probed
|
||||
(auto-recovery for a channel that starts adding captions). A successful fetch resets the streak;
|
||||
a fresh 429 does NOT count (transient, not a caption verdict). An explicit manual request
|
||||
bypasses suppression. `threshold = 0` disables the feature.
|
||||
|
||||
**Why per-user, not global.** Caption availability is really a channel property (public), so a
|
||||
global table would let users share the learning. But subscriptions are per-user (ADR-012) and at
|
||||
pilot scale users' channel sets barely overlap, so per-user + RLS keeps it consistent with the
|
||||
existing isolation model with no new non-RLS exception to justify. Promoting to a shared table
|
||||
(like transcripts, ADR-021) is a future optimisation if channel overlap grows.
|
||||
|
||||
**Reversibility.** Migration 016 is a clean drop; `threshold = 0` disables at runtime. The
|
||||
memory only ever *suppresses fetches* — it never deletes content or affects already-stored
|
||||
summaries.
|
||||
|
||||
---
|
||||
|
||||
## ADR-025 — Honest, state-aware foreground summarization status
|
||||
|
||||
**Status:** Accepted (2026-06-10). **Pillar B of the manual-mode UX work** (Pillar A, foreground
|
||||
fetch priority, is a separate follow-up). Builds on ADR-014 (the rate limit the UX must make
|
||||
legible).
|
||||
|
||||
**Context.** Clicking "Summarize" spawned a background goroutine and polled `/status`, which
|
||||
returned only two states: the spinner (in-flight) or the normal card (done). But the web
|
||||
`ProcessVideo` only recorded an outcome on *success* — a 429'd or caption-less click left
|
||||
`transcript_status` unset, so the next poll silently reverted to the "Summarize" button. The
|
||||
user saw either an endless spinner or a button that did nothing useful when clicked again. The
|
||||
binding constraint (YouTube's caption rate limit) was completely invisible.
|
||||
|
||||
**Decision.**
|
||||
1. **Record every outcome on the web path**, mirroring the runner: `ProcessVideo` stamps
|
||||
`rate_limited` / `none` / `fetched`. A rate-limited video keeps its requested flag so the
|
||||
background sweep retries it; `none` and `fetched` are terminal.
|
||||
2. **`/status` is state-aware**: summarized → summary card; in-flight → working spinner;
|
||||
`rate_limited` → a calm "waiting, will retry" card that keeps polling (every 30s) so the
|
||||
summary appears on its own when the retry lands — the user never clicks again;
|
||||
`none` → a terminal "no captions" card with no poll and no dead-end button.
|
||||
3. **Charm status text** (Claude-Code / Crush inspired): the working spinner cycles playful,
|
||||
tapir-themed gerunds ("Chewing the cud…", "Munching leaves…", "Distilling the gist…") via
|
||||
CSS only — no JS, keeping the HTMX/no-JS ethos. Decorative (aria-hidden) with a stable
|
||||
`role=status` line for assistive tech.
|
||||
|
||||
**Principle.** When the system cannot be fast (throttled IP), it is at least honest, and it
|
||||
self-resolves without making the user retry. Honesty is the load-bearing half — Pillar A's
|
||||
priority lane only improves the odds of a fast slot; it cannot beat an already-hot IP.
|
||||
|
||||
**Reversibility.** Pure transport-layer + view change over the unchanged engine/ports. No
|
||||
schema change (reuses `transcript_status` from migration 007).
|
||||
|
||||
---
|
||||
|
||||
## ADR-026 — Foreground caption fetches take priority; the credentials probe is dead
|
||||
|
||||
**Status:** Accepted (2026-06-10). **Pillar A of the manual-mode UX work** (Pillar B was
|
||||
ADR-025). Builds on ADR-014 (the shared per-IP gate).
|
||||
|
||||
**Context.** Every caption fetch — the background sweep and the web click-path — shared one
|
||||
process-wide rate gate equally. So a user waiting on a "Summarize" click competed with the
|
||||
firehose for both pacing and the scarce pre-429 window; on a busy IP the click was slow or
|
||||
429'd while the background churned.
|
||||
|
||||
**Decision.** A context-marked priority lane. The web path
|
||||
(`engineProcessor.ProcessVideo`) wraps its context with `ForegroundContext`; the gate gives
|
||||
foreground fetches a token immediately, while **background fetches yield** — they wait until no
|
||||
foreground fetch is pending before taking a token. Threaded via a context value (not new
|
||||
signatures) and a process-wide `foregroundPending` counter. Clicks are rare and bursty, so the
|
||||
background barely loses throughput; the waiting human gets the next (and cleanest) slot.
|
||||
|
||||
**Credentials probe — rejected, not built.** The idea was to fetch captions with the user's
|
||||
auth in manual mode to dodge 429s. It is a dead end, already settled by ADR-010 and the code:
|
||||
the caption path is *deliberately anonymous* because the InnerTube/timedtext endpoints **reject
|
||||
or break on authenticated requests** (`captions.go`: "no OAuth token — it can break the
|
||||
timedtext endpoint"). The user's OAuth (a Data API credential) does not authenticate InnerTube
|
||||
at all, and the official `captions.download` is owner-only (403 on third-party). So auth cannot
|
||||
help here and can actively hurt. No probe needed — building one would only re-confirm the ADR.
|
||||
|
||||
**Reversibility.** Context-marker + a yield loop in the gate; removing the marker collapses to
|
||||
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
|
||||
|
||||
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")
|
||||
}
|
||||
}
|
||||
+11
-1
@@ -136,7 +136,8 @@ func cmdRun(ctx context.Context, log *slog.Logger) error {
|
||||
|
||||
r := runner.New(engine.Source, st, engine, cfg.UserID, log,
|
||||
runner.WithBackoff(cfg.FetchBackoff),
|
||||
runner.WithAutoWindow(cfg.AutoSummarizeWindow))
|
||||
runner.WithAutoWindow(cfg.AutoSummarizeWindow),
|
||||
runner.WithCaptionMemory(cfg.ChannelCaptionlessThreshold, cfg.ChannelCaptionlessWindow))
|
||||
|
||||
log.Info("starting run", "user", cfg.UserID, "model", cfg.SummarizerModel,
|
||||
"gateway", cfg.GatewayURL, "poll_interval", cfg.PollInterval, "fetch_backoff", cfg.FetchBackoff,
|
||||
@@ -196,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.
|
||||
|
||||
+71
-1
@@ -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 {
|
||||
@@ -113,6 +157,11 @@ type engineProcessor struct {
|
||||
}
|
||||
|
||||
func (p *engineProcessor) ProcessVideo(ctx context.Context, userID, videoID string) error {
|
||||
// This is the user-initiated (foreground) path — a click on "Summarize",
|
||||
// "Try now", or a pasted URL. Mark the context so the caption gate gives it
|
||||
// priority over the background sweep (ADR-026, Pillar A).
|
||||
ctx = youtube.ForegroundContext(ctx)
|
||||
|
||||
row, err := p.store.GetVideoRow(ctx, userID, videoID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("load video %q: %w", videoID, err)
|
||||
@@ -132,7 +181,28 @@ func (p *engineProcessor) ProcessVideo(ctx context.Context, userID, videoID stri
|
||||
if err != nil {
|
||||
return fmt.Errorf("process video %q: %w", videoID, err)
|
||||
}
|
||||
if res.Summary != nil {
|
||||
|
||||
// Record the outcome so the status endpoint can show honest state (ADR-025):
|
||||
// a 429'd or caption-less click used to leave transcript_status unset, so the
|
||||
// poll silently reverted to the "Summarize" button. Mirror the runner: stamp
|
||||
// rate_limited / none / fetched. A rate-limited video keeps its requested flag
|
||||
// so the background sweep retries it; none and fetched are terminal here.
|
||||
switch {
|
||||
case res.Skipped && res.TranscriptSource == string(domain.SourceRateLimited):
|
||||
if err := p.store.SetTranscriptStatus(ctx, userID, videoID, "rate_limited"); err != nil {
|
||||
return fmt.Errorf("set rate_limited status %q: %w", videoID, err)
|
||||
}
|
||||
case res.Skipped:
|
||||
if err := p.store.SetTranscriptStatus(ctx, userID, videoID, "none"); err != nil {
|
||||
return fmt.Errorf("set none status %q: %w", videoID, err)
|
||||
}
|
||||
if err := p.store.ClearSummarizeRequested(ctx, userID, videoID); err != nil {
|
||||
return fmt.Errorf("clear summarize flag %q: %w", videoID, err)
|
||||
}
|
||||
case res.Summary != nil:
|
||||
if err := p.store.SetTranscriptStatus(ctx, userID, videoID, "fetched"); err != nil {
|
||||
return fmt.Errorf("set fetched status %q: %w", videoID, err)
|
||||
}
|
||||
if err := p.store.ClearSummarizeRequested(ctx, userID, videoID); err != nil {
|
||||
return fmt.Errorf("clear summarize flag %q: %w", videoID, err)
|
||||
}
|
||||
|
||||
+26
-14
@@ -45,7 +45,8 @@ func buildUserRunner(cfg config.Config, st *store.Store, secretStore ports.Secre
|
||||
|
||||
return runner.New(src, st, engine, userID, log,
|
||||
runner.WithBackoff(cfg.FetchBackoff),
|
||||
runner.WithAutoWindow(cfg.AutoSummarizeWindow)), nil
|
||||
runner.WithAutoWindow(cfg.AutoSummarizeWindow),
|
||||
runner.WithCaptionMemory(cfg.ChannelCaptionlessThreshold, cfg.ChannelCaptionlessWindow)), nil
|
||||
}
|
||||
|
||||
// userLister enumerates every registered user and reports a user's video
|
||||
@@ -121,6 +122,7 @@ func runDiscoveryPass(
|
||||
"skipped_seen", total.SkippedSeen, "skipped_no_text", total.SkippedNoText,
|
||||
"skipped_manual", total.SkippedManual, "skipped_too_old", total.SkippedTooOld,
|
||||
"skipped_rate_limited", total.SkippedRateLimited,
|
||||
"skipped_no_caption_channel", total.SkippedNoCaptionChannel,
|
||||
"channel_unavailable", total.ChannelUnavailable, "errors", total.Errors)
|
||||
return total
|
||||
}
|
||||
@@ -141,8 +143,18 @@ func runScheduler(
|
||||
return // disabled
|
||||
}
|
||||
|
||||
pass := 0
|
||||
runDiscoveryPass(ctx, pass, lister, runUser, log)
|
||||
// Derive the rotation offset from wall-clock, NOT an in-memory counter. A
|
||||
// counter reset to 0 on every pod restart always hands the lead to the
|
||||
// first-listed user — so frequent deploys re-starve whoever is last (exactly
|
||||
// what happened to the first pilot user during a deploy-heavy session). A
|
||||
// time-based offset advances with real time and is identical across restarts,
|
||||
// so the lead rotates fairly regardless of how often the pod bounces.
|
||||
runPass := func() {
|
||||
pass := int(time.Now().Unix() / int64(interval/time.Second))
|
||||
runDiscoveryPass(ctx, pass, lister, runUser, log)
|
||||
}
|
||||
|
||||
runPass()
|
||||
|
||||
ticker := time.NewTicker(interval)
|
||||
defer ticker.Stop()
|
||||
@@ -151,8 +163,7 @@ func runScheduler(
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-ticker.C:
|
||||
pass++
|
||||
runDiscoveryPass(ctx, pass, lister, runUser, log)
|
||||
runPass()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -181,14 +192,15 @@ func rotateUsers(users []store.UserIdentity, pass int) []store.UserIdentity {
|
||||
// per-tick aggregate across all users.
|
||||
func sumStats(a, b runner.Stats) runner.Stats {
|
||||
return runner.Stats{
|
||||
Candidates: a.Candidates + b.Candidates,
|
||||
Summarized: a.Summarized + b.Summarized,
|
||||
SkippedSeen: a.SkippedSeen + b.SkippedSeen,
|
||||
SkippedNoText: a.SkippedNoText + b.SkippedNoText,
|
||||
SkippedManual: a.SkippedManual + b.SkippedManual,
|
||||
SkippedTooOld: a.SkippedTooOld + b.SkippedTooOld,
|
||||
SkippedRateLimited: a.SkippedRateLimited + b.SkippedRateLimited,
|
||||
ChannelUnavailable: a.ChannelUnavailable + b.ChannelUnavailable,
|
||||
Errors: a.Errors + b.Errors,
|
||||
Candidates: a.Candidates + b.Candidates,
|
||||
Summarized: a.Summarized + b.Summarized,
|
||||
SkippedSeen: a.SkippedSeen + b.SkippedSeen,
|
||||
SkippedNoText: a.SkippedNoText + b.SkippedNoText,
|
||||
SkippedManual: a.SkippedManual + b.SkippedManual,
|
||||
SkippedTooOld: a.SkippedTooOld + b.SkippedTooOld,
|
||||
SkippedRateLimited: a.SkippedRateLimited + b.SkippedRateLimited,
|
||||
SkippedNoCaptionChannel: a.SkippedNoCaptionChannel + b.SkippedNoCaptionChannel,
|
||||
ChannelUnavailable: a.ChannelUnavailable + b.ChannelUnavailable,
|
||||
Errors: a.Errors + b.Errors,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
on failure or unparseable output the summarizer advances to the next model. All reached through
|
||||
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`.**
|
||||
**Set this empty (`""`) for any client/NDA deployment** so content never leaves the local
|
||||
stack — the chain then contains only local endpoints.
|
||||
@@ -44,6 +46,11 @@ it** — endpoints and aliases drift, and this file is a snapshot (2026-06-06),
|
||||
the timedtext 429 path) and drops videos shorter than this plus any live/upcoming broadcast,
|
||||
so the scarce caption-fetch budget isn't spent on Shorts. `0` disables the filter. The
|
||||
paste-a-URL path is never filtered.
|
||||
- **Per-channel caption memory (ADR-024).** `TAPIR_CHANNEL_CAPTIONLESS_THRESHOLD` — **default
|
||||
`5`** consecutive no-caption results before a channel is suppressed (its videos listed but not
|
||||
caption-fetched). `TAPIR_CHANNEL_CAPTIONLESS_WINDOW` — **default `336h`** (14d) suppression
|
||||
before one video is re-probed. `THRESHOLD=0` disables. A successful fetch resets the channel;
|
||||
a 429 does not count; an explicit manual request bypasses suppression.
|
||||
- **Thinking models need an explicit `max_tokens`.** qwen3 / deepseek-r1 spend the budget on
|
||||
reasoning and return **empty content** if `max_tokens` is too low (or unset). The summarizer's
|
||||
parser treats an empty summary as an error for exactly this reason. **Done (2026-06-02, Worker F):**
|
||||
|
||||
@@ -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,69 @@
|
||||
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
|
||||
And it opens the chat in place, below the summary, without leaving the page
|
||||
|
||||
Scenario: The summary and the chat are on one page
|
||||
When I open the chat
|
||||
Then the summary stays visible alongside the chat
|
||||
And I can read the summary while I ask questions
|
||||
|
||||
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")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
)
|
||||
|
||||
// CaptionlessChannels returns the set of channel ids currently suppressed for the
|
||||
// user — channels whose recent videos all yielded no captions, within their
|
||||
// suppression window (ADR-024). The runner skips caption fetches for these
|
||||
// channels' videos. A channel whose window has expired is not returned, so its
|
||||
// next video is re-probed (auto-recovery).
|
||||
func (s *Store) CaptionlessChannels(ctx context.Context, userID string) (map[string]bool, error) {
|
||||
out := map[string]bool{}
|
||||
err := s.withUser(ctx, userID, func(tx pgx.Tx) error {
|
||||
rows, err := tx.Query(ctx, `
|
||||
SELECT channel_id FROM channel_caption_state
|
||||
WHERE user_id = $1 AND captionless_until IS NOT NULL AND captionless_until > now()`,
|
||||
userID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("store: caption-less channels: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
for rows.Next() {
|
||||
var ch string
|
||||
if err := rows.Scan(&ch); err != nil {
|
||||
return fmt.Errorf("store: scan caption-less channel: %w", err)
|
||||
}
|
||||
out[ch] = true
|
||||
}
|
||||
return rows.Err()
|
||||
})
|
||||
return out, err
|
||||
}
|
||||
|
||||
// RecordChannelCaptionOutcome updates a channel's caption-availability memory
|
||||
// after a fetch attempt (ADR-024). hadCaptions resets the channel (consecutive
|
||||
// count to 0, suppression cleared). Otherwise the consecutive no-caption count is
|
||||
// incremented; once it reaches threshold the channel is suppressed for window.
|
||||
// threshold <= 0 is a no-op (feature disabled). An empty channelID is ignored
|
||||
// (some sources may not carry one).
|
||||
func (s *Store) RecordChannelCaptionOutcome(ctx context.Context, userID, channelID string, hadCaptions bool, threshold int, window time.Duration) error {
|
||||
if channelID == "" || threshold <= 0 {
|
||||
return nil
|
||||
}
|
||||
return s.withUser(ctx, userID, func(tx pgx.Tx) error {
|
||||
if hadCaptions {
|
||||
_, err := tx.Exec(ctx, `
|
||||
INSERT INTO channel_caption_state (user_id, channel_id, consecutive_none, captionless_until, updated_at)
|
||||
VALUES ($1, $2, 0, NULL, now())
|
||||
ON CONFLICT (user_id, channel_id)
|
||||
DO UPDATE SET consecutive_none = 0, captionless_until = NULL, updated_at = now()`,
|
||||
userID, channelID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("store: reset channel caption state: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
// No captions: increment the streak; suppress once it reaches threshold.
|
||||
// captionless_until is set from the NEW count inside the same statement so
|
||||
// the decision is atomic with the increment.
|
||||
until := time.Now().Add(window)
|
||||
_, err := tx.Exec(ctx, `
|
||||
INSERT INTO channel_caption_state (user_id, channel_id, consecutive_none, captionless_until, updated_at)
|
||||
VALUES ($1, $2, 1, CASE WHEN 1 >= $3 THEN $4::timestamptz ELSE NULL END, now())
|
||||
ON CONFLICT (user_id, channel_id)
|
||||
DO UPDATE SET
|
||||
consecutive_none = channel_caption_state.consecutive_none + 1,
|
||||
captionless_until = CASE
|
||||
WHEN channel_caption_state.consecutive_none + 1 >= $3 THEN $4::timestamptz
|
||||
ELSE channel_caption_state.captionless_until
|
||||
END,
|
||||
updated_at = now()`,
|
||||
userID, channelID, threshold, until)
|
||||
if err != nil {
|
||||
return fmt.Errorf("store: record channel no-caption: %w", err)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
package store_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestChannelCaptionMemory_SuppressesAfterThreshold(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
s := newStore(t)
|
||||
super := rawPool(t)
|
||||
resetDB(t, super)
|
||||
seedUser(t, super, userA)
|
||||
|
||||
const threshold = 3
|
||||
window := time.Hour
|
||||
|
||||
// Below threshold: not yet suppressed.
|
||||
require.NoError(t, s.RecordChannelCaptionOutcome(ctx, userA, "chanX", false, threshold, window))
|
||||
require.NoError(t, s.RecordChannelCaptionOutcome(ctx, userA, "chanX", false, threshold, window))
|
||||
got, err := s.CaptionlessChannels(ctx, userA)
|
||||
require.NoError(t, err)
|
||||
require.NotContains(t, got, "chanX", "2 < threshold 3: not suppressed yet")
|
||||
|
||||
// Crossing the threshold suppresses the channel.
|
||||
require.NoError(t, s.RecordChannelCaptionOutcome(ctx, userA, "chanX", false, threshold, window))
|
||||
got, err = s.CaptionlessChannels(ctx, userA)
|
||||
require.NoError(t, err)
|
||||
require.Contains(t, got, "chanX", "3 consecutive no-caption results suppress the channel")
|
||||
|
||||
// A successful caption fetch resets it.
|
||||
require.NoError(t, s.RecordChannelCaptionOutcome(ctx, userA, "chanX", true, threshold, window))
|
||||
got, err = s.CaptionlessChannels(ctx, userA)
|
||||
require.NoError(t, err)
|
||||
require.NotContains(t, got, "chanX", "a captioned video clears suppression")
|
||||
}
|
||||
|
||||
func TestChannelCaptionMemory_WindowExpiryReProbes(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
s := newStore(t)
|
||||
super := rawPool(t)
|
||||
resetDB(t, super)
|
||||
seedUser(t, super, userA)
|
||||
|
||||
// A negative window means captionless_until lands in the past — modelling an
|
||||
// elapsed suppression window, which must make the channel eligible again.
|
||||
require.NoError(t, s.RecordChannelCaptionOutcome(ctx, userA, "chanY", false, 1, -time.Hour))
|
||||
got, err := s.CaptionlessChannels(ctx, userA)
|
||||
require.NoError(t, err)
|
||||
require.NotContains(t, got, "chanY", "an expired window re-enables the channel for a re-probe")
|
||||
}
|
||||
|
||||
func TestChannelCaptionMemory_DisabledThresholdIsNoOp(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
s := newStore(t)
|
||||
super := rawPool(t)
|
||||
resetDB(t, super)
|
||||
seedUser(t, super, userA)
|
||||
|
||||
require.NoError(t, s.RecordChannelCaptionOutcome(ctx, userA, "chanZ", false, 0, time.Hour))
|
||||
got, err := s.CaptionlessChannels(ctx, userA)
|
||||
require.NoError(t, err)
|
||||
require.Empty(t, got, "threshold 0 disables the memory — nothing recorded")
|
||||
}
|
||||
@@ -53,7 +53,9 @@ func TestMigration010LoginEventsUpDown(t *testing.T) {
|
||||
require.True(t, loginEventsExists(t), "login_events must exist at latest migration")
|
||||
|
||||
m := fileMigrator(t)
|
||||
// 011..015 sit above 010; step them down first so 010 is exercised in isolation.
|
||||
// 011..016 sit above 010; step them down first so 010 is exercised in isolation.
|
||||
require.NoError(t, m.Steps(-1), "down 016 drops channel_caption_state, login_events intact")
|
||||
require.True(t, loginEventsExists(t), "016 down leaves login_events intact")
|
||||
require.NoError(t, m.Steps(-1), "down 015 reshapes transcripts, login_events intact")
|
||||
require.True(t, loginEventsExists(t), "015 down leaves login_events intact")
|
||||
require.NoError(t, m.Steps(-1), "down 014 drops channel_title, login_events intact")
|
||||
@@ -68,7 +70,7 @@ func TestMigration010LoginEventsUpDown(t *testing.T) {
|
||||
require.NoError(t, m.Steps(-1), "down 010 must drop login_events")
|
||||
require.False(t, loginEventsExists(t), "login_events must be gone after the down migration")
|
||||
|
||||
require.NoError(t, m.Steps(6), "up must recreate 010 then re-apply 011..015")
|
||||
require.NoError(t, m.Steps(7), "up must recreate 010 then re-apply 011..016")
|
||||
require.True(t, loginEventsExists(t), "login_events must be restored after the up migration")
|
||||
}
|
||||
|
||||
@@ -91,6 +93,7 @@ func TestMigration011AutoSummarizeDefaultUpDown(t *testing.T) {
|
||||
require.Equal(t, "true", autoSummarizeDefault(t), "011 sets the default to TRUE")
|
||||
|
||||
m := fileMigrator(t)
|
||||
require.NoError(t, m.Steps(-1), "down 016 drops channel_caption_state")
|
||||
require.NoError(t, m.Steps(-1), "down 015 reshapes transcripts")
|
||||
require.NoError(t, m.Steps(-1), "down 014 drops channel_title")
|
||||
require.NoError(t, m.Steps(-1), "down 013 drops channel_errors")
|
||||
@@ -104,6 +107,7 @@ func TestMigration011AutoSummarizeDefaultUpDown(t *testing.T) {
|
||||
require.NoError(t, m.Steps(1), "up 013 creates channel_errors")
|
||||
require.NoError(t, m.Steps(1), "up 014 recreates channel_title")
|
||||
require.NoError(t, m.Steps(1), "up 015 reshapes transcripts to shared")
|
||||
require.NoError(t, m.Steps(1), "up 016 recreates channel_caption_state (HEAD)")
|
||||
}
|
||||
|
||||
// channelTitleExists reports whether videos.channel_title is present.
|
||||
@@ -123,6 +127,8 @@ func TestMigration014VideoChannelTitleUpDown(t *testing.T) {
|
||||
require.True(t, channelTitleExists(t), "channel_title exists at latest migration")
|
||||
|
||||
m := fileMigrator(t)
|
||||
require.NoError(t, m.Steps(-1), "down 016 drops channel_caption_state, channel_title intact")
|
||||
require.True(t, channelTitleExists(t), "016 down leaves channel_title intact")
|
||||
require.NoError(t, m.Steps(-1), "down 015 reshapes transcripts, channel_title intact")
|
||||
require.True(t, channelTitleExists(t), "015 down leaves channel_title intact")
|
||||
require.NoError(t, m.Steps(-1), "down 014 must drop channel_title")
|
||||
@@ -130,7 +136,8 @@ func TestMigration014VideoChannelTitleUpDown(t *testing.T) {
|
||||
|
||||
require.NoError(t, m.Steps(1), "up 014 must recreate channel_title")
|
||||
require.True(t, channelTitleExists(t), "channel_title must be restored after the up migration")
|
||||
require.NoError(t, m.Steps(1), "up 015 restores the shared transcripts shape (HEAD)")
|
||||
require.NoError(t, m.Steps(1), "up 015 restores the shared transcripts shape")
|
||||
require.NoError(t, m.Steps(1), "up 016 recreates channel_caption_state (HEAD)")
|
||||
}
|
||||
|
||||
// TestMigration012FixAutoSummarizeRLS proves 012 runs cleanly and flips any
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
DROP TABLE channel_caption_state;
|
||||
@@ -0,0 +1,30 @@
|
||||
-- Migration 016: per-(user, channel) caption-availability memory (ADR-024).
|
||||
--
|
||||
-- Some channels never publish English captions (foreign-language news, music,
|
||||
-- etc.). Each of their new videos still costs ONE rate-limited caption fetch
|
||||
-- (ADR-014) before resolving to "none" — and on a throttled egress IP that fetch
|
||||
-- may 429 and churn through the backoff machinery first. This table remembers
|
||||
-- channels that repeatedly yield no captions so discovery can stop attempting
|
||||
-- their videos, freeing the scarce fetch budget for channels that do have them.
|
||||
--
|
||||
-- consecutive_none counts no-caption outcomes in a row; a successful fetch resets
|
||||
-- it to 0. Once it crosses the threshold the channel is suppressed until
|
||||
-- captionless_until, after which one video is re-probed (auto-recovery for a
|
||||
-- channel that starts adding captions). Per-user + RLS-scoped, consistent with
|
||||
-- the rest of the user-owned schema (subscriptions are per-user; ADR-012).
|
||||
CREATE TABLE channel_caption_state (
|
||||
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
channel_id TEXT NOT NULL,
|
||||
consecutive_none INT NOT NULL DEFAULT 0,
|
||||
captionless_until TIMESTAMPTZ,
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
PRIMARY KEY (user_id, channel_id)
|
||||
);
|
||||
|
||||
CREATE INDEX idx_channel_caption_state_user_id ON channel_caption_state(user_id);
|
||||
|
||||
ALTER TABLE channel_caption_state ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE channel_caption_state FORCE ROW LEVEL SECURITY;
|
||||
CREATE POLICY channel_caption_state_isolation ON channel_caption_state
|
||||
FOR ALL
|
||||
USING (user_id = current_setting('tapir.current_user_id', true)::uuid);
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
embeddedpostgres "github.com/fergusstrange/embedded-postgres"
|
||||
@@ -24,11 +25,22 @@ var _ ports.Sink = (*store.Store)(nil)
|
||||
var dsn string
|
||||
|
||||
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)
|
||||
|
||||
rt := filepath.Join(os.TempDir(), fmt.Sprintf("tapir-epg-store-%d", os.Getpid()))
|
||||
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 {
|
||||
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 {
|
||||
fmt.Fprintf(os.Stderr, "embedded-postgres stop: %v\n", err)
|
||||
}
|
||||
_ = os.RemoveAll(rt)
|
||||
os.Exit(code)
|
||||
}
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ package youtube
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"golang.org/x/time/rate"
|
||||
@@ -29,9 +30,55 @@ func SetFetchRate(interval time.Duration) {
|
||||
globalFetchGate = rate.NewLimiter(rate.Every(interval), 1)
|
||||
}
|
||||
|
||||
// foregroundPending counts in-flight foreground (user-initiated) caption fetches.
|
||||
// The background sweep yields the gate while this is non-zero so a human waiting
|
||||
// on a click gets the next slot — and, on a near-throttled IP, the pre-429 window
|
||||
// — instead of competing equally with the firehose (ADR-026, Pillar A). Clicks are
|
||||
// rare and bursty, so background barely notices; the win to the click is large.
|
||||
var foregroundPending atomic.Int64
|
||||
|
||||
// fgCtxKey marks a context as foreground (user-initiated). Unexported; set via
|
||||
// ForegroundContext and read via isForeground so only this package owns the key.
|
||||
type fgCtxKey struct{}
|
||||
|
||||
// ForegroundContext marks ctx as a user-initiated (foreground) fetch so the gate
|
||||
// gives it priority. The web "Summarize"/paste/retry path wraps its context with
|
||||
// this; the background scheduler leaves it unset.
|
||||
func ForegroundContext(ctx context.Context) context.Context {
|
||||
return context.WithValue(ctx, fgCtxKey{}, true)
|
||||
}
|
||||
|
||||
func isForeground(ctx context.Context) bool {
|
||||
v, _ := ctx.Value(fgCtxKey{}).(bool)
|
||||
return v
|
||||
}
|
||||
|
||||
// fgYieldPoll is how often a background waiter re-checks whether a foreground
|
||||
// fetch is still pending. Short enough to feel immediate, long enough not to spin.
|
||||
const fgYieldPoll = 200 * time.Millisecond
|
||||
|
||||
// WaitFetchGate blocks until the process-wide gate allows one timedtext fetch,
|
||||
// respecting ctx cancellation. Called from httpDo before every live outbound
|
||||
// caption fetch so the scheduler and the click-path share the same egress budget.
|
||||
//
|
||||
// Foreground (user-initiated) fetches take priority: they register as pending and
|
||||
// acquire a token immediately. Background fetches first yield — they wait until no
|
||||
// foreground fetch is pending — so a live click is never stuck behind the
|
||||
// background sweep and gets the cleaner slot against the per-IP limit (ADR-026).
|
||||
func WaitFetchGate(ctx context.Context) error {
|
||||
if isForeground(ctx) {
|
||||
foregroundPending.Add(1)
|
||||
defer foregroundPending.Add(-1)
|
||||
return globalFetchGate.Wait(ctx)
|
||||
}
|
||||
|
||||
// Background: defer to any pending foreground fetch before taking a token.
|
||||
for foregroundPending.Load() > 0 {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
case <-time.After(fgYieldPoll):
|
||||
}
|
||||
}
|
||||
return globalFetchGate.Wait(ctx)
|
||||
}
|
||||
|
||||
@@ -82,3 +82,37 @@ func TestSetFetchRateZeroIsUnlimited(t *testing.T) {
|
||||
require.NoError(t, WaitFetchGate(context.Background()))
|
||||
}
|
||||
}
|
||||
|
||||
func TestForegroundContextMarker(t *testing.T) {
|
||||
require.False(t, isForeground(context.Background()), "plain context is background")
|
||||
require.True(t, isForeground(ForegroundContext(context.Background())), "marked context is foreground")
|
||||
}
|
||||
|
||||
// TestWaitFetchGateForegroundProceedsImmediately: a foreground fetch acquires a
|
||||
// token without yielding, even when background callers exist.
|
||||
func TestWaitFetchGateForegroundProceedsImmediately(t *testing.T) {
|
||||
SetFetchRate(0) // unlimited limiter — isolate the yield logic from pacing
|
||||
foregroundPending.Store(0)
|
||||
t.Cleanup(func() { foregroundPending.Store(0) })
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
|
||||
defer cancel()
|
||||
require.NoError(t, WaitFetchGate(ForegroundContext(ctx)), "foreground proceeds immediately")
|
||||
}
|
||||
|
||||
// TestWaitFetchGateBackgroundYieldsToForeground: while a foreground fetch is
|
||||
// pending, a background fetch yields (does not take a token) until the foreground
|
||||
// clears — proven by a background wait timing out against its own deadline, then
|
||||
// succeeding once the foreground is done.
|
||||
func TestWaitFetchGateBackgroundYieldsToForeground(t *testing.T) {
|
||||
SetFetchRate(0)
|
||||
foregroundPending.Store(1) // simulate a foreground fetch in flight
|
||||
t.Cleanup(func() { foregroundPending.Store(0) })
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 250*time.Millisecond)
|
||||
defer cancel()
|
||||
require.Error(t, WaitFetchGate(ctx), "background yields (blocks) while foreground is pending")
|
||||
|
||||
foregroundPending.Store(0) // foreground done
|
||||
require.NoError(t, WaitFetchGate(context.Background()), "background proceeds once foreground clears")
|
||||
}
|
||||
|
||||
@@ -33,7 +33,10 @@ type Config struct {
|
||||
SummarizerModel string
|
||||
// FallbackModel is the LOCAL fallback alias tried when the primary fails or
|
||||
// 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
|
||||
// CloudFallbackModel is the worst-case EXTERNAL fallback alias, tried only
|
||||
// after every local endpoint has failed (ADR-022). For client deployments set
|
||||
@@ -52,6 +55,14 @@ type Config struct {
|
||||
// budget, ADR-014/ADR-023). Enforced via a cheap Data API videos.list lookup at
|
||||
// discovery, never the rate-limited caption path. 0 disables the filter.
|
||||
MinVideoSeconds int
|
||||
|
||||
// ChannelCaptionlessThreshold is how many consecutive no-caption results a
|
||||
// channel may yield before its videos are suppressed from caption fetching
|
||||
// (ADR-024). 0 disables the per-channel caption memory entirely.
|
||||
ChannelCaptionlessThreshold int
|
||||
// ChannelCaptionlessWindow is how long a suppressed channel stays suppressed
|
||||
// before one video is re-probed (auto-recovery for a channel that adds captions).
|
||||
ChannelCaptionlessWindow time.Duration
|
||||
// SummarizerTimeout bounds a single completion call. Thinking models are
|
||||
// slow, so the default is generous.
|
||||
SummarizerTimeout time.Duration
|
||||
@@ -140,11 +151,13 @@ func (c Config) DexConfigured() bool { return strings.TrimSpace(c.OIDCIssuer) !=
|
||||
const (
|
||||
defaultGatewayURL = "http://koala:30401/v1"
|
||||
defaultSummarizerModel = "koala/phi4-mini"
|
||||
defaultFallbackModel = "koala/phi4-14b"
|
||||
defaultFallbackModel = "iguana/gemma4-26b"
|
||||
defaultCloudFallbackModel = "berget/mistral-small"
|
||||
defaultSummaryMaxTokens = 1500
|
||||
defaultMaxTranscriptChars = 18000
|
||||
defaultMinVideoSeconds = 60
|
||||
defaultCaptionlessThreshold = 5
|
||||
defaultCaptionlessWindow = 14 * 24 * time.Hour
|
||||
defaultSummarizerTimeout = 5 * time.Minute
|
||||
defaultYTTokenRef = "youtube/refresh_token"
|
||||
defaultYTConnectRedirectURL = "https://tapir.d-ma.be/oauth/youtube/callback"
|
||||
@@ -246,6 +259,21 @@ func Load() (Config, error) {
|
||||
}
|
||||
c.MinVideoSeconds = minVideo
|
||||
|
||||
captionThreshold, err := intOr("TAPIR_CHANNEL_CAPTIONLESS_THRESHOLD", defaultCaptionlessThreshold)
|
||||
if err != nil {
|
||||
return Config{}, err
|
||||
}
|
||||
if captionThreshold < 0 {
|
||||
captionThreshold = 0
|
||||
}
|
||||
c.ChannelCaptionlessThreshold = captionThreshold
|
||||
|
||||
captionWindow, err := durationOr("TAPIR_CHANNEL_CAPTIONLESS_WINDOW", defaultCaptionlessWindow)
|
||||
if err != nil {
|
||||
return Config{}, err
|
||||
}
|
||||
c.ChannelCaptionlessWindow = captionWindow
|
||||
|
||||
onboard, err := intOr("TAPIR_ONBOARD_SUMMARIZE_COUNT", defaultOnboardSummarizeCount)
|
||||
if err != nil {
|
||||
return Config{}, err
|
||||
|
||||
+63
-12
@@ -27,8 +27,9 @@ import (
|
||||
// passCandidate is a video that passed all pre-filters (seen/manual/backoff)
|
||||
// and is queued for transcript fetch + summarization in this pass.
|
||||
type passCandidate struct {
|
||||
v domain.Video
|
||||
pos int // discovery position — used as a stable tiebreak when published_at ties
|
||||
v domain.Video
|
||||
channelID string // owning channel — keys the caption-availability memory (ADR-024)
|
||||
pos int // discovery position — used as a stable tiebreak when published_at ties
|
||||
}
|
||||
|
||||
// compareNewestFirst orders candidates by published_at descending, NULLS LAST,
|
||||
@@ -74,6 +75,14 @@ type VideoStore interface {
|
||||
// Called when NewVideos returns domain.ErrChannelUnavailable; best-effort, errors
|
||||
// are logged and never abort the pass.
|
||||
UpsertChannelError(ctx context.Context, userID, channelID, channelTitle string) error
|
||||
// CaptionlessChannels returns channel ids currently suppressed because their
|
||||
// recent videos all yielded no captions (ADR-024). The loop skips caption
|
||||
// fetches for these channels' (non-requested) videos.
|
||||
CaptionlessChannels(ctx context.Context, userID string) (map[string]bool, error)
|
||||
// RecordChannelCaptionOutcome updates a channel's caption memory after a fetch:
|
||||
// hadCaptions resets it, otherwise the no-caption streak grows and the channel
|
||||
// is suppressed for window once it reaches threshold. A no-op when threshold<=0.
|
||||
RecordChannelCaptionOutcome(ctx context.Context, userID, channelID string, hadCaptions bool, threshold int, window time.Duration) error
|
||||
}
|
||||
|
||||
// Processor runs the core use case for a single video. *usecase.Engine
|
||||
@@ -93,6 +102,9 @@ type Runner struct {
|
||||
backoff time.Duration // rate-limit retry window; 0 = always retry
|
||||
autoWindow time.Duration // recency bound for auto-summarize; 0 = no bound
|
||||
now func() time.Time // injectable clock (tests); defaults to time.Now
|
||||
|
||||
captionThreshold int // consecutive no-caption results before a channel is suppressed; 0 = feature off
|
||||
captionWindow time.Duration // how long a caption-less channel stays suppressed before re-probe
|
||||
}
|
||||
|
||||
// Option configures a Runner at construction. Variadic so existing call sites
|
||||
@@ -114,6 +126,14 @@ func WithClock(now func() time.Time) Option { return func(r *Runner) { r.now = n
|
||||
// bypasses the bound. 0 (the default) disables it (summarize every unseen video).
|
||||
func WithAutoWindow(d time.Duration) Option { return func(r *Runner) { r.autoWindow = d } }
|
||||
|
||||
// WithCaptionMemory enables per-channel caption-availability suppression
|
||||
// (ADR-024): after threshold consecutive no-caption results a channel's videos
|
||||
// are skipped (no caption fetch) for window, then one is re-probed. threshold<=0
|
||||
// (the default) disables the feature entirely.
|
||||
func WithCaptionMemory(threshold int, window time.Duration) Option {
|
||||
return func(r *Runner) { r.captionThreshold = threshold; r.captionWindow = window }
|
||||
}
|
||||
|
||||
// New builds a Runner. A nil logger falls back to slog.Default.
|
||||
func New(src ports.VideoSource, store VideoStore, engine Processor, userID string, log *slog.Logger, opts ...Option) *Runner {
|
||||
if log == nil {
|
||||
@@ -131,15 +151,16 @@ func New(src ports.VideoSource, store VideoStore, engine Processor, userID strin
|
||||
|
||||
// Stats summarizes one RunOnce pass.
|
||||
type Stats struct {
|
||||
Candidates int
|
||||
Summarized int
|
||||
SkippedSeen int
|
||||
SkippedNoText int
|
||||
SkippedManual int // discovered but not queued, in manual mode
|
||||
SkippedTooOld int // auto mode: published outside the recency window (not requested)
|
||||
SkippedRateLimited int // 429'd previously and still inside the backoff window
|
||||
Errors int
|
||||
ChannelUnavailable int // channels that returned HTTP 404 (deleted/private)
|
||||
Candidates int
|
||||
Summarized int
|
||||
SkippedSeen int
|
||||
SkippedNoText int
|
||||
SkippedManual int // discovered but not queued, in manual mode
|
||||
SkippedTooOld int // auto mode: published outside the recency window (not requested)
|
||||
SkippedRateLimited int // 429'd previously and still inside the backoff window
|
||||
SkippedNoCaptionChannel int // channel suppressed as caption-less (ADR-024)
|
||||
Errors int
|
||||
ChannelUnavailable int // channels that returned HTTP 404 (deleted/private)
|
||||
}
|
||||
|
||||
// tooOld reports whether a video published at publishedAt falls outside the
|
||||
@@ -213,6 +234,17 @@ func (r *Runner) RunOnce(ctx context.Context) (Stats, error) {
|
||||
}
|
||||
}
|
||||
|
||||
// Per-channel caption memory (ADR-024): channels whose recent videos all
|
||||
// yielded no captions are suppressed so their new videos don't burn the scarce
|
||||
// fetch budget. Loaded only when the feature is enabled (threshold > 0).
|
||||
var captionless map[string]bool
|
||||
if r.captionThreshold > 0 {
|
||||
captionless, err = r.store.CaptionlessChannels(ctx, r.userID)
|
||||
if err != nil {
|
||||
return stats, fmt.Errorf("runner: load caption-less channels: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
subs, err := r.src.ListSubscriptions(ctx, r.userID)
|
||||
if err != nil {
|
||||
return stats, fmt.Errorf("runner: list subscriptions: %w", err)
|
||||
@@ -273,6 +305,14 @@ func (r *Runner) RunOnce(ctx context.Context) (Stats, error) {
|
||||
continue
|
||||
}
|
||||
|
||||
// Caption-less channel (ADR-024): its recent videos all returned no
|
||||
// captions, so skip the fetch entirely. The video is still listed
|
||||
// (UpsertVideo above); an explicit manual request bypasses the skip.
|
||||
if !requested[id] && captionless[sub.ChannelID] {
|
||||
stats.SkippedNoCaptionChannel++
|
||||
continue
|
||||
}
|
||||
|
||||
// Still inside the rate-limit backoff window: skip without fetching.
|
||||
if at, ok := rateLimited[id]; ok && r.now().Sub(at) < r.backoff {
|
||||
stats.SkippedRateLimited++
|
||||
@@ -280,7 +320,7 @@ func (r *Runner) RunOnce(ctx context.Context) (Stats, error) {
|
||||
continue
|
||||
}
|
||||
|
||||
candidates = append(candidates, passCandidate{v: v, pos: pos})
|
||||
candidates = append(candidates, passCandidate{v: v, channelID: sub.ChannelID, pos: pos})
|
||||
pos++
|
||||
}
|
||||
}
|
||||
@@ -320,6 +360,11 @@ func (r *Runner) RunOnce(ctx context.Context) (Stats, error) {
|
||||
errs = append(errs, fmt.Errorf("set none status %q: %w", c.v.ProviderVideoID, err))
|
||||
stats.Errors++
|
||||
}
|
||||
// No captions: grow this channel's no-caption streak (ADR-024).
|
||||
if err := r.store.RecordChannelCaptionOutcome(ctx, r.userID, c.channelID, false, r.captionThreshold, r.captionWindow); err != nil {
|
||||
errs = append(errs, fmt.Errorf("record no-caption %q: %w", c.v.ProviderVideoID, err))
|
||||
stats.Errors++
|
||||
}
|
||||
r.log.Info("skipped video (no transcript)", "video", c.v.ProviderVideoID, "title", c.v.Title)
|
||||
case res.Summary != nil:
|
||||
stats.Summarized++
|
||||
@@ -327,6 +372,11 @@ func (r *Runner) RunOnce(ctx context.Context) (Stats, error) {
|
||||
errs = append(errs, fmt.Errorf("set fetched status %q: %w", c.v.ProviderVideoID, err))
|
||||
stats.Errors++
|
||||
}
|
||||
// Captions present: reset this channel's caption memory (ADR-024).
|
||||
if err := r.store.RecordChannelCaptionOutcome(ctx, r.userID, c.channelID, true, r.captionThreshold, r.captionWindow); err != nil {
|
||||
errs = append(errs, fmt.Errorf("record has-caption %q: %w", c.v.ProviderVideoID, err))
|
||||
stats.Errors++
|
||||
}
|
||||
// In manual mode the video was explicitly queued; clear the flag so
|
||||
// it is not re-summarized and the UI drops the "Queued" chip.
|
||||
if !auto {
|
||||
@@ -354,6 +404,7 @@ func (r *Runner) Loop(ctx context.Context, interval time.Duration) error {
|
||||
"skipped_seen", stats.SkippedSeen, "skipped_no_text", stats.SkippedNoText,
|
||||
"skipped_manual", stats.SkippedManual, "skipped_too_old", stats.SkippedTooOld,
|
||||
"skipped_rate_limited", stats.SkippedRateLimited,
|
||||
"skipped_no_caption_channel", stats.SkippedNoCaptionChannel,
|
||||
"channel_unavailable", stats.ChannelUnavailable, "errors", stats.Errors)
|
||||
if err != nil {
|
||||
r.log.Warn("run pass had errors", "err", err)
|
||||
|
||||
@@ -51,6 +51,13 @@ type fakeStore struct {
|
||||
cleared []string
|
||||
rateLimited map[string]time.Time // id -> when 429'd (seeds the backoff window)
|
||||
statuses map[string]string // id -> last SetTranscriptStatus value
|
||||
captionless map[string]bool // channel ids currently suppressed (ADR-024)
|
||||
captionRecs []captionRec // RecordChannelCaptionOutcome calls, in order
|
||||
}
|
||||
|
||||
type captionRec struct {
|
||||
channelID string
|
||||
had bool
|
||||
}
|
||||
|
||||
func (f *fakeStore) UpsertVideo(_ context.Context, v domain.Video) (string, error) {
|
||||
@@ -93,6 +100,22 @@ func (f *fakeStore) RateLimitedVideoIDs(_ context.Context, _ string) (map[string
|
||||
|
||||
func (f *fakeStore) UpsertChannelError(_ context.Context, _, _, _ string) error { return nil }
|
||||
|
||||
func (f *fakeStore) CaptionlessChannels(_ context.Context, _ string) (map[string]bool, error) {
|
||||
cp := make(map[string]bool, len(f.captionless))
|
||||
for k, v := range f.captionless {
|
||||
cp[k] = v
|
||||
}
|
||||
return cp, nil
|
||||
}
|
||||
|
||||
func (f *fakeStore) RecordChannelCaptionOutcome(_ context.Context, _, channelID string, hadCaptions bool, threshold int, _ time.Duration) error {
|
||||
if threshold <= 0 {
|
||||
return nil
|
||||
}
|
||||
f.captionRecs = append(f.captionRecs, captionRec{channelID: channelID, had: hadCaptions})
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f *fakeStore) SetTranscriptStatus(_ context.Context, _, videoID, status string) error {
|
||||
if f.statuses == nil {
|
||||
f.statuses = map[string]string{}
|
||||
@@ -284,6 +307,55 @@ func TestRunOnce_AutoMode_OldVideoRequestedBypassesWindow(t *testing.T) {
|
||||
require.Len(t, sink.delivered, 1)
|
||||
}
|
||||
|
||||
// TestRunOnce_CaptionlessChannelSkipped: a channel flagged caption-less (ADR-024)
|
||||
// has its videos skipped from fetching but still discovered/listed, while a
|
||||
// normal channel's video is summarized.
|
||||
func TestRunOnce_CaptionlessChannelSkipped(t *testing.T) {
|
||||
src := &fakeSource{
|
||||
subs: []domain.Subscription{sub("dead", "Dead Channel"), sub("live", "Live Channel")},
|
||||
videos: map[string][]domain.Video{
|
||||
"dead": {vid("d1", "Dead One")},
|
||||
"live": {vid("l1", "Live One")},
|
||||
},
|
||||
}
|
||||
st := &fakeStore{seen: map[string]bool{}, auto: true, captionless: map[string]bool{"dead": true}}
|
||||
sink := &recordingSink{}
|
||||
eng := usecase.NewEngine(src, fakeSummarizer{}, sink)
|
||||
r := runner.New(src, st, eng, testUser, quietLogger(),
|
||||
runner.WithCaptionMemory(5, 14*24*time.Hour))
|
||||
|
||||
stats, err := r.RunOnce(context.Background())
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, 1, stats.SkippedNoCaptionChannel, "dead channel's video skipped from fetch")
|
||||
require.Equal(t, 1, stats.Summarized, "live channel's video still summarized")
|
||||
require.Len(t, st.upserted, 2, "both videos are still discovered and listed")
|
||||
}
|
||||
|
||||
// TestRunOnce_RecordsCaptionOutcomes: a no-caption result grows the channel's
|
||||
// streak (had=false); a successful summary resets it (had=true).
|
||||
func TestRunOnce_RecordsCaptionOutcomes(t *testing.T) {
|
||||
src := &fakeSource{
|
||||
subs: []domain.Subscription{sub("c1", "Has Caps"), sub("c2", "No Caps")},
|
||||
videos: map[string][]domain.Video{
|
||||
"c1": {vid("good", "Good")},
|
||||
"c2": {vid("bad", "Bad")},
|
||||
},
|
||||
transcripts: map[string]domain.Transcript{
|
||||
"bad": {Source: domain.SourceNone}, // no usable text → engine skips
|
||||
},
|
||||
}
|
||||
st := &fakeStore{seen: map[string]bool{}, auto: true}
|
||||
sink := &recordingSink{}
|
||||
eng := usecase.NewEngine(src, fakeSummarizer{}, sink)
|
||||
r := runner.New(src, st, eng, testUser, quietLogger(),
|
||||
runner.WithCaptionMemory(5, 14*24*time.Hour))
|
||||
|
||||
_, err := r.RunOnce(context.Background())
|
||||
require.NoError(t, err)
|
||||
require.Contains(t, st.captionRecs, captionRec{channelID: "c1", had: true}, "captioned channel reset")
|
||||
require.Contains(t, st.captionRecs, captionRec{channelID: "c2", had: false}, "no-caption channel streak grown")
|
||||
}
|
||||
|
||||
// TestRunOnce_AutoWindowZero_SummarizesOld: a zero window disables the bound —
|
||||
// the pre-recency behaviour (summarize every unseen video) is preserved.
|
||||
func TestRunOnce_AutoWindowZero_SummarizesOld(t *testing.T) {
|
||||
|
||||
@@ -0,0 +1,227 @@
|
||||
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
|
||||
}
|
||||
view := chatView{
|
||||
VideoID: row.VideoID,
|
||||
Title: displayTitle(*row),
|
||||
Available: hasText,
|
||||
Models: a.Chat.Models(),
|
||||
Selected: a.Chat.DefaultModel(row.AIModel),
|
||||
}
|
||||
// HTMX (the in-place reveal from the summary) gets just the open chat section,
|
||||
// swapped over the closed dock so the summary above it stays put. A no-JS
|
||||
// navigation gets the full page: the whole summary plus the open chat.
|
||||
if isHTMX(r) {
|
||||
a.render(w, r, chatSection(view))
|
||||
return
|
||||
}
|
||||
a.render(w, r, ChatPage(*row, view))
|
||||
}
|
||||
|
||||
// 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.renderChatTurn(w, r, *row, 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
|
||||
}
|
||||
|
||||
// renderChatTurn returns the chat panel fragment for an HTMX answer (swapped in
|
||||
// place within the open dock), or the full chat page otherwise — the no-JS POST
|
||||
// re-renders the whole summary + open chat with the new turn.
|
||||
func (a *App) renderChatTurn(w http.ResponseWriter, r *http.Request, row store.SummaryRow, v chatView) {
|
||||
if isHTMX(r) {
|
||||
a.render(w, r, chatPanel(v))
|
||||
return
|
||||
}
|
||||
a.render(w, r, ChatPage(row, 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,375 @@
|
||||
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")
|
||||
require.Contains(t, html, `id="chat-section"`, "the dock lives on the detail page")
|
||||
require.Contains(t, html, `hx-get="/v/`+videoX+`/chat"`, "it opens the chat in place (HTMX), not a navigation")
|
||||
|
||||
// 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 summary and the chat live together (the integrated UX): the no-JS chat page
|
||||
// renders the full summary alongside the chat, and the HTMX reveal returns just
|
||||
// the open chat section as a fragment so it docks in below the summary already on
|
||||
// screen — the summary is never navigated away from.
|
||||
func TestChatIntegratedWithSummaryOnSamePage(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
chatter := &fakeChatter{models: []string{"phi4-mini", "gemma4-26b"}}
|
||||
app := newChatApp(t, chatter, nil)
|
||||
p := rawPool(t)
|
||||
resetDB(t, p)
|
||||
require.NoError(t, deliver(ctx, app, videoX, "SUMMARY-BODY-MARKER"))
|
||||
seedVideo(t, p, videoX, "X Title", "https://x", time.Time{})
|
||||
seedTranscript(t, app.Store.(*store.Store), videoX, "the transcript")
|
||||
|
||||
// No-JS full page: the summary payload and the chat are on one page.
|
||||
html := body(t, getChat(t, app, videoX))
|
||||
require.Contains(t, html, "SUMMARY-BODY-MARKER", "the summary text is shown on the chat page")
|
||||
require.Contains(t, html, "takeaway one", "takeaways shown alongside the chat")
|
||||
require.Contains(t, html, "highlight one", "highlights shown alongside the chat")
|
||||
require.Contains(t, html, "Ask about this video", "the chat sits on the same page as the summary")
|
||||
require.Contains(t, html, `name="question"`, "the ask form is present")
|
||||
|
||||
// HTMX reveal: the open chat section ONLY (a fragment) — no full-page chrome and
|
||||
// no duplicated summary, so it swaps in below the summary already rendered.
|
||||
req := httptest.NewRequest(http.MethodGet, "/v/"+videoX+"/chat", nil)
|
||||
req.Header.Set("HX-Request", "true")
|
||||
frag := body(t, do(t, app, req))
|
||||
require.NotContains(t, frag, "<html", "the reveal is a fragment, not a full page")
|
||||
require.NotContains(t, frag, "SUMMARY-BODY-MARKER", "the reveal does not re-send the summary (it's already on screen)")
|
||||
require.Contains(t, frag, `id="chat-section"`, "the fragment replaces the dock in place")
|
||||
require.Contains(t, frag, `name="question"`, "the ask form is in the revealed section")
|
||||
}
|
||||
|
||||
// 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
|
||||
@@ -495,11 +510,22 @@ func (a *App) handleStatus(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
if row.Summarized || !a.Processing.Has(processingKey(userID, videoID)) {
|
||||
// Honest, state-aware status (ADR-025). Order matters: a finished summary wins;
|
||||
// an in-flight goroutine shows the working spinner; a recorded rate-limit shows
|
||||
// the calm "waiting, will retry" card that keeps polling; a recorded "none" is
|
||||
// terminal; anything else falls back to the normal card.
|
||||
switch {
|
||||
case row.Summarized:
|
||||
a.render(w, r, VideoCard(*row))
|
||||
case a.Processing.Has(processingKey(userID, videoID)):
|
||||
a.render(w, r, processingCard(*row))
|
||||
case row.TranscriptStatus == "rate_limited":
|
||||
a.render(w, r, waitingCard(*row))
|
||||
case row.TranscriptStatus == "none":
|
||||
a.render(w, r, noCaptionsCard(*row))
|
||||
default:
|
||||
a.render(w, r, VideoCard(*row))
|
||||
return
|
||||
}
|
||||
a.render(w, r, processingCard(*row))
|
||||
}
|
||||
|
||||
// handleSummarizeMode toggles the user's auto/manual summarization mode. The form
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
@@ -26,10 +27,22 @@ import (
|
||||
var dsn string
|
||||
|
||||
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)
|
||||
|
||||
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 {
|
||||
fmt.Fprintf(os.Stderr, "embedded-postgres start: %v\n", err)
|
||||
os.Exit(1)
|
||||
@@ -38,6 +51,7 @@ func TestMain(m *testing.M) {
|
||||
if err := pg.Stop(); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "embedded-postgres stop: %v\n", err)
|
||||
}
|
||||
_ = os.RemoveAll(rt)
|
||||
os.Exit(code)
|
||||
}
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"gitea.d-ma.be/mathias/tapir/internal/web"
|
||||
@@ -129,3 +130,42 @@ func getStatus(t *testing.T, app *web.App, videoID string) *httptest.ResponseRec
|
||||
app.Router().ServeHTTP(rec, req)
|
||||
return rec
|
||||
}
|
||||
|
||||
// setTranscriptStatus stamps videos.transcript_status directly (bypassing RLS via
|
||||
// the super pool) so a test can drive the status endpoint into a given state.
|
||||
func setTranscriptStatus(t *testing.T, p *pgxpool.Pool, videoID, status string) {
|
||||
t.Helper()
|
||||
_, err := p.Exec(context.Background(),
|
||||
`UPDATE videos SET transcript_status = $2 WHERE id = $1`, videoID, status)
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
// TestStatusRateLimitedShowsWaitingCard: a click that hit YouTube's rate limit
|
||||
// must surface the honest "waiting, will retry" card that keeps polling — not a
|
||||
// silent revert to the Summarize button.
|
||||
func TestStatusRateLimitedShowsWaitingCard(t *testing.T) {
|
||||
app := newApp(t)
|
||||
p := rawPool(t)
|
||||
resetDB(t, p)
|
||||
seedVideo(t, p, videoX, "Throttled Title", "https://x", time.Time{})
|
||||
setTranscriptStatus(t, p, videoX, "rate_limited")
|
||||
|
||||
html := body(t, getStatus(t, app, videoX))
|
||||
require.Contains(t, html, "Waiting on YouTube rate limits", "honest rate-limit copy")
|
||||
require.Contains(t, html, `hx-trigger="every 30s"`, "waiting card keeps polling so it self-resolves")
|
||||
require.NotContains(t, html, "Summarize this video", "must not revert to the Summarize button")
|
||||
}
|
||||
|
||||
// TestStatusNoCaptionsTerminal: a no-captions outcome is terminal — an honest
|
||||
// message, no poll, no button to click back into the same dead end.
|
||||
func TestStatusNoCaptionsTerminal(t *testing.T) {
|
||||
app := newApp(t)
|
||||
p := rawPool(t)
|
||||
resetDB(t, p)
|
||||
seedVideo(t, p, videoX, "Silent Title", "https://x", time.Time{})
|
||||
setTranscriptStatus(t, p, videoX, "none")
|
||||
|
||||
html := body(t, getStatus(t, app, videoX))
|
||||
require.Contains(t, html, "No captions available", "honest terminal copy")
|
||||
require.NotContains(t, html, "hx-trigger", "terminal card must stop polling")
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
@@ -658,11 +664,30 @@ a.btn, a.btn:visited { color: var(--accent-fg); }
|
||||
.tapir-bar-fill { animation: tapir-fill 8s linear infinite; text-shadow: 0 0 6px rgba(14, 249, 182, .7); }
|
||||
@keyframes tapir-fill { 0% { clip-path: inset(0 100% 0 0); } 100% { clip-path: inset(0 0 0 0); } }
|
||||
.tapir-label { color: var(--muted); font-size: .9rem; margin: 0; }
|
||||
/* Cycling status verbs (Claude-Code / Crush style): five gerunds stacked, each
|
||||
visible 1/5 of a 6s loop, cross-faded. The container reserves one line height
|
||||
so the layout does not jump as verbs swap. */
|
||||
.tapir-verbs { position: relative; height: 1.3em; margin: .2em 0 0; color: var(--muted); font-size: .9rem; }
|
||||
.tapir-verbs span { position: absolute; left: 0; top: 0; white-space: nowrap; opacity: 0; animation: tapir-verb 6s steps(1, end) infinite; }
|
||||
.tapir-verbs .tv1 { animation-delay: 0s; }
|
||||
.tapir-verbs .tv2 { animation-delay: 1.2s; }
|
||||
.tapir-verbs .tv3 { animation-delay: 2.4s; }
|
||||
.tapir-verbs .tv4 { animation-delay: 3.6s; }
|
||||
.tapir-verbs .tv5 { animation-delay: 4.8s; }
|
||||
@keyframes tapir-verb { 0%, 19.99% { opacity: 1; } 20%, 100% { opacity: 0; } }
|
||||
/* Resting tapir for the rate-limit waiting state: the panel, one still frame, no
|
||||
animation — calm, not busy, signalling "parked, not stuck". */
|
||||
.tapir-resting pre { position: relative; opacity: 1; animation: none; }
|
||||
.card-waiting { border-style: dashed; opacity: .92; }
|
||||
.card-no-captions .card-state { font-style: italic; }
|
||||
.sr-only { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0, 0, 0, 0); white-space: nowrap; border: 0; }
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.tapir-charm pre { animation: none; }
|
||||
.tapir-charm .tapir-f2, .tapir-charm .tapir-f3 { display: none; }
|
||||
.tapir-charm .tapir-f1 { opacity: 1; }
|
||||
.tapir-bar-fill { animation: none; clip-path: inset(0 35% 0 0); }
|
||||
.tapir-verbs span { animation: none; }
|
||||
.tapir-verbs .tv1 { opacity: 1; }
|
||||
}
|
||||
|
||||
/* summarization mode toggle on the account page */
|
||||
@@ -706,6 +731,30 @@ 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) — docks in place below the summary */
|
||||
.chat-dock { margin-top: var(--s5); border-top: 1px solid var(--line); padding-top: var(--s4); }
|
||||
.chat-dock .chat-open { display: inline-block; }
|
||||
.chat-heading { font-size: 1.1rem; margin: 0 0 var(--s2); }
|
||||
.chat-scope { margin: 0 0 var(--s3); 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; }
|
||||
|
||||
+208
-55
@@ -339,7 +339,17 @@ templ TapirSpinner() {
|
||||
<pre class="tapir-f3">@templ.Raw(tapirFrameHTML3)</pre>
|
||||
<div class="tapir-bar"><span class="tapir-bar-fill" style={ "color:" + CharmMint }>{ tapirBarFill }</span></div>
|
||||
</div>
|
||||
<p class="tapir-label" role="status" aria-live="polite"><em>Summarizing…</em></p>
|
||||
// Claude-Code / Crush-style status: playful gerunds cycle in place (CSS only,
|
||||
// no JS). Decorative — aria-hidden — with one stable status line below for
|
||||
// assistive tech.
|
||||
<p class="tapir-verbs" aria-hidden="true">
|
||||
<span class="tv1"><em>Fetching captions…</em></span>
|
||||
<span class="tv2"><em>Chewing the cud…</em></span>
|
||||
<span class="tv3"><em>Munching leaves…</em></span>
|
||||
<span class="tv4"><em>Distilling the gist…</em></span>
|
||||
<span class="tv5"><em>Summarizing…</em></span>
|
||||
</p>
|
||||
<p class="sr-only" role="status" aria-live="polite">Summarizing…</p>
|
||||
}
|
||||
|
||||
// processingCard is the in-flight summarization card. It replaces the Summarize
|
||||
@@ -363,69 +373,212 @@ templ processingCard(r store.SummaryRow) {
|
||||
</li>
|
||||
}
|
||||
|
||||
// DetailPage is the full summary view: text, highlights, takeaways, metadata,
|
||||
// and the action button group.
|
||||
templ DetailPage(r store.SummaryRow) {
|
||||
// waitingCard is the honest rate-limited state: the click landed but YouTube is
|
||||
// throttling the caption fetch, so the tapir rests and the card keeps polling
|
||||
// (gently, every 30s) until the background retry lands the summary — the user
|
||||
// never has to click again. Replaces the old silent revert to a Summarize button.
|
||||
templ waitingCard(r store.SummaryRow) {
|
||||
<li
|
||||
class="card card-waiting"
|
||||
id={ "video-" + r.VideoID }
|
||||
hx-get={ string(statusURL(r.VideoID)) }
|
||||
hx-trigger="every 30s"
|
||||
hx-swap="outerHTML"
|
||||
>
|
||||
<div class="card-title">{ displayTitle(r) }</div>
|
||||
if cardMeta(r) != "" {
|
||||
<div class="card-meta">{ cardMeta(r) }</div>
|
||||
}
|
||||
<div class="tapir-charm tapir-resting" aria-hidden="true">
|
||||
<pre class="tapir-f1">@templ.Raw(tapirFrameHTML2)</pre>
|
||||
</div>
|
||||
<p class="tapir-label" role="status" aria-live="polite">
|
||||
Waiting on YouTube rate limits. Tapir keeps trying, slowly and politely, and the summary will appear here on its own.
|
||||
</p>
|
||||
</li>
|
||||
}
|
||||
|
||||
// noCaptionsCard is the terminal no-captions state: nothing to summarize, so the
|
||||
// card stops (no poll, no button to click again into the same dead end).
|
||||
templ noCaptionsCard(r store.SummaryRow) {
|
||||
<li class="card card-no-captions" id={ "video-" + r.VideoID }>
|
||||
<div class="card-title">{ displayTitle(r) }</div>
|
||||
if cardMeta(r) != "" {
|
||||
<div class="card-meta">{ cardMeta(r) }</div>
|
||||
}
|
||||
<p class="card-state muted">No captions available, so Tapir cannot summarize this one.</p>
|
||||
</li>
|
||||
}
|
||||
|
||||
// summaryBody is the summary payload shared by the detail page and the no-JS
|
||||
// chat page (so the chat page shows the same summary, not a separate view):
|
||||
// metadata, embed, source, the action toggles, then the attention-saving order
|
||||
// Takeaways → Highlights → Summary (UX review A8).
|
||||
templ summaryBody(r store.SummaryRow) {
|
||||
<p class="meta">
|
||||
if detailMeta(r) != "" {
|
||||
<span>{ detailMeta(r) }</span>
|
||||
}
|
||||
if r.FallbackUsed {
|
||||
<span class="badge" title="summarized with the fallback model" aria-label="summarized with the fallback model">fallback</span>
|
||||
}
|
||||
</p>
|
||||
if url, ok := embedURL(r.ProviderVideoID); ok {
|
||||
<div class="embed">
|
||||
<iframe
|
||||
src={ url }
|
||||
title={ displayTitle(r) }
|
||||
loading="lazy"
|
||||
referrerpolicy="strict-origin-when-cross-origin"
|
||||
allow="accelerometer; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share"
|
||||
allowfullscreen
|
||||
></iframe>
|
||||
</div>
|
||||
}
|
||||
if r.URL != "" {
|
||||
<p class="source"><a href={ externalURL(r.URL) } rel="noopener noreferrer">watch on source ↗</a></p>
|
||||
}
|
||||
@ActionButtons(r.VideoID, actionSet(r.Actions))
|
||||
if len(r.Takeaways) > 0 {
|
||||
<section>
|
||||
<h2>Takeaways</h2>
|
||||
<ul>
|
||||
for _, t := range r.Takeaways {
|
||||
<li>{ t }</li>
|
||||
}
|
||||
</ul>
|
||||
</section>
|
||||
}
|
||||
if len(r.Highlights) > 0 {
|
||||
<section>
|
||||
<h2>Highlights</h2>
|
||||
<ul>
|
||||
for _, h := range r.Highlights {
|
||||
<li>{ h }</li>
|
||||
}
|
||||
</ul>
|
||||
</section>
|
||||
}
|
||||
<section>
|
||||
<h2>Summary</h2>
|
||||
<p class="body">{ r.Summary }</p>
|
||||
</section>
|
||||
}
|
||||
|
||||
// DetailPage is the full summary view: the summary payload, then (when chat is
|
||||
// enabled) the deeper-dive dock (ADR-027) — a reveal that opens the chat IN PLACE
|
||||
// below the summary, so the summary stays on screen as the context being asked
|
||||
// about rather than being navigated away from.
|
||||
templ DetailPage(r store.SummaryRow, chatEnabled bool) {
|
||||
@Layout("Tapir — " + displayTitle(r)) {
|
||||
<article class="detail">
|
||||
<p class="back"><a href="/">← Summaries</a></p>
|
||||
<h1>{ displayTitle(r) }</h1>
|
||||
<p class="meta">
|
||||
if detailMeta(r) != "" {
|
||||
<span>{ detailMeta(r) }</span>
|
||||
}
|
||||
if r.FallbackUsed {
|
||||
<span class="badge" title="summarized with the fallback model" aria-label="summarized with the fallback model">fallback</span>
|
||||
}
|
||||
</p>
|
||||
if url, ok := embedURL(r.ProviderVideoID); ok {
|
||||
<div class="embed">
|
||||
<iframe
|
||||
src={ url }
|
||||
title={ displayTitle(r) }
|
||||
loading="lazy"
|
||||
referrerpolicy="strict-origin-when-cross-origin"
|
||||
allow="accelerometer; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share"
|
||||
allowfullscreen
|
||||
></iframe>
|
||||
</div>
|
||||
@summaryBody(r)
|
||||
if chatEnabled {
|
||||
@chatReveal(r.VideoID)
|
||||
}
|
||||
if r.URL != "" {
|
||||
<p class="source"><a href={ externalURL(r.URL) } rel="noopener noreferrer">watch on source ↗</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
|
||||
// A8). Takeaways/Highlights are conditional, so a video without them falls
|
||||
// through to the Summary leading naturally.
|
||||
if len(r.Takeaways) > 0 {
|
||||
<section>
|
||||
<h2>Takeaways</h2>
|
||||
<ul>
|
||||
for _, t := range r.Takeaways {
|
||||
<li>{ t }</li>
|
||||
}
|
||||
</ul>
|
||||
</section>
|
||||
}
|
||||
if len(r.Highlights) > 0 {
|
||||
<section>
|
||||
<h2>Highlights</h2>
|
||||
<ul>
|
||||
for _, h := range r.Highlights {
|
||||
<li>{ h }</li>
|
||||
}
|
||||
</ul>
|
||||
</section>
|
||||
}
|
||||
<section>
|
||||
<h2>Summary</h2>
|
||||
<p class="body">{ r.Summary }</p>
|
||||
</section>
|
||||
</article>
|
||||
}
|
||||
}
|
||||
|
||||
// chatReveal is the CLOSED dock at the foot of the summary: a quiet affordance,
|
||||
// not a loud CTA (it deepens value for a reader already here, never nudges). With
|
||||
// JS it swaps itself for the open chat section in place (HTMX, summary stays
|
||||
// above); without JS the same href navigates to the full chat page, which renders
|
||||
// the summary alongside the chat. Either way the summary is never lost.
|
||||
templ chatReveal(videoID string) {
|
||||
<section id="chat-section" class="chat-dock">
|
||||
<a
|
||||
class="btn-secondary chat-open"
|
||||
href={ chatURL(videoID) }
|
||||
hx-get={ string(chatURL(videoID)) }
|
||||
hx-target="#chat-section"
|
||||
hx-swap="outerHTML"
|
||||
>
|
||||
Dig deeper — ask about this video →
|
||||
</a>
|
||||
</section>
|
||||
}
|
||||
|
||||
// chatSection is the OPEN dock: heading + scope note + the chat panel, swapped in
|
||||
// over the closed reveal (same #chat-section id, outerHTML). It is the HTMX reveal
|
||||
// response AND the inline chat block on the no-JS chat page.
|
||||
templ chatSection(v chatView) {
|
||||
<section id="chat-section" class="chat-dock chat-dock-open">
|
||||
<h2 class="chat-heading">Ask about this video</h2>
|
||||
<p class="chat-scope muted">Answers come only from this video's stored transcript — Tapir never fetches anything new here.</p>
|
||||
@chatPanel(v)
|
||||
</section>
|
||||
}
|
||||
|
||||
// ChatPage is the no-JS full-page render of the chat: the whole summary followed
|
||||
// by the open chat dock, so a visitor without JS sees the same integrated view
|
||||
// (summary beside the conversation) that JS users get inline via the reveal.
|
||||
templ ChatPage(r store.SummaryRow, v chatView) {
|
||||
@Layout("Tapir — " + displayTitle(r)) {
|
||||
<article class="detail">
|
||||
<p class="back"><a href="/">← Summaries</a></p>
|
||||
<h1>{ displayTitle(r) }</h1>
|
||||
@summaryBody(r)
|
||||
@chatSection(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.
|
||||
|
||||
+959
-320
File diff suppressed because it is too large
Load Diff
@@ -63,6 +63,19 @@ 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",
|
||||
"The summary and the chat are on one page": "TestChatIntegratedWithSummaryOnSamePage",
|
||||
"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