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>
This commit is contained in:
2026-06-11 09:26:58 +02:00
co-authored by Claude Opus 4.8
parent cc3cda4ab8
commit 71df696448
11 changed files with 1410 additions and 206 deletions
+76 -2
View File
@@ -411,8 +411,9 @@ templ noCaptionsCard(r store.SummaryRow) {
}
// DetailPage is the full summary view: text, highlights, takeaways, metadata,
// and the action button group.
templ DetailPage(r store.SummaryRow) {
// and the action button group. chatEnabled adds the "dig deeper" affordance
// (ADR-027) — a quiet link into the per-video chat over the stored transcript.
templ DetailPage(r store.SummaryRow, chatEnabled bool) {
@Layout("Tapir — " + displayTitle(r)) {
<article class="detail">
<p class="back"><a href="/"> Summaries</a></p>
@@ -440,6 +441,11 @@ templ DetailPage(r store.SummaryRow) {
if r.URL != "" {
<p class="source"><a href={ externalURL(r.URL) } rel="noopener noreferrer">watch on source </a></p>
}
if chatEnabled {
// Where the "I want more" reaction goes (ADR-027). A quiet link, not a
// loud CTA — it deepens value for a reader already here, never nudges.
<p class="dig-deeper"><a class="btn-secondary" href={ chatURL(r.VideoID) }>Dig deeper ask about this video </a></p>
}
@ActionButtons(r.VideoID, actionSet(r.Actions))
// Lead with the attention-saving payload: Takeaways ("is this worth my
// time?") first, then Highlights, then the full Summary last (UX review
@@ -473,6 +479,74 @@ templ DetailPage(r store.SummaryRow) {
}
}
// ChatPage is the per-video deeper-dive chat (ADR-027): a question/answer surface
// over the video's ALREADY-STORED transcript. The back link returns to the
// summary it was entered from. All the interaction lives in chatPanel so the HTMX
// answer-swap and the no-JS full-page render share one component.
templ ChatPage(v chatView) {
@Layout("Tapir — Ask — " + v.Title) {
<article class="detail chat">
<p class="back"><a href={ videoURL(v.VideoID) }> { v.Title }</a></p>
<h1>Ask about this video</h1>
<p class="chat-scope muted">Answers come only from this video's stored transcript Tapir never fetches anything new here.</p>
@chatPanel(v)
</article>
}
}
// chatPanel is the conversation + ask form, swapped in place on each answer
// (HTMX targets #chat-panel, outerHTML). When no transcript is stored it shows the
// honest "not available" state and no form (ADR-027: never a fetch). The prior
// turns ride as hidden hq/ha fields so the ephemeral conversation survives the
// round-trip without any persisted state.
templ chatPanel(v chatView) {
<div id="chat-panel" class="chat-panel">
if !v.Available {
<p class="chat-unavailable muted">Chat isn't available for this video its transcript isn't stored, and chat never fetches new captions. Summarize the video first to store its transcript.</p>
} else {
if len(v.History) > 0 {
<div class="chat-log">
for _, t := range v.History {
<div class="chat-turn chat-q"><p>{ t.Question }</p></div>
<div class="chat-turn chat-a"><p class="body">{ t.Answer }</p></div>
}
</div>
}
if v.Truncated {
<p class="chat-note muted">Working from a bounded portion of a long transcript answers about the end of the video may be incomplete.</p>
}
if v.Error != "" {
<p class="chat-error" role="alert">{ v.Error }</p>
}
<form
class="chat-form"
method="post"
action={ chatURL(v.VideoID) }
hx-post={ string(chatURL(v.VideoID)) }
hx-target="#chat-panel"
hx-swap="outerHTML"
>
for _, t := range v.History {
<input type="hidden" name="hq" value={ t.Question }/>
<input type="hidden" name="ha" value={ t.Answer }/>
}
<label class="chat-model">
Model
<select name="model">
for _, m := range v.Models {
<option value={ m } selected?={ m == v.Selected }>{ m }</option>
}
</select>
<span class="chat-model-hint muted">Switch models to compare answers on the same transcript.</span>
</label>
<textarea name="question" rows="3" placeholder="Ask a question about this video…" required aria-label="Your question"></textarea>
<button type="submit" class="btn">Ask</button>
<span class="htmx-indicator chat-thinking">thinking…</span>
</form>
}
</div>
}
// RegisterPage is the explicit registration step (ADR-012): an authenticated Dex
// subject with no tapir user picks a display name to create their account.
// errMsg, when set, reports a validation problem on the prior POST.