Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1665a1e7c4 |
@@ -923,6 +923,41 @@ 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).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## 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
|
||||||
|
|||||||
+22
-1
@@ -132,7 +132,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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -495,11 +495,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))
|
||||||
|
case a.Processing.Has(processingKey(userID, videoID)):
|
||||||
|
a.render(w, r, processingCard(*row))
|
||||||
|
case row.TranscriptStatus == "rate_limited":
|
||||||
|
a.render(w, r, waitingCard(*row))
|
||||||
|
case row.TranscriptStatus == "none":
|
||||||
|
a.render(w, r, noCaptionsCard(*row))
|
||||||
|
default:
|
||||||
a.render(w, r, VideoCard(*row))
|
a.render(w, r, VideoCard(*row))
|
||||||
return
|
|
||||||
}
|
}
|
||||||
a.render(w, r, processingCard(*row))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// handleSummarizeMode toggles the user's auto/manual summarization mode. The form
|
// handleSummarizeMode toggles the user's auto/manual summarization mode. The form
|
||||||
|
|||||||
@@ -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")
|
||||||
|
}
|
||||||
|
|||||||
@@ -658,11 +658,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 */
|
||||||
|
|||||||
@@ -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,6 +373,43 @@ templ processingCard(r store.SummaryRow) {
|
|||||||
</li>
|
</li>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// waitingCard is the honest rate-limited state: the click landed but YouTube is
|
||||||
|
// throttling the caption fetch, so the tapir rests and the card keeps polling
|
||||||
|
// (gently, every 30s) until the background retry lands the summary — the user
|
||||||
|
// never has to click again. Replaces the old silent revert to a Summarize button.
|
||||||
|
templ waitingCard(r store.SummaryRow) {
|
||||||
|
<li
|
||||||
|
class="card card-waiting"
|
||||||
|
id={ "video-" + r.VideoID }
|
||||||
|
hx-get={ string(statusURL(r.VideoID)) }
|
||||||
|
hx-trigger="every 30s"
|
||||||
|
hx-swap="outerHTML"
|
||||||
|
>
|
||||||
|
<div class="card-title">{ displayTitle(r) }</div>
|
||||||
|
if cardMeta(r) != "" {
|
||||||
|
<div class="card-meta">{ cardMeta(r) }</div>
|
||||||
|
}
|
||||||
|
<div class="tapir-charm tapir-resting" aria-hidden="true">
|
||||||
|
<pre class="tapir-f1">@templ.Raw(tapirFrameHTML2)</pre>
|
||||||
|
</div>
|
||||||
|
<p class="tapir-label" role="status" aria-live="polite">
|
||||||
|
Waiting on YouTube rate limits. Tapir keeps trying, slowly and politely, and the summary will appear here on its own.
|
||||||
|
</p>
|
||||||
|
</li>
|
||||||
|
}
|
||||||
|
|
||||||
|
// noCaptionsCard is the terminal no-captions state: nothing to summarize, so the
|
||||||
|
// card stops (no poll, no button to click again into the same dead end).
|
||||||
|
templ noCaptionsCard(r store.SummaryRow) {
|
||||||
|
<li class="card card-no-captions" id={ "video-" + r.VideoID }>
|
||||||
|
<div class="card-title">{ displayTitle(r) }</div>
|
||||||
|
if cardMeta(r) != "" {
|
||||||
|
<div class="card-meta">{ cardMeta(r) }</div>
|
||||||
|
}
|
||||||
|
<p class="card-state muted">No captions available, so Tapir cannot summarize this one.</p>
|
||||||
|
</li>
|
||||||
|
}
|
||||||
|
|
||||||
// DetailPage is the full summary view: text, highlights, takeaways, metadata,
|
// DetailPage is the full summary view: text, highlights, takeaways, metadata,
|
||||||
// and the action button group.
|
// and the action button group.
|
||||||
templ DetailPage(r store.SummaryRow) {
|
templ DetailPage(r store.SummaryRow) {
|
||||||
|
|||||||
+401
-218
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user