Compare commits

...
15 Commits
Author SHA1 Message Date
mathiasandClaude Opus 4.8 19ca4282a8 refactor(web): dock chat inline below the summary, integrated view (ADR-027)
CI / Lint / Test / Vet (push) Successful in 10s
CI / Build & Import (push) Successful in 10s
The chat was a separate page — opening it left the summary behind, the very
context you're asking about. Now the chat docks open IN PLACE below the summary:
"Dig deeper" reveals the chat section (HTMX, outerHTML over the closed dock) so
the summary stays on screen above it; no navigation. The no-JS fallback renders
the full summary AND the open chat on one page (the same integrated view), so
progressive enhancement holds.

Extracts a shared summaryBody templ so the detail page and the chat page render
one identical summary, not two divergent ones. GET /v/{id}/chat returns just the
open chat section as a fragment for the inline reveal, or the full summary+chat
page for a no-JS navigation; POST swaps the panel inline or re-renders the whole
page. Tests assert summary+chat coexist on the page and the reveal is a fragment.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-11 14:02:39 +02:00
mathiasandClaude Opus 4.8 71df696448 feat(web): per-video chat over the stored transcript (ADR-027)
CI / Lint / Test / Vet (push) Successful in 10s
CI / Build & Import (push) Successful in 10s
A deeper-dive chat entered from the summary view: ask questions about a video
against its already-stored transcript (ADR-021), no caption fetch, ever.

Safety by construction — the load-bearing property. The chat handlers reach the
chat.Service only after reading the SHARED stored transcript via Store.GetTranscript
(a pure DB read); the service holds no VideoSource. So an enabled chat cannot
trigger a caption fetch, touch the rate gate, or reach YouTube. A video with no
stored transcript gets an honest "not available" — no fetch, no model call. The
key web test wires the summarize/fetch collaborators as tripwires that fail the
test if chat ever routes into them, and asserts the model answered from the
stored text.

Model defaults to the summary's own model and is switchable among the ADR-022
chain (phi4-mini → gemma4-26b → mistral-small); switching re-runs against the
same transcript — deliberate model-comparison instrumentation. The cloud model
is absent from the switcher when TAPIR_CLOUD_FALLBACK_MODEL="" (the local-first /
NDA lever), honoured the same way the summarizer honours it. Reuses the existing
LiteLLM gateway client (a chat is a different call, not a new integration) and
the TAPIR_MAX_TRANSCRIPT_CHARS truncation, surfacing an honest bounded-context
note when a long transcript is cut.

Ephemeral v1: the multi-turn conversation rides in hidden request fields; no
table, no migration, nothing persisted. Entry is RLS-scoped through
GetSummaryByVideo, so chat is reachable only from the user's own summary view.
Show-source verification and on-demand fetch are deferred (ADR-027).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-11 09:26:58 +02:00
mathiasandClaude Opus 4.8 cc3cda4ab8 feat(chat): stored-transcript QA service (ADR-027)
A read-only deeper-dive over a video's already-stored transcript (ADR-021).
Safe by construction: the Service has no VideoSource and no caption-fetch
dependency — only a Completer factory over the existing LiteLLM gateway — so it
cannot reach YouTube or the rate gate. Reuses the summarizer's truncation
discipline (TAPIR_MAX_TRANSCRIPT_CHARS), reporting the cut so the UI can be
honest about a bounded transcript. Model defaults to the summary's model and is
switchable among an offered, local-first list; an un-offered alias is forced
back to the default so chat can never call the gateway with an arbitrary model.
Ephemeral: history is carried per-request, nothing persisted.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-11 09:16:45 +02:00
mathiasandClaude Opus 4.8 69a49bc603 docs(decisions): add ADR-027 — chat with a video's stored transcript
Records the deeper-dive chat decision before its build: stored-transcript-only
(safe by construction — no caption fetch, no rate gate, no YouTube), entered
from the summary view, model defaulting to the summary's model and switchable
among the ADR-022 chain. Ephemeral v1; show-source verification deferred to v2.
Inserted before "Rejected alternatives" so the decision precedes the feature.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-11 09:13:31 +02:00
mathias 137804b0b1 docs: spec chat-with-transcript (ADR-027)
CI / Lint / Test / Vet (push) Successful in 11s
CI / Build & Import (push) Successful in 10s
Per-video chat against the ADR-021 stored transcript, entered from the summary
view, born from observed demand (maintainer read real summaries, some made him
want to dig deeper). HARD constraint: stored-transcript-only — never fetches
captions, never touches the rate gate or YouTube, safe by construction. Default
model = the summary's model, user-switchable among the ADR-022 chain models
(doubles as model-comparison instrumentation). Ephemeral v1 (no persisted
history); chat-only/trust-the-model with show-source verification recorded as the
natural v2. ADR-027 to be appended to DECISIONS.md as the first commit.
2026-06-11 06:38:16 +00:00
mathiasandClaude Opus 4.8 a9be5f285b feat(summarizer): move local fallback off koala to iguana/gemma4-26b
CI / Build & Import (push) Successful in 10s
CI / Lint / Test / Vet (push) Successful in 10s
koala now carries other GPU loads, so the first fallback should not run there.
Change the default chain to koala/phi4-mini → iguana/gemma4-26b → berget/mistral-small:
the local fallback now runs on iguana (M2 Ultra headroom, different host = 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 fallback path).

Pure config default (TAPIR_FALLBACK_MODEL); chain mechanism (ADR-022) unchanged.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-11 07:54:56 +02:00
mathiasandClaude Opus 4.8 fe56e2fe01 test: make embedded-postgres per-process so concurrent CI runs don't collide
CI / Lint / Test / Vet (push) Successful in 9s
CI / Build & Import (push) Successful in 10s
A push to main and its version tag fire two CI runs for the same commit. Both ran
`go test ./...`, which starts embedded-postgres on a FIXED port (54329/54330) and
a shared data dir. -p 1 serialises packages WITHIN a run, not across two
concurrent runs — so when the two runs overlapped they collided on the port/data
dir and BOTH failed the Lint/Test job (no image built). Prior commits passed only
because their two runs happened not to overlap.

Derive the port and runtime/data dirs from the PID; share only CachePath so the
PG archive downloads once. Proven: two concurrent `go test` of the store package
now both pass. Unblocks the v0.21.0 (Pillar A) build.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-11 07:26:33 +02:00
mathiasandClaude Opus 4.8 beeb5bc31b feat(gate): foreground caption fetches take priority over the background sweep (ADR-026)
CI / Lint / Test / Vet (push) Failing after 9s
CI / Build & Import (push) Has been skipped
A user waiting on a Summarize click shared the per-IP caption gate equally with
the background firehose, so on a busy IP the click was slow or 429'd. Add a
context-marked priority lane: the web path (engineProcessor.ProcessVideo) marks
its context foreground; the gate serves foreground immediately while background
fetches yield until no foreground is pending. Threaded via a context value (no
new signatures) + a process-wide foregroundPending counter. Clicks are rare, so
the background barely loses throughput; the waiting human gets the cleaner slot.

Drops the credentials probe: ADR-010 and captions.go already settle it — the
timedtext/InnerTube path rejects authenticated requests and the OAuth token does
not authenticate it anyway, so auth cannot help and can hurt. Documented in
ADR-026 rather than built.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-10 22:10:15 +02:00
mathiasandClaude Opus 4.8 09eb31d1fe fix(scheduler): derive rotation offset from wall-clock, not a reset-on-restart counter
CI / Lint / Test / Vet (push) Successful in 11s
CI / Build & Import (push) Successful in 14s
The lead-user rotation used an in-memory pass counter reset to 0 on every pod
restart, so the first-listed user always re-took the lead after a restart — a
deploy-heavy session re-starved the last user (the pilot stalled at 2 summaries
because each deploy reset his every-other-pass lead before the 2h tick fired).
Derive the offset from wall-clock (floor(now/interval)) so it advances with real
time and is identical across restarts: rotation stays fair however often the pod
bounces.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-10 21:59:12 +02:00
mathiasandClaude Opus 4.8 1665a1e7c4 feat(web): honest, state-aware summarize status with charm spinner (ADR-025)
CI / Build & Import (push) Successful in 10s
CI / Lint / Test / Vet (push) Successful in 12s
Clicking Summarize polled /status, which knew only "spinning" or "done". The web
ProcessVideo recorded an outcome only on success, so a 429'd or caption-less
click left transcript_status unset and the poll silently reverted to the
Summarize button — the rate limit was invisible and the click felt broken.

- ProcessVideo now records rate_limited / none / fetched (mirrors the runner); a
  rate-limited video keeps its requested flag so the background sweep retries it.
- /status is state-aware: summary card (done), working spinner (in-flight),
  a calm "waiting on rate limit, will retry" card that keeps polling so the
  summary lands on its own (no re-click), and a terminal "no captions" card.
- Charm status text (Claude-Code / Crush inspired): the spinner cycles playful
  tapir-themed gerunds via CSS only (no JS), decorative + an sr-only stable
  status line for a11y.

Pillar A (foreground fetch priority lane) is a separate follow-up.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-10 21:52:33 +02:00
mathiasandClaude Opus 4.8 5219561a91 feat(discovery): per-channel caption-availability memory (ADR-024)
CI / Lint / Test / Vet (push) Successful in 30s
CI / Build & Import (push) Successful in 11s
After transcript caching (ADR-021) and the Shorts filter (ADR-023), the remaining
caption waste is the first fetch on every new video of a channel that never has
English captions — each costs one rate-limited fetch to resolve to "none", and on
a throttled IP churns the backoff machinery first.

Remember, per (user, channel), a streak of consecutive no-caption outcomes
(channel_caption_state, migration 016, RLS-scoped). Once it reaches
TAPIR_CHANNEL_CAPTIONLESS_THRESHOLD (default 5) the channel is suppressed — videos
discovered/listed but not caption-fetched — for TAPIR_CHANNEL_CAPTIONLESS_WINDOW
(default 14d), then one is re-probed (auto-recovery). A successful fetch resets
the streak; a 429 does not count; an explicit manual request bypasses suppression.
threshold=0 disables.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-10 20:27:25 +02:00
mathiasandClaude Opus 4.8 9db06d8a63 feat(discovery): drop Shorts and livestreams before the caption fetch (ADR-023)
CI / Lint / Test / Vet (push) Successful in 12s
CI / Build & Import (push) Successful in 10s
The scarce resource is the per-IP timedtext caption fetch (ADR-014); the pilot's
candidate set was mostly Shorts/clips/livestreams, each burning a fetch (a "none"
result is a completed fetch — it costs budget even when it yields nothing).

NewVideos now enriches candidates with one cheap Data API videos.list call
(contentDetails.duration + snippet.liveBroadcastContent — the quota API, a
DIFFERENT limit from the timedtext 429) and drops, before returning: videos
shorter than TAPIR_MIN_VIDEO_SECONDS (default 60) and any live/upcoming
broadcast. Dropped videos are never persisted, so the list declutters too.

Degrade-open: MinVideoSeconds=0 disables it (no quota call); a videos.list error
returns candidates unfiltered so discovery never breaks on a metadata hiccup. The
paste-a-URL path (VideoByID) is not filtered — an explicit request is honoured.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-10 19:34:45 +02:00
mathiasandClaude Opus 4.8 1aa8a97f95 fix(scheduler): rotate over connected users only for true fair share
CI / Lint / Test / Vet (push) Successful in 12s
CI / Build & Import (push) Successful in 10s
The lead-user rotation rotated the full ListAllUsers set, so a connectionless
orphan identity ate a rotation slot — collapsing onto the next real user and
skewing the lead share (two real users got 2/3 vs 1/3 instead of 50/50). Filter
to connected users BEFORE rotating so the rotation is over exactly the users that
consume the caption budget. A dead identity can no longer skew fairness.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-10 18:28:54 +02:00
mathiasandClaude Opus 4.8 cc69a912f4 fix(scheduler): rotate lead user each pass so caption budget is shared
CI / Lint / Test / Vet (push) Successful in 12s
CI / Build & Import (push) Successful in 10s
Caption fetches share one per-egress-IP rate budget; whoever runs first each pass
spends the pre-throttle window before YouTube starts 429ing. ListAllUsers order
is unspecified and was stable, so the last-listed user was permanently starved —
a friendly-pilot user got 0 fetches in 12h (all rate_limited) while the
first-listed user got every successful fetch. rotateUsers left-rotates the user
order by pass index so each user leads 1/N passes and the lead slot is shared.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-10 18:07:01 +02:00
mathiasandClaude Opus 4.8 f4a0544903 fix(scheduler): cache transcripts on the scheduled path (ADR-021 regression)
buildUserRunner built the engine without engine.Transcripts = st, so the
scheduler — unlike the web "Summarize now" path — never read or wrote the shared
transcript cache. Every discovery pass re-fetched transcripts it had already
fetched, burning the scarce per-egress-IP caption budget (ADR-014) on redundant
work and starving other users' first-time fetches. The transcripts table was
empty despite summaries existing. Wire the cache on this path too.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-10 18:07:01 +02:00
33 changed files with 3560 additions and 433 deletions
+209 -4
View File
@@ -827,10 +827,18 @@ The fix is resilience around it, not replacing it.
**Decision.** **Decision.**
1. **Ordered endpoint chain (`summarizer.NewChain`).** Endpoints are tried in order; the first to 1. **Ordered endpoint chain (`summarizer.NewChain`).** Endpoints are tried in order; the first to
return a *parseable* summary wins. Default chain: return a *parseable* summary wins. Default chain:
`koala/phi4-mini` (primary, local) → `koala/phi4-14b` (fallback, local) → `koala/phi4-mini` (primary, local) → `iguana/gemma4-26b` (fallback, local on a
`berget/mistral-small` (worst-case, external). All three are reached through the **one** LiteLLM *different host*) → `berget/mistral-small` (worst-case, external). All three are reached through
gateway by alias — the gateway already fronts both llama-swap and berget — so a fallback is a the **one** LiteLLM gateway by alias — the gateway already fronts both llama-swap and berget — so
different alias, not a second client config. a fallback is a different alias, not a second client config.
**Update 2026-06-11:** the local fallback moved from `koala/phi4-14b` to `iguana/gemma4-26b`.
koala now carries other GPU loads, so keeping the fallback on koala competed with them; iguana
(M2 Ultra) has the headroom, and a different host is also a different egress IP for the rare
fallback fetch. `gemma4-26b` is the brain-validated homelab general-purpose model (agentsquad
H2/H3 executor) and returned valid summary JSON on the real prompt in a smoke test
(~37s incl. cold-load — fine for a path hit only when the fast primary fails). Pure config:
`TAPIR_FALLBACK_MODEL`.
2. **A parse failure advances the chain, same as a transport error.** "Reliably summarized" means 2. **A parse failure advances the chain, same as a transport error.** "Reliably summarized" means
*parseable summary returned*, not *HTTP 200*. This is the behaviour the old Primary→Fallback *parseable summary returned*, not *HTTP 200*. This is the behaviour the old Primary→Fallback
shape missed. shape missed.
@@ -855,6 +863,203 @@ reply still parses; a transcript within budget is unchanged).
--- ---
## ADR-023 — Drop Shorts/livestreams at discovery to protect the caption budget
**Status:** Accepted (2026-06-10). **Builds on ADR-014** (per-IP caption rate limit is the
binding constraint) and the ADR-022 live-run findings.
**Context.** The scarce resource is the unofficial timedtext caption fetch (per-egress-IP
429, ~3 successful/pass). The first multi-user run showed the candidate set was mostly noise —
Shorts, sub-minute clips, and live broadcasts — each of which still consumes a caption-fetch
attempt (and a "none" result is a *completed* fetch, so it costs budget even when it yields
nothing). Spending the rate-limited budget on content the user will not read is the waste to
cut first; it is cheaper and lower-risk than raising the ceiling (multi-IP, Whisper).
Duration and live status are NOT in the playlistItems discovery response, but they ARE in the
Data API `videos.list` (contentDetails.duration + snippet.liveBroadcastContent) — the official
**quota-based** API (1 unit/call, 50 ids/call), which is a *different* limit from the timedtext
429. So one cheap quota call buys a filter that saves many expensive throttled fetches.
**Decision.**
1. `NewVideos` enriches its candidates with a single `videos.list` call and drops, before
returning: videos shorter than `TAPIR_MIN_VIDEO_SECONDS` (default 60) and any `live`/
`upcoming` broadcast. Dropped videos are never persisted, so they also declutter the list.
2. The filter is **degrade-open**: `MinVideoSeconds=0` disables it (no quota call), and a
`videos.list` error returns the candidates unfiltered — discovery must never break because a
metadata call hiccuped (worst case = pre-ADR-023 behaviour).
3. The paste-a-URL path (`VideoByID`) is **not** filtered — an explicit user request for a
specific video (even a Short) is honoured.
**Reversibility.** Pure discovery-time filter + config. `TAPIR_MIN_VIDEO_SECONDS=0` restores
the old behaviour. No schema change, no effect on already-stored videos.
**Quota note.** Per-channel enrichment adds ~1 unit/channel/pass. At pilot scale (≤3 users)
this is well under the 10k/day cap; at larger scale, batch `videos.list` across channels
(50 ids/call) by collecting all discovered ids per pass before enriching.
---
## 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 ## Rejected alternatives
Approaches considered during the 2026-06-02 planning + grill session and **deliberately not Approaches considered during the 2026-06-02 planning + grill session and **deliberately not
+64
View File
@@ -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
View File
@@ -136,7 +136,8 @@ func cmdRun(ctx context.Context, log *slog.Logger) error {
r := runner.New(engine.Source, st, engine, cfg.UserID, log, r := runner.New(engine.Source, st, engine, cfg.UserID, log,
runner.WithBackoff(cfg.FetchBackoff), 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, log.Info("starting run", "user", cfg.UserID, "model", cfg.SummarizerModel,
"gateway", cfg.GatewayURL, "poll_interval", cfg.PollInterval, "fetch_backoff", cfg.FetchBackoff, "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) secretStore := secrets.NewFileStore(cfg.SecretsFile)
app := &web.App{Store: st, Identity: st, Auth: authn, Secrets: secretStore, Log: log, RecencyWindow: cfg.AutoSummarizeWindow} app := &web.App{Store: st, Identity: st, Auth: authn, Secrets: secretStore, Log: log, RecencyWindow: cfg.AutoSummarizeWindow}
// Per-video deeper-dive chat over the STORED transcript (ADR-027). Enabled
// whenever a gateway is configured — it needs no YouTube credentials because it
// never fetches. Guarded so a typed-nil never lands in the interface field
// (which would mount the routes over a nil backend).
if c := buildChat(cfg); c != nil {
app.Chat = c
log.Info("web chat enabled (stored-transcript only)", "models", chatModels(cfg))
}
// User onboarding is handled by the IdP (Authentik invite flow), not Tapir — // User onboarding is handled by the IdP (Authentik invite flow), not Tapir —
// the Dex local-password provisioning path was removed (ADR-019). An // the Dex local-password provisioning path was removed (ADR-019). An
// authenticated subject with no Tapir user is routed to /register. // authenticated subject with no Tapir user is routed to /register.
+72 -1
View File
@@ -5,6 +5,7 @@ import (
"fmt" "fmt"
"strings" "strings"
"gitea.d-ma.be/mathias/tapir/internal/adapters/chat"
"gitea.d-ma.be/mathias/tapir/internal/adapters/llm" "gitea.d-ma.be/mathias/tapir/internal/adapters/llm"
"gitea.d-ma.be/mathias/tapir/internal/adapters/secrets" "gitea.d-ma.be/mathias/tapir/internal/adapters/secrets"
"gitea.d-ma.be/mathias/tapir/internal/adapters/store" "gitea.d-ma.be/mathias/tapir/internal/adapters/store"
@@ -68,6 +69,49 @@ func buildSummarizer(cfg config.Config) *summarizer.Summarizer {
return summarizer.NewChain(eps, cfg.MaxTranscriptChars) return summarizer.NewChain(eps, cfg.MaxTranscriptChars)
} }
// chatModels is the ordered, local-first set of models offered in the chat
// switcher (ADR-027), reusing the ADR-022 chain: primary → local fallback →
// cloud. Empty entries are dropped and duplicates collapsed, so a client/NDA
// deployment that sets the cloud fallback empty simply has no external model in
// the switcher — the same local-first lever the summarizer honours.
func chatModels(cfg config.Config) []string {
var models []string
add := func(m string) {
if m == "" {
return
}
for _, e := range models {
if e == m {
return
}
}
models = append(models, m)
}
add(cfg.SummarizerModel)
add(cfg.FallbackModel)
add(cfg.CloudFallbackModel)
return models
}
// buildChat wires the per-video chat service (ADR-027): a Completer factory over
// the SAME LiteLLM gateway the summarizer uses (a different alias per model, not a
// second client config) and the same transcript-truncation budget. It returns nil
// when no gateway is configured — chat is simply not mounted, the read path is
// unaffected. It deliberately takes NO YouTube source: chat is stored-only.
func buildChat(cfg config.Config) *chat.Service {
if cfg.GatewayURL == "" {
return nil
}
models := chatModels(cfg)
if len(models) == 0 {
return nil
}
newClient := func(model string) chat.Completer {
return llm.New(cfg.GatewayURL, cfg.GatewayKey, model, cfg.SummarizerTimeout, llm.WithMaxTokens(cfg.SummaryMaxTokens))
}
return chat.New(newClient, models, cfg.MaxTranscriptChars)
}
// providerOf maps a model alias to the domain AIProvider recorded on summaries. // providerOf maps a model alias to the domain AIProvider recorded on summaries.
// A "berget/" alias is an external provider; everything else is the local stack. // A "berget/" alias is an external provider; everything else is the local stack.
func providerOf(model string) string { func providerOf(model string) string {
@@ -88,6 +132,7 @@ func buildProcessor(cfg config.Config, st *store.Store) (*usecase.Engine, error)
ClientSecret: cfg.YTClientSecret, ClientSecret: cfg.YTClientSecret,
TokenSecretRef: cfg.YTTokenRef, TokenSecretRef: cfg.YTTokenRef,
PreferredLanguages: []string{"en"}, PreferredLanguages: []string{"en"},
MinVideoSeconds: cfg.MinVideoSeconds,
}, secretStore) }, secretStore)
sum := buildSummarizer(cfg) sum := buildSummarizer(cfg)
@@ -112,6 +157,11 @@ type engineProcessor struct {
} }
func (p *engineProcessor) ProcessVideo(ctx context.Context, userID, videoID string) error { 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) row, err := p.store.GetVideoRow(ctx, userID, videoID)
if err != nil { if err != nil {
return fmt.Errorf("load video %q: %w", videoID, err) return fmt.Errorf("load video %q: %w", videoID, err)
@@ -131,7 +181,28 @@ func (p *engineProcessor) ProcessVideo(ctx context.Context, userID, videoID stri
if err != nil { if err != nil {
return fmt.Errorf("process video %q: %w", videoID, err) 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 { if err := p.store.ClearSummarizeRequested(ctx, userID, videoID); err != nil {
return fmt.Errorf("clear summarize flag %q: %w", videoID, err) return fmt.Errorf("clear summarize flag %q: %w", videoID, err)
} }
+68 -9
View File
@@ -33,13 +33,20 @@ func buildUserRunner(cfg config.Config, st *store.Store, secretStore ports.Secre
ClientSecret: cfg.YTClientSecret, ClientSecret: cfg.YTClientSecret,
TokenSecretRef: web.YouTubeTokenRef(userID), TokenSecretRef: web.YouTubeTokenRef(userID),
PreferredLanguages: []string{"en"}, PreferredLanguages: []string{"en"},
MinVideoSeconds: cfg.MinVideoSeconds,
}, secretStore) }, secretStore)
engine := usecase.NewEngine(src, buildSummarizer(cfg), st) engine := usecase.NewEngine(src, buildSummarizer(cfg), st)
// Share the transcript cache (ADR-021) on the scheduler path too — without
// this every scheduled pass re-fetches transcripts it already had, burning the
// scarce per-IP caption budget (ADR-014) and starving other users. The web
// "Summarize now" path already sets this; the scheduler omitting it was a bug.
engine.Transcripts = st
return runner.New(src, st, engine, userID, log, return runner.New(src, st, engine, userID, log,
runner.WithBackoff(cfg.FetchBackoff), 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 // userLister enumerates every registered user and reports a user's video
@@ -57,6 +64,7 @@ type userLister interface {
// isolation). Returns the stats summed across users. // isolation). Returns the stats summed across users.
func runDiscoveryPass( func runDiscoveryPass(
ctx context.Context, ctx context.Context,
pass int,
lister userLister, lister userLister,
runUser func(context.Context, string) (runner.Stats, error), runUser func(context.Context, string) (runner.Stats, error),
log *slog.Logger, log *slog.Logger,
@@ -67,15 +75,17 @@ func runDiscoveryPass(
return runner.Stats{} return runner.Stats{}
} }
log.Info("scheduler: starting discovery pass", "users", len(users)) // Keep only users with a video connection. A pass for a connectionless user
var total runner.Stats // (e.g. a stale Dex-era orphan identity) only tries to resolve a token that
// was never minted, logging a spurious "ref not found" every tick. Filtering
// here — BEFORE rotation — also keeps fairness honest: rotation is over the
// users that actually consume the caption budget, so a dead identity can't eat
// a rotation slot and skew the lead share.
var connected []store.UserIdentity
for _, u := range users { for _, u := range users {
if ctx.Err() != nil { if ctx.Err() != nil {
break // shutting down: stop enumerating return runner.Stats{} // shutting down
} }
// Skip users with no video connection. A discovery pass for them only
// attempts to resolve a token that was never minted, logging a spurious
// "ref not found" every tick (e.g. stale Dex-era orphan identities).
conns, err := lister.ConnectionsForUser(ctx, u.UserID) conns, err := lister.ConnectionsForUser(ctx, u.UserID)
if err != nil { if err != nil {
log.Warn("scheduler: list connections failed", "user", u.UserID, "err", err) log.Warn("scheduler: list connections failed", "user", u.UserID, "err", err)
@@ -85,6 +95,22 @@ func runDiscoveryPass(
log.Debug("scheduler: skipping user with no video connections", "user", u.UserID) log.Debug("scheduler: skipping user with no video connections", "user", u.UserID)
continue continue
} }
connected = append(connected, u)
}
// Rotate who goes first each pass. Caption fetches share one per-egress-IP
// rate budget (ADR-014); whoever runs first each pass spends the pre-throttle
// window, so a FIXED order permanently starves whoever is last (a new pilot
// user got 0 fetches for 12h while the first-listed user got all of them).
// Rotation over the connected set gives each real user the lead in turn.
connected = rotateUsers(connected, pass)
log.Info("scheduler: starting discovery pass", "users", len(connected))
var total runner.Stats
for _, u := range connected {
if ctx.Err() != nil {
break // shutting down: stop enumerating
}
stats, err := runUser(ctx, u.UserID) stats, err := runUser(ctx, u.UserID)
total = sumStats(total, stats) total = sumStats(total, stats)
if err != nil { if err != nil {
@@ -96,6 +122,7 @@ func runDiscoveryPass(
"skipped_seen", total.SkippedSeen, "skipped_no_text", total.SkippedNoText, "skipped_seen", total.SkippedSeen, "skipped_no_text", total.SkippedNoText,
"skipped_manual", total.SkippedManual, "skipped_too_old", total.SkippedTooOld, "skipped_manual", total.SkippedManual, "skipped_too_old", total.SkippedTooOld,
"skipped_rate_limited", total.SkippedRateLimited, "skipped_rate_limited", total.SkippedRateLimited,
"skipped_no_caption_channel", total.SkippedNoCaptionChannel,
"channel_unavailable", total.ChannelUnavailable, "errors", total.Errors) "channel_unavailable", total.ChannelUnavailable, "errors", total.Errors)
return total return total
} }
@@ -116,7 +143,18 @@ func runScheduler(
return // disabled return // disabled
} }
runDiscoveryPass(ctx, 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) ticker := time.NewTicker(interval)
defer ticker.Stop() defer ticker.Stop()
@@ -125,11 +163,31 @@ func runScheduler(
case <-ctx.Done(): case <-ctx.Done():
return return
case <-ticker.C: case <-ticker.C:
runDiscoveryPass(ctx, lister, runUser, log) runPass()
} }
} }
} }
// rotateUsers left-rotates users by pass positions so a different user leads each
// pass. With n users, user i leads on every pass where pass ≡ i (mod n). A pass
// offset that is negative or exceeds n is normalised. Order within the rotation
// is otherwise preserved, so the set of users run is unchanged — only who is
// first (and thus wins the scarce caption-fetch budget) rotates.
func rotateUsers(users []store.UserIdentity, pass int) []store.UserIdentity {
n := len(users)
if n <= 1 {
return users
}
off := ((pass % n) + n) % n
if off == 0 {
return users
}
out := make([]store.UserIdentity, 0, n)
out = append(out, users[off:]...)
out = append(out, users[:off]...)
return out
}
// sumStats adds two passes' stats field-wise, so runDiscoveryPass can report a // sumStats adds two passes' stats field-wise, so runDiscoveryPass can report a
// per-tick aggregate across all users. // per-tick aggregate across all users.
func sumStats(a, b runner.Stats) runner.Stats { func sumStats(a, b runner.Stats) runner.Stats {
@@ -141,6 +199,7 @@ func sumStats(a, b runner.Stats) runner.Stats {
SkippedManual: a.SkippedManual + b.SkippedManual, SkippedManual: a.SkippedManual + b.SkippedManual,
SkippedTooOld: a.SkippedTooOld + b.SkippedTooOld, SkippedTooOld: a.SkippedTooOld + b.SkippedTooOld,
SkippedRateLimited: a.SkippedRateLimited + b.SkippedRateLimited, SkippedRateLimited: a.SkippedRateLimited + b.SkippedRateLimited,
SkippedNoCaptionChannel: a.SkippedNoCaptionChannel + b.SkippedNoCaptionChannel,
ChannelUnavailable: a.ChannelUnavailable + b.ChannelUnavailable, ChannelUnavailable: a.ChannelUnavailable + b.ChannelUnavailable,
Errors: a.Errors + b.Errors, Errors: a.Errors + b.Errors,
} }
+45 -4
View File
@@ -45,6 +45,7 @@ func (f fakeLister) ConnectionsForUser(_ context.Context, userID string) ([]stor
type countingRunUser struct { type countingRunUser struct {
mu sync.Mutex mu sync.Mutex
calls map[string]int calls map[string]int
order []string // userIDs in the order they were run, across all passes
failFor map[string]bool failFor map[string]bool
} }
@@ -60,12 +61,19 @@ func (c *countingRunUser) run(_ context.Context, userID string) (runner.Stats, e
c.mu.Lock() c.mu.Lock()
defer c.mu.Unlock() defer c.mu.Unlock()
c.calls[userID]++ c.calls[userID]++
c.order = append(c.order, userID)
if c.failFor[userID] { if c.failFor[userID] {
return runner.Stats{Errors: 1}, errors.New("boom") return runner.Stats{Errors: 1}, errors.New("boom")
} }
return runner.Stats{Summarized: 1}, nil return runner.Stats{Summarized: 1}, nil
} }
func (c *countingRunUser) runOrder() []string {
c.mu.Lock()
defer c.mu.Unlock()
return append([]string(nil), c.order...)
}
func (c *countingRunUser) count(userID string) int { func (c *countingRunUser) count(userID string) int {
c.mu.Lock() c.mu.Lock()
defer c.mu.Unlock() defer c.mu.Unlock()
@@ -94,7 +102,7 @@ func TestDiscoveryPassRunsEveryUserOnce(t *testing.T) {
lister := fakeLister{users: usersN("a", "b", "c")} lister := fakeLister{users: usersN("a", "b", "c")}
rc := newCountingRunUser() rc := newCountingRunUser()
stats := runDiscoveryPass(context.Background(), lister, rc.run, quietLog()) stats := runDiscoveryPass(context.Background(), 0, lister, rc.run, quietLog())
require.Equal(t, 1, rc.count("a")) require.Equal(t, 1, rc.count("a"))
require.Equal(t, 1, rc.count("b")) require.Equal(t, 1, rc.count("b"))
@@ -102,13 +110,46 @@ func TestDiscoveryPassRunsEveryUserOnce(t *testing.T) {
require.Equal(t, 3, stats.Summarized, "stats are summed across users") require.Equal(t, 3, stats.Summarized, "stats are summed across users")
} }
// Caption fetches share one per-IP budget; a fixed user order starves whoever is
// last. Each pass must rotate which user leads so the lead slot is shared.
func TestDiscoveryPassRotatesLeadUser(t *testing.T) {
lister := fakeLister{users: usersN("a", "b", "c")}
rc := newCountingRunUser()
runDiscoveryPass(context.Background(), 0, lister, rc.run, quietLog())
runDiscoveryPass(context.Background(), 1, lister, rc.run, quietLog())
runDiscoveryPass(context.Background(), 2, lister, rc.run, quietLog())
require.Equal(t, []string{"a", "b", "c", "b", "c", "a", "c", "a", "b"}, rc.runOrder(),
"each pass left-rotates the user order so every user leads in turn")
// Fairness: over a full rotation cycle every user ran the same number of times.
require.Equal(t, 3, rc.count("a"))
require.Equal(t, 3, rc.count("b"))
require.Equal(t, 3, rc.count("c"))
}
// A connectionless orphan must not consume a rotation slot: rotation is over the
// connected users only, so two real users alternate the lead 50/50 even with a
// dead identity listed between them.
func TestDiscoveryPassRotationIgnoresConnectionlessUsers(t *testing.T) {
lister := fakeLister{users: usersN("a", "orphan", "c"), noConn: map[string]bool{"orphan": true}}
rc := newCountingRunUser()
runDiscoveryPass(context.Background(), 0, lister, rc.run, quietLog())
runDiscoveryPass(context.Background(), 1, lister, rc.run, quietLog())
require.Equal(t, []string{"a", "c", "c", "a"}, rc.runOrder(),
"only connected users rotate; the orphan never runs and never holds a slot")
require.Equal(t, 0, rc.count("orphan"))
}
func TestDiscoveryPassSkipsUsersWithoutConnections(t *testing.T) { func TestDiscoveryPassSkipsUsersWithoutConnections(t *testing.T) {
// b never connected a video source (e.g. a stale Dex-era orphan identity). // b never connected a video source (e.g. a stale Dex-era orphan identity).
// It must be skipped silently — not run and logged as a token error every pass. // It must be skipped silently — not run and logged as a token error every pass.
lister := fakeLister{users: usersN("a", "b", "c"), noConn: map[string]bool{"b": true}} lister := fakeLister{users: usersN("a", "b", "c"), noConn: map[string]bool{"b": true}}
rc := newCountingRunUser() rc := newCountingRunUser()
stats := runDiscoveryPass(context.Background(), lister, rc.run, quietLog()) stats := runDiscoveryPass(context.Background(), 0, lister, rc.run, quietLog())
require.Equal(t, 1, rc.count("a")) require.Equal(t, 1, rc.count("a"))
require.Equal(t, 0, rc.count("b"), "a user with no connection must be skipped, not run") require.Equal(t, 0, rc.count("b"), "a user with no connection must be skipped, not run")
@@ -121,7 +162,7 @@ func TestDiscoveryPassOneUserFailureDoesNotStopOthers(t *testing.T) {
lister := fakeLister{users: usersN("a", "b", "c")} lister := fakeLister{users: usersN("a", "b", "c")}
rc := newCountingRunUser("b") // user b's pass errors rc := newCountingRunUser("b") // user b's pass errors
stats := runDiscoveryPass(context.Background(), lister, rc.run, quietLog()) stats := runDiscoveryPass(context.Background(), 0, lister, rc.run, quietLog())
require.Equal(t, 1, rc.count("a")) require.Equal(t, 1, rc.count("a"))
require.Equal(t, 1, rc.count("b")) require.Equal(t, 1, rc.count("b"))
@@ -134,7 +175,7 @@ func TestDiscoveryPassListerErrorIsContained(t *testing.T) {
lister := fakeLister{err: errors.New("db down")} lister := fakeLister{err: errors.New("db down")}
rc := newCountingRunUser() rc := newCountingRunUser()
stats := runDiscoveryPass(context.Background(), lister, rc.run, quietLog()) stats := runDiscoveryPass(context.Background(), 0, lister, rc.run, quietLog())
require.Equal(t, 0, rc.total(), "no users enumerated → no passes") require.Equal(t, 0, rc.total(), "no users enumerated → no passes")
require.Equal(t, runner.Stats{}, stats) require.Equal(t, runner.Stats{}, stats)
+13 -1
View File
@@ -30,7 +30,9 @@ it** — endpoints and aliases drift, and this file is a snapshot (2026-06-06),
- **Summarizer fallback chain (ADR-022).** The primary alias is the *first* of an ordered chain; - **Summarizer fallback chain (ADR-022).** The primary alias is the *first* of an ordered chain;
on failure or unparseable output the summarizer advances to the next model. All reached through on failure or unparseable output the summarizer advances to the next model. All reached through
the same gateway by alias. the same gateway by alias.
- `TAPIR_FALLBACK_MODEL` — local fallback. **Default `koala/phi4-14b`.** Empty disables it. - `TAPIR_FALLBACK_MODEL` — local fallback. **Default `iguana/gemma4-26b`** — on iguana, NOT
koala, so the fallback does not compete with koala's other GPU loads (and runs from a different
egress IP). Empty disables it.
- `TAPIR_CLOUD_FALLBACK_MODEL` — worst-case EXTERNAL fallback. **Default `berget/mistral-small`.** - `TAPIR_CLOUD_FALLBACK_MODEL` — worst-case EXTERNAL fallback. **Default `berget/mistral-small`.**
**Set this empty (`""`) for any client/NDA deployment** so content never leaves the local **Set this empty (`""`) for any client/NDA deployment** so content never leaves the local
stack — the chain then contains only local endpoints. stack — the chain then contains only local endpoints.
@@ -39,6 +41,16 @@ it** — endpoints and aliases drift, and this file is a snapshot (2026-06-06),
- `TAPIR_MAX_TRANSCRIPT_CHARS` — transcript truncation budget sent to the model. **Default - `TAPIR_MAX_TRANSCRIPT_CHARS` — transcript truncation budget sent to the model. **Default
`18000`** (~fits an 8k-context model). `0` disables truncation. Prevents the context-overflow `18000`** (~fits an 8k-context model). `0` disables truncation. Prevents the context-overflow
HTTP 400 a long transcript caused on `phi4-mini`. HTTP 400 a long transcript caused on `phi4-mini`.
- **Discovery low-value filter (ADR-023).** `TAPIR_MIN_VIDEO_SECONDS`**default `60`**. At
discovery, `NewVideos` enriches candidates with one cheap `videos.list` call (quota API, NOT
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 - **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 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):** parser treats an empty summary as an error for exactly this reason. **Done (2026-06-02, Worker F):**
+89
View File
@@ -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.
+69
View File
@@ -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
+171
View File
@@ -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
}
+164
View File
@@ -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")
}
+10 -3
View File
@@ -53,7 +53,9 @@ func TestMigration010LoginEventsUpDown(t *testing.T) {
require.True(t, loginEventsExists(t), "login_events must exist at latest migration") require.True(t, loginEventsExists(t), "login_events must exist at latest migration")
m := fileMigrator(t) 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.NoError(t, m.Steps(-1), "down 015 reshapes transcripts, login_events intact")
require.True(t, loginEventsExists(t), "015 down leaves 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") 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.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.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") 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") require.Equal(t, "true", autoSummarizeDefault(t), "011 sets the default to TRUE")
m := fileMigrator(t) 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 015 reshapes transcripts")
require.NoError(t, m.Steps(-1), "down 014 drops channel_title") require.NoError(t, m.Steps(-1), "down 014 drops channel_title")
require.NoError(t, m.Steps(-1), "down 013 drops channel_errors") 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 013 creates channel_errors")
require.NoError(t, m.Steps(1), "up 014 recreates channel_title") 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 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. // 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") require.True(t, channelTitleExists(t), "channel_title exists at latest migration")
m := fileMigrator(t) 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.NoError(t, m.Steps(-1), "down 015 reshapes transcripts, channel_title intact")
require.True(t, channelTitleExists(t), "015 down leaves 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") 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.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.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 // 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);
+15 -2
View File
@@ -4,6 +4,7 @@ import (
"context" "context"
"fmt" "fmt"
"os" "os"
"path/filepath"
"testing" "testing"
embeddedpostgres "github.com/fergusstrange/embedded-postgres" embeddedpostgres "github.com/fergusstrange/embedded-postgres"
@@ -24,11 +25,22 @@ var _ ports.Sink = (*store.Store)(nil)
var dsn string var dsn string
func TestMain(m *testing.M) { func TestMain(m *testing.M) {
const port = 54329 // Port + runtime/data dirs are per-process (PID-derived) so two concurrent
// `go test` invocations — e.g. a push-run and a tag-run firing together in CI —
// don't collide on a fixed port or a shared data dir (which silently failed
// both runs). CachePath is shared so the PG archive is downloaded once, not
// per process. Base 54000 keeps this package's range distinct from web's.
port := uint32(54000 + os.Getpid()%1000)
dsn = fmt.Sprintf("postgres://postgres:postgres@localhost:%d/postgres?sslmode=disable", port) dsn = fmt.Sprintf("postgres://postgres:postgres@localhost:%d/postgres?sslmode=disable", port)
rt := filepath.Join(os.TempDir(), fmt.Sprintf("tapir-epg-store-%d", os.Getpid()))
pg := embeddedpostgres.NewDatabase( pg := embeddedpostgres.NewDatabase(
embeddedpostgres.DefaultConfig().Port(port), embeddedpostgres.DefaultConfig().
Port(port).
RuntimePath(rt).
DataPath(filepath.Join(rt, "data")).
BinariesPath(filepath.Join(rt, "bin")).
CachePath(filepath.Join(os.TempDir(), "tapir-epg-cache")),
) )
if err := pg.Start(); err != nil { if err := pg.Start(); err != nil {
fmt.Fprintf(os.Stderr, "embedded-postgres start: %v\n", err) fmt.Fprintf(os.Stderr, "embedded-postgres start: %v\n", err)
@@ -40,6 +52,7 @@ func TestMain(m *testing.M) {
if err := pg.Stop(); err != nil { if err := pg.Stop(); err != nil {
fmt.Fprintf(os.Stderr, "embedded-postgres stop: %v\n", err) fmt.Fprintf(os.Stderr, "embedded-postgres stop: %v\n", err)
} }
_ = os.RemoveAll(rt)
os.Exit(code) os.Exit(code)
} }
+47
View File
@@ -2,6 +2,7 @@ package youtube
import ( import (
"context" "context"
"sync/atomic"
"time" "time"
"golang.org/x/time/rate" "golang.org/x/time/rate"
@@ -29,9 +30,55 @@ func SetFetchRate(interval time.Duration) {
globalFetchGate = rate.NewLimiter(rate.Every(interval), 1) 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, // WaitFetchGate blocks until the process-wide gate allows one timedtext fetch,
// respecting ctx cancellation. Called from httpDo before every live outbound // respecting ctx cancellation. Called from httpDo before every live outbound
// caption fetch so the scheduler and the click-path share the same egress budget. // 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 { 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) return globalFetchGate.Wait(ctx)
} }
+34
View File
@@ -82,3 +82,37 @@ func TestSetFetchRateZeroIsUnlimited(t *testing.T) {
require.NoError(t, WaitFetchGate(context.Background())) 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")
}
+103 -1
View File
@@ -66,6 +66,13 @@ type Config struct {
// poll. Zero means defaultMaxVideos. // poll. Zero means defaultMaxVideos.
MaxVideosPerSubscription int MaxVideosPerSubscription int
// MinVideoSeconds drops videos shorter than this from discovery (Shorts/clips,
// ADR-023). NewVideos enriches candidates with a single cheap videos.list call
// (contentDetails.duration + snippet.liveBroadcastContent) and filters before
// returning, so the scarce caption-fetch budget is never spent on them. Live
// and upcoming broadcasts are dropped too. Zero disables the filter.
MinVideoSeconds int
// BaseURL overrides the Data API root. Empty means defaultBaseURL. // BaseURL overrides the Data API root. Empty means defaultBaseURL.
BaseURL string BaseURL string
@@ -242,7 +249,97 @@ func (a *Adapter) NewVideos(ctx context.Context, sub domain.Subscription) ([]dom
break break
} }
} }
return videos, nil
// Drop Shorts/sub-minute clips and live/upcoming broadcasts before they ever
// reach the rate-limited caption path (ADR-023). One cheap videos.list call
// (quota API, not the timedtext throttle) supplies duration + live status.
return a.filterLowValue(ctx, client, videos), nil
}
// filterLowValue removes videos shorter than cfg.MinVideoSeconds and any live or
// upcoming broadcast, using a single videos.list lookup for duration +
// liveBroadcastContent. The filter is best-effort: if MinVideoSeconds is 0 (off)
// or the lookup fails, the input is returned unfiltered — discovery must not break
// because a metadata call hiccuped; the worst case is the pre-ADR-023 behaviour.
func (a *Adapter) filterLowValue(ctx context.Context, client *http.Client, videos []domain.Video) []domain.Video {
if a.cfg.MinVideoSeconds <= 0 || len(videos) == 0 {
return videos
}
ids := make([]string, 0, len(videos))
for _, v := range videos {
ids = append(ids, v.ProviderVideoID)
}
q := url.Values{
"part": {"contentDetails,snippet"},
"id": {strings.Join(ids, ",")},
}
var resp videoListResponse
if err := a.getJSON(ctx, client, "/videos", q, &resp); err != nil {
// Degrade open: keep the candidates rather than lose discovery.
return videos
}
type meta struct {
seconds int
live string
}
byID := make(map[string]meta, len(resp.Items))
for _, it := range resp.Items {
byID[it.ID] = meta{seconds: parseISO8601Seconds(it.ContentDetails.Duration), live: it.Snippet.LiveBroadcastContent}
}
kept := videos[:0]
for _, v := range videos {
m, ok := byID[v.ProviderVideoID]
if !ok {
kept = append(kept, v) // unknown metadata: keep, let the fetch decide
continue
}
if m.live != "" && m.live != "none" {
continue // live or upcoming broadcast
}
if m.seconds > 0 && m.seconds < a.cfg.MinVideoSeconds {
continue // Short / sub-threshold clip
}
kept = append(kept, v)
}
return kept
}
// parseISO8601Seconds parses an ISO 8601 duration as returned by the YouTube Data
// API (e.g. "PT1H2M3S", "PT45S", "PT3M") into seconds. Only the hour/minute/second
// components YouTube emits are handled; an unparseable or zero value returns 0,
// which the caller treats as "unknown" (not filtered on duration).
func parseISO8601Seconds(d string) int {
if !strings.HasPrefix(d, "PT") {
return 0
}
d = d[2:]
total, num := 0, 0
seen := false
for _, r := range d {
switch {
case r >= '0' && r <= '9':
num = num*10 + int(r-'0')
seen = true
case r == 'H':
total += num * 3600
num, seen = 0, false
case r == 'M':
total += num * 60
num, seen = 0, false
case r == 'S':
total += num
num, seen = 0, false
default:
return 0 // unexpected component (days/weeks) — treat as unknown
}
}
if seen {
return 0 // trailing digits without a unit: malformed
}
return total
} }
// VideoByID fetches a single video's metadata (videos.list, snippet) for an // VideoByID fetches a single video's metadata (videos.list, snippet) for an
@@ -377,11 +474,16 @@ type playlistItemListResponse struct {
type videoListResponse struct { type videoListResponse struct {
Items []struct { Items []struct {
ID string `json:"id"`
Snippet struct { Snippet struct {
Title string `json:"title"` Title string `json:"title"`
ChannelTitle string `json:"channelTitle"` ChannelTitle string `json:"channelTitle"`
PublishedAt time.Time `json:"publishedAt"` PublishedAt time.Time `json:"publishedAt"`
LiveBroadcastContent string `json:"liveBroadcastContent"`
} `json:"snippet"` } `json:"snippet"`
ContentDetails struct {
Duration string `json:"duration"` // ISO 8601, e.g. "PT1M30S"
} `json:"contentDetails"`
} `json:"items"` } `json:"items"`
} }
+85
View File
@@ -186,6 +186,91 @@ func TestNewVideosCapsAtMax(t *testing.T) {
} }
} }
// TestNewVideosFiltersShortsAndLive: with MinVideoSeconds set, discovery enriches
// candidates via videos.list and drops sub-threshold clips (Shorts) and
// live/upcoming broadcasts before they reach the rate-limited caption path.
func TestNewVideosFiltersShortsAndLive(t *testing.T) {
a, _ := newTestAdapter(t, func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/playlistItems":
_, _ = w.Write([]byte(`{
"items": [
{"snippet": {"title": "Real Talk", "publishedAt": "2026-06-03T10:00:00Z", "resourceId": {"videoId": "long1"}}},
{"snippet": {"title": "A Short", "publishedAt": "2026-06-03T09:00:00Z", "resourceId": {"videoId": "short1"}}},
{"snippet": {"title": "Live Now", "publishedAt": "2026-06-03T08:00:00Z", "resourceId": {"videoId": "live1"}}}
]
}`))
case "/videos":
if got := r.URL.Query().Get("part"); got != "contentDetails,snippet" {
t.Errorf("videos.list part=%q, want contentDetails,snippet", got)
}
_, _ = w.Write([]byte(`{
"items": [
{"id": "long1", "contentDetails": {"duration": "PT12M30S"}, "snippet": {"liveBroadcastContent": "none"}},
{"id": "short1", "contentDetails": {"duration": "PT45S"}, "snippet": {"liveBroadcastContent": "none"}},
{"id": "live1", "contentDetails": {"duration": "PT0S"}, "snippet": {"liveBroadcastContent": "live"}}
]
}`))
default:
t.Errorf("unexpected path %q", r.URL.Path)
}
})
a.cfg.MinVideoSeconds = 60
vids, err := a.NewVideos(context.Background(), domain.Subscription{ID: "s1", UserID: "u1", ChannelID: "UC_acme"})
if err != nil {
t.Fatalf("NewVideos: %v", err)
}
if len(vids) != 1 || vids[0].ProviderVideoID != "long1" {
t.Fatalf("expected only long1 to survive the filter, got %+v", vids)
}
}
// TestNewVideosNoFilterWhenDisabled: MinVideoSeconds=0 keeps the pre-ADR-023
// behaviour — no videos.list call, no filtering.
func TestNewVideosNoFilterWhenDisabled(t *testing.T) {
a, _ := newTestAdapter(t, func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/videos" {
t.Errorf("videos.list must not be called when MinVideoSeconds is 0")
}
_, _ = w.Write([]byte(`{"items": [
{"snippet": {"title": "A Short", "publishedAt": "2026-06-03T09:00:00Z", "resourceId": {"videoId": "short1"}}}
]}`))
})
a.cfg.MinVideoSeconds = 0
vids, err := a.NewVideos(context.Background(), domain.Subscription{ID: "s1", UserID: "u1", ChannelID: "UC_acme"})
if err != nil {
t.Fatalf("NewVideos: %v", err)
}
if len(vids) != 1 {
t.Fatalf("filter disabled must keep all videos, got %d", len(vids))
}
}
func TestParseISO8601Seconds(t *testing.T) {
cases := []struct {
in string
want int
}{
{"PT45S", 45},
{"PT1M30S", 90},
{"PT3M", 180},
{"PT1H2M3S", 3723},
{"PT2H", 7200},
{"PT0S", 0},
{"", 0},
{"garbage", 0},
{"P1D", 0}, // days component not handled → unknown
{"PT10", 0}, // trailing digits without a unit → malformed
}
for _, c := range cases {
if got := parseISO8601Seconds(c.in); got != c.want {
t.Errorf("parseISO8601Seconds(%q) = %d, want %d", c.in, got, c.want)
}
}
}
// TestUploadsPlaylistID covers the zero-cost UC->UU derivation, including // TestUploadsPlaylistID covers the zero-cost UC->UU derivation, including
// non-standard ids that must fall through unchanged (handled via fallback). // non-standard ids that must fall through unchanged (handled via fallback).
func TestUploadsPlaylistID(t *testing.T) { func TestUploadsPlaylistID(t *testing.T) {
+46 -2
View File
@@ -33,7 +33,10 @@ type Config struct {
SummarizerModel string SummarizerModel string
// FallbackModel is the LOCAL fallback alias tried when the primary fails or // FallbackModel is the LOCAL fallback alias tried when the primary fails or
// returns unparseable output (ADR-022). Kept local so content stays on the // returns unparseable output (ADR-022). Kept local so content stays on the
// homelab stack. Empty disables it. Default a bigger-context local model. // homelab stack. Default is an IGUANA model (not koala) so the fallback runs
// on a different host than the koala primary — koala carries other loads, and
// a different host also means a different egress IP for the (rare) fallback.
// Empty disables it.
FallbackModel string FallbackModel string
// CloudFallbackModel is the worst-case EXTERNAL fallback alias, tried only // CloudFallbackModel is the worst-case EXTERNAL fallback alias, tried only
// after every local endpoint has failed (ADR-022). For client deployments set // after every local endpoint has failed (ADR-022). For client deployments set
@@ -46,6 +49,20 @@ type Config struct {
// MaxTranscriptChars bounds the transcript text sent to the model so a long // MaxTranscriptChars bounds the transcript text sent to the model so a long
// transcript does not overflow a small-context primary. 0 disables truncation. // transcript does not overflow a small-context primary. 0 disables truncation.
MaxTranscriptChars int MaxTranscriptChars int
// MinVideoSeconds drops videos shorter than this from discovery (Shorts and
// other sub-minute clips that are noise and waste the scarce caption-fetch
// 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 // SummarizerTimeout bounds a single completion call. Thinking models are
// slow, so the default is generous. // slow, so the default is generous.
SummarizerTimeout time.Duration SummarizerTimeout time.Duration
@@ -134,10 +151,13 @@ func (c Config) DexConfigured() bool { return strings.TrimSpace(c.OIDCIssuer) !=
const ( const (
defaultGatewayURL = "http://koala:30401/v1" defaultGatewayURL = "http://koala:30401/v1"
defaultSummarizerModel = "koala/phi4-mini" defaultSummarizerModel = "koala/phi4-mini"
defaultFallbackModel = "koala/phi4-14b" defaultFallbackModel = "iguana/gemma4-26b"
defaultCloudFallbackModel = "berget/mistral-small" defaultCloudFallbackModel = "berget/mistral-small"
defaultSummaryMaxTokens = 1500 defaultSummaryMaxTokens = 1500
defaultMaxTranscriptChars = 18000 defaultMaxTranscriptChars = 18000
defaultMinVideoSeconds = 60
defaultCaptionlessThreshold = 5
defaultCaptionlessWindow = 14 * 24 * time.Hour
defaultSummarizerTimeout = 5 * time.Minute defaultSummarizerTimeout = 5 * time.Minute
defaultYTTokenRef = "youtube/refresh_token" defaultYTTokenRef = "youtube/refresh_token"
defaultYTConnectRedirectURL = "https://tapir.d-ma.be/oauth/youtube/callback" defaultYTConnectRedirectURL = "https://tapir.d-ma.be/oauth/youtube/callback"
@@ -230,6 +250,30 @@ func Load() (Config, error) {
} }
c.MaxTranscriptChars = maxChars c.MaxTranscriptChars = maxChars
minVideo, err := intOr("TAPIR_MIN_VIDEO_SECONDS", defaultMinVideoSeconds)
if err != nil {
return Config{}, err
}
if minVideo < 0 {
minVideo = 0
}
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) onboard, err := intOr("TAPIR_ONBOARD_SUMMARIZE_COUNT", defaultOnboardSummarizeCount)
if err != nil { if err != nil {
return Config{}, err return Config{}, err
+52 -1
View File
@@ -28,6 +28,7 @@ import (
// and is queued for transcript fetch + summarization in this pass. // and is queued for transcript fetch + summarization in this pass.
type passCandidate struct { type passCandidate struct {
v domain.Video 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 pos int // discovery position — used as a stable tiebreak when published_at ties
} }
@@ -74,6 +75,14 @@ type VideoStore interface {
// Called when NewVideos returns domain.ErrChannelUnavailable; best-effort, errors // Called when NewVideos returns domain.ErrChannelUnavailable; best-effort, errors
// are logged and never abort the pass. // are logged and never abort the pass.
UpsertChannelError(ctx context.Context, userID, channelID, channelTitle string) error 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 // 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 backoff time.Duration // rate-limit retry window; 0 = always retry
autoWindow time.Duration // recency bound for auto-summarize; 0 = no bound autoWindow time.Duration // recency bound for auto-summarize; 0 = no bound
now func() time.Time // injectable clock (tests); defaults to time.Now 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 // 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). // 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 } } 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. // 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 { func New(src ports.VideoSource, store VideoStore, engine Processor, userID string, log *slog.Logger, opts ...Option) *Runner {
if log == nil { if log == nil {
@@ -138,6 +158,7 @@ type Stats struct {
SkippedManual int // discovered but not queued, in manual mode SkippedManual int // discovered but not queued, in manual mode
SkippedTooOld int // auto mode: published outside the recency window (not requested) SkippedTooOld int // auto mode: published outside the recency window (not requested)
SkippedRateLimited int // 429'd previously and still inside the backoff window SkippedRateLimited int // 429'd previously and still inside the backoff window
SkippedNoCaptionChannel int // channel suppressed as caption-less (ADR-024)
Errors int Errors int
ChannelUnavailable int // channels that returned HTTP 404 (deleted/private) ChannelUnavailable int // channels that returned HTTP 404 (deleted/private)
} }
@@ -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) subs, err := r.src.ListSubscriptions(ctx, r.userID)
if err != nil { if err != nil {
return stats, fmt.Errorf("runner: list subscriptions: %w", err) return stats, fmt.Errorf("runner: list subscriptions: %w", err)
@@ -273,6 +305,14 @@ func (r *Runner) RunOnce(ctx context.Context) (Stats, error) {
continue 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. // Still inside the rate-limit backoff window: skip without fetching.
if at, ok := rateLimited[id]; ok && r.now().Sub(at) < r.backoff { if at, ok := rateLimited[id]; ok && r.now().Sub(at) < r.backoff {
stats.SkippedRateLimited++ stats.SkippedRateLimited++
@@ -280,7 +320,7 @@ func (r *Runner) RunOnce(ctx context.Context) (Stats, error) {
continue continue
} }
candidates = append(candidates, passCandidate{v: v, pos: pos}) candidates = append(candidates, passCandidate{v: v, channelID: sub.ChannelID, pos: 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)) errs = append(errs, fmt.Errorf("set none status %q: %w", c.v.ProviderVideoID, err))
stats.Errors++ 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) r.log.Info("skipped video (no transcript)", "video", c.v.ProviderVideoID, "title", c.v.Title)
case res.Summary != nil: case res.Summary != nil:
stats.Summarized++ 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)) errs = append(errs, fmt.Errorf("set fetched status %q: %w", c.v.ProviderVideoID, err))
stats.Errors++ 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 // In manual mode the video was explicitly queued; clear the flag so
// it is not re-summarized and the UI drops the "Queued" chip. // it is not re-summarized and the UI drops the "Queued" chip.
if !auto { 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_seen", stats.SkippedSeen, "skipped_no_text", stats.SkippedNoText,
"skipped_manual", stats.SkippedManual, "skipped_too_old", stats.SkippedTooOld, "skipped_manual", stats.SkippedManual, "skipped_too_old", stats.SkippedTooOld,
"skipped_rate_limited", stats.SkippedRateLimited, "skipped_rate_limited", stats.SkippedRateLimited,
"skipped_no_caption_channel", stats.SkippedNoCaptionChannel,
"channel_unavailable", stats.ChannelUnavailable, "errors", stats.Errors) "channel_unavailable", stats.ChannelUnavailable, "errors", stats.Errors)
if err != nil { if err != nil {
r.log.Warn("run pass had errors", "err", err) r.log.Warn("run pass had errors", "err", err)
+72
View File
@@ -51,6 +51,13 @@ type fakeStore struct {
cleared []string cleared []string
rateLimited map[string]time.Time // id -> when 429'd (seeds the backoff window) rateLimited map[string]time.Time // id -> when 429'd (seeds the backoff window)
statuses map[string]string // id -> last SetTranscriptStatus value 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) { 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) 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 { func (f *fakeStore) SetTranscriptStatus(_ context.Context, _, videoID, status string) error {
if f.statuses == nil { if f.statuses == nil {
f.statuses = map[string]string{} f.statuses = map[string]string{}
@@ -284,6 +307,55 @@ func TestRunOnce_AutoMode_OldVideoRequestedBypassesWindow(t *testing.T) {
require.Len(t, sink.delivered, 1) 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 — // TestRunOnce_AutoWindowZero_SummarizesOld: a zero window disables the bound —
// the pre-recency behaviour (summarize every unseen video) is preserved. // the pre-recency behaviour (summarize every unseen video) is preserved.
func TestRunOnce_AutoWindowZero_SummarizesOld(t *testing.T) { func TestRunOnce_AutoWindowZero_SummarizesOld(t *testing.T) {
+227
View File
@@ -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:]
}
+375
View File
@@ -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)
}
+30 -4
View File
@@ -26,6 +26,10 @@ type Store interface {
DistinctChannels(ctx context.Context, userID string) ([]string, error) DistinctChannels(ctx context.Context, userID string) ([]string, error)
GetSummaryByVideo(ctx context.Context, userID, videoID string) (*store.SummaryRow, error) GetSummaryByVideo(ctx context.Context, userID, videoID string) (*store.SummaryRow, error)
GetVideoRow(ctx context.Context, userID, videoID string) (*store.SummaryRow, error) GetVideoRow(ctx context.Context, userID, videoID string) (*store.SummaryRow, error)
// GetTranscript reads the shared, stored transcript keyed by (provider,
// providerVideoID) — ADR-021. It is a pure DB read: it never fetches captions,
// so the chat path (ADR-027) reaches it without any caption-fetch surface.
GetTranscript(ctx context.Context, provider, providerVideoID string) (domain.Transcript, bool, error)
ActionsFor(ctx context.Context, userID string, videoIDs []string) (map[string][]string, error) ActionsFor(ctx context.Context, userID string, videoIDs []string) (map[string][]string, error)
SetAction(ctx context.Context, userID, videoID, action string) error SetAction(ctx context.Context, userID, videoID, action string) error
ClearAction(ctx context.Context, userID, videoID, action string) error ClearAction(ctx context.Context, userID, videoID, action string) error
@@ -91,6 +95,11 @@ type App struct {
// Fetcher, when non-nil, resolves an arbitrary YouTube video id to metadata for // Fetcher, when non-nil, resolves an arbitrary YouTube video id to metadata for
// the paste-a-URL flow (Feature 2). Nil = the /paste route is not mounted. // the paste-a-URL flow (Feature 2). Nil = the /paste route is not mounted.
Fetcher VideoFetcher Fetcher VideoFetcher
// Chat, when non-nil, answers per-video questions against a video's STORED
// transcript (ADR-027). Nil = the /v/{id}/chat routes are not mounted and the
// summary view shows no "dig deeper" affordance. It holds no caption-fetch
// dependency, so an enabled chat cannot reach YouTube or the rate gate.
Chat Chatter
// Processing tracks in-flight immediate summarizations so the status endpoint // Processing tracks in-flight immediate summarizations so the status endpoint
// shows the animation until the summary lands. The zero value is ready to use. // shows the animation until the summary lands. The zero value is ready to use.
Processing ProcessingSet Processing ProcessingSet
@@ -149,6 +158,12 @@ func (a *App) Router() http.Handler {
app.HandleFunc("POST /paste", a.handlePaste) app.HandleFunc("POST /paste", a.handlePaste)
} }
app.HandleFunc("GET /v/{videoId}/status", a.handleStatus) app.HandleFunc("GET /v/{videoId}/status", a.handleStatus)
// Per-video deeper-dive chat over the STORED transcript (ADR-027). Mounted only
// when a Chat backend is wired; it never fetches captions.
if a.Chat != nil {
app.HandleFunc("GET /v/{videoId}/chat", a.handleChat)
app.HandleFunc("POST /v/{videoId}/chat", a.handleChatMessage)
}
app.HandleFunc("GET /register", a.handleRegisterForm) app.HandleFunc("GET /register", a.handleRegisterForm)
app.HandleFunc("POST /register", a.handleRegister) app.HandleFunc("POST /register", a.handleRegister)
@@ -265,7 +280,7 @@ func (a *App) handleDetail(w http.ResponseWriter, r *http.Request) {
a.serverError(w, r, "get summary", err) a.serverError(w, r, "get summary", err)
return return
} }
a.render(w, r, DetailPage(*row)) a.render(w, r, DetailPage(*row, a.Chat != nil))
} }
// handleAction toggles one action: re-clicking an active verb clears it, else it // handleAction toggles one action: re-clicking an active verb clears it, else it
@@ -495,11 +510,22 @@ func (a *App) handleStatus(w http.ResponseWriter, r *http.Request) {
return 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)) a.render(w, r, VideoCard(*row))
return case a.Processing.Has(processingKey(userID, videoID)):
}
a.render(w, r, processingCard(*row)) 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))
}
} }
// handleSummarizeMode toggles the user's auto/manual summarization mode. The form // handleSummarizeMode toggles the user's auto/manual summarization mode. The form
+16 -2
View File
@@ -7,6 +7,7 @@ import (
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
"os" "os"
"path/filepath"
"strings" "strings"
"testing" "testing"
"time" "time"
@@ -26,10 +27,22 @@ import (
var dsn string var dsn string
func TestMain(m *testing.M) { func TestMain(m *testing.M) {
const port = 54330 // distinct from the store package's embedded PG (54329) // Per-process port + dirs so concurrent `go test` runs (e.g. a push-run and a
// tag-run in CI) never collide on a fixed port or shared data dir. Base 55000
// keeps web's range distinct from the store package (54000). Shared CachePath
// downloads the PG archive once.
port := uint32(55000 + os.Getpid()%1000)
dsn = fmt.Sprintf("postgres://postgres:postgres@localhost:%d/postgres?sslmode=disable", port) dsn = fmt.Sprintf("postgres://postgres:postgres@localhost:%d/postgres?sslmode=disable", port)
pg := embeddedpostgres.NewDatabase(embeddedpostgres.DefaultConfig().Port(port)) rt := filepath.Join(os.TempDir(), fmt.Sprintf("tapir-epg-web-%d", os.Getpid()))
pg := embeddedpostgres.NewDatabase(
embeddedpostgres.DefaultConfig().
Port(port).
RuntimePath(rt).
DataPath(filepath.Join(rt, "data")).
BinariesPath(filepath.Join(rt, "bin")).
CachePath(filepath.Join(os.TempDir(), "tapir-epg-cache")),
)
if err := pg.Start(); err != nil { if err := pg.Start(); err != nil {
fmt.Fprintf(os.Stderr, "embedded-postgres start: %v\n", err) fmt.Fprintf(os.Stderr, "embedded-postgres start: %v\n", err)
os.Exit(1) os.Exit(1)
@@ -38,6 +51,7 @@ func TestMain(m *testing.M) {
if err := pg.Stop(); err != nil { if err := pg.Stop(); err != nil {
fmt.Fprintf(os.Stderr, "embedded-postgres stop: %v\n", err) fmt.Fprintf(os.Stderr, "embedded-postgres stop: %v\n", err)
} }
_ = os.RemoveAll(rt)
os.Exit(code) os.Exit(code)
} }
+40
View File
@@ -7,6 +7,7 @@ import (
"testing" "testing"
"time" "time"
"github.com/jackc/pgx/v5/pgxpool"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
"gitea.d-ma.be/mathias/tapir/internal/web" "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) app.Router().ServeHTTP(rec, req)
return rec 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")
}
+49
View File
@@ -184,6 +184,12 @@ func statusURL(videoID string) templ.SafeURL {
return templ.SafeURL("/v/" + videoID + "/status") return templ.SafeURL("/v/" + videoID + "/status")
} }
// chatURL builds the per-video chat path (GET renders the page, POST answers) —
// the deeper-dive over the stored transcript (ADR-027).
func chatURL(videoID string) templ.SafeURL {
return templ.SafeURL("/v/" + videoID + "/chat")
}
// Charmbracelet-inspired palette for the summarizing animation (TapirSpinner) — // Charmbracelet-inspired palette for the summarizing animation (TapirSpinner) —
// a charm purple box, pink tapir, mint snout/eyes/progress. Kept as named consts // a charm purple box, pink tapir, mint snout/eyes/progress. Kept as named consts
// so the inline span colours and the CSS track/fill share one source of truth. // so the inline span colours and the CSS track/fill share one source of truth.
@@ -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); } .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); } } @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; } .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) { @media (prefers-reduced-motion: reduce) {
.tapir-charm pre { animation: none; } .tapir-charm pre { animation: none; }
.tapir-charm .tapir-f2, .tapir-charm .tapir-f3 { display: none; } .tapir-charm .tapir-f2, .tapir-charm .tapir-f3 { display: none; }
.tapir-charm .tapir-f1 { opacity: 1; } .tapir-charm .tapir-f1 { opacity: 1; }
.tapir-bar-fill { animation: none; clip-path: inset(0 35% 0 0); } .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 */ /* 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 ul { margin: 0; padding-left: 1.2rem; line-height: 1.6; }
.detail li { margin-bottom: var(--s1); } .detail li { margin-bottom: var(--s1); }
/* deeper-dive chat (ADR-027) — 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 /* action toggles — watched|skipped form one segmented control (they are mutually
exclusive), "saved" sits apart as an independent toggle */ exclusive), "saved" sits apart as an independent toggle */
.actions { display: flex; gap: var(--s3); margin: var(--s4) 0; flex-wrap: wrap; align-items: center; } .actions { display: flex; gap: var(--s3); margin: var(--s4) 0; flex-wrap: wrap; align-items: center; }
+165 -12
View File
@@ -339,7 +339,17 @@ templ TapirSpinner() {
<pre class="tapir-f3">@templ.Raw(tapirFrameHTML3)</pre> <pre class="tapir-f3">@templ.Raw(tapirFrameHTML3)</pre>
<div class="tapir-bar"><span class="tapir-bar-fill" style={ "color:" + CharmMint }>{ tapirBarFill }</span></div> <div class="tapir-bar"><span class="tapir-bar-fill" style={ "color:" + CharmMint }>{ tapirBarFill }</span></div>
</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 // processingCard is the in-flight summarization card. It replaces the Summarize
@@ -363,13 +373,48 @@ templ processingCard(r store.SummaryRow) {
</li> </li>
} }
// DetailPage is the full summary view: text, highlights, takeaways, metadata, // waitingCard is the honest rate-limited state: the click landed but YouTube is
// and the action button group. // throttling the caption fetch, so the tapir rests and the card keeps polling
templ DetailPage(r store.SummaryRow) { // (gently, every 30s) until the background retry lands the summary — the user
@Layout("Tapir — " + displayTitle(r)) { // never has to click again. Replaces the old silent revert to a Summarize button.
<article class="detail"> templ waitingCard(r store.SummaryRow) {
<p class="back"><a href="/"> Summaries</a></p> <li
<h1>{ displayTitle(r) }</h1> 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"> <p class="meta">
if detailMeta(r) != "" { if detailMeta(r) != "" {
<span>{ detailMeta(r) }</span> <span>{ detailMeta(r) }</span>
@@ -394,10 +439,6 @@ templ DetailPage(r store.SummaryRow) {
<p class="source"><a href={ externalURL(r.URL) } rel="noopener noreferrer">watch on source </a></p> <p class="source"><a href={ externalURL(r.URL) } rel="noopener noreferrer">watch on source </a></p>
} }
@ActionButtons(r.VideoID, actionSet(r.Actions)) @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 { if len(r.Takeaways) > 0 {
<section> <section>
<h2>Takeaways</h2> <h2>Takeaways</h2>
@@ -422,10 +463,122 @@ templ DetailPage(r store.SummaryRow) {
<h2>Summary</h2> <h2>Summary</h2>
<p class="body">{ r.Summary }</p> <p class="body">{ r.Summary }</p>
</section> </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>
@summaryBody(r)
if chatEnabled {
@chatReveal(r.VideoID)
}
</article> </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 // RegisterPage is the explicit registration step (ADR-012): an authenticated Dex
// subject with no tapir user picks a display name to create their account. // subject with no tapir user picks a display name to create their account.
// errMsg, when set, reports a validation problem on the prior POST. // errMsg, when set, reports a validation problem on the prior POST.
File diff suppressed because it is too large Load Diff
+13
View File
@@ -63,6 +63,19 @@ var scenarioCoverage = map[string]string{
"A returning subject passes straight through": "TestRegisteredSubjectPassesThrough", "A returning subject passes straight through": "TestRegisteredSubjectPassesThrough",
"Deleting an account removes only my data and leaves other users untouched": "TestDeleteAccountWipesDataAndSecretsAndLogsOut", "Deleting an account removes only my data and leaves other users untouched": "TestDeleteAccountWipesDataAndSecretsAndLogsOut",
// chat_transcript.feature (ADR-027)
"A summary view offers a deeper-dive into the video": "TestChatEntryAffordanceOnSummaryView",
"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 // summarize_new_video.feature
"A subscribed channel posts a video that has captions": "TestSubscribedVideoWithCaptionsIsSummarizedAndDelivered", "A subscribed channel posts a video that has captions": "TestSubscribedVideoWithCaptionsIsSummarizedAndDelivered",
"A subscribed channel posts a video with no usable transcript": "TestVideoWithNoTranscriptIsSkipped", "A subscribed channel posts a video with no usable transcript": "TestVideoWithNoTranscriptIsSkipped",