Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
09eb31d1fe | ||
|
|
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
|
||||
|
||||
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 {
|
||||
return fmt.Errorf("process video %q: %w", videoID, err)
|
||||
}
|
||||
if res.Summary != nil {
|
||||
|
||||
// Record the outcome so the status endpoint can show honest state (ADR-025):
|
||||
// a 429'd or caption-less click used to leave transcript_status unset, so the
|
||||
// poll silently reverted to the "Summarize" button. Mirror the runner: stamp
|
||||
// rate_limited / none / fetched. A rate-limited video keeps its requested flag
|
||||
// so the background sweep retries it; none and fetched are terminal here.
|
||||
switch {
|
||||
case res.Skipped && res.TranscriptSource == string(domain.SourceRateLimited):
|
||||
if err := p.store.SetTranscriptStatus(ctx, userID, videoID, "rate_limited"); err != nil {
|
||||
return fmt.Errorf("set rate_limited status %q: %w", videoID, err)
|
||||
}
|
||||
case res.Skipped:
|
||||
if err := p.store.SetTranscriptStatus(ctx, userID, videoID, "none"); err != nil {
|
||||
return fmt.Errorf("set none status %q: %w", videoID, err)
|
||||
}
|
||||
if err := p.store.ClearSummarizeRequested(ctx, userID, videoID); err != nil {
|
||||
return fmt.Errorf("clear summarize flag %q: %w", videoID, err)
|
||||
}
|
||||
case res.Summary != nil:
|
||||
if err := p.store.SetTranscriptStatus(ctx, userID, videoID, "fetched"); err != nil {
|
||||
return fmt.Errorf("set fetched status %q: %w", videoID, err)
|
||||
}
|
||||
if err := p.store.ClearSummarizeRequested(ctx, userID, videoID); err != nil {
|
||||
return fmt.Errorf("clear summarize flag %q: %w", videoID, err)
|
||||
}
|
||||
|
||||
+12
-3
@@ -143,8 +143,18 @@ func runScheduler(
|
||||
return // disabled
|
||||
}
|
||||
|
||||
pass := 0
|
||||
// Derive the rotation offset from wall-clock, NOT an in-memory counter. A
|
||||
// counter reset to 0 on every pod restart always hands the lead to the
|
||||
// first-listed user — so frequent deploys re-starve whoever is last (exactly
|
||||
// what happened to the first pilot user during a deploy-heavy session). A
|
||||
// time-based offset advances with real time and is identical across restarts,
|
||||
// so the lead rotates fairly regardless of how often the pod bounces.
|
||||
runPass := func() {
|
||||
pass := int(time.Now().Unix() / int64(interval/time.Second))
|
||||
runDiscoveryPass(ctx, pass, lister, runUser, log)
|
||||
}
|
||||
|
||||
runPass()
|
||||
|
||||
ticker := time.NewTicker(interval)
|
||||
defer ticker.Stop()
|
||||
@@ -153,8 +163,7 @@ func runScheduler(
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-ticker.C:
|
||||
pass++
|
||||
runDiscoveryPass(ctx, pass, lister, runUser, log)
|
||||
runPass()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -495,11 +495,22 @@ func (a *App) handleStatus(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
if row.Summarized || !a.Processing.Has(processingKey(userID, videoID)) {
|
||||
// Honest, state-aware status (ADR-025). Order matters: a finished summary wins;
|
||||
// an in-flight goroutine shows the working spinner; a recorded rate-limit shows
|
||||
// the calm "waiting, will retry" card that keeps polling; a recorded "none" is
|
||||
// terminal; anything else falls back to the normal card.
|
||||
switch {
|
||||
case row.Summarized:
|
||||
a.render(w, r, VideoCard(*row))
|
||||
return
|
||||
}
|
||||
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))
|
||||
}
|
||||
}
|
||||
|
||||
// handleSummarizeMode toggles the user's auto/manual summarization mode. The form
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"gitea.d-ma.be/mathias/tapir/internal/web"
|
||||
@@ -129,3 +130,42 @@ func getStatus(t *testing.T, app *web.App, videoID string) *httptest.ResponseRec
|
||||
app.Router().ServeHTTP(rec, req)
|
||||
return rec
|
||||
}
|
||||
|
||||
// setTranscriptStatus stamps videos.transcript_status directly (bypassing RLS via
|
||||
// the super pool) so a test can drive the status endpoint into a given state.
|
||||
func setTranscriptStatus(t *testing.T, p *pgxpool.Pool, videoID, status string) {
|
||||
t.Helper()
|
||||
_, err := p.Exec(context.Background(),
|
||||
`UPDATE videos SET transcript_status = $2 WHERE id = $1`, videoID, status)
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
// TestStatusRateLimitedShowsWaitingCard: a click that hit YouTube's rate limit
|
||||
// must surface the honest "waiting, will retry" card that keeps polling — not a
|
||||
// silent revert to the Summarize button.
|
||||
func TestStatusRateLimitedShowsWaitingCard(t *testing.T) {
|
||||
app := newApp(t)
|
||||
p := rawPool(t)
|
||||
resetDB(t, p)
|
||||
seedVideo(t, p, videoX, "Throttled Title", "https://x", time.Time{})
|
||||
setTranscriptStatus(t, p, videoX, "rate_limited")
|
||||
|
||||
html := body(t, getStatus(t, app, videoX))
|
||||
require.Contains(t, html, "Waiting on YouTube rate limits", "honest rate-limit copy")
|
||||
require.Contains(t, html, `hx-trigger="every 30s"`, "waiting card keeps polling so it self-resolves")
|
||||
require.NotContains(t, html, "Summarize this video", "must not revert to the Summarize button")
|
||||
}
|
||||
|
||||
// TestStatusNoCaptionsTerminal: a no-captions outcome is terminal — an honest
|
||||
// message, no poll, no button to click back into the same dead end.
|
||||
func TestStatusNoCaptionsTerminal(t *testing.T) {
|
||||
app := newApp(t)
|
||||
p := rawPool(t)
|
||||
resetDB(t, p)
|
||||
seedVideo(t, p, videoX, "Silent Title", "https://x", time.Time{})
|
||||
setTranscriptStatus(t, p, videoX, "none")
|
||||
|
||||
html := body(t, getStatus(t, app, videoX))
|
||||
require.Contains(t, html, "No captions available", "honest terminal copy")
|
||||
require.NotContains(t, html, "hx-trigger", "terminal card must stop polling")
|
||||
}
|
||||
|
||||
@@ -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); }
|
||||
@keyframes tapir-fill { 0% { clip-path: inset(0 100% 0 0); } 100% { clip-path: inset(0 0 0 0); } }
|
||||
.tapir-label { color: var(--muted); font-size: .9rem; margin: 0; }
|
||||
/* Cycling status verbs (Claude-Code / Crush style): five gerunds stacked, each
|
||||
visible 1/5 of a 6s loop, cross-faded. The container reserves one line height
|
||||
so the layout does not jump as verbs swap. */
|
||||
.tapir-verbs { position: relative; height: 1.3em; margin: .2em 0 0; color: var(--muted); font-size: .9rem; }
|
||||
.tapir-verbs span { position: absolute; left: 0; top: 0; white-space: nowrap; opacity: 0; animation: tapir-verb 6s steps(1, end) infinite; }
|
||||
.tapir-verbs .tv1 { animation-delay: 0s; }
|
||||
.tapir-verbs .tv2 { animation-delay: 1.2s; }
|
||||
.tapir-verbs .tv3 { animation-delay: 2.4s; }
|
||||
.tapir-verbs .tv4 { animation-delay: 3.6s; }
|
||||
.tapir-verbs .tv5 { animation-delay: 4.8s; }
|
||||
@keyframes tapir-verb { 0%, 19.99% { opacity: 1; } 20%, 100% { opacity: 0; } }
|
||||
/* Resting tapir for the rate-limit waiting state: the panel, one still frame, no
|
||||
animation — calm, not busy, signalling "parked, not stuck". */
|
||||
.tapir-resting pre { position: relative; opacity: 1; animation: none; }
|
||||
.card-waiting { border-style: dashed; opacity: .92; }
|
||||
.card-no-captions .card-state { font-style: italic; }
|
||||
.sr-only { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0, 0, 0, 0); white-space: nowrap; border: 0; }
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.tapir-charm pre { animation: none; }
|
||||
.tapir-charm .tapir-f2, .tapir-charm .tapir-f3 { display: none; }
|
||||
.tapir-charm .tapir-f1 { opacity: 1; }
|
||||
.tapir-bar-fill { animation: none; clip-path: inset(0 35% 0 0); }
|
||||
.tapir-verbs span { animation: none; }
|
||||
.tapir-verbs .tv1 { opacity: 1; }
|
||||
}
|
||||
|
||||
/* summarization mode toggle on the account page */
|
||||
|
||||
@@ -339,7 +339,17 @@ templ TapirSpinner() {
|
||||
<pre class="tapir-f3">@templ.Raw(tapirFrameHTML3)</pre>
|
||||
<div class="tapir-bar"><span class="tapir-bar-fill" style={ "color:" + CharmMint }>{ tapirBarFill }</span></div>
|
||||
</div>
|
||||
<p class="tapir-label" role="status" aria-live="polite"><em>Summarizing…</em></p>
|
||||
// Claude-Code / Crush-style status: playful gerunds cycle in place (CSS only,
|
||||
// no JS). Decorative — aria-hidden — with one stable status line below for
|
||||
// assistive tech.
|
||||
<p class="tapir-verbs" aria-hidden="true">
|
||||
<span class="tv1"><em>Fetching captions…</em></span>
|
||||
<span class="tv2"><em>Chewing the cud…</em></span>
|
||||
<span class="tv3"><em>Munching leaves…</em></span>
|
||||
<span class="tv4"><em>Distilling the gist…</em></span>
|
||||
<span class="tv5"><em>Summarizing…</em></span>
|
||||
</p>
|
||||
<p class="sr-only" role="status" aria-live="polite">Summarizing…</p>
|
||||
}
|
||||
|
||||
// processingCard is the in-flight summarization card. It replaces the Summarize
|
||||
@@ -363,6 +373,43 @@ templ processingCard(r store.SummaryRow) {
|
||||
</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,
|
||||
// and the action button group.
|
||||
templ DetailPage(r store.SummaryRow) {
|
||||
|
||||
+401
-218
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user