Compare commits

...
26 Commits
Author SHA1 Message Date
mathiasandClaude Opus 5 489b4bb2a1 chore(spike): recover the /bygge toolchain from tmpfs (#28)
CI / Lint / Test / Vet (push) Successful in 37s
CI / Build & Import (push) Failing after 5s
CI / Deploy via GitOps (push) Skipped
analyze_srt.py, build_page.py and transcode.yaml produced the /bygge
prototype. They were sitting in a session scratchpad on tmpfs, one reboot
from gone, while spike issues #29-#31 were written as if they had to be
built from scratch.

Committed as found, defects documented rather than fixed: max_tokens=6000
truncates anything past ~6 minutes of audio, the page title is hardcoded,
and the schema still carries fields no consumer renders.

The four-check validator is the part worth keeping — the coverage-gap and
Swedish-number-grounding checks catch errors that timestamp and citation
checks structurally cannot.

Real transcripts and analyses stay out: this repo is public and the
recordings are a named person discussing a client's project. Only the
synthetic fixture is committed, which exercises all four checks with no
model call.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BeAp5LTscnz2W7Ubt4eJ6m
2026-08-13 11:37:03 +02:00
mathias 21e7d6c74b fix(ci): smoke test hung 14min — buildah run doesn't die under plain timeout
CI / Lint / Test / Vet (push) Successful in 15s
CI / Build & Import (push) Successful in 20s
CI / Deploy via GitOps (push) Successful in 5s
Bare `/tapir` runs the long-running server, same as the old ctr-based smoke
test. ctr's --rm reliably force-killed it; a plain `timeout N buildah run`
does not — it only signals the wrapper, and the container process can
survive that and keep the log pipe open, hanging the whole job (observed
live: run 155, 14min before failure). Backgrounds the run and tears it down
with `buildah rm -f`, which forcibly kills regardless of wrapper state, and
captures output via a file instead of a blocking pipe.

Refs infra#132.
2026-07-26 06:17:18 +00:00
mathias 8814ba6673 ci(smoke): use buildah run instead of sudo k3s ctr for smoke test
CI / Lint / Test / Vet (push) Successful in 12s
CI / Build & Import (push) Failing after 13m42s
CI / Deploy via GitOps (push) Has been skipped
Same fix as cobalt-dingo — removes the sudo/host-containerd dependency so
this still works once the act_runner is containerized (infra#132).

Refs infra#132.
2026-07-26 05:57:37 +00:00
mathias 4b557a4325 ci(deploy): add Flux GitOps deploy job, mirroring cobalt-dingo's pattern
CI / Lint / Test / Vet (push) Successful in 27s
CI / Build & Import (push) Successful in 18s
CI / Deploy via GitOps (push) Successful in 5s
Fixes silent stale-deploy gap: CI built+pushed images but nothing bumped
k3s/apps/tapir/deployment.yaml, so merged features sat CI-green with zero
production effect (infra#111, infra#168). Flux native image-automation
can't scan localhost:5000 from inside k3s pods, so this patches the infra
repo directly via the existing INFRA_DEPLOY_KEY org secret (same key
cobalt-dingo and brain-gardener already use) on every push to main.

Refs infra#111.
2026-07-25 21:21:02 +00:00
mathiasandClaude Opus 4.8 72cb25111f test(store): address migrations by version, not step count (#8)
CI / Lint / Test / Vet (push) Successful in 12s
CI / Build & Import (push) Successful in 12s
The up/down migration tests stepped a hard-coded number of Steps(-N)/Steps(+N)
down from HEAD and back. The counts assumed a specific latest migration, so
adding one shifted every count by one and unrelated tests (010/011/014) went
red with confusing off-by-one symptoms — a papercut on every new migration.

Drive the schema to an exact version with m.Migrate(version) via two helpers
(headVersion, migrateTo). Each test now steps to just below its target by
version, asserts the down effect, steps up to the target, asserts the up
effect, then restores to the captured HEAD. A migration added on top changes
HEAD but shifts no count, so no test needs editing.

Verified by adding a throwaway migration 017 on top: all four tests stayed
green with zero edits.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015QbdxXWxLefS5AwLN5eyze
2026-07-02 14:49:02 +02:00
mathiasandClaude Sonnet 4.6 38f222c931 chore: rename Go module path gitea.d-ma.be → git.d-ma.be
CI / Lint / Test / Vet (push) Successful in 11s
CI / Build & Import (push) Successful in 12s
Infra ADR-0004 renamed the Gitea host. Bulk replace across go.mod and
all .go import paths. Build and tests pass unchanged.

Closes #20

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Dt6aHEDWRjkK14Voi6HnGh
2026-07-02 14:37:33 +02:00
mathias e042b6d26d chore: add .mcp.json (brain + gitea) — wire tapir for the single-harness workflow
CI / Lint / Test / Vet (push) Successful in 16s
CI / Build & Import (push) Successful in 12s
tapir had no MCP config at all — a hyperguild session here couldn't reach brain
or Gitea, breaking the report-back path before it could start. Matches the
shape now standard post-consolidation (hyperguild#75/#76, infra#178 template
refresh). Prerequisite for tapir#20 being run as the first real workflow test.
2026-07-02 07:00:53 +00:00
mathias c85a32e770 Merge pull request 'LANGUAGE.md: fix LiteLLM gateway never-say (stale endpoints, not piguard)' (#21) from language-md-litellm-fix into main
CI / Lint / Test / Vet (push) Successful in 12s
CI / Build & Import (push) Successful in 10s
2026-06-16 15:45:45 +00:00
mathias b246c0e688 fix(language): LiteLLM gateway never-say — stale endpoints, not piguard
CI / Lint / Test / Vet (pull_request) Successful in 12s
CI / Build & Import (pull_request) Has been skipped
piguard is legitimately in the request path as the reverse proxy; only the
stale endpoint forms (piguard:4000, koala:4000) are wrong. Per brain#6 review
amendment #3 / tapir#18 follow-on.
2026-06-16 15:35:31 +00:00
mathias c7896cb3cc Merge pull request 'LANGUAGE.md pilot: hand-written vocabulary + caveman rubric (Phase 1)' (#19) from language-md-pilot into main
CI / Lint / Test / Vet (push) Successful in 12s
CI / Build & Import (push) Successful in 10s
2026-06-16 15:22:14 +00:00
mathiasandClaude Opus 4.8 b79fb892c8 docs(claude): point to LANGUAGE.md + caveman rubric
CI / Lint / Test / Vet (pull_request) Successful in 13s
CI / Build & Import (pull_request) Has been skipped
Wires the Phase 1 vocabulary pilot into the canonical agent instructions
(tapir#18). CLAUDE.md is canonical here (no .context/ source, no generation
header).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-12 22:54:57 +02:00
mathias c315e3e003 feat(language): add hand-written vocabulary + caveman rubric pilot
Phase 1 of the ubiquitous-language system (tapir#18). Tapir-specific terms
verified against domain/ports code + README/VISION; homelab-core terms (brain
sink, LiteLLM gateway) derived from brain#5 glossary. Tooling-free pilot.
2026-06-12 20:52:23 +00:00
mathiasandClaude Opus 4.8 64e3368f5f feat(web): charm-reader visual refresh with light/dark theme toggle (ADR-032, #17)
CI / Lint / Test / Vet (push) Successful in 11s
CI / Build & Import (push) Successful in 10s
The UI read flat and boring. Reskin to one charm/TUI-inspired layout in two
palettes (CSS custom properties): a warm "reader" light theme (sketch B) and a
"cozy terminal" dark theme (sketch C).

- Palette chosen in cascade order: :root light default; an OS-preference dark
  block scoped to :root:not([data-theme]) so it applies only absent an explicit
  choice; and :root[data-theme="dark"|"light"] set by a header toggle that
  outranks the media query by specificity and persists in localStorage (guarded,
  degrades to OS default). A <head> init script applies the stored choice before
  paint, so no flash of the wrong palette.
- Charm touches via existing classes (no templ structure churn): monospace meta
  lines, accent uppercase section dividers with a trailing rule, pill buttons, a
  lifted/accent-edged expanded card.
- Error/danger shades become --err-* tokens so they follow the theme, replacing
  three per-block prefers-color-scheme dark overrides.
- Theme toggle wired into Layout and PublicLayout headers.

BDD: docs/use-cases/visual_theme.feature un-pended, mapped in scenarioCoverage.
TDD: internal/web/visual_theme_test.go (palettes, OS default, persisted toggle,
expanded-card embed). Verified light+dark on list/reader/welcome via web-shot.
Sketches kept as the design record. ui-spec.md as-built row added.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-12 12:54:06 +02:00
mathias b01f8d5783 docs(bdd): visual_theme.feature scenarios (@pending until TDD, #17) 2026-06-12 12:29:31 +02:00
mathias 8ca767c144 docs(adr): ADR-032 visual refresh — light(B)+dark(C) charm-reader themes + sketches (issue #17) 2026-06-12 11:54:54 +02:00
mathiasandClaude Opus 4.8 f98b640531 feat(web): inline-expand summary + Q&A in the list (ADR-031, #16)
CI / Lint / Test / Vet (push) Successful in 11s
CI / Build & Import (push) Successful in 11s
Click a summarized card → its full summary (summaryBody) + chat dock (chatReveal)
expand in place via HTMX (GET /v/{id}/expand → expandedCard), collapse back via
GET /v/{id}/card → compact VideoCard. Same <li id>, outerHTML swap — the existing
list-fragment pattern. The card title carries href=/v/{id} as the no-JS fallback
(detail page stays for no-JS + deep links); only summarized cards expand. Reuses
summaryBody + chatReveal so the expanded card never drifts from the detail page.

BDD: inline_expand.feature un-pended + mapped. TDD: 6 handler/fragment tests
(expand/collapse fragments, chat dock, summarized-only, no-JS href, shared body).
Minimal CSS only — the TUI/charm restyle is #17.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-12 10:47:47 +02:00
mathias 607a8cbe8d docs(bdd): inline_expand.feature scenarios (@pending until TDD, #16) 2026-06-12 10:38:40 +02:00
mathias 54d60e53b9 docs(adr): ADR-031 inline-expand summary + Q&A in the list (issue #16) 2026-06-12 10:37:55 +02:00
mathias 9298e0c686 docs(homelab): TAPIR_METRICS_ADDR + key metric series (ADR-030, #15)
CI / Lint / Test / Vet (push) Successful in 10s
CI / Build & Import (push) Successful in 10s
2026-06-12 08:50:36 +02:00
mathiasandClaude Opus 4.8 cd461b95f8 feat(observability): instrument AI + HTTP paths, serve /metrics on a side port (ADR-030, #15)
CI / Build & Import (push) Successful in 11s
CI / Lint / Test / Vet (push) Successful in 10s
Wire the metrics package into the live paths and serve it:
- summarizer: per-endpoint latency by model/outcome(success|error|parse_error)/fallback + slog.
- youtube.FetchTranscript: latency by outcome (captions|none|rate_limited) + slog.
- chat: answer latency by model + slog.
- llm usage hook → token counts (prompt|completion) per model, wired in buildSummarizer/buildChat.
- oidc callback: login counter.
- cmdServe: wrap Router in metrics.HTTPMiddleware (request count + latency by bounded
  route pattern) and serve /metrics on TAPIR_METRICS_ADDR (default :9090), a SEPARATE
  port — never on the public app mux.

BDD: observability.feature scenarios un-pended + mapped. TDD: summarizer wiring tested
black-box via the /metrics scrape; metrics-not-on-public-mux asserted.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-12 08:49:51 +02:00
mathiasandClaude Opus 4.8 9c2a04406b feat(llm): usage hook to surface token counts (ADR-030, #15)
WithUsageHook callback fires with model + prompt/completion tokens parsed from the
response usage block. Keeps the copied stdlib-only llm package decoupled from
metrics (ADR-004) — the caller wires it to internal/metrics. TDD covered.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-12 08:36:02 +02:00
mathiasandClaude Opus 4.8 a5a8cf6f6d feat(metrics): Prometheus collectors + typed API + HTTP middleware (ADR-030, #15)
New internal/metrics package: AI metrics (summarize/caption/chat latency, LLM
tokens), session metrics (requests+latency by bounded route pattern, logins), and
a /metrics Handler. Adapters call a typed API; never touch prometheus types.
Dep: github.com/prometheus/client_golang (standard Go client; cluster runs
prometheus-operator). TDD: collectors + middleware route-pattern cardinality
covered.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-12 08:32:41 +02:00
mathias 64d11af9ef docs(bdd): observability.feature scenarios (@pending until TDD, #15) 2026-06-12 08:28:46 +02:00
mathias b590d2708d docs(adr): ADR-030 observability — slog + Prometheus metrics (issue #15) 2026-06-12 08:27:56 +02:00
mathiasandClaude Opus 4.8 7314895ec4 fix(auth): stateless session cookie — stop logging users out on deploy (ADR-029)
CI / Lint / Test / Vet (push) Successful in 10s
CI / Build & Import (push) Successful in 11s
Pilot feedback: lots of re-logging-in on iPhone. Three causes: sessions lived in
an in-memory map (wiped on every pod restart/deploy), a 1h TTL (idle >1h forced
re-login on a check-back-tomorrow reader), and a session cookie with no Max-Age
(dropped on Safari close). Each re-login is the full IdP redirect dance.

Make sessions stateless: identity + absolute expiry live inside the existing
HMAC-signed cookie (no server table), TTL 1h → 30 days sliding, cookie now
persistent (Max-Age). Survives restarts (test: a cookie from one instance is
accepted by a fresh instance with the same secret), browser-close, and idle.
Trade: no server-side revocation — logout clears the cookie client-side; rotating
tapir-session-secret is the global logout lever. Accepted for the Stage-0 reader.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-11 23:07:13 +02:00
mathiasandClaude Opus 4.8 36dd182fb5 feat(report): Stage-0 usage gate counts from a baseline date (default 2026-06-11)
CI / Build & Import (push) Successful in 11s
CI / Lint / Test / Vet (push) Successful in 10s
The return-usage gate (ADR-016) counted distinct active weeks over ALL history,
so pre-launch noise — testing churn and the period the pilot sat blocked on zero
summaries — would inflate the signal. Add a baseline: ActiveWeeks(ctx, since)
filters login_events + summary_actions to seen_at/acted_at >= since. The report
command sets it to TAPIR_USAGE_GATE_START (YYYY-MM-DD, default 2026-06-11 — the
morning the pilot was unblocked) and prints the baseline. The gate now measures
whether users RETURN once it genuinely works.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-11 22:52:15 +02:00
94 changed files with 3785 additions and 922 deletions
+137 -5
View File
@@ -78,16 +78,148 @@ jobs:
${REGISTRY}/${{ env.IMAGE }}:${{ steps.meta.outputs.version-tag }} || true
echo "Image pushed to ${REF}"
# Run the just-built local image briefly via buildah, not k3s ctr —
# avoids sudo/host-containerd access so this still works once the
# runner itself is containerized (infra#132). Tests the local
# buildah-store image directly, no registry round-trip needed.
#
# Bare `/tapir` (no subcommand) runs the long-running server, same as
# the old ctr-based smoke test -- ctr's --rm reliably force-killed it,
# but a plain `timeout N buildah run` does NOT: it only signals the
# `buildah run` wrapper, and the actual container process can survive
# that and keep the output pipe open, hanging the whole job (hit this
# live: 14min hang, infra#132). Backgrounding the run + `buildah rm -f`
# decouples "is the script blocked" from "did the process exit" --
# rm -f forcibly tears down the container regardless of wrapper state.
- name: Smoke test
run: |
REGISTRY="localhost:5000"
REF="${REGISTRY}/${{ env.IMAGE }}:${{ steps.meta.outputs.sha-tag }}"
CNAME="smoke-${{ steps.meta.outputs.sha-tag }}"
sudo k3s ctr images pull --plain-http ${REF}
OUTPUT=$(timeout 5 sudo k3s ctr run --rm ${REF} ${CNAME} /tapir 2>&1 || true)
sudo k3s ctr containers delete ${CNAME} 2>/dev/null || true
CONTAINER=$(buildah from ${REF})
LOG=$(mktemp)
buildah run "$CONTAINER" -- /tapir > "$LOG" 2>&1 &
RUNPID=$!
sleep 5
kill -9 "$RUNPID" 2>/dev/null || true
wait "$RUNPID" 2>/dev/null || true
buildah rm -f "$CONTAINER" >/dev/null 2>&1 || true
OUTPUT=$(cat "$LOG"); rm -f "$LOG"
echo "$OUTPUT" | grep -q "tapir" \
&& echo "Smoke test passed" \
|| echo "Smoke test inconclusive: $OUTPUT"
# ── 3. Mirror to GitHub — skipped for now (SSH key rotation pending)
# ── 3. Deploy via infra repo + Flux ────────────────────────────────────────
# Flux native image-automation can't scan localhost:5000 from inside k3s pods
# (mathias/infra k3s/flux/flux-system/image-automation.yaml) — this job
# mirrors cobalt-dingo's proven pattern instead: patch the infra repo's
# manifest directly on every push to main, then annotate Flux for a fast
# reconcile. Fixes infra#111 (image built+pushed but manifest bump was
# manual, so merged features silently didn't deploy).
deploy:
name: Deploy via GitOps
needs: build
runs-on: self-hosted
if: github.ref == 'refs/heads/main' && github.event_name == 'push'
environment: staging
steps:
- name: Update image tag in infra repo
env:
IMAGE_TAG: ${{ needs.build.outputs.image-tag }}
DEPLOY_KEY: ${{ secrets.INFRA_DEPLOY_KEY }}
run: |
set -euo pipefail
# INFRA_DEPLOY_KEY is a Gitea org secret (mathias org), already
# configured per docs/cd-pipeline.md in the infra repo — same key
# cobalt-dingo and brain-gardener use, no new secret needed.
mkdir -p ~/.ssh
echo "$DEPLOY_KEY" > ~/.ssh/id_infra
chmod 600 ~/.ssh/id_infra
ssh-keyscan -p 30022 10.0.1.20 >> ~/.ssh/known_hosts 2>/dev/null
export GIT_SSH_COMMAND="ssh -i ~/.ssh/id_infra -o IdentitiesOnly=yes"
rm -rf /tmp/infra
git clone -b main ssh://git@10.0.1.20:30022/mathias/infra.git /tmp/infra
cd /tmp/infra
DEPLOYMENT="k3s/apps/tapir/deployment.yaml"
# In-place update of the image tag. sed (not yq) so we don't
# depend on additional tooling on the runner — same as cobalt-dingo.
sed -i "s|image: localhost:5000/tapir:.*|image: localhost:5000/tapir:${IMAGE_TAG}|" "$DEPLOYMENT"
# Verify the patch took effect.
grep -q "localhost:5000/tapir:${IMAGE_TAG}" "$DEPLOYMENT" \
|| { echo "✗ image tag patch failed"; exit 1; }
if git diff --quiet "$DEPLOYMENT"; then
echo " image tag unchanged — skipping push"
else
git -c user.name="tapir CI" \
-c user.email="ci@tapir.local" \
commit -m "chore(deploy): tapir → ${IMAGE_TAG}" "$DEPLOYMENT"
git push origin main
echo "✓ pushed to infra repo"
fi
shred -u ~/.ssh/id_infra
- name: Trigger Flux reconcile (immediate)
run: |
# Without these annotations, Flux would still pick up the change
# within 30s (the apps Kustomization interval). The annotations
# cut latency to ~1s.
kubectl -n flux-system annotate gitrepository flux-system \
reconcile.fluxcd.io/requestedAt="$(date +%s)" --overwrite
kubectl -n flux-system annotate kustomization apps \
reconcile.fluxcd.io/requestedAt="$(date +%s)" --overwrite
- name: Wait for Flux to apply new image
env:
IMAGE_TAG: ${{ needs.build.outputs.image-tag }}
run: |
# Poll the Deployment spec until it reflects the new tag.
# Bound to 60s so a stuck Flux doesn't hang CI.
EXPECTED="localhost:5000/tapir:${IMAGE_TAG}"
for i in $(seq 1 60); do
CURRENT=$(kubectl get deploy tapir -n tapir \
-o jsonpath='{.spec.template.spec.containers[0].image}' 2>/dev/null || echo "")
if [ "$CURRENT" = "$EXPECTED" ]; then
echo "✓ Flux applied new image after ${i}s"
break
fi
sleep 1
done
# Final assertion (in case the loop exited without matching).
kubectl get deploy tapir -n tapir \
-o jsonpath='{.spec.template.spec.containers[0].image}' \
| grep -qx "$EXPECTED" \
|| { echo "✗ Flux did not apply new image within 60s"; exit 1; }
- name: Verify rollout
run: |
kubectl rollout status deployment/tapir \
--namespace tapir \
--timeout=120s \
|| {
echo "── pod status ──"
kubectl get pods -n tapir -o wide
echo "── events ──"
kubectl get events -n tapir --sort-by='.lastTimestamp' | tail -20
echo "── describe ──"
kubectl describe pods -n tapir -l app=tapir | tail -40
exit 1
}
- name: Confirm pod running new image
env:
IMAGE_TAG: ${{ needs.build.outputs.image-tag }}
run: |
kubectl get pods -n tapir \
-l app=tapir \
--field-selector=status.phase=Running \
-o jsonpath='{.items[*].spec.containers[0].image}' \
| grep -q "localhost:5000/tapir:${IMAGE_TAG}" \
&& echo "✓ pod running new image" \
|| { echo "✗ pod image mismatch"; exit 1; }
# ── 4. Mirror to GitHub — skipped for now (SSH key rotation pending) ─
+11
View File
@@ -11,3 +11,14 @@
.env.*
!.env.example
*.local
# Spike media (#28): real recordings, their transcripts and derived analyses are
# private third-party content and this repo is public. Only synthetic fixtures
# are committed — see scripts/spike-media/README.md.
/scripts/spike-media/*.mov
/scripts/spike-media/*.mp4
/scripts/spike-media/*.wav
/scripts/spike-media/*.srt
/scripts/spike-media/*.json
/scripts/spike-media/*.html
!/scripts/spike-media/fixtures/
+18
View File
@@ -0,0 +1,18 @@
{
"mcpServers": {
"brain": {
"type": "http",
"url": "https://brain-mcp.d-ma.be/mcp",
"headers": {
"Authorization": "Bearer ${BRAIN_MCP_TOKEN}"
}
},
"gitea": {
"type": "http",
"url": "https://git-mcp.d-ma.be/mcp",
"headers": {
"Authorization": "Bearer ${GITEA_MCP_TOKEN}"
}
}
}
}
+1
View File
@@ -12,6 +12,7 @@ docs it indexes.
3. `DECISIONS.md` — the ADRs. Decisions are settled here; do not re-litigate without a new ADR.
4. `docs/architecture/architecture.md`, `docs/data-model.md`, `docs/use-cases/*.feature`.
5. `docs/homelab-integration.md` — the concrete endpoints/conventions you'll need.
6. `LANGUAGE.md` — the project vocabulary. Apply the caveman rubric before destructive operations.
## How to work in this repo
+186
View File
@@ -1128,6 +1128,192 @@ Spec: `docs/specs/onboarding-wow-burst.md`.
---
## ADR-029 — Stateless session cookie (survives restarts, browser-close, idle)
**Status:** Accepted (2026-06-11). Triggered by pilot feedback: "lots of clicking to log in again
on iPhone." Supersedes the in-memory session store in the ADR-011 login.
**Context.** Three compounding causes made users re-login constantly:
1. **In-memory session store** (`sessionStore` map) — wiped on every pod restart, so each deploy
logged everyone out. During the active build period that was ~15 logouts.
2. **1-hour session TTL** — for a "check back tomorrow" reader, idle > 1h forced a re-login on
nearly every visit.
3. **No cookie Max-Age** — a session cookie (deleted on browser/app close); iPhone Safari closing
the tab dropped it.
Each re-login is the full Dex/Authentik redirect dance — many taps on mobile.
**Decision.** Make the session **stateless**: the identity (subject + email) and an absolute
expiry live INSIDE the existing HMAC-signed (HS256) cookie — no server-side table. Plus:
- **30-day sliding TTL** (was 1h), re-signed on each request so an active user never lapses.
- **Persistent cookie** (`Max-Age` set) so it survives browser/app close.
The cookie is HttpOnly + Secure + SameSite=Lax; the HMAC (keyed by the stable ESO
`tapir-session-secret`, which does NOT rotate per deploy) makes it tamper-proof. The payload is
identity, not secrets — the OIDC access/ID tokens are still discarded after callback.
**Consequences.** A deploy/restart no longer logs anyone out (proven by a test: a cookie issued by
one instance is accepted by a fresh instance with the same secret); works across replicas for
free. **Trade:** no server-side revocation — `logout` clears the cookie client-side, but a copied
cookie stays valid until expiry. Accepted for the Stage-0 reader pilot; revisit (server-side
revocation list, or shorter TTL + refresh) if it ever holds sensitive actions. Rotating
`tapir-session-secret` invalidates all sessions — the global logout lever.
**Not addressed here:** the tap-count of the IdP login page itself is Authentik's UX; with
re-login now rare (30-day idle or explicit logout), it matters far less.
---
## ADR-030 — Observability: slog timing + Prometheus metrics (AI-focused)
**Status:** Proposed (2026-06-11). Issue #15. **Draft for review — no code yet.**
**Context / requirements.** Nothing measures the activities that drive Tapir's performance and
UX, and the Stage-0 eval gate (ADR-016) needs a *performance* dimension to sit beside the
return-usage one. We need timing for: caption fetches (the scarce op), summarization (which model
won, how long, fallbacks), Q&A latency, LLM token spend, and basic session/usage (request rate,
latency by route, logins). Requirements:
- R1: structured `slog` timing at each AI call site (human-readable, already the logging stack).
- R2: Prometheus metrics for the same, scrapeable by the cluster's prometheus-operator.
- R3: **AI metrics are the priority** — summarize latency by `model`/`outcome`/`fallback`,
caption-fetch latency by `outcome`, chat latency by `model`, and LLM `tokens` by model+kind.
- R4: HTTP/session metrics via middleware — request count + latency by route, logins.
- R5: bounded label cardinality (no per-user, no raw-path labels).
- R6: `/metrics` must NOT be publicly exposed.
**Decision / architecture.**
1. **New package `internal/metrics`** owns all Prometheus collectors + a typed API
(`ObserveSummarize`, `ObserveCaptionFetch`, `ObserveChat`, `RecordTokens`, `IncLogin`,
`HTTPMiddleware`, `Handler`). Adapters call this API; they never import prometheus types.
2. **New dependency `github.com/prometheus/client_golang`.** Justification: it is *the* standard
Go Prometheus client and the cluster already runs prometheus-operator; hand-rolling exposition
is not worth it. (Needs the dep-justification note in the commit per repo rules.)
3. **The copied `llm` package stays stdlib-only (ADR-004).** It must not import `internal/metrics`.
Token usage is surfaced via an **optional callback** `llm.WithUsageHook(func(model string, prompt, completion int))`
set at wiring time (`buildSummarizer`/`buildChat`) to `metrics.RecordTokens`; `llm.Client` only
gains parsing of the response `usage` block. Our own adapters (`summarizer`, `youtube`, `chat`)
may import `internal/metrics` directly.
4. **HTTP middleware** reads `r.Pattern` AFTER routing (Go 1.22 sets it during ServeMux match), so
the `route` label is the bounded registered pattern (`GET /v/{videoId}`), satisfying R5;
unmatched → `other`.
5. **Dedicated metrics port** (`TAPIR_METRICS_ADDR`, default `:9090`) served by a second
`http.Server` in `cmdServe`; `/metrics` is never on the public app mux (R6). A **PodMonitor**
in `mathias/infra` scrapes it; the deployment exposes the port.
6. **slog** elapsed fields are emitted alongside each metric at the call sites (R1).
**Hook points (where the instrumentation lands).**
- `summarizer.Summarize` — per-endpoint timing + outcome (`success`/`parse_error`/`error`) + fallback flag.
- `youtube.FetchTranscript` — fetch timing + outcome from `domain.Transcript.Source`.
- `chat.Service` answer — timing by model.
- `llm.Client.Complete` — parse `usage`, fire the usage hook.
- `oidc.handleCallback``IncLogin`.
- `cmdServe` — wrap `Router()` in `metrics.HTTPMiddleware`; start the metrics server.
**Out of scope / later.** Persisting per-summary latency into Postgres for `tapir report`
(derive UX latency — publish/discovery → summary — from existing timestamps first; only persist
op-latency if the scrape proves insufficient). SPA view (#16) and visual refresh (#17).
**Reversibility.** Additive: a new package + middleware + a metrics port. Removing the PodMonitor
stops scraping; the app is unaffected. No schema change.
**Next steps (gated):** on approval of this ADR → BDD scenarios (`docs/use-cases/observability.feature`
+ scenario-coverage map) → TDD → implement → SemVer + docs + PodMonitor.
---
## ADR-031 — SPA-like reader: inline-expand summary + Q&A in the list (HTMX, no framework)
**Status:** Proposed (2026-06-12). Issue #16. **Draft for review — no code yet.**
**Context / requirements.** The reader is multi-page: a list of compact cards (`/`), then a
navigation to a separate detail page (`/v/{id}`) for the full summary + the docked chat (ADR-027).
It feels less fluid than a single integrated view. We want the full summary AND the per-video
Q&A to open **in place in the list**, no page hop. Requirements:
- R1: clicking a summarized card expands it in place to the full summary (summary/highlights/
takeaways) + the chat dock; a collapse returns it to the compact card.
- R2: **no SPA framework** — stay HTMX + Templ (ADR-003); reuse existing fragments, not a rewrite.
- R3: **progressive enhancement** — with JS off, the card link still navigates to `/v/{id}`
(the detail page stays as the no-JS + deep-link surface). Nothing becomes JS-only.
- R4: only **summarized** cards expand; pending/rate-limited/no-caption cards keep their current
footer behaviour (Summarize button, waiting/none states).
- R5: chat inside an expanded card works exactly as on the detail page (reuse `chatReveal`/
`chatSection` + the existing `/v/{id}/chat` endpoints, unchanged).
**Decision / architecture.**
1. **Reuse the existing fragments.** `summaryBody(r)` and `chatReveal(videoID)` already exist and
render the detail page; a new `expandedCard(r, chatEnabled)` composes the compact header + a
collapse control + `summaryBody` + `chatReveal`. `DetailPage` is refactored to also compose
`summaryBody` so the two never drift (DRY).
2. **Two fragment endpoints** (mirroring the existing list/status HTMX fragment pattern):
`GET /v/{videoId}/expand``expandedCard`; collapse reuses the existing compact `VideoCard`
via `GET /v/{videoId}/card`. Both are list-card `<li>` fragments with the SAME `id`
(`video-{id}`), swapped `outerHTML` — same mechanism as `processingCard`/`VideoCard` today.
3. **The compact card's title/"Read" affordance** becomes `hx-get=/v/{id}/expand`,
`hx-target=#video-{id}`, `hx-swap=outerHTML`, with `href=/v/{id}` as the no-JS fallback (R3).
The expanded card's collapse control is the inverse (`hx-get=/v/{id}/card`).
4. **Only when `r.Summarized`** does the expand affordance render (R4); the other states are
unchanged.
5. **v1 does NOT push the URL** (`hx-push-url`) — expand/collapse is ephemeral list UI state; the
detail page remains the deep-link/shareable URL. Deep-linking the open state via `hx-push-url`
is noted as a later option (needs list-state restore on back).
**Out of scope / later.** URL push / deep-linkable open state; the visual refresh (#17) — though
the expanded-card markup is where #17's TUI/charm styling will land, so they pair.
**Reversibility.** Additive: two fragment endpoints + one templ + an affordance swap on the
compact card. Removing the affordance reverts to plain list→detail navigation; the detail page is
untouched. No schema change.
**Next steps (gated):** on approval → BDD (`docs/use-cases/inline_expand.feature` + coverage
map) → TDD → implement → SemVer + docs.
---
## ADR-032 — Visual refresh: one charm-reader layout, light + dark themes
**Status:** Proposed (2026-06-12). Issue #17. **Draft for review — no code yet.** Follows the
sketch-first explore step (3 throwaway mockups in `docs/sketches/`, screenshotted for review).
**Context / decision.** The UI is flat. From the mockups, directions **B (light reader + charm)**
and **C (dark cozy terminal)** are the SAME layout — readable sans body, monospace meta, charm
palette accents, lipgloss-style bordered cards — in two palettes. Direction A (full-monospace TUI)
is dropped as too heavy to read long summaries. Decision: ship that one layout with **both a light
theme (B) and a dark theme (C)**, user-toggleable, defaulting to the OS preference.
**Requirements.**
- R1: one set of markup/structure; the two themes are pure palette (CSS variables), no duplicate templates.
- R2: a **theme toggle** persisted across visits; default to `prefers-color-scheme` when no choice stored.
- R3: charm language in both — mint/purple/pink accents, mono meta + section labels, lipgloss
bordered/gradient cards, the (fixed) ASCII tapir; readable sans body.
- R4: style the existing pieces — list, compact card, **expanded card incl. the already-present
video embed (ADR-031/summaryBody)**, detail page, chat dock, the queue note, the charm spinner.
- R5: WCAG-AA contrast for body text in BOTH themes; keep `prefers-reduced-motion` (already honoured).
- R6: stay HTMX+Templ; no CSS framework.
**Architecture.**
1. **Palette as CSS variables.** `:root` holds the light (B) tokens; `:root[data-theme="dark"]`
holds the dark (C) tokens; a `prefers-color-scheme: dark` media block sets the dark tokens when
no explicit `data-theme` is set. All component CSS references variables only (R1). The existing
`CharmMint/Purple/Pink/Cream/Dim` Go consts remain the source for the spinner's inline colours.
2. **Theme toggle** = a small inline script (a dozen lines, no framework) in `Layout`: on load,
apply stored theme (localStorage) or fall through to the media query; a header toggle button
flips `data-theme` on `<html>` and stores it. This is the one new bit of JS; everything else
stays server-rendered + HTMX. (Considered: cookie + server-render — rejected, a full round-trip
per toggle is clunky for a pure presentation flip.)
3. **Scope = the `styleTag` CSS in `view.go`** (the single style source) plus tiny class hooks in
the templ where needed; content/structure are unchanged, so existing view tests keep passing.
**Verification.** The deployed UI is behind auth (web-shot can't log in), so visual review is via
the mockups now + a styled full-set mockup screenshot before merge, then a live eyeball on device.
Automated tests stay structural/behavioural (theme tokens present, toggle persists, expanded card
embeds the video, no-JS still renders a readable default) — colours are not unit-tested.
**Reversibility.** A CSS theme swap + one small script + a few class hooks; revert `styleTag` to
roll back. No schema, no structural change.
**Next steps (gated):** on approval → BDD (`docs/use-cases/visual_theme.feature` + coverage map)
→ TDD → implement → SemVer + docs.
---
## Rejected alternatives
Approaches considered during the 2026-06-02 planning + grill session and **deliberately not
+26
View File
@@ -0,0 +1,26 @@
# Vocabulary (hand-written pilot 2026-06 — will be generated by langgen in Phase 2)
| Term | Means | Never say |
|---|---|---|
| transcript | Captions-first text of a video; the source for summarizing. Shared store keyed by `(provider, video_id)`. | "subtitles file", "the audio" |
| video | Per-user record of a seen video; the unit of work. Not globally deduped. | "the shared video" |
| subscription | A watched channel on a user's connection that Tapir polls for new videos. | "feed", "follow" |
| summary | A video's produced output: summary text + highlights + takeaways + AI provenance. | "transcript" |
| highlight | A notable point pulled from a video (`Summary.Highlights`). | "takeaway" |
| takeaway | An actionable conclusion from a video (`Summary.Takeaways`). | "highlight" |
| sink | A delivery destination for a summary (`store`, `brain`). New one = new adapter. | "the database" |
| AI router | Local-first chain: local Primary → local fallback → external worst-case. | "the API" |
| BYO-AI fallback | User's own external AI key, used only when local fails; opt-in. Without it, content is never sent externally. | "the default AI" |
| brain | Persistent homelab knowledge store; in Tapir, one optional HTTP sink (`brain_ingest`), not the filesystem package. | "the database", "the store" |
| LiteLLM gateway | Local AI gateway (`koala:30401/v1`); the Primary in the AI router. | "piguard:4000", "koala:4000", "the cloud" |
| connection | A connected video account a user authorizes via OAuth; subscriptions hang off it. | "login", "session" |
## Caveman rubric
Before any HIGH/CRITICAL operation, output one line:
`caveman: me <verb> <object>, not <excluded thing>`
Valid iff: (1) only vocabulary terms + plain verbs, (2) a stranger could identify
the exact operation, (3) names one thing explicitly NOT being done.
Examples:
- `caveman: me delete user connection, not the shared transcript`
- `caveman: me send transcript to BYO-AI fallback, not the LiteLLM gateway`
+1 -1
View File
@@ -4,7 +4,7 @@ import (
"strings"
"testing"
"gitea.d-ma.be/mathias/tapir/internal/config"
"git.d-ma.be/mathias/tapir/internal/config"
)
// TestChatModelsReuseTheChainLocalFirst: the switcher offers the ADR-022 chain in
+1 -1
View File
@@ -5,7 +5,7 @@ import (
"log/slog"
"sync"
"gitea.d-ma.be/mathias/tapir/internal/runner"
"git.d-ma.be/mathias/tapir/internal/runner"
)
// discoveryRunner runs one user's discovery pass.
+1 -1
View File
@@ -10,7 +10,7 @@ import (
"github.com/stretchr/testify/require"
"gitea.d-ma.be/mathias/tapir/internal/runner"
"git.d-ma.be/mathias/tapir/internal/runner"
)
// serialize must guarantee at most one discovery pass runs at a time, so a
+1 -1
View File
@@ -5,7 +5,7 @@ import (
"fmt"
"os"
"gitea.d-ma.be/mathias/tapir/internal/adapters/store"
"git.d-ma.be/mathias/tapir/internal/adapters/store"
)
// Env var names for the read-only CLI. DSN and user id are never hardcoded — the
+1 -1
View File
@@ -7,7 +7,7 @@ import (
"github.com/stretchr/testify/require"
"gitea.d-ma.be/mathias/tapir/internal/adapters/store"
"git.d-ma.be/mathias/tapir/internal/adapters/store"
)
func TestFormatListColumnsAndOrdering(t *testing.T) {
+1 -1
View File
@@ -9,7 +9,7 @@ import (
"strings"
"text/tabwriter"
"gitea.d-ma.be/mathias/tapir/internal/adapters/store"
"git.d-ma.be/mathias/tapir/internal/adapters/store"
)
// runList prints the user's stored summaries as a table, most recent first.
+28 -9
View File
@@ -23,14 +23,15 @@ import (
"sync"
"time"
"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/youtube"
"gitea.d-ma.be/mathias/tapir/internal/auth"
"gitea.d-ma.be/mathias/tapir/internal/config"
"gitea.d-ma.be/mathias/tapir/internal/runner"
"gitea.d-ma.be/mathias/tapir/internal/web"
"gitea.d-ma.be/mathias/tapir/internal/web/oidc"
"git.d-ma.be/mathias/tapir/internal/adapters/secrets"
"git.d-ma.be/mathias/tapir/internal/adapters/store"
"git.d-ma.be/mathias/tapir/internal/adapters/youtube"
"git.d-ma.be/mathias/tapir/internal/auth"
"git.d-ma.be/mathias/tapir/internal/config"
"git.d-ma.be/mathias/tapir/internal/metrics"
"git.d-ma.be/mathias/tapir/internal/runner"
"git.d-ma.be/mathias/tapir/internal/web"
"git.d-ma.be/mathias/tapir/internal/web/oidc"
)
func main() {
@@ -310,16 +311,34 @@ func cmdServe(ctx context.Context, log *slog.Logger) error {
srv := &http.Server{
Addr: cfg.HTTPAddr,
Handler: app.Router(),
Handler: metrics.HTTPMiddleware(app.Router()),
ReadHeaderTimeout: 10 * time.Second,
}
// Prometheus /metrics on a SEPARATE port (ADR-030) — never on the public app
// mux, so a scrape is in-cluster only. Empty TAPIR_METRICS_ADDR disables it.
var metricsSrv *http.Server
if cfg.MetricsAddr != "" {
mmux := http.NewServeMux()
mmux.Handle("GET /metrics", metrics.Handler())
metricsSrv = &http.Server{Addr: cfg.MetricsAddr, Handler: mmux, ReadHeaderTimeout: 10 * time.Second}
go func() {
log.Info("serving metrics", "addr", cfg.MetricsAddr)
if err := metricsSrv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
log.Error("metrics server", "err", err)
}
}()
}
// Graceful shutdown on signal: stop accepting, drain in-flight requests.
go func() {
<-ctx.Done()
shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
_ = srv.Shutdown(shutdownCtx)
if metricsSrv != nil {
_ = metricsSrv.Shutdown(shutdownCtx)
}
}()
log.Info("serving web ui", "addr", cfg.HTTPAddr, "user", cfg.UserID)
+14 -13
View File
@@ -5,17 +5,18 @@ import (
"fmt"
"strings"
"gitea.d-ma.be/mathias/tapir/internal/adapters/chat"
"gitea.d-ma.be/mathias/tapir/internal/adapters/llm"
"gitea.d-ma.be/mathias/tapir/internal/adapters/secrets"
"gitea.d-ma.be/mathias/tapir/internal/adapters/store"
"gitea.d-ma.be/mathias/tapir/internal/adapters/summarizer"
"gitea.d-ma.be/mathias/tapir/internal/adapters/youtube"
"gitea.d-ma.be/mathias/tapir/internal/config"
"gitea.d-ma.be/mathias/tapir/internal/domain"
"gitea.d-ma.be/mathias/tapir/internal/ports"
"gitea.d-ma.be/mathias/tapir/internal/usecase"
"gitea.d-ma.be/mathias/tapir/internal/web"
"git.d-ma.be/mathias/tapir/internal/adapters/chat"
"git.d-ma.be/mathias/tapir/internal/adapters/llm"
"git.d-ma.be/mathias/tapir/internal/adapters/secrets"
"git.d-ma.be/mathias/tapir/internal/adapters/store"
"git.d-ma.be/mathias/tapir/internal/adapters/summarizer"
"git.d-ma.be/mathias/tapir/internal/adapters/youtube"
"git.d-ma.be/mathias/tapir/internal/config"
"git.d-ma.be/mathias/tapir/internal/domain"
"git.d-ma.be/mathias/tapir/internal/metrics"
"git.d-ma.be/mathias/tapir/internal/ports"
"git.d-ma.be/mathias/tapir/internal/usecase"
"git.d-ma.be/mathias/tapir/internal/web"
)
// videoFetcher adapts the YouTube adapter to web.VideoFetcher for the paste flow
@@ -69,7 +70,7 @@ func buildSummarizer(cfg config.Config) *summarizer.Summarizer {
func summarizerEndpoint(cfg config.Config) func(model string) summarizer.Endpoint {
return func(model string) summarizer.Endpoint {
return summarizer.Endpoint{
Client: llm.New(cfg.GatewayURL, cfg.GatewayKey, model, cfg.SummarizerTimeout, llm.WithMaxTokens(cfg.SummaryMaxTokens)),
Client: llm.New(cfg.GatewayURL, cfg.GatewayKey, model, cfg.SummarizerTimeout, llm.WithMaxTokens(cfg.SummaryMaxTokens), llm.WithUsageHook(metrics.RecordTokens)),
Provider: providerOf(model),
Model: model,
}
@@ -150,7 +151,7 @@ func buildChat(cfg config.Config) *chat.Service {
return nil
}
newClient := func(model string) chat.Completer {
return llm.New(cfg.GatewayURL, cfg.GatewayKey, model, cfg.SummarizerTimeout, llm.WithMaxTokens(cfg.SummaryMaxTokens))
return llm.New(cfg.GatewayURL, cfg.GatewayKey, model, cfg.SummarizerTimeout, llm.WithMaxTokens(cfg.SummaryMaxTokens), llm.WithUsageHook(metrics.RecordTokens))
}
return chat.New(newClient, models, cfg.MaxTranscriptChars)
}
+1 -1
View File
@@ -3,7 +3,7 @@ package main
import (
"testing"
"gitea.d-ma.be/mathias/tapir/internal/config"
"git.d-ma.be/mathias/tapir/internal/config"
)
// TestBuildProcessorNilOnIncompleteConfig asserts the queue-only fallback: when a
+34 -4
View File
@@ -6,14 +6,36 @@ import (
"io"
"os"
"text/tabwriter"
"time"
"gitea.d-ma.be/mathias/tapir/internal/adapters/store"
"git.d-ma.be/mathias/tapir/internal/adapters/store"
)
// gateThreshold is the Stage-0 gate (VISION/ADR-016): usage in >= 2 distinct
// weeks. The gate passes when any user reaches it.
const gateThreshold = 2
// defaultGateStart is the date Stage-0 return-usage tracking begins: the morning
// the pilot was actually unblocked and summaries started flowing (2026-06-11).
// Activity before this — testing, the period the pilot was stuck on zero — is
// noise and must not count toward the gate. Override with TAPIR_USAGE_GATE_START
// (YYYY-MM-DD). The gate measures whether users RETURN once it genuinely works.
const defaultGateStart = "2026-06-11"
// gateStart resolves the baseline date from TAPIR_USAGE_GATE_START or the default,
// parsed as a UTC calendar day.
func gateStart() (time.Time, error) {
v := os.Getenv("TAPIR_USAGE_GATE_START")
if v == "" {
v = defaultGateStart
}
t, err := time.Parse("2006-01-02", v)
if err != nil {
return time.Time{}, fmt.Errorf("TAPIR_USAGE_GATE_START=%q: want YYYY-MM-DD: %w", v, err)
}
return t, nil
}
// runReport prints the Stage-0 usage gate: per-user distinct active weeks (reads
// UNION acts) and the pass/fail verdict. Read-only, cross-user — needs only
// TAPIR_DB_DSN (not TAPIR_USER_ID; the report enumerates all users itself).
@@ -28,16 +50,24 @@ func runReport(ctx context.Context, _ []string) error {
}
defer s.Close()
rows, err := s.ActiveWeeks(ctx)
since, err := gateStart()
if err != nil {
return err
}
return formatReport(os.Stdout, rows)
rows, err := s.ActiveWeeks(ctx, since)
if err != nil {
return err
}
return formatReport(os.Stdout, rows, since)
}
// formatReport renders the per-user week counts and the gate verdict. Pure: no DB,
// no env — so the layout and verdict logic are unit-testable without Postgres.
func formatReport(w io.Writer, rows []store.UserActiveWeeks) error {
func formatReport(w io.Writer, rows []store.UserActiveWeeks, since time.Time) error {
if _, err := fmt.Fprintf(w, "Counting usage since %s (Stage-0 gate baseline)\n\n", since.Format("2006-01-02")); err != nil {
return err
}
if len(rows) == 0 {
_, err := fmt.Fprintln(w, "no users yet")
return err
+8 -4
View File
@@ -3,12 +3,15 @@ package main
import (
"strings"
"testing"
"time"
"github.com/stretchr/testify/require"
"gitea.d-ma.be/mathias/tapir/internal/adapters/store"
"git.d-ma.be/mathias/tapir/internal/adapters/store"
)
var testSince = time.Date(2026, 6, 11, 0, 0, 0, 0, time.UTC)
func TestFormatReportColumnsAndGatePass(t *testing.T) {
rows := []store.UserActiveWeeks{
{UserID: "user-a", DisplayName: "Ada", ActiveWeeks: 3},
@@ -16,9 +19,10 @@ func TestFormatReportColumnsAndGatePass(t *testing.T) {
}
var b strings.Builder
require.NoError(t, formatReport(&b, rows))
require.NoError(t, formatReport(&b, rows, testSince))
out := b.String()
require.Contains(t, out, "since 2026-06-11", "report states the gate baseline date")
require.Contains(t, out, "USER")
require.Contains(t, out, "ACTIVE_WEEKS")
require.Contains(t, out, "Ada")
@@ -34,12 +38,12 @@ func TestFormatReportGateNotMet(t *testing.T) {
rows := []store.UserActiveWeeks{{UserID: "user-a", ActiveWeeks: 1}}
var b strings.Builder
require.NoError(t, formatReport(&b, rows))
require.NoError(t, formatReport(&b, rows, testSince))
require.Contains(t, b.String(), "NOT YET MET", "no user at >= 2 weeks fails the gate")
}
func TestFormatReportEmpty(t *testing.T) {
var b strings.Builder
require.NoError(t, formatReport(&b, nil))
require.NoError(t, formatReport(&b, nil, testSince))
require.Contains(t, b.String(), "no users yet")
}
+7 -7
View File
@@ -6,13 +6,13 @@ import (
"log/slog"
"time"
"gitea.d-ma.be/mathias/tapir/internal/adapters/store"
"gitea.d-ma.be/mathias/tapir/internal/adapters/youtube"
"gitea.d-ma.be/mathias/tapir/internal/config"
"gitea.d-ma.be/mathias/tapir/internal/ports"
"gitea.d-ma.be/mathias/tapir/internal/runner"
"gitea.d-ma.be/mathias/tapir/internal/usecase"
"gitea.d-ma.be/mathias/tapir/internal/web"
"git.d-ma.be/mathias/tapir/internal/adapters/store"
"git.d-ma.be/mathias/tapir/internal/adapters/youtube"
"git.d-ma.be/mathias/tapir/internal/config"
"git.d-ma.be/mathias/tapir/internal/ports"
"git.d-ma.be/mathias/tapir/internal/runner"
"git.d-ma.be/mathias/tapir/internal/usecase"
"git.d-ma.be/mathias/tapir/internal/web"
)
// buildUserRunner constructs a runner.Runner for one user, reusing the same
+2 -2
View File
@@ -11,8 +11,8 @@ import (
"github.com/stretchr/testify/require"
"gitea.d-ma.be/mathias/tapir/internal/adapters/store"
"gitea.d-ma.be/mathias/tapir/internal/runner"
"git.d-ma.be/mathias/tapir/internal/adapters/store"
"git.d-ma.be/mathias/tapir/internal/runner"
)
func quietLog() *slog.Logger {
+1 -1
View File
@@ -8,7 +8,7 @@ import (
"os"
"strings"
"gitea.d-ma.be/mathias/tapir/internal/adapters/store"
"git.d-ma.be/mathias/tapir/internal/adapters/store"
)
// runShow prints the full summary for one video: text, highlights, takeaways,
+9
View File
@@ -224,6 +224,15 @@ knobs plus one load-bearing deployment constraint:
- `TAPIR_DISCOVERY_INTERVAL` — Go duration, e.g. `2h`. The cadence the serve process runs a
discovery pass for every registered user (run-once-on-startup, then every interval).
**Unset or `0` = disabled** (dev/tests never auto-fetch).
- `TAPIR_USAGE_GATE_START``YYYY-MM-DD`, default **`2026-06-11`** (the morning the pilot was
unblocked and summaries started flowing). `tapir report` counts return-usage (distinct active
weeks, ADR-016) only from this date, so pre-launch testing and the blocked period are excluded.
- `TAPIR_METRICS_ADDR` — listen address for the Prometheus `/metrics` endpoint (ADR-030).
**Default `:9090`** — a SEPARATE port from `TAPIR_HTTP_ADDR` so metrics are never on the public
app; scraped in-cluster only (PodMonitor). Empty disables the metrics server. Key series:
`tapir_summarize_duration_seconds{model,outcome,fallback}`, `tapir_caption_fetch_duration_seconds{outcome}`,
`tapir_chat_duration_seconds{model}`, `tapir_llm_tokens_total{model,kind}`,
`tapir_http_request_duration_seconds{method,route}`, `tapir_logins_total`.
- `TAPIR_FETCH_RATE` — Go duration, default `2s`. The **process-wide per-egress-IP caption-fetch
rate gate** (ADR-014 item 2). Every caption fetch — scheduler runners *and* the web "Summarize"
click-path — serialises through this one limiter so the pod cannot collectively trip 429s. `0`
+63
View File
@@ -0,0 +1,63 @@
<!DOCTYPE html><html lang="en"><head><meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>tapir — A · TUI panels</title>
<style>
:root{--bg:#0d0d12;--panel:#14141c;--line:#2a2a3a;--mint:#0EF9B6;--pink:#F740A0;--purple:#7653fc;--cream:#F5E9D6;--dim:#6b6b86;--text:#d9d9e6}
*{box-sizing:border-box}
body{margin:0;background:var(--bg);color:var(--text);font:14px/1.5 ui-monospace,SFMono-Regular,Menlo,"Cascadia Code",monospace}
.wrap{max-width:760px;margin:0 auto;padding:18px 14px 60px}
header{display:flex;align-items:center;gap:10px;border-bottom:1px solid var(--line);padding-bottom:12px;margin-bottom:18px}
.brand{color:var(--mint);font-weight:700;letter-spacing:.5px}
.brand b{color:var(--cream)}
.tip{margin-left:auto;color:var(--dim);font-size:12px}
.note{color:var(--dim);font-size:12.5px;border-left:2px solid var(--purple);padding:6px 10px;margin:0 0 16px;background:#11111a}
/* lipgloss-style bordered panel */
.card{border:1px solid var(--line);border-radius:8px;background:var(--panel);padding:12px 14px;margin:0 0 12px;position:relative}
.card::before{content:"";position:absolute;left:0;top:10px;bottom:10px;width:3px;border-radius:3px;background:var(--purple)}
.card.ready::before{background:var(--mint)}
.title{color:var(--cream);font-size:15px;font-weight:600;margin:0 0 4px}
.meta{color:var(--dim);font-size:12px}
.chip{display:inline-block;border:1px solid var(--mint);color:var(--mint);border-radius:4px;padding:0 6px;font-size:11px;margin-left:6px}
.chip.q{border-color:var(--pink);color:var(--pink)}
.preview{color:var(--dim);margin:6px 0 0;font-size:13px}
/* expanded */
.exp{border-color:var(--purple)}
.exp .head{display:flex;justify-content:space-between;align-items:baseline}
.collapse{color:var(--mint);font-size:12px;text-decoration:none;border:1px solid var(--line);border-radius:4px;padding:1px 7px}
.sec h2{color:var(--mint);font-size:12px;text-transform:uppercase;letter-spacing:1px;margin:16px 0 6px;border-bottom:1px dashed var(--line);padding-bottom:3px}
.sec ul{margin:0;padding-left:18px}.sec li{margin:3px 0}
.body{color:var(--text)}
.dock{margin-top:16px;border-top:1px solid var(--line);padding-top:12px}
.ask{display:inline-block;background:linear-gradient(90deg,var(--purple),var(--pink));color:#fff;border:0;border-radius:6px;padding:7px 12px;font:inherit;font-size:13px;cursor:pointer}
pre.tapir{margin:0;color:var(--mint);font-size:10px;line-height:1.05}
</style></head><body><div class="wrap">
<header>
<pre class="tapir"> ▄█▓▓█▄ ∩
█▓( ◕ ◕ )▓█──┘</pre>
<span class="brand"><b>tapir</b> · watch less, know more</span>
<span class="tip">261 in queue</span>
</header>
<p class="note">Tapir fetches captions slowly on purpose, to respect YouTube's limits — new summaries land gradually.</p>
<div class="card ready"><div class="title">How the Attention Economy Rewires Your Brain</div>
<div class="meta">youtube · 2026-06-11 · 18 min <span class="chip">ready</span></div>
<div class="preview">A tour of the incentive loops behind infinite feeds and three concrete ways to claw back focus…</div></div>
<article class="card exp ready">
<div class="head"><div class="title">Postgres 18 — What's Actually New</div><a class="collapse" href="#">collapse ↑</a></div>
<div class="meta">youtube · 2026-06-10 · 42 min · phi4-mini</div>
<div class="sec"><h2>Takeaways</h2><ul>
<li>Async I/O cuts cold-cache read latency materially on NVMe.</li>
<li>Skip-scan makes more multicolumn indexes usable without rewrites.</li>
<li>Upgrade path is smooth; test the new planner stats first.</li></ul></div>
<div class="sec"><h2>Highlights</h2><ul>
<li>Async I/O subsystem (effective_io_concurrency now matters more).</li>
<li>B-tree skip scan for leading-column gaps.</li>
<li>Better partition-wise joins.</li></ul></div>
<div class="sec"><h2>Summary</h2><p class="body">Postgres 18 is an incremental but meaningful release: the headline is the new asynchronous I/O path, with skip-scan and planner improvements close behind. For most homelabs the upgrade is low-risk and worth it for the read-latency wins.</p></div>
<div class="dock"><button class="ask">Dig deeper — ask about this video →</button></div>
</article>
<div class="card"><div class="title">RAG is dead, right?? — Conference Talk</div>
<div class="meta">youtube · 2026-06-09 · 31 min <span class="chip q">queued</span></div></div>
</div></body></html>
+62
View File
@@ -0,0 +1,62 @@
<!DOCTYPE html><html lang="en"><head><meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>tapir — B · reader + charm accents</title>
<style>
:root{--bg:#faf7f2;--card:#fff;--ink:#1c1b22;--soft:#6a6878;--line:#e7e2d8;--mint:#0bbf8c;--purple:#6a4cf0;--pink:#e0379a}
*{box-sizing:border-box}
body{margin:0;background:var(--bg);color:var(--ink);font:16px/1.6 -apple-system,BlinkMacSystemFont,"Segoe UI",Inter,sans-serif}
.mono{font-family:ui-monospace,SFMono-Regular,Menlo,monospace}
.wrap{max-width:680px;margin:0 auto;padding:20px 16px 60px}
header{display:flex;align-items:center;gap:10px;margin-bottom:18px}
.brand{font-weight:800;font-size:18px;letter-spacing:-.3px}
.brand .dot{color:var(--mint)}
.tag{color:var(--soft);font-size:13px}
.tip{margin-left:auto;color:var(--soft);font-size:12px}
.note{color:var(--soft);font-size:13px;background:#fff;border:1px solid var(--line);border-left:3px solid var(--mint);border-radius:8px;padding:8px 12px;margin:0 0 18px}
.card{background:var(--card);border:1px solid var(--line);border-radius:12px;padding:16px 18px;margin:0 0 14px;box-shadow:0 1px 2px rgba(20,18,40,.04)}
.title{font-size:18px;font-weight:700;letter-spacing:-.2px;margin:0 0 4px;line-height:1.3}
.meta{color:var(--soft);font-size:13px}
.meta .mono{font-size:12.5px}
.chip{display:inline-block;background:rgba(11,191,140,.12);color:var(--mint);border-radius:999px;padding:1px 9px;font-size:12px;font-weight:600;margin-left:6px}
.chip.q{background:rgba(224,55,154,.12);color:var(--pink)}
.preview{color:var(--soft);margin:8px 0 0}
.exp{border-color:#d9d0ee;box-shadow:0 6px 24px rgba(106,76,240,.10)}
.head{display:flex;justify-content:space-between;align-items:baseline;gap:10px}
.collapse{color:var(--purple);font-size:13px;font-weight:600;text-decoration:none;white-space:nowrap}
.sec h2{font-size:12px;text-transform:uppercase;letter-spacing:1.2px;color:var(--purple);margin:18px 0 6px}
.sec ul{margin:0;padding-left:20px}.sec li{margin:5px 0}
.body{line-height:1.7}
.dock{margin-top:18px;border-top:1px solid var(--line);padding-top:14px}
.ask{display:inline-flex;align-items:center;gap:6px;background:var(--ink);color:#fff;border:0;border-radius:999px;padding:9px 16px;font:inherit;font-size:14px;font-weight:600;cursor:pointer}
pre.tapir{margin:0;color:var(--mint);font-size:10px;line-height:1.05}
</style></head><body><div class="wrap">
<header>
<pre class="tapir"> ▄█▓▓█▄ ∩
█▓( ◕ ◕ )▓█──┘</pre>
<span class="brand">tapir<span class="dot">.</span></span><span class="tag">watch less, know more</span>
<span class="tip mono">261 in queue</span>
</header>
<p class="note">Tapir fetches captions slowly on purpose, to respect YouTube's limits — new summaries land gradually.</p>
<div class="card"><div class="title">How the Attention Economy Rewires Your Brain</div>
<div class="meta mono">youtube · 2026-06-11 · 18 min <span class="chip">ready</span></div>
<div class="preview">A tour of the incentive loops behind infinite feeds and three concrete ways to claw back focus…</div></div>
<article class="card exp">
<div class="head"><div class="title">Postgres 18 — What's Actually New</div><a class="collapse" href="#">collapse ↑</a></div>
<div class="meta mono">youtube · 2026-06-10 · 42 min · phi4-mini</div>
<div class="sec"><h2>Takeaways</h2><ul>
<li>Async I/O cuts cold-cache read latency materially on NVMe.</li>
<li>Skip-scan makes more multicolumn indexes usable without rewrites.</li>
<li>Upgrade path is smooth; test the new planner stats first.</li></ul></div>
<div class="sec"><h2>Highlights</h2><ul>
<li>Async I/O subsystem (effective_io_concurrency now matters more).</li>
<li>B-tree skip scan for leading-column gaps.</li>
<li>Better partition-wise joins.</li></ul></div>
<div class="sec"><h2>Summary</h2><p class="body">Postgres 18 is an incremental but meaningful release: the headline is the new asynchronous I/O path, with skip-scan and planner improvements close behind. For most homelabs the upgrade is low-risk and worth it for the read-latency wins.</p></div>
<div class="dock"><button class="ask">✦ Ask about this video</button></div>
</article>
<div class="card"><div class="title">RAG is dead, right?? — Conference Talk</div>
<div class="meta mono">youtube · 2026-06-09 · 31 min <span class="chip q">queued</span></div></div>
</div></body></html>
+65
View File
@@ -0,0 +1,65 @@
<!DOCTYPE html><html lang="en"><head><meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>tapir — C · cozy terminal</title>
<style>
:root{--bg:#16131d;--card:#1e1a28;--ink:#ece7f5;--soft:#9a92b4;--line:#322b44;--mint:#2ee6b6;--purple:#9d7bff;--pink:#ff6bdb;--cream:#f3ead8}
*{box-sizing:border-box}
body{margin:0;background:radial-gradient(1200px 600px at 70% -10%,#231b33,transparent),var(--bg);color:var(--ink);font:15px/1.6 -apple-system,BlinkMacSystemFont,"Segoe UI",Inter,sans-serif}
.mono{font-family:ui-monospace,SFMono-Regular,Menlo,monospace}
.wrap{max-width:700px;margin:0 auto;padding:20px 16px 60px}
header{display:flex;align-items:center;gap:12px;margin-bottom:18px}
.brand{font-weight:800;font-size:18px}.brand .c{color:var(--mint)}
.tag{color:var(--soft);font-size:13px}
.tip{margin-left:auto;color:var(--soft);font-size:12px}
.note{color:var(--soft);font-size:13px;background:#1b1726;border:1px solid var(--line);border-radius:10px;padding:9px 12px;margin:0 0 18px}
.note b{color:var(--mint);font-weight:600}
.card{position:relative;background:var(--card);border:1px solid var(--line);border-radius:12px;padding:14px 16px 14px 18px;margin:0 0 13px;overflow:hidden}
.card::before{content:"";position:absolute;left:0;top:0;bottom:0;width:4px;background:var(--purple)}
.card.ready::before{background:linear-gradient(var(--mint),var(--purple))}
.title{font-size:16px;font-weight:700;margin:0 0 4px;color:var(--cream)}
.meta{color:var(--soft);font-size:12.5px}
.chip{display:inline-block;border-radius:999px;padding:1px 9px;font-size:11px;font-weight:600;margin-left:6px;background:rgba(46,230,182,.14);color:var(--mint)}
.chip.q{background:rgba(157,123,255,.18);color:var(--purple)}
.preview{color:var(--soft);margin:7px 0 0;font-size:14px}
.exp{border-color:#473a63;box-shadow:0 10px 40px rgba(0,0,0,.35)}
.head{display:flex;justify-content:space-between;align-items:baseline;gap:10px}
.collapse{color:var(--mint);font-size:12px;text-decoration:none;border:1px solid var(--line);border-radius:6px;padding:2px 8px;white-space:nowrap}
.sec h2{font-size:11px;text-transform:uppercase;letter-spacing:1.3px;color:var(--mint);margin:18px 0 7px;display:flex;align-items:center;gap:8px}
.sec h2::after{content:"";flex:1;height:1px;background:var(--line)}
.sec ul{margin:0;padding-left:18px}.sec li{margin:5px 0}
.body{line-height:1.7;color:var(--ink)}
.dock{margin-top:18px;border-top:1px dashed var(--line);padding-top:14px;display:flex;align-items:center;gap:10px}
.ask{display:inline-flex;align-items:center;gap:7px;background:linear-gradient(90deg,var(--purple),var(--mint));color:#10101a;border:0;border-radius:999px;padding:9px 16px;font:inherit;font-weight:700;font-size:14px;cursor:pointer}
.dockhint{color:var(--soft);font-size:12px}
pre.tapir{margin:0;color:var(--mint);font-size:11px;line-height:1.05}
</style></head><body><div class="wrap">
<header>
<pre class="tapir"> ▄█▓▓█▄ ∩
█▓( ◕ ◕ )▓█──┘</pre>
<span class="brand">tapir<span class="c">_</span></span><span class="tag">watch less, know more</span>
<span class="tip mono">261 in queue</span>
</header>
<p class="note">Tapir fetches captions slowly on purpose, to respect YouTube's limits — <b>new summaries land gradually</b>.</p>
<div class="card ready"><div class="title">How the Attention Economy Rewires Your Brain</div>
<div class="meta mono">youtube · 2026-06-11 · 18 min <span class="chip">ready</span></div>
<div class="preview">A tour of the incentive loops behind infinite feeds and three concrete ways to claw back focus…</div></div>
<article class="card exp ready">
<div class="head"><div class="title">Postgres 18 — What's Actually New</div><a class="collapse" href="#">collapse ↑</a></div>
<div class="meta mono">youtube · 2026-06-10 · 42 min · phi4-mini</div>
<div class="sec"><h2>Takeaways</h2><ul>
<li>Async I/O cuts cold-cache read latency materially on NVMe.</li>
<li>Skip-scan makes more multicolumn indexes usable without rewrites.</li>
<li>Upgrade path is smooth; test the new planner stats first.</li></ul></div>
<div class="sec"><h2>Highlights</h2><ul>
<li>Async I/O subsystem (effective_io_concurrency now matters more).</li>
<li>B-tree skip scan for leading-column gaps.</li>
<li>Better partition-wise joins.</li></ul></div>
<div class="sec"><h2>Summary</h2><p class="body">Postgres 18 is an incremental but meaningful release: the headline is the new asynchronous I/O path, with skip-scan and planner improvements close behind. For most homelabs the upgrade is low-risk and worth it for the read-latency wins.</p></div>
<div class="dock"><button class="ask">◆ Ask about this video</button><span class="dockhint mono">answers only from this video's transcript</span></div>
</article>
<div class="card"><div class="title">RAG is dead, right?? — Conference Talk</div>
<div class="meta mono">youtube · 2026-06-09 · 31 min <span class="chip q">queued</span></div></div>
</div></body></html>
+1
View File
@@ -179,4 +179,5 @@ distinguishable.
| **"Summarize now" foreground path** | Unified quiet nudge button on actionable non-summarized cards. Five explicit card states — (1) summarized: chip + no button; (2) no captions (`transcript_status = 'none'`): "No transcript available", no button; (3) queued: "Queued" chip, no button; (4) rate-limited: "Fetching soon…" + "Summarize now" → `POST /v/{id}/retry-now` (clears `rate_limited_at`, triggers engine); (5) pending: "Not summarized" + "Summarize now" → `POST /v/{id}/summarize` (queues + triggers engine). One verb, one style (`.btn-quiet`); backend difference invisible to user. Both handlers call `ProcessVideo` through `globalFetchGate`. Rate gate respected, not bypassed — this is onboarding prioritisation. | Fast onboarding value; honest dead-end for no-captions videos (no button that fails). | `internal/web/handlers.go` (`handleRetryNow`, `handleRequestSummarize`); `internal/web/views.templ` (`VideoCard`) |
| **Pipeline stats bar** | A one-line status bar above the video list: `N summarized · M fetching soon · K no captions`. Computed from the unfiltered row set; hidden when all videos are summarized. Gives the user a clear read on pipeline state without any interaction. | Replaces the "why is nothing happening?" confusion when most videos are pending or rate-limited. | `internal/web/view.go` (`PipelineStats`, `pipelineStats`) |
| **Unavailable channels (account page)** | The `/account` page shows a "Unavailable channels" section when any channels returned HTTP 404 on the last discovery pass. Lists channel name, an "unavailable" badge, and the first-seen date. Data sourced from the `channel_errors` table (migration 013). | Surfaces silent failures so users know why some subscribed channels produce no new videos. | migration 013; `internal/web/account.go`; `internal/adapters/youtube/youtube.go` (`domain.ErrChannelUnavailable`) |
| **Visual refresh — charm-reader theme + light/dark toggle (ADR-032)** | One layout in two palettes expressed as CSS custom properties: a warm "reader" light theme (sketch B) and a "cozy terminal" dark theme (sketch C). Palette is chosen in cascade order — `:root` light default, an OS-preference dark block scoped to `:root:not([data-theme])` so it only applies absent an explicit choice, and `:root[data-theme="dark"\|"light"]` set by a header toggle that outranks the media query and persists in `localStorage` (guarded; degrades to OS default). An init script in `<head>` applies the stored choice before paint (no flash). Charm touches: monospace meta lines, accent uppercase section dividers with a trailing rule, pill buttons, a lifted/accent-edged expanded card. Error/danger shades became `--err-*` tokens so they follow the theme without per-block dark overrides. Sketches kept as the design record under `docs/sketches/`. | The UI read "flat and boring"; the charm/TUI aesthetic makes it distinctive and gives a real light/dark choice rather than OS-only. | ADR-032; `docs/use-cases/visual_theme.feature`; `internal/web/visual_theme_test.go`; `internal/web/view.go` (`stylesheet`, `themeScript`), `internal/web/views.templ` (Layout/PublicLayout) |
| **Recency window + sparse-state honesty (ADR-020)** | Supersedes the copy/sort in the rows above. Auto-summarize is bounded to videos published within `TAPIR_AUTO_SUMMARIZE_WINDOW` (~7d); older un-summarized videos collapse behind a single "Show N older videos — summarize on demand" disclosure, and caption-less videos collapse to a one-line count (not N cards). List order is now `summarized-first, published_at DESC NULLS LAST`. Copy reframed for honest scarcity: pipeline bar reads "N ready · M in queue · K no captions" (no "fetching soon"); a gradual-fill note explains the rate limit; the nudge verb is "Summarize" (not "Summarize now"); the queued card says "summarizing shortly"; the empty-connected state drops the impossible `tapir run` instruction. Detail leads with Takeaways. Filters slimmed (no date pickers; hidden when empty); watched/skipped segmented; back link on detail; empty terms checkbox removed. | Make the sparse reality legible and honest instead of implying abundance/imminence; bound auto load so the back-catalogue doesn't re-drive the caption gate. Never fetch harder — scarcity is surfaced, not engineered around. | ADR-020; `2384c47`, `3df0459`, `40b703e`, `a1a5217`, `4a0a56e`, `9bf1c31`, `980638d`, `12fb031`, `f775441`, `51aa5d9` |
+37
View File
@@ -0,0 +1,37 @@
Feature: Inline-expand summary + Q&A in the list (ADR-031, #16)
As a reader skimming my summaries
I want to open a summary and its Q&A in place in the list
So that I get the full read and follow-up without leaving the list (SPA-like, no page hop)
# HTMX inline-expand, no SPA framework (ADR-031). Each scenario maps to a Go test
# in scenario_coverage_test.go (the BDD name-coverage gate).
Scenario: A summarized card expands to the full summary in place
Given a summarized video in my list
When I expand its card
Then the full summary, highlights, and takeaways are returned as an in-place card fragment, not a full page
Scenario: An expanded card collapses back to the compact card
Given an expanded card
When I collapse it
Then the compact card fragment is returned in its place
Scenario: The expanded card offers the Q&A dock
Given chat is enabled
When a summarized card is expanded
Then the expanded card includes the deeper-dive chat affordance for that video
Scenario: Only a summarized card offers expand
Given a discovered but not-yet-summarized card
When the card is rendered
Then it shows its summarize/queue footer and no expand affordance
Scenario: With JS off the card still reaches the full summary
Given a summarized card
When it is rendered
Then its expand affordance carries an href to the detail page as a no-JS fallback
Scenario: The detail page and the expanded card show the same summary
Given a summarized video
When I view it on the detail page and as an expanded card
Then both render the same summary body (one shared fragment, no drift)
+46
View File
@@ -0,0 +1,46 @@
Feature: Observability — timing and metrics for performance and UX (ADR-030, #15)
As the maintainer running Tapir for pilot users
I want timing and Prometheus metrics for the activities that drive performance and UX
So that I can see latency, model behaviour, and usage and feed the Stage-0 eval gate
# AI metrics are the priority (ADR-030 R3). Each scenario maps to a Go test in
# test/acceptance/scenario_coverage_test.go (the BDD name-coverage gate).
Scenario: Summarization latency is recorded per endpoint
Given the summarizer runs a transcript through its endpoint chain
When an endpoint returns a parseable summary
Then the summarize latency is recorded with the model, outcome "success", and whether it was a fallback
Scenario: A failing summarizer endpoint records its failure outcome
Given the summarizer runs a transcript through its endpoint chain
When an endpoint errors or returns unparseable output
Then the summarize latency is recorded with outcome "error" or "parse_error" before the chain advances
Scenario: Caption fetch latency is recorded by outcome
Given a caption fetch is attempted for a video
When it resolves to captions, no captions, or a rate limit
Then the caption-fetch latency is recorded labelled by that outcome
Scenario: LLM token usage is recorded from the completion
Given an LLM completion returns a usage block with prompt and completion tokens
When the client finishes the call
Then the prompt and completion tokens are recorded for that model
Scenario: Q&A answer latency is recorded
Given a user asks a question about a video
When the answer is produced from the stored transcript
Then the chat answer latency is recorded for the answering model
Scenario: HTTP requests are counted by route, method, and status
Given the metrics HTTP middleware wraps the app
When a request is served against a registered route
Then it is counted and timed under the bounded route pattern, not the raw path
Scenario: A successful login is counted
Given a user completes the OIDC callback and a session is established
Then the login counter is incremented
Scenario: The metrics endpoint is not on the public app port
Given the service is running
When the public app mux is inspected
Then it exposes no /metrics route metrics are served on the dedicated metrics port only
+29
View File
@@ -0,0 +1,29 @@
Feature: Visual refresh — one charm-reader layout, light + dark themes (ADR-032, #17)
As a reader who likes a TUI/charm aesthetic
I want a fresh look with a light and a dark theme
So that the app feels distinctive and reads well in either mode
# Direction B (light) + C (dark) are one layout in two palettes (CSS variables),
# plus a persisted toggle and an OS-preference default. Colours are reviewed via
# the mockups in docs/sketches/, not unit-tested; these scenarios cover the
# testable structure. Each maps to a Go test in scenario_coverage_test.go.
Scenario: Light and dark themes share one layout via CSS variables
Given the app stylesheet
When it is rendered
Then it defines a light palette on the root and a dark palette under data-theme="dark", with no duplicate markup
Scenario: Without a stored choice the theme follows the OS preference
Given a visitor with no saved theme
When the page loads
Then a prefers-color-scheme dark block applies the dark palette automatically
Scenario: A persisted toggle switches light and dark
Given any page
When it is rendered
Then it includes a theme-toggle control and a small script that flips data-theme and persists the choice
Scenario: The expanded card embeds the video player
Given a summarized video with a valid provider id
When its card is expanded
Then the expanded card includes the embedded video player
+12 -2
View File
@@ -1,4 +1,4 @@
module gitea.d-ma.be/mathias/tapir
module git.d-ma.be/mathias/tapir
go 1.26.1
@@ -9,23 +9,33 @@ require (
github.com/go-jose/go-jose/v4 v4.1.4
github.com/golang-migrate/migrate/v4 v4.19.1
github.com/jackc/pgx/v5 v5.9.2
github.com/prometheus/client_golang v1.23.2
github.com/prometheus/client_model v0.6.2
github.com/stretchr/testify v1.11.1
golang.org/x/crypto v0.45.0
golang.org/x/oauth2 v0.36.0
golang.org/x/time v0.15.0
)
require (
github.com/beorn7/perks v1.0.1 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
github.com/jackc/pgerrcode v0.0.0-20220416144525-469b46aa5efa // indirect
github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
github.com/jackc/puddle/v2 v2.2.2 // indirect
github.com/kylelemons/godebug v1.1.0 // indirect
github.com/lib/pq v1.10.9 // indirect
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
github.com/prometheus/common v0.66.1 // indirect
github.com/prometheus/procfs v0.16.1 // indirect
github.com/rogpeppe/go-internal v1.15.0 // indirect
github.com/xi2/xz v0.0.0-20171230120015-48954b6210f8 // indirect
go.yaml.in/yaml/v2 v2.4.2 // indirect
golang.org/x/sync v0.18.0 // indirect
golang.org/x/sys v0.41.0 // indirect
golang.org/x/text v0.31.0 // indirect
google.golang.org/protobuf v1.36.8 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)
+26 -6
View File
@@ -4,6 +4,10 @@ github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERo
github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU=
github.com/a-h/templ v0.3.1020 h1:ypAT/L5ySWEnZ6Zft/5yfoWXYYkhFNvEFOeeqecg4tw=
github.com/a-h/templ v0.3.1020/go.mod h1:A2DlK61v+K+NRoGnhmYbNYVmtYHcFO5/AisMvBdDxTM=
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/containerd/errdefs v1.0.0 h1:tg5yIfIlQIrxYtu9ajqY42W3lpS19XqdxRQeEwYG8PI=
github.com/containerd/errdefs v1.0.0/go.mod h1:+YBYIdtsnF4Iw6nWZhJcqGSg/dwvV7tyJ/kCkyJ2k+M=
github.com/containerd/errdefs/pkg v0.3.0 h1:9IKJ06FvyNlexW690DXuQNx2KA2cUJXx151Xdx3ZPPE=
@@ -37,8 +41,8 @@ github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q=
github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q=
github.com/golang-migrate/migrate/v4 v4.19.1 h1:OCyb44lFuQfYXYLx1SCxPZQGU7mcaZ7gH9yH4jSFbBA=
github.com/golang-migrate/migrate/v4 v4.19.1/go.mod h1:CTcgfjxhaUtsLipnLoQRWCrjYXycRz/g5+RWDuYgPrE=
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/jackc/pgerrcode v0.0.0-20220416144525-469b46aa5efa h1:s+4MhCQ6YrzisK6hFJUX53drDT4UsSW3DEhKn0ifuHw=
github.com/jackc/pgerrcode v0.0.0-20220416144525-469b46aa5efa/go.mod h1:a/s9Lp5W7n/DD0VrVoyJ00FbP2ytTPDVOivvn2bMlds=
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
@@ -49,10 +53,14 @@ github.com/jackc/pgx/v5 v5.9.2 h1:3ZhOzMWnR4yJ+RW1XImIPsD1aNSz4T4fyP7zlQb56hw=
github.com/jackc/pgx/v5 v5.9.2/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4=
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
github.com/kr/pretty v0.3.0 h1:WgNl7dwNpEZ6jJ9k1snq4pZsg7DOEN8hP9Xw0Tsjwk0=
github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk=
github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo=
github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw=
github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0=
@@ -61,6 +69,8 @@ github.com/moby/term v0.5.0 h1:xt8Q1nalod/v7BqbG21f8mQPqH+xAaC9C3N3wfWbVP0=
github.com/moby/term v0.5.0/go.mod h1:8FzsFHVUBGZdbDsJw/ot+X+d5HLUbvklYLJ9uGfcI3Y=
github.com/morikuni/aec v1.0.0 h1:nP9CBfwrvYnBRgY6qfDQkygYDmYwOilePFkwzv4dU8A=
github.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc=
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U=
github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM=
github.com/opencontainers/image-spec v1.1.0 h1:8SG7/vwALn54lVB/0yZ/MMwhFrPYtpEHQb2IpWsCzug=
@@ -70,6 +80,14 @@ github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINE
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h0RJWRi/o0o=
github.com/prometheus/client_golang v1.23.2/go.mod h1:Tb1a6LWHB3/SPIzCoaDXI4I8UHKeFTEQ1YCr+0Gyqmg=
github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk=
github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE=
github.com/prometheus/common v0.66.1 h1:h5E0h5/Y8niHc5DlaLlWLArTQI7tMrsfQjHV+d9ZoGs=
github.com/prometheus/common v0.66.1/go.mod h1:gcaUsgf3KfRSwHY4dIMXLPV0K/Wg1oZ8+SbZk/HH/dA=
github.com/prometheus/procfs v0.16.1 h1:hZ15bTNuirocR6u0JZ6BAHHmwS1p8B4P6MRqxtzMyRg=
github.com/prometheus/procfs v0.16.1/go.mod h1:teAbpZRB1iIAJYREa1LsoWUXykVXA1KlTmWl8x/U+Is=
github.com/rogpeppe/go-internal v1.15.0 h1:D0RCU5rMAp+SpgkiNdrjfJ+LX4J1M32V2NeCY7EJ6hc=
github.com/rogpeppe/go-internal v1.15.0/go.mod h1:DrUVZyrJU+txYW5/1kwtXQSMFio52ZOxX7yM1VHvnxs=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
@@ -91,8 +109,8 @@ go.opentelemetry.io/otel/trace v1.37.0 h1:HLdcFNbRQBE2imdSEgm/kwqmQj1Or1l/7bW6mx
go.opentelemetry.io/otel/trace v1.37.0/go.mod h1:TlgrlQ+PtQO5XFerSPUYG0JSgGyryXewPGyayAWSBS0=
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
golang.org/x/crypto v0.45.0 h1:jMBrvKuj23MTlT0bQEOBcAE0mjg8mK9RXFhRH6nyF3Q=
golang.org/x/crypto v0.45.0/go.mod h1:XTGrrkGJve7CYK7J8PEww4aY7gM3qMCElcJQ8n8JdX4=
go.yaml.in/yaml/v2 v2.4.2 h1:DzmwEr2rDGHl7lsFgAHxmNz/1NlQ7xLIrlN2h5d1eGI=
go.yaml.in/yaml/v2 v2.4.2/go.mod h1:081UH+NErpNdqlCXm3TtEran0rJZGxAYx9hb/ELlsPU=
golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs=
golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q=
golang.org/x/sync v0.18.0 h1:kr88TuHDroi+UVf+0hZnirlk8o8T+4MrK6mr60WkH/I=
@@ -103,6 +121,8 @@ golang.org/x/text v0.31.0 h1:aC8ghyu4JhP8VojJ2lEHBnochRno1sgL6nEi9WGFGMM=
golang.org/x/text v0.31.0/go.mod h1:tKRAlv61yKIjGGHX/4tP1LTbc13YSec1pxVEWXzfoeM=
golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U=
golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno=
google.golang.org/protobuf v1.36.8 h1:xHScyCOEuuwZEc6UtSOvPbAT4zRh0xcNRYekJwfqyMc=
google.golang.org/protobuf v1.36.8/go.mod h1:fuxRtAxBytpl4zzqUh6/eyUujkJdNiuEkXntxiD/uRU=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
+8
View File
@@ -13,8 +13,12 @@ package chat
import (
"context"
"fmt"
"log/slog"
"strings"
"time"
"unicode/utf8"
"git.d-ma.be/mathias/tapir/internal/metrics"
)
// Completer is the minimal LLM chat surface the Service needs. *llm.Client
@@ -113,10 +117,14 @@ func (s *Service) Answer(ctx context.Context, req Request) (Reply, error) {
system := buildSystem(transcript, truncated)
user := buildUser(req.History, req.Question)
start := time.Now()
out, err := s.newClient(model).Complete(ctx, system, user)
if err != nil {
return Reply{}, fmt.Errorf("chat: %s: %w", model, err)
}
dur := time.Since(start)
metrics.ObserveChat(model, dur)
slog.Default().Info("chat answer", "model", model, "elapsed_ms", dur.Milliseconds())
answer := strings.TrimSpace(out)
if answer == "" {
return Reply{}, fmt.Errorf("chat: %s returned an empty answer", model)
+16
View File
@@ -32,6 +32,7 @@ type Client struct {
model string
maxTokens int
httpClient *http.Client
usageHook func(model string, prompt, completion int)
}
// Option configures a Client at construction. Variadic so the existing 4-arg
@@ -50,6 +51,14 @@ func WithMaxTokens(n int) Option {
}
}
// WithUsageHook registers a callback fired after a successful completion with the
// model and the prompt/completion token counts from the response usage block. It
// keeps this copied, stdlib-only package (ADR-004) decoupled from metrics: the
// caller wires it to internal/metrics, the client imports nothing. nil is ignored.
func WithUsageHook(fn func(model string, prompt, completion int)) Option {
return func(c *Client) { c.usageHook = fn }
}
// New constructs a Client.
func New(baseURL, apiKey, model string, timeout time.Duration, opts ...Option) *Client {
c := &Client{
@@ -81,6 +90,10 @@ type chatResponse struct {
Choices []struct {
Message message `json:"message"`
} `json:"choices"`
Usage struct {
PromptTokens int `json:"prompt_tokens"`
CompletionTokens int `json:"completion_tokens"`
} `json:"usage"`
}
// Complete sends a system + user message and returns the assistant's reply.
@@ -152,5 +165,8 @@ func (c *Client) Complete(ctx context.Context, system, user string) (string, err
if len(cr.Choices) == 0 {
return "", fmt.Errorf("LLM returned no choices")
}
if c.usageHook != nil {
c.usageHook(c.model, cr.Usage.PromptTokens, cr.Usage.CompletionTokens)
}
return cr.Choices[0].Message.Content, nil
}
+24
View File
@@ -85,6 +85,30 @@ func TestClient_WithMaxTokens(t *testing.T) {
}
}
// TestClient_UsageHookRecordsTokens: the usage hook fires with the model and the
// prompt/completion token counts parsed from the response usage block.
func TestClient_UsageHookRecordsTokens(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
_ = json.NewEncoder(w).Encode(map[string]any{
"choices": []map[string]any{{"message": map[string]any{"content": "ok"}}},
"usage": map[string]any{"prompt_tokens": 123, "completion_tokens": 45},
})
}))
defer srv.Close()
var gotModel string
var gotPrompt, gotCompletion int
c := New(srv.URL, "", "test-model", 10*time.Second, WithUsageHook(func(model string, p, comp int) {
gotModel, gotPrompt, gotCompletion = model, p, comp
}))
if _, err := c.Complete(context.Background(), "sys", "user"); err != nil {
t.Fatalf("Complete: %v", err)
}
if gotModel != "test-model" || gotPrompt != 123 || gotCompletion != 45 {
t.Errorf("usage hook got (%q, %d, %d), want (test-model, 123, 45)", gotModel, gotPrompt, gotCompletion)
}
}
func TestClient_ReturnsErrorOnNon200(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
http.Error(w, "overloaded", http.StatusServiceUnavailable)
+1 -1
View File
@@ -18,7 +18,7 @@ import (
"path/filepath"
"sync"
"gitea.d-ma.be/mathias/tapir/internal/ports"
"git.d-ma.be/mathias/tapir/internal/ports"
)
// FileStore is a SecretStore backed by a single 0600 JSON file mapping opaque
+1 -1
View File
@@ -7,7 +7,7 @@ import (
"path/filepath"
"testing"
"gitea.d-ma.be/mathias/tapir/internal/adapters/secrets"
"git.d-ma.be/mathias/tapir/internal/adapters/secrets"
)
func TestPutThenGet(t *testing.T) {
+1 -1
View File
@@ -6,7 +6,7 @@ import (
"github.com/stretchr/testify/require"
"gitea.d-ma.be/mathias/tapir/internal/adapters/store"
"git.d-ma.be/mathias/tapir/internal/adapters/store"
)
// seedUserRow inserts a bare users row (FK target for a connection) as the
+1 -1
View File
@@ -7,7 +7,7 @@ import (
"github.com/stretchr/testify/require"
"gitea.d-ma.be/mathias/tapir/internal/adapters/store"
"git.d-ma.be/mathias/tapir/internal/adapters/store"
)
const (
+48 -37
View File
@@ -3,6 +3,7 @@ package store_test
import (
"context"
"database/sql"
"errors"
"os"
"testing"
@@ -34,6 +35,30 @@ func fileMigrator(t *testing.T) *migrate.Migrate {
return m
}
// headVersion reports the current (HEAD) schema version so a test can restore
// to it after stepping down, without hard-coding what HEAD is. Adding a
// migration on top changes HEAD but no test that uses this needs editing.
func headVersion(t *testing.T, m *migrate.Migrate) uint {
t.Helper()
v, dirty, err := m.Version()
require.NoError(t, err)
require.False(t, dirty, "schema must not be dirty")
return v
}
// migrateTo drives the schema to an exact version *by version number*, not by
// step count. This is the whole point of the migrate-test design: a migration
// added above the target does not shift any count here, so unrelated tests stay
// green (see issue #8). ErrNoChange (already at that version) is not a failure.
func migrateTo(t *testing.T, m *migrate.Migrate, version uint) {
t.Helper()
err := m.Migrate(version)
if errors.Is(err, migrate.ErrNoChange) {
return
}
require.NoError(t, err)
}
// loginEventsExists reports whether the login_events relation is present.
func loginEventsExists(t *testing.T) bool {
t.Helper()
@@ -53,25 +78,15 @@ func TestMigration010LoginEventsUpDown(t *testing.T) {
require.True(t, loginEventsExists(t), "login_events must exist at latest migration")
m := fileMigrator(t)
// 011..016 sit above 010; step them down first so 010 is exercised in isolation.
require.NoError(t, m.Steps(-1), "down 016 drops channel_caption_state, login_events intact")
require.True(t, loginEventsExists(t), "016 down leaves login_events intact")
require.NoError(t, m.Steps(-1), "down 015 reshapes transcripts, login_events intact")
require.True(t, loginEventsExists(t), "015 down leaves login_events intact")
require.NoError(t, m.Steps(-1), "down 014 drops channel_title, login_events intact")
require.True(t, loginEventsExists(t), "014 down leaves login_events intact")
require.NoError(t, m.Steps(-1), "down 013 drops channel_errors, login_events intact")
require.True(t, loginEventsExists(t), "013 down leaves login_events intact")
require.NoError(t, m.Steps(-1), "down 012 is a no-op, login_events intact")
require.True(t, loginEventsExists(t), "012 down leaves login_events intact")
require.NoError(t, m.Steps(-1), "down 011 must not touch login_events")
require.True(t, loginEventsExists(t), "011 down leaves login_events intact")
head := headVersion(t, m)
require.NoError(t, m.Steps(-1), "down 010 must drop login_events")
migrateTo(t, m, 9) // just below 010 — everything above steps down
require.False(t, loginEventsExists(t), "login_events must be gone after the down migration")
require.NoError(t, m.Steps(7), "up must recreate 010 then re-apply 011..016")
migrateTo(t, m, 10) // up 010
require.True(t, loginEventsExists(t), "login_events must be restored after the up migration")
migrateTo(t, m, head) // restore to HEAD for sibling tests
}
// autoSummarizeDefault reads the users.auto_summarize column default as text
@@ -93,21 +108,15 @@ func TestMigration011AutoSummarizeDefaultUpDown(t *testing.T) {
require.Equal(t, "true", autoSummarizeDefault(t), "011 sets the default to TRUE")
m := fileMigrator(t)
require.NoError(t, m.Steps(-1), "down 016 drops channel_caption_state")
require.NoError(t, m.Steps(-1), "down 015 reshapes transcripts")
require.NoError(t, m.Steps(-1), "down 014 drops channel_title")
require.NoError(t, m.Steps(-1), "down 013 drops channel_errors")
require.NoError(t, m.Steps(-1), "down 012 is a no-op")
require.NoError(t, m.Steps(-1), "down 011 reverts the column default")
head := headVersion(t, m)
migrateTo(t, m, 10) // just below 011 — reverts the column default
require.Equal(t, "false", autoSummarizeDefault(t), "default is FALSE after the down migration")
require.NoError(t, m.Steps(1), "up 011 re-applies the TRUE default")
migrateTo(t, m, 11) // up 011 re-applies the TRUE default
require.Equal(t, "true", autoSummarizeDefault(t))
require.NoError(t, m.Steps(1), "up 012 runs clean (no FORCE RLS on fresh schema)")
require.NoError(t, m.Steps(1), "up 013 creates channel_errors")
require.NoError(t, m.Steps(1), "up 014 recreates channel_title")
require.NoError(t, m.Steps(1), "up 015 reshapes transcripts to shared")
require.NoError(t, m.Steps(1), "up 016 recreates channel_caption_state (HEAD)")
migrateTo(t, m, head) // restore to HEAD for sibling tests
}
// channelTitleExists reports whether videos.channel_title is present.
@@ -127,17 +136,15 @@ func TestMigration014VideoChannelTitleUpDown(t *testing.T) {
require.True(t, channelTitleExists(t), "channel_title exists at latest migration")
m := fileMigrator(t)
require.NoError(t, m.Steps(-1), "down 016 drops channel_caption_state, channel_title intact")
require.True(t, channelTitleExists(t), "016 down leaves channel_title intact")
require.NoError(t, m.Steps(-1), "down 015 reshapes transcripts, channel_title intact")
require.True(t, channelTitleExists(t), "015 down leaves channel_title intact")
require.NoError(t, m.Steps(-1), "down 014 must drop channel_title")
head := headVersion(t, m)
migrateTo(t, m, 13) // just below 014 — drops channel_title
require.False(t, channelTitleExists(t), "channel_title must be gone after the down migration")
require.NoError(t, m.Steps(1), "up 014 must recreate channel_title")
migrateTo(t, m, 14) // up 014 recreates channel_title
require.True(t, channelTitleExists(t), "channel_title must be restored after the up migration")
require.NoError(t, m.Steps(1), "up 015 restores the shared transcripts shape")
require.NoError(t, m.Steps(1), "up 016 recreates channel_caption_state (HEAD)")
migrateTo(t, m, head) // restore to HEAD for sibling tests
}
// TestMigration012FixAutoSummarizeRLS proves 012 runs cleanly and flips any
@@ -148,7 +155,11 @@ func TestMigration012FixAutoSummarizeRLS(t *testing.T) {
// Round-trip: down 012, then up 012 — must be idempotent.
m := fileMigrator(t)
require.NoError(t, m.Steps(-1), "down 012 must not error")
require.NoError(t, m.Steps(1), "up 012 must re-apply cleanly")
head := headVersion(t, m)
migrateTo(t, m, 11) // down 012 must not error
migrateTo(t, m, 12) // up 012 must re-apply cleanly
require.Equal(t, "true", autoSummarizeDefault(t), "default still TRUE after 012 re-applied")
migrateTo(t, m, head) // restore to HEAD for sibling tests
}
+1 -1
View File
@@ -8,7 +8,7 @@ import (
"github.com/jackc/pgx/v5/pgxpool"
"github.com/stretchr/testify/require"
"gitea.d-ma.be/mathias/tapir/internal/adapters/store"
"git.d-ma.be/mathias/tapir/internal/adapters/store"
)
// seedVideo inserts a videos row whose id matches a summary's video_id, so the
+12 -6
View File
@@ -4,6 +4,7 @@ import (
"context"
"fmt"
"sort"
"time"
"github.com/jackc/pgx/v5"
)
@@ -32,7 +33,12 @@ type UserActiveWeeks struct {
// Scope note: the enumeration covers users with a Dex identity (the web users the
// gate is about). A CLI-only user created by the store sink without an identity
// row would not appear — out of scope for this gate.
func (s *Store) ActiveWeeks(ctx context.Context) ([]UserActiveWeeks, error) {
// ActiveWeeks counts each user's distinct active weeks from `since` onward. A zero
// `since` means no lower bound (count all history). The Stage-0 gate baseline is
// set by the caller (the report command) to the date real usage tracking began,
// so pre-launch noise — testing, the period the pilot was blocked — does not count
// toward the return-usage signal (ADR-016).
func (s *Store) ActiveWeeks(ctx context.Context, since time.Time) ([]UserActiveWeeks, error) {
userIDs, err := s.identityUserIDs(ctx)
if err != nil {
return nil, err
@@ -40,7 +46,7 @@ func (s *Store) ActiveWeeks(ctx context.Context) ([]UserActiveWeeks, error) {
out := make([]UserActiveWeeks, 0, len(userIDs))
for _, uid := range userIDs {
row, err := s.activeWeeksFor(ctx, uid)
row, err := s.activeWeeksFor(ctx, uid, since)
if err != nil {
return nil, err
}
@@ -85,18 +91,18 @@ func (s *Store) identityUserIDs(ctx context.Context) ([]string, error) {
// activeWeeksFor counts one user's distinct active weeks (reads UNION acts) and
// reads their display name, RLS-scoped via withUser. The UNION dedups a week that
// has both a login and an action so it counts once.
func (s *Store) activeWeeksFor(ctx context.Context, userID string) (UserActiveWeeks, error) {
func (s *Store) activeWeeksFor(ctx context.Context, userID string, since time.Time) (UserActiveWeeks, error) {
res := UserActiveWeeks{UserID: userID}
if err := s.withUser(ctx, userID, func(tx pgx.Tx) error {
if err := tx.QueryRow(ctx,
`WITH weeks AS (
SELECT date_trunc('week', seen_at) AS wk
FROM login_events WHERE user_id = $1
FROM login_events WHERE user_id = $1 AND seen_at >= $2
UNION
SELECT date_trunc('week', acted_at)
FROM summary_actions WHERE user_id = $1
FROM summary_actions WHERE user_id = $1 AND acted_at >= $2
)
SELECT count(DISTINCT wk) FROM weeks`, userID).Scan(&res.ActiveWeeks); err != nil {
SELECT count(DISTINCT wk) FROM weeks`, userID, since).Scan(&res.ActiveWeeks); err != nil {
return fmt.Errorf("store: count active weeks: %w", err)
}
if err := tx.QueryRow(ctx,
+29 -2
View File
@@ -3,6 +3,7 @@ package store_test
import (
"context"
"testing"
"time"
"github.com/jackc/pgx/v5/pgxpool"
"github.com/stretchr/testify/require"
@@ -54,7 +55,7 @@ func TestActiveWeeksCountsDistinctWeeksAcrossReadsAndActs(t *testing.T) {
($1, 'vid-2', 'saved', '2026-01-19T18:00:00Z')`, userA)
require.NoError(t, err)
got, err := s.ActiveWeeks(ctx)
got, err := s.ActiveWeeks(ctx, time.Time{}) // zero since = no lower bound
require.NoError(t, err)
require.Len(t, got, 2, "both identity users must appear")
@@ -72,7 +73,33 @@ func TestActiveWeeksEmptyWhenNoUsers(t *testing.T) {
s := newStore(t)
resetDB(t, rawPool(t))
got, err := s.ActiveWeeks(ctx)
got, err := s.ActiveWeeks(ctx, time.Time{})
require.NoError(t, err)
require.Empty(t, got)
}
// TestActiveWeeksExcludesBeforeGateStart proves the baseline cutoff: activity
// before `since` does not count, so pre-launch noise (testing, the pilot's blocked
// period) is excluded from the Stage-0 return-usage gate (ADR-016).
func TestActiveWeeksExcludesBeforeGateStart(t *testing.T) {
ctx := context.Background()
s := newStore(t)
p := rawPool(t)
resetDB(t, p)
seedReportUser(t, p, userA, "subject-a", "Ada")
// One read well before the baseline, two reads in distinct weeks after it.
_, err := p.Exec(ctx,
`INSERT INTO login_events (user_id, seen_at) VALUES
($1, '2026-05-01T09:00:00Z'),
($1, '2026-06-12T09:00:00Z'),
($1, '2026-06-19T09:00:00Z')`, userA)
require.NoError(t, err)
since := time.Date(2026, 6, 11, 0, 0, 0, 0, time.UTC)
got, err := s.ActiveWeeks(ctx, since)
require.NoError(t, err)
require.Len(t, got, 1)
require.Equal(t, 2, got[0].ActiveWeeks, "only the two post-baseline weeks count; the May read is excluded")
}
+1 -1
View File
@@ -24,7 +24,7 @@ import (
_ "github.com/jackc/pgx/v5/stdlib" // register the "pgx" database/sql driver for migrate
"gitea.d-ma.be/mathias/tapir/internal/domain"
"git.d-ma.be/mathias/tapir/internal/domain"
)
//go:embed migrations/*.sql
+3 -3
View File
@@ -11,9 +11,9 @@ import (
"github.com/jackc/pgx/v5/pgxpool"
"github.com/stretchr/testify/require"
"gitea.d-ma.be/mathias/tapir/internal/adapters/store"
"gitea.d-ma.be/mathias/tapir/internal/domain"
"gitea.d-ma.be/mathias/tapir/internal/ports"
"git.d-ma.be/mathias/tapir/internal/adapters/store"
"git.d-ma.be/mathias/tapir/internal/domain"
"git.d-ma.be/mathias/tapir/internal/ports"
)
// Static check: Store satisfies the Sink port.
@@ -8,7 +8,7 @@ import (
"github.com/jackc/pgx/v5/pgxpool"
"github.com/stretchr/testify/require"
"gitea.d-ma.be/mathias/tapir/internal/adapters/store"
"git.d-ma.be/mathias/tapir/internal/adapters/store"
)
// seedBareVideo inserts a videos row with no summary, so the all-videos read and
+1 -1
View File
@@ -7,7 +7,7 @@ import (
"github.com/jackc/pgx/v5"
"gitea.d-ma.be/mathias/tapir/internal/domain"
"git.d-ma.be/mathias/tapir/internal/domain"
)
// GetTranscript returns the shared, stored transcript for a video keyed by the
@@ -6,7 +6,7 @@ import (
"github.com/stretchr/testify/require"
"gitea.d-ma.be/mathias/tapir/internal/adapters/store"
"git.d-ma.be/mathias/tapir/internal/adapters/store"
)
func TestSetTranscriptStatus_RoundTrip(t *testing.T) {
+3 -3
View File
@@ -6,9 +6,9 @@ import (
"github.com/stretchr/testify/require"
"gitea.d-ma.be/mathias/tapir/internal/adapters/store"
"gitea.d-ma.be/mathias/tapir/internal/domain"
"gitea.d-ma.be/mathias/tapir/internal/ports"
"git.d-ma.be/mathias/tapir/internal/adapters/store"
"git.d-ma.be/mathias/tapir/internal/domain"
"git.d-ma.be/mathias/tapir/internal/ports"
)
// Static check: Store satisfies the shared TranscriptStore port (ADR-021).
+1 -1
View File
@@ -7,7 +7,7 @@ import (
"github.com/jackc/pgx/v5"
"gitea.d-ma.be/mathias/tapir/internal/domain"
"git.d-ma.be/mathias/tapir/internal/domain"
)
// UpsertVideo persists a video's metadata and returns its durable store id (the
+1 -1
View File
@@ -7,7 +7,7 @@ import (
"github.com/stretchr/testify/require"
"gitea.d-ma.be/mathias/tapir/internal/domain"
"git.d-ma.be/mathias/tapir/internal/domain"
)
func ytVideo(userID, provVideoID, title string) domain.Video {
+11 -2
View File
@@ -13,11 +13,13 @@ import (
"encoding/json"
"errors"
"fmt"
"log/slog"
"strings"
"time"
"unicode/utf8"
"gitea.d-ma.be/mathias/tapir/internal/domain"
"git.d-ma.be/mathias/tapir/internal/domain"
"git.d-ma.be/mathias/tapir/internal/metrics"
)
// Completer is the minimal LLM chat surface the Summarizer needs.
@@ -95,16 +97,23 @@ func (s *Summarizer) Summarize(ctx context.Context, v domain.Video, t domain.Tra
var errs []error
for i, ep := range s.endpoints {
fallback := i > 0
start := time.Now()
out, err := ep.Client.Complete(ctx, systemPrompt, user)
dur := time.Since(start)
if err != nil {
metrics.ObserveSummarize(ep.Model, "error", fallback, dur)
errs = append(errs, fmt.Errorf("%s/%s call: %w", ep.Provider, ep.Model, err))
continue
}
sum, perr := s.build(v, ep, i > 0, out)
sum, perr := s.build(v, ep, fallback, out)
if perr != nil {
metrics.ObserveSummarize(ep.Model, "parse_error", fallback, dur)
errs = append(errs, fmt.Errorf("%s/%s output: %w", ep.Provider, ep.Model, perr))
continue
}
metrics.ObserveSummarize(ep.Model, "success", fallback, dur)
slog.Default().Info("summarized", "model", ep.Model, "fallback", fallback, "elapsed_ms", dur.Milliseconds())
return sum, nil
}
return domain.Summary{}, fmt.Errorf("summarize: all %d endpoint(s) failed: %w", len(s.endpoints), errors.Join(errs...))
@@ -7,13 +7,36 @@ package summarizer
import (
"context"
"errors"
"net/http"
"net/http/httptest"
"strings"
"testing"
"gitea.d-ma.be/mathias/tapir/internal/domain"
"gitea.d-ma.be/mathias/tapir/internal/ports"
"git.d-ma.be/mathias/tapir/internal/domain"
"git.d-ma.be/mathias/tapir/internal/metrics"
"git.d-ma.be/mathias/tapir/internal/ports"
)
// TestSummarizerRecordsMetric verifies the summarizer→metrics wiring (ADR-030)
// black-box: after a successful summarize, the public /metrics scrape shows a
// success observation for that endpoint's model.
func TestSummarizerRecordsMetric(t *testing.T) {
const model = "metrics-test-model"
s := New(Endpoint{Client: &fakeClient{reply: goodReply}, Provider: "local", Model: model}, nil)
if _, err := s.Summarize(context.Background(), testVideo(), testTranscript()); err != nil {
t.Fatalf("Summarize: %v", err)
}
rec := httptest.NewRecorder()
metrics.Handler().ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/metrics", nil))
body := rec.Body.String()
if !strings.Contains(body, `tapir_summarize_duration_seconds`) ||
!strings.Contains(body, `model="`+model+`"`) ||
!strings.Contains(body, `outcome="success"`) {
t.Errorf("metrics scrape missing summarize success for %s", model)
}
}
// compile-time check: Summarizer satisfies the port.
var _ ports.Summarizer = (*Summarizer)(nil)
+32 -1
View File
@@ -7,10 +7,13 @@ import (
"encoding/xml"
"fmt"
"io"
"log/slog"
"net/http"
"strings"
"time"
"gitea.d-ma.be/mathias/tapir/internal/domain"
"git.d-ma.be/mathias/tapir/internal/domain"
"git.d-ma.be/mathias/tapir/internal/metrics"
)
// defaultPlayerBaseURL is the InnerTube / watch-page host. Overridable via
@@ -43,7 +46,35 @@ const maxCaptionBytes = 16 << 20 // 16 MiB
// fetch, or an unparseable body all yield SourceNone rather than an error. Only
// genuine transport (network) faults return an error. Audio download and
// speech-to-text remain absent (ADR-007).
// FetchTranscript times the caption fetch and records its latency by outcome
// (ADR-030) before returning. Transport errors are surfaced to the caller and not
// recorded as an outcome (logged upstream); the three resolved outcomes
// captions|none|rate_limited are the ones that consume the scarce fetch budget.
func (a *Adapter) FetchTranscript(ctx context.Context, v domain.Video) (domain.Transcript, error) {
start := time.Now()
tr, err := a.fetchTranscript(ctx, v)
if err == nil {
dur := time.Since(start)
outcome := captionOutcome(tr.Source)
metrics.ObserveCaptionFetch(outcome, dur)
slog.Default().Info("caption fetch", "video", v.ProviderVideoID, "outcome", outcome, "elapsed_ms", dur.Milliseconds())
}
return tr, err
}
// captionOutcome maps a transcript source to the metric outcome label.
func captionOutcome(s domain.TranscriptSource) string {
switch s {
case domain.SourceCaptions:
return "captions"
case domain.SourceRateLimited:
return "rate_limited"
default:
return "none"
}
}
func (a *Adapter) fetchTranscript(ctx context.Context, v domain.Video) (domain.Transcript, error) {
client := a.plainClient()
tracks, err := a.captionTracks(ctx, client, v.ProviderVideoID)
+1 -1
View File
@@ -6,7 +6,7 @@ import (
"net/http"
"testing"
"gitea.d-ma.be/mathias/tapir/internal/domain"
"git.d-ma.be/mathias/tapir/internal/domain"
)
func TestVideoByID(t *testing.T) {
+2 -2
View File
@@ -27,8 +27,8 @@ import (
"golang.org/x/oauth2"
"gitea.d-ma.be/mathias/tapir/internal/domain"
"gitea.d-ma.be/mathias/tapir/internal/ports"
"git.d-ma.be/mathias/tapir/internal/domain"
"git.d-ma.be/mathias/tapir/internal/ports"
)
// defaultBaseURL is the YouTube Data API v3 root. Overridable via Config.BaseURL
+1 -1
View File
@@ -7,7 +7,7 @@ import (
"net/http/httptest"
"testing"
"gitea.d-ma.be/mathias/tapir/internal/domain"
"git.d-ma.be/mathias/tapir/internal/domain"
)
// --- fakes ------------------------------------------------------------------
+1 -1
View File
@@ -12,7 +12,7 @@ import (
"golang.org/x/oauth2"
"gitea.d-ma.be/mathias/tapir/internal/auth"
"git.d-ma.be/mathias/tapir/internal/auth"
)
// fakeWriter is a TokenWriter capturing the persisted (ref, value).
+7
View File
@@ -143,6 +143,11 @@ type Config struct {
// HTTPAddr is the listen address for `tapir serve` (the Stage-0 web UI).
HTTPAddr string
// MetricsAddr is the listen address for the Prometheus /metrics endpoint
// (ADR-030). A SEPARATE port from HTTPAddr so /metrics is never exposed on the
// public app — only scraped in-cluster. Empty disables the metrics server.
MetricsAddr string
// PublicURL is the externally-reachable base URL of the deployed service,
// e.g. "https://tapir.d-ma.be". Used to build absolute links handed to humans
// (the `tapir invite` URL). No trailing slash is assumed — callers trim it.
@@ -178,6 +183,7 @@ const (
defaultYTConnectRedirectURL = "https://tapir.d-ma.be/oauth/youtube/callback"
defaultOAuthRedirectAddr = "localhost:8080"
defaultHTTPAddr = ":8080"
defaultMetricsAddr = ":9090"
defaultFetchBackoff = time.Hour
defaultFetchRate = 2 * time.Second
defaultPublicURL = "https://tapir.d-ma.be"
@@ -209,6 +215,7 @@ func Load() (Config, error) {
SecretsFile: envOr("TAPIR_SECRETS_FILE", defaultSecretsFile()),
OAuthRedirectAddr: envOr("TAPIR_OAUTH_REDIRECT_ADDR", defaultOAuthRedirectAddr),
HTTPAddr: envOr("TAPIR_HTTP_ADDR", defaultHTTPAddr),
MetricsAddr: lookupOr("TAPIR_METRICS_ADDR", defaultMetricsAddr),
PublicURL: envOr("TAPIR_PUBLIC_URL", defaultPublicURL),
OIDCIssuer: os.Getenv("TAPIR_OIDC_ISSUER"),
DexClientID: os.Getenv("TAPIR_DEX_CLIENT_ID"),
+139
View File
@@ -0,0 +1,139 @@
// Package metrics is Tapir's Prometheus instrumentation (ADR-030, issue #15). It
// owns the collectors and a small typed API the rest of the app calls — adapters
// never touch prometheus types directly. Two themes:
//
// - HTTP/session: request count + latency by route (the matched pattern, so
// cardinality stays bounded), and logins.
// - AI (the priority): summarization latency by model/outcome/fallback, caption
// fetch latency by outcome, chat latency by model, and LLM token usage.
//
// Handler() is served on a dedicated port (never the public app port) so a scrape
// is in-cluster only. slog timing lines are emitted at the call sites too.
package metrics
import (
"net/http"
"strconv"
"time"
"github.com/prometheus/client_golang/prometheus"
"github.com/prometheus/client_golang/prometheus/promauto"
"github.com/prometheus/client_golang/prometheus/promhttp"
)
// latencyBuckets spans sub-second UI calls up to multi-minute model calls (a cold
// local model load is tens of seconds; the cloud fallback can be longer).
var latencyBuckets = []float64{0.05, 0.1, 0.25, 0.5, 1, 2, 5, 10, 20, 30, 60, 120, 300}
var (
httpRequests = promauto.NewCounterVec(prometheus.CounterOpts{
Name: "tapir_http_requests_total",
Help: "HTTP requests by method, matched route pattern, and status code.",
}, []string{"method", "route", "code"})
httpDuration = promauto.NewHistogramVec(prometheus.HistogramOpts{
Name: "tapir_http_request_duration_seconds",
Help: "HTTP request latency by method and matched route pattern.",
Buckets: []float64{0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1, 2, 5},
}, []string{"method", "route"})
logins = promauto.NewCounter(prometheus.CounterOpts{
Name: "tapir_logins_total",
Help: "Successful OIDC logins (session established).",
})
summarizeDuration = promauto.NewHistogramVec(prometheus.HistogramOpts{
Name: "tapir_summarize_duration_seconds",
Help: "Per-endpoint summarization latency by model, outcome (success|parse_error|error), and whether it was a fallback.",
Buckets: latencyBuckets,
}, []string{"model", "outcome", "fallback"})
captionFetchDuration = promauto.NewHistogramVec(prometheus.HistogramOpts{
Name: "tapir_caption_fetch_duration_seconds",
Help: "Caption fetch latency by outcome (captions|none|rate_limited).",
Buckets: latencyBuckets,
}, []string{"outcome"})
chatDuration = promauto.NewHistogramVec(prometheus.HistogramOpts{
Name: "tapir_chat_duration_seconds",
Help: "Per-video Q&A answer latency by model.",
Buckets: latencyBuckets,
}, []string{"model"})
llmTokens = promauto.NewCounterVec(prometheus.CounterOpts{
Name: "tapir_llm_tokens_total",
Help: "LLM tokens consumed by model and kind (prompt|completion).",
}, []string{"model", "kind"})
)
// Handler serves the Prometheus exposition format. Mount on the dedicated metrics
// port, never the public app mux.
func Handler() http.Handler { return promhttp.Handler() }
// IncLogin records a successful login.
func IncLogin() { logins.Inc() }
// ObserveSummarize records one summarization endpoint attempt.
func ObserveSummarize(model, outcome string, fallback bool, d time.Duration) {
summarizeDuration.WithLabelValues(model, outcome, strconv.FormatBool(fallback)).Observe(d.Seconds())
}
// ObserveCaptionFetch records one caption fetch by outcome.
func ObserveCaptionFetch(outcome string, d time.Duration) {
captionFetchDuration.WithLabelValues(outcome).Observe(d.Seconds())
}
// ObserveChat records one Q&A answer latency.
func ObserveChat(model string, d time.Duration) {
chatDuration.WithLabelValues(model).Observe(d.Seconds())
}
// RecordTokens records LLM token usage from a completion's usage block. Zero
// counts are skipped so a provider that omits usage adds nothing.
func RecordTokens(model string, prompt, completion int) {
if prompt > 0 {
llmTokens.WithLabelValues(model, "prompt").Add(float64(prompt))
}
if completion > 0 {
llmTokens.WithLabelValues(model, "completion").Add(float64(completion))
}
}
// HTTPMiddleware records request count + latency. It reads r.Pattern AFTER the
// inner handler routes (Go 1.22 sets it during ServeMux matching), so the label is
// the bounded registered pattern (e.g. "GET /v/{videoId}"), never the raw path
// with its high-cardinality ids. Unmatched requests bucket as "other".
func HTTPMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
start := time.Now()
sw := &statusWriter{ResponseWriter: w, code: http.StatusOK}
next.ServeHTTP(sw, r)
route := r.Pattern
if route == "" {
route = "other"
}
httpRequests.WithLabelValues(r.Method, route, strconv.Itoa(sw.code)).Inc()
httpDuration.WithLabelValues(r.Method, route).Observe(time.Since(start).Seconds())
})
}
// statusWriter captures the response status for the request-count label.
type statusWriter struct {
http.ResponseWriter
code int
wroteHeader bool
}
func (s *statusWriter) WriteHeader(code int) {
if !s.wroteHeader {
s.code = code
s.wroteHeader = true
}
s.ResponseWriter.WriteHeader(code)
}
func (s *statusWriter) Write(b []byte) (int, error) {
s.wroteHeader = true // an implicit 200
return s.ResponseWriter.Write(b)
}
+92
View File
@@ -0,0 +1,92 @@
package metrics
import (
"net/http"
"net/http/httptest"
"testing"
"time"
"github.com/prometheus/client_golang/prometheus"
"github.com/prometheus/client_golang/prometheus/testutil"
dto "github.com/prometheus/client_model/go"
"github.com/stretchr/testify/require"
)
// histCount reads a histogram child's observation count (testutil.ToFloat64 only
// works on counters/gauges; a histogram's WithLabelValues child is an Observer).
func histCount(t *testing.T, o prometheus.Observer) uint64 {
t.Helper()
m, ok := o.(prometheus.Metric)
require.True(t, ok, "histogram child must be a prometheus.Metric")
var d dto.Metric
require.NoError(t, m.Write(&d))
return d.GetHistogram().GetSampleCount()
}
// TestObserveSummarizeRecordsModelOutcomeFallback: a success observation lands on
// the right model/outcome/fallback series.
func TestObserveSummarizeRecordsModelOutcomeFallback(t *testing.T) {
before := histCount(t, summarizeDuration.WithLabelValues("koala/phi4-mini", "success", "false"))
ObserveSummarize("koala/phi4-mini", "success", false, 1200*time.Millisecond)
after := histCount(t, summarizeDuration.WithLabelValues("koala/phi4-mini", "success", "false"))
require.Equal(t, before+1, after, "one success observation recorded for the model")
}
// TestObserveSummarizeRecordsFailureOutcomes: error and parse_error are distinct
// series so a fallback chain's failures are visible.
func TestObserveSummarizeRecordsFailureOutcomes(t *testing.T) {
e0 := histCount(t, summarizeDuration.WithLabelValues("m", "error", "false"))
p0 := histCount(t, summarizeDuration.WithLabelValues("m", "parse_error", "false"))
ObserveSummarize("m", "error", false, time.Second)
ObserveSummarize("m", "parse_error", false, time.Second)
require.Equal(t, e0+1, histCount(t, summarizeDuration.WithLabelValues("m", "error", "false")))
require.Equal(t, p0+1, histCount(t, summarizeDuration.WithLabelValues("m", "parse_error", "false")))
}
func TestObserveCaptionFetchByOutcome(t *testing.T) {
b := histCount(t, captionFetchDuration.WithLabelValues("captions"))
ObserveCaptionFetch("captions", 3*time.Second)
require.Equal(t, b+1, histCount(t, captionFetchDuration.WithLabelValues("captions")))
}
func TestChatAnswerLatencyRecorded(t *testing.T) {
b := histCount(t, chatDuration.WithLabelValues("iguana/gemma4-26b"))
ObserveChat("iguana/gemma4-26b", 2*time.Second)
require.Equal(t, b+1, histCount(t, chatDuration.WithLabelValues("iguana/gemma4-26b")))
}
// TestRecordTokens: prompt + completion land on their kind series; zero is skipped.
func TestRecordTokens(t *testing.T) {
p0 := testutil.ToFloat64(llmTokens.WithLabelValues("m", "prompt"))
c0 := testutil.ToFloat64(llmTokens.WithLabelValues("m", "completion"))
RecordTokens("m", 100, 40)
RecordTokens("m", 0, 0) // skipped, no panic
require.Equal(t, p0+100, testutil.ToFloat64(llmTokens.WithLabelValues("m", "prompt")))
require.Equal(t, c0+40, testutil.ToFloat64(llmTokens.WithLabelValues("m", "completion")))
}
func TestLoginCounted(t *testing.T) {
b := testutil.ToFloat64(logins)
IncLogin()
require.Equal(t, b+1, testutil.ToFloat64(logins))
}
// TestHTTPMiddlewareRecordsByRoutePattern: the request is counted under the bounded
// registered pattern (r.Pattern after routing), not the raw path with its ids.
func TestHTTPMiddlewareRecordsByRoutePattern(t *testing.T) {
mux := http.NewServeMux()
mux.HandleFunc("GET /v/{videoId}", func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusTeapot)
})
h := HTTPMiddleware(mux)
before := testutil.ToFloat64(httpRequests.WithLabelValues("GET", "GET /v/{videoId}", "418"))
rec := httptest.NewRecorder()
h.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/v/abc-123", nil))
require.Equal(t, http.StatusTeapot, rec.Code)
after := testutil.ToFloat64(httpRequests.WithLabelValues("GET", "GET /v/{videoId}", "418"))
require.Equal(t, before+1, after, "counted under the pattern, not /v/abc-123")
require.Equal(t, float64(0), testutil.ToFloat64(httpRequests.WithLabelValues("GET", "/v/abc-123", "418")),
"raw path must never be a label value")
}
+1 -1
View File
@@ -7,7 +7,7 @@ package ports
import (
"context"
"gitea.d-ma.be/mathias/tapir/internal/domain"
"git.d-ma.be/mathias/tapir/internal/domain"
)
// VideoSource is a video platform Tapir watches (YouTube, Vimeo).
+3 -3
View File
@@ -19,9 +19,9 @@ import (
"slices"
"time"
"gitea.d-ma.be/mathias/tapir/internal/domain"
"gitea.d-ma.be/mathias/tapir/internal/ports"
"gitea.d-ma.be/mathias/tapir/internal/usecase"
"git.d-ma.be/mathias/tapir/internal/domain"
"git.d-ma.be/mathias/tapir/internal/ports"
"git.d-ma.be/mathias/tapir/internal/usecase"
)
// passCandidate is a video that passed all pre-filters (seen/manual/backoff)
+3 -3
View File
@@ -9,9 +9,9 @@ import (
"github.com/stretchr/testify/require"
"gitea.d-ma.be/mathias/tapir/internal/domain"
"gitea.d-ma.be/mathias/tapir/internal/runner"
"gitea.d-ma.be/mathias/tapir/internal/usecase"
"git.d-ma.be/mathias/tapir/internal/domain"
"git.d-ma.be/mathias/tapir/internal/runner"
"git.d-ma.be/mathias/tapir/internal/usecase"
)
const testUser = "11111111-1111-1111-1111-111111111111"
+2 -2
View File
@@ -14,8 +14,8 @@ import (
"fmt"
"sync"
"gitea.d-ma.be/mathias/tapir/internal/domain"
"gitea.d-ma.be/mathias/tapir/internal/ports"
"git.d-ma.be/mathias/tapir/internal/domain"
"git.d-ma.be/mathias/tapir/internal/ports"
)
// ErrNotImplemented marks scaffold methods awaiting implementation.
+1 -1
View File
@@ -4,7 +4,7 @@ import (
"context"
"testing"
"gitea.d-ma.be/mathias/tapir/internal/domain"
"git.d-ma.be/mathias/tapir/internal/domain"
)
// These tests pin the ADR-021 read-stored-first behaviour at the engine core:
+1 -1
View File
@@ -3,7 +3,7 @@ package web
import (
"net/http"
"gitea.d-ma.be/mathias/tapir/internal/adapters/store"
"git.d-ma.be/mathias/tapir/internal/adapters/store"
)
// handleAccount renders the account page: the user's display name, the
+2 -2
View File
@@ -9,8 +9,8 @@ import (
"github.com/stretchr/testify/require"
"gitea.d-ma.be/mathias/tapir/internal/adapters/store"
"gitea.d-ma.be/mathias/tapir/internal/web"
"git.d-ma.be/mathias/tapir/internal/adapters/store"
"git.d-ma.be/mathias/tapir/internal/web"
)
// fakeSecrets is a SecretRemover that records the refs it was asked to delete, so
+2 -2
View File
@@ -6,8 +6,8 @@ import (
"net/http"
"strings"
"gitea.d-ma.be/mathias/tapir/internal/adapters/chat"
"gitea.d-ma.be/mathias/tapir/internal/adapters/store"
"git.d-ma.be/mathias/tapir/internal/adapters/chat"
"git.d-ma.be/mathias/tapir/internal/adapters/store"
)
// Chatter is the per-video chat backend (ADR-027). *chat.Service satisfies it;
+4 -4
View File
@@ -12,10 +12,10 @@ import (
"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"
"git.d-ma.be/mathias/tapir/internal/adapters/chat"
"git.d-ma.be/mathias/tapir/internal/adapters/store"
"git.d-ma.be/mathias/tapir/internal/domain"
"git.d-ma.be/mathias/tapir/internal/web"
)
// videoZ is a video id used by the isolation test for a DIFFERENT user's video.
+2 -2
View File
@@ -12,8 +12,8 @@ import (
"sync"
"time"
"gitea.d-ma.be/mathias/tapir/internal/adapters/store"
"gitea.d-ma.be/mathias/tapir/internal/auth"
"git.d-ma.be/mathias/tapir/internal/adapters/store"
"git.d-ma.be/mathias/tapir/internal/auth"
)
// Connections is the narrow write port the connect flow depends on (Clean
+3 -3
View File
@@ -11,9 +11,9 @@ import (
"github.com/stretchr/testify/require"
"golang.org/x/oauth2"
"gitea.d-ma.be/mathias/tapir/internal/adapters/store"
"gitea.d-ma.be/mathias/tapir/internal/auth"
"gitea.d-ma.be/mathias/tapir/internal/web"
"git.d-ma.be/mathias/tapir/internal/adapters/store"
"git.d-ma.be/mathias/tapir/internal/auth"
"git.d-ma.be/mathias/tapir/internal/web"
)
// fakeWriter is a TokenWriter capturing the persisted (ref, value).
+46 -2
View File
@@ -11,8 +11,8 @@ import (
"github.com/a-h/templ"
"gitea.d-ma.be/mathias/tapir/internal/adapters/store"
"gitea.d-ma.be/mathias/tapir/internal/domain"
"git.d-ma.be/mathias/tapir/internal/adapters/store"
"git.d-ma.be/mathias/tapir/internal/domain"
)
// Store is the read/write surface the web handlers depend on — a narrow port over
@@ -151,6 +151,8 @@ func (a *App) Router() http.Handler {
app := http.NewServeMux()
app.HandleFunc("GET /{$}", a.handleList)
app.HandleFunc("GET /v/{videoId}", a.handleDetail)
app.HandleFunc("GET /v/{videoId}/expand", a.handleExpand)
app.HandleFunc("GET /v/{videoId}/card", a.handleCard)
app.HandleFunc("POST /v/{videoId}/action", a.handleAction)
app.HandleFunc("POST /v/{videoId}/summarize", a.handleRequestSummarize)
app.HandleFunc("POST /v/{videoId}/retry-now", a.handleRetryNow)
@@ -283,6 +285,48 @@ func (a *App) handleDetail(w http.ResponseWriter, r *http.Request) {
a.render(w, r, DetailPage(*row, a.Chat != nil))
}
// handleExpand returns the inline-expanded card fragment — the full summary +
// chat dock swapped into the list card in place (ADR-031). Only summarized videos
// have a summary to expand; a non-summarized id is a 404 (the compact card never
// offers expand for it).
func (a *App) handleExpand(w http.ResponseWriter, r *http.Request) {
userID, ok := a.currentUserID(w, r)
if !ok {
return
}
videoID := r.PathValue("videoId")
row, err := a.Store.GetSummaryByVideo(r.Context(), userID, videoID)
if errors.Is(err, store.ErrNotFound) {
http.NotFound(w, r)
return
}
if err != nil {
a.serverError(w, r, "get summary", err)
return
}
a.render(w, r, expandedCard(*row, a.Chat != nil))
}
// handleCard returns the compact card fragment — the collapse target that returns
// an expanded card to its compact form in the list (ADR-031).
func (a *App) handleCard(w http.ResponseWriter, r *http.Request) {
userID, ok := a.currentUserID(w, r)
if !ok {
return
}
videoID := r.PathValue("videoId")
row, err := a.Store.GetVideoRow(r.Context(), userID, videoID)
if errors.Is(err, store.ErrNotFound) {
http.NotFound(w, r)
return
}
if err != nil {
a.serverError(w, r, "get video", err)
return
}
a.render(w, r, VideoCard(*row))
}
// handleAction toggles one action: re-clicking an active verb clears it, else it
// is set (the store enforces watched↔skipped exclusion atomically). It returns
// the refreshed button-group fragment for HTMX; without JS it redirects back to
+12 -3
View File
@@ -16,9 +16,9 @@ import (
"github.com/jackc/pgx/v5/pgxpool"
"github.com/stretchr/testify/require"
"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"
"git.d-ma.be/mathias/tapir/internal/adapters/store"
"git.d-ma.be/mathias/tapir/internal/domain"
"git.d-ma.be/mathias/tapir/internal/web"
)
// dsn points at the in-process Postgres started in TestMain. Handler tests run
@@ -524,3 +524,12 @@ func TestListAutoModeBannerCopy(t *testing.T) {
require.Contains(t, html, "land gradually")
require.NotContains(t, html, "are not summarized automatically")
}
// TestMetricsNotOnPublicMux: the public app router exposes no /metrics route —
// Prometheus is served on the dedicated metrics port only (ADR-030, security R6).
func TestMetricsNotOnPublicMux(t *testing.T) {
app := newApp(t)
resetDB(t, rawPool(t))
rec := do(t, app, httptest.NewRequest(http.MethodGet, "/metrics", nil))
require.Equal(t, http.StatusNotFound, rec.Code, "/metrics must not be on the public mux")
}
+107
View File
@@ -0,0 +1,107 @@
package web_test
import (
"context"
"net/http"
"net/http/httptest"
"testing"
"time"
"github.com/stretchr/testify/require"
)
// TestExpandReturnsSummaryBodyFragment: GET /v/{id}/expand returns the full
// summary as an in-place card fragment (not a full page) — ADR-031.
func TestExpandReturnsSummaryBodyFragment(t *testing.T) {
ctx := context.Background()
app := newApp(t)
p := rawPool(t)
resetDB(t, p)
require.NoError(t, deliver(ctx, app, videoX, "the full summary text"))
seedVideo(t, p, videoX, "X Title", "https://x", time.Time{})
html := body(t, do(t, app, httptest.NewRequest(http.MethodGet, "/v/"+videoX+"/expand", nil)))
require.Contains(t, html, "the full summary text")
require.Contains(t, html, "Takeaways")
require.Contains(t, html, "highlight one")
require.Contains(t, html, "card-expanded", "rendered as the expanded card")
require.Contains(t, html, "/v/"+videoX+"/card", "carries a collapse affordance")
require.NotContains(t, html, "<html", "fragment, not a full page")
}
// TestCollapseReturnsCompactCard: GET /v/{id}/card returns the compact card with
// the expand affordance — the collapse target.
func TestCollapseReturnsCompactCard(t *testing.T) {
ctx := context.Background()
app := newApp(t)
p := rawPool(t)
resetDB(t, p)
require.NoError(t, deliver(ctx, app, videoX, "summary text"))
seedVideo(t, p, videoX, "X Title", "https://x", time.Time{})
html := body(t, do(t, app, httptest.NewRequest(http.MethodGet, "/v/"+videoX+"/card", nil)))
require.Contains(t, html, `class="card"`, "compact card")
require.Contains(t, html, "/v/"+videoX+"/expand", "compact card offers expand")
require.NotContains(t, html, "card-expanded")
require.NotContains(t, html, "<html", "fragment, not a full page")
}
// TestExpandedCardOffersChatDock: with chat enabled, the expanded card includes
// the deeper-dive chat affordance.
func TestExpandedCardOffersChatDock(t *testing.T) {
ctx := context.Background()
app := newChatApp(t, &fakeChatter{models: []string{"m"}}, nil)
p := rawPool(t)
resetDB(t, p)
require.NoError(t, deliver(ctx, app, videoX, "summary text"))
seedVideo(t, p, videoX, "X Title", "https://x", time.Time{})
html := body(t, do(t, app, httptest.NewRequest(http.MethodGet, "/v/"+videoX+"/expand", nil)))
require.Contains(t, html, "/v/"+videoX+"/chat", "expanded card wires the chat dock")
}
// TestCompactCardExpandOnlyWhenSummarized: a not-yet-summarized card shows its
// summarize footer and no expand affordance.
func TestCompactCardExpandOnlyWhenSummarized(t *testing.T) {
app := newApp(t)
p := rawPool(t)
resetDB(t, p)
seedVideo(t, p, videoX, "Pending Title", "https://x", time.Time{}) // no summary
html := body(t, do(t, app, httptest.NewRequest(http.MethodGet, "/v/"+videoX+"/card", nil)))
require.Contains(t, html, "Not summarized")
require.NotContains(t, html, "/v/"+videoX+"/expand", "pending card offers no expand")
}
// TestCompactCardHasNoJSDetailFallback: the expand affordance carries an href to
// the detail page, so JS-off users still reach the full summary.
func TestCompactCardHasNoJSDetailFallback(t *testing.T) {
ctx := context.Background()
app := newApp(t)
p := rawPool(t)
resetDB(t, p)
require.NoError(t, deliver(ctx, app, videoX, "summary text"))
seedVideo(t, p, videoX, "X Title", "https://x", time.Time{})
html := body(t, do(t, app, httptest.NewRequest(http.MethodGet, "/v/"+videoX+"/card", nil)))
require.Contains(t, html, `href="/v/`+videoX+`"`, "no-JS fallback to the detail page")
require.Contains(t, html, "/v/"+videoX+"/expand", "and the HTMX expand for JS users")
}
// TestDetailAndExpandShareSummaryBody: the detail page and the expanded card render
// the same summary body (one shared fragment, no drift).
func TestDetailAndExpandShareSummaryBody(t *testing.T) {
ctx := context.Background()
app := newApp(t)
p := rawPool(t)
resetDB(t, p)
require.NoError(t, deliver(ctx, app, videoX, "shared summary text"))
seedVideo(t, p, videoX, "X Title", "https://x", time.Time{})
detail := body(t, do(t, app, httptest.NewRequest(http.MethodGet, "/v/"+videoX, nil)))
expand := body(t, do(t, app, httptest.NewRequest(http.MethodGet, "/v/"+videoX+"/expand", nil)))
for _, want := range []string{"shared summary text", "Takeaways", "highlight one"} {
require.Contains(t, detail, want)
require.Contains(t, expand, want)
}
}
+39 -45
View File
@@ -7,10 +7,11 @@
// Authentication is real (Dex OIDC) and is the only gate: any Dex-authenticated
// subject may sign in (ADR-012 dropped ADR-011's single-subject allowlist).
// Authorization/registration is layered on top in internal/web (an authenticated
// subject with no tapir user is routed to registration). Sessions are server-side
// (in-memory, fine for the single Stage-1 replica) addressed by an HMAC-signed
// (HS256) HttpOnly Secure SameSite=Lax cookie with a short TTL and sliding
// refresh. Tokens are never logged.
// subject with no tapir user is routed to registration). Sessions are STATELESS
// (ADR-029): the identity + expiry live inside an HMAC-signed (HS256) HttpOnly
// Secure SameSite=Lax persistent cookie with a long sliding TTL — no server-side
// table, so a deploy/restart never logs anyone out and the cookie also survives
// browser-close. Tokens are never logged; logout clears the cookie client-side.
//
// This is mcp-chassis's cousin but NOT the same code: mcp-chassis validates
// inbound Bearer JWTs for MCP APIs; this is a browser session login.
@@ -26,7 +27,8 @@ import (
"github.com/coreos/go-oidc/v3/oidc"
"golang.org/x/oauth2"
"gitea.d-ma.be/mathias/tapir/internal/web"
"git.d-ma.be/mathias/tapir/internal/metrics"
"git.d-ma.be/mathias/tapir/internal/web"
)
// Config is the OIDC + session configuration. cmd/tapir maps these from
@@ -47,7 +49,11 @@ type Config struct {
}
const (
defaultSessionTTL = time.Hour
// defaultSessionTTL is generous and sliding: Tapir is a "check back tomorrow"
// reader, so a short TTL meant a re-login (full IdP redirect dance) on almost
// every visit. 30 days, slid forward on each request, keeps a regular user
// logged in indefinitely while an abandoned session still lapses.
defaultSessionTTL = 30 * 24 * time.Hour
pendingTTL = 10 * time.Minute
sessionCookie = "tapir_session"
loginPath = "/auth/login"
@@ -59,7 +65,6 @@ type DexAuth struct {
oauth *oauth2.Config
verifier *oidc.IDTokenVerifier
sessions *sessionStore
pending *pendingStore
secret []byte
sessionTTL time.Duration
@@ -127,7 +132,6 @@ func New(ctx context.Context, cfg Config, opts ...Option) (*DexAuth, error) {
RedirectURL: cfg.RedirectURL,
Scopes: []string{oidc.ScopeOpenID, "profile", "email"},
},
sessions: newSessionStore(),
pending: newPendingStore(),
secret: []byte(cfg.SessionSecret),
sessionTTL: defaultSessionTTL,
@@ -159,31 +163,31 @@ func (d *DexAuth) Middleware(h http.Handler) http.Handler {
h.ServeHTTP(w, r)
return
}
sid, ok := d.sessionID(r)
c, err := r.Cookie(sessionCookie)
if err != nil {
d.redirectUnauthenticated(w, r)
return
}
user, _, ok := d.decodeSession(c.Value, d.now())
if !ok {
d.redirectUnauthenticated(w, r)
return
}
if _, ok := d.sessions.get(sid, d.now()); !ok {
d.redirectUnauthenticated(w, r)
return
}
d.sessions.refresh(sid, d.now().Add(d.sessionTTL)) // sliding refresh
// Sliding refresh: re-issue the cookie with a fresh expiry so an active
// user never lapses (the expiry lives in the cookie, so sliding = re-sign).
d.setSessionCookie(w, d.encodeSession(user, d.now().Add(d.sessionTTL)))
h.ServeHTTP(w, r)
})
}
// CurrentUser resolves the authenticated principal from the session cookie.
// CurrentUser resolves the authenticated principal from the stateless cookie.
func (d *DexAuth) CurrentUser(r *http.Request) (web.User, bool) {
sid, ok := d.sessionID(r)
if !ok {
c, err := r.Cookie(sessionCookie)
if err != nil {
return web.User{}, false
}
data, ok := d.sessions.get(sid, d.now())
if !ok {
return web.User{}, false
}
return data.user, true
user, _, ok := d.decodeSession(c.Value, d.now())
return user, ok
}
func (d *DexAuth) handleLogin(w http.ResponseWriter, r *http.Request) {
@@ -248,23 +252,16 @@ func (d *DexAuth) handleCallback(w http.ResponseWriter, r *http.Request) {
}
_ = idToken.Claims(&claims) // email is best-effort; subject is the identity
sid, err := randToken()
if err != nil {
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
d.sessions.put(sid, sessionData{
user: web.User{Subject: idToken.Subject, Email: claims.Email},
expiry: d.now().Add(d.sessionTTL),
})
d.setSessionCookie(w, sid)
user := web.User{Subject: idToken.Subject, Email: claims.Email}
d.setSessionCookie(w, d.encodeSession(user, d.now().Add(d.sessionTTL)))
metrics.IncLogin()
http.Redirect(w, r, "/", http.StatusFound)
}
func (d *DexAuth) handleLogout(w http.ResponseWriter, r *http.Request) {
if sid, ok := d.sessionID(r); ok {
d.sessions.delete(sid)
}
// Stateless sessions: clearing the cookie logs the browser out. There is no
// server-side record to delete (ADR-029); a copy of the cookie stays valid
// until its expiry — an accepted trade for the Stage-0 reader app.
d.clearSessionCookie(w)
// Land on the public landing page, not the login endpoint: a just-logged-out
// visitor should see /welcome, not be bounced straight back into a Dex login.
@@ -287,22 +284,19 @@ func (d *DexAuth) redirectToLogin(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, loginPath, http.StatusFound)
}
func (d *DexAuth) sessionID(r *http.Request) (string, bool) {
c, err := r.Cookie(sessionCookie)
if err != nil {
return "", false
}
return d.unsign(c.Value)
}
func (d *DexAuth) setSessionCookie(w http.ResponseWriter, sid string) {
// setSessionCookie writes the signed session value as a PERSISTENT cookie
// (Max-Age set), so it survives the browser/app being closed — a session cookie
// (no Max-Age) was dropped on iPhone Safari close, forcing re-login. value is the
// already-signed payload from encodeSession.
func (d *DexAuth) setSessionCookie(w http.ResponseWriter, value string) {
http.SetCookie(w, &http.Cookie{
Name: sessionCookie,
Value: d.sign(sid),
Value: value,
Path: "/",
HttpOnly: true,
Secure: !d.insecure,
SameSite: http.SameSiteLaxMode,
MaxAge: int(d.sessionTTL.Seconds()),
})
}
+35 -6
View File
@@ -14,8 +14,8 @@ import (
josev4 "github.com/go-jose/go-jose/v4"
"github.com/stretchr/testify/require"
"gitea.d-ma.be/mathias/tapir/internal/web"
"gitea.d-ma.be/mathias/tapir/internal/web/oidc"
"git.d-ma.be/mathias/tapir/internal/web"
"git.d-ma.be/mathias/tapir/internal/web/oidc"
)
const (
@@ -306,13 +306,42 @@ func TestLogoutClearsSession(t *testing.T) {
require.Equal(t, http.StatusFound, rec.Code)
require.Equal(t, "/welcome", rec.Header().Get("Location"), "logout lands on the public page")
cleared := sessionCookie(t, rec.Result())
require.Less(t, cleared.MaxAge, 0, "logout expires the cookie")
require.Less(t, cleared.MaxAge, 0, "logout expires the cookie so the browser drops it")
require.Empty(t, cleared.Value, "logout blanks the cookie value")
// The server-side session is gone: the original cookie no longer resolves.
// Sessions are stateless (ADR-029): logout clears the cookie client-side, so a
// request carrying the cleared (empty) cookie is unauthenticated. The original
// signed cookie remains technically valid until its expiry — the accepted
// trade for no server-side store; the browser no longer holds it.
check := httptest.NewRequest(http.MethodGet, "/", nil)
check.AddCookie(cookie)
check.AddCookie(cleared)
_, ok := auth.CurrentUser(check)
require.False(t, ok)
require.False(t, ok, "the cleared cookie does not authenticate")
}
// TestSessionSurvivesRestart is the core of ADR-029: a cookie issued by one
// process is accepted by a FRESH instance with the same session secret — so a
// deploy/pod-restart no longer logs users out (the old in-memory store did).
func TestSessionSurvivesRestart(t *testing.T) {
f := newFakeIssuer(t)
auth1 := newAuth(t, f)
cookie := authenticate(t, auth1, f)
auth2 := newAuth(t, f) // simulate a redeploy: new process, same SessionSecret
req := httptest.NewRequest(http.MethodGet, "/", nil)
req.AddCookie(cookie)
user, ok := auth2.CurrentUser(req)
require.True(t, ok, "a session must survive a restart (stateless signed cookie)")
require.Equal(t, testSubject, user.Subject)
}
// TestSessionCookieIsPersistent: the cookie carries a positive Max-Age so it
// survives the browser/app being closed (a session cookie was dropped on iOS).
func TestSessionCookieIsPersistent(t *testing.T) {
f := newFakeIssuer(t)
auth := newAuth(t, f)
cookie := authenticate(t, auth, f)
require.Greater(t, cookie.MaxAge, 0, "session cookie must be persistent (Max-Age set)")
}
func TestExpiredSessionRejected(t *testing.T) {
+32 -43
View File
@@ -6,12 +6,13 @@ import (
"crypto/sha256"
"encoding/base64"
"encoding/hex"
"encoding/json"
"fmt"
"strings"
"sync"
"time"
"gitea.d-ma.be/mathias/tapir/internal/web"
"git.d-ma.be/mathias/tapir/internal/web"
)
// randToken returns a URL-safe 256-bit random string for session IDs, OIDC
@@ -53,56 +54,44 @@ func (d *DexAuth) unsign(signed string) (string, bool) {
return value, true
}
// sessionData is the server-side session record.
type sessionData struct {
user web.User
expiry time.Time
// sessionClaims is the self-contained session payload carried INSIDE the signed
// cookie — there is no server-side session table. This is deliberate (ADR-029):
// an in-memory store was wiped on every pod restart, logging every user out on
// each deploy, and a stateless cookie also survives browser-close and works
// across replicas. It holds only the identity (subject + email, not secret) and
// an absolute expiry; the HMAC tag (sign/unsign) makes it tamper-proof.
type sessionClaims struct {
Sub string `json:"s"`
Email string `json:"e"`
Exp int64 `json:"x"` // unix seconds; absolute expiry
}
// sessionStore is an in-memory session table. Single replica at Stage 0, so an
// in-process map is sufficient; it is safe for concurrent use.
type sessionStore struct {
mu sync.Mutex
m map[string]sessionData
// encodeSession produces the signed cookie value for a user with the given expiry.
func (d *DexAuth) encodeSession(u web.User, exp time.Time) string {
b, _ := json.Marshal(sessionClaims{Sub: u.Subject, Email: u.Email, Exp: exp.Unix()})
return d.sign(base64.RawURLEncoding.EncodeToString(b))
}
func newSessionStore() *sessionStore { return &sessionStore{m: make(map[string]sessionData)} }
func (s *sessionStore) put(id string, d sessionData) {
s.mu.Lock()
defer s.mu.Unlock()
s.m[id] = d
}
// get returns the session if present and unexpired; expired entries are evicted.
func (s *sessionStore) get(id string, now time.Time) (sessionData, bool) {
s.mu.Lock()
defer s.mu.Unlock()
d, ok := s.m[id]
// decodeSession verifies the cookie's HMAC, parses the claims, and checks expiry.
// It returns the user and the absolute expiry on success.
func (d *DexAuth) decodeSession(cookieValue string, now time.Time) (web.User, time.Time, bool) {
payload, ok := d.unsign(cookieValue)
if !ok {
return sessionData{}, false
return web.User{}, time.Time{}, false
}
if !now.Before(d.expiry) {
delete(s.m, id)
return sessionData{}, false
raw, err := base64.RawURLEncoding.DecodeString(payload)
if err != nil {
return web.User{}, time.Time{}, false
}
return d, true
}
// refresh slides an existing session's expiry forward; a no-op for unknown ids.
func (s *sessionStore) refresh(id string, expiry time.Time) {
s.mu.Lock()
defer s.mu.Unlock()
if d, ok := s.m[id]; ok {
d.expiry = expiry
s.m[id] = d
var c sessionClaims
if err := json.Unmarshal(raw, &c); err != nil {
return web.User{}, time.Time{}, false
}
}
func (s *sessionStore) delete(id string) {
s.mu.Lock()
defer s.mu.Unlock()
delete(s.m, id)
exp := time.Unix(c.Exp, 0)
if !now.Before(exp) {
return web.User{}, time.Time{}, false // expired
}
return web.User{Subject: c.Sub, Email: c.Email}, exp, true
}
// pendingData holds the nonce bound to an in-flight authorization request.
+1 -1
View File
@@ -11,7 +11,7 @@ import (
"github.com/stretchr/testify/require"
"gitea.d-ma.be/mathias/tapir/internal/domain"
"git.d-ma.be/mathias/tapir/internal/domain"
)
// fakeFetcher is a web.VideoFetcher returning a fixed video (or an error),
+1 -1
View File
@@ -3,7 +3,7 @@ package web
import (
"bytes"
"context"
"gitea.d-ma.be/mathias/tapir/internal/adapters/store"
"git.d-ma.be/mathias/tapir/internal/adapters/store"
"strings"
"testing"
)
+1 -1
View File
@@ -4,7 +4,7 @@ import (
"context"
"sync"
"gitea.d-ma.be/mathias/tapir/internal/domain"
"git.d-ma.be/mathias/tapir/internal/domain"
)
// Processor runs the core summarization use case for a single already-discovered
+1 -1
View File
@@ -10,7 +10,7 @@ import (
"github.com/jackc/pgx/v5/pgxpool"
"github.com/stretchr/testify/require"
"gitea.d-ma.be/mathias/tapir/internal/web"
"git.d-ma.be/mathias/tapir/internal/web"
)
// fakeProcessor records ProcessVideo calls. With block set it parks until the
+1 -1
View File
@@ -5,7 +5,7 @@ import (
"strings"
"testing"
"gitea.d-ma.be/mathias/tapir/internal/adapters/store"
"git.d-ma.be/mathias/tapir/internal/adapters/store"
)
func renderVideoCard(t *testing.T, r store.SummaryRow) string {
+79 -31
View File
@@ -9,7 +9,7 @@ import (
"github.com/a-h/templ"
"gitea.d-ma.be/mathias/tapir/internal/adapters/store"
"git.d-ma.be/mathias/tapir/internal/adapters/store"
)
// youtubeIDRe matches a canonical 11-char YouTube video id (the provider's
@@ -190,6 +190,18 @@ func chatURL(videoID string) templ.SafeURL {
return templ.SafeURL("/v/" + videoID + "/chat")
}
// expandURL builds the inline-expand fragment path (GET) — the full summary + chat
// dock swapped into the list card in place (ADR-031).
func expandURL(videoID string) templ.SafeURL {
return templ.SafeURL("/v/" + videoID + "/expand")
}
// cardURL builds the compact-card fragment path (GET) — the collapse target that
// returns an expanded card to its compact form (ADR-031).
func cardURL(videoID string) templ.SafeURL {
return templ.SafeURL("/v/" + videoID + "/card")
}
// Charmbracelet-inspired palette for the summarizing animation (TapirSpinner) —
// a charm purple box, pink tapir, mint snout/eyes/progress. Kept as named consts
// so the inline span colours and the CSS track/fill share one source of truth.
@@ -566,33 +578,71 @@ func (f Filter) apply(rows []store.SummaryRow) []store.SummaryRow {
// not as template expressions — so the CSS is rendered as a raw node instead).
var styleTag = "<style>" + stylesheet + "</style>"
// The design system is a small set of CSS custom properties: one accent, a
// 4/8px-derived spacing scale, a single radius, and full light+dark palettes so
// color-scheme: light dark is actually honoured (review #1). Muted is #595959
// (~7:1 on white) / #9aa0a8 on dark to clear WCAG AA (review #4).
// themeScript powers the light/dark toggle (ADR-032). The init runs in <head>
// before paint: if the visitor has a stored choice it is applied as data-theme
// immediately, so there is no flash of the wrong palette; with no stored choice
// nothing is set and the CSS @media (prefers-color-scheme) default takes over.
// tapirToggleTheme flips to the opposite of the *effective* theme (reading
// matchMedia when no explicit choice is set yet) and persists it. localStorage
// access is guarded so a privacy-locked browser degrades to the OS default.
const themeScript = `
(function(){try{var t=localStorage.getItem('theme');if(t==='dark'||t==='light')document.documentElement.setAttribute('data-theme',t);}catch(e){}})();
function tapirToggleTheme(){var d=document.documentElement,c=d.getAttribute('data-theme');if(!c){c=window.matchMedia&&window.matchMedia('(prefers-color-scheme: dark)').matches?'dark':'light';}var n=c==='dark'?'light':'dark';d.setAttribute('data-theme',n);try{localStorage.setItem('theme',n);}catch(e){}}
`
// themeScriptTag is the init/toggle script, injected verbatim in <head>.
var themeScriptTag = "<script>" + themeScript + "</script>"
// themeToggleButton is the header control that calls tapirToggleTheme. Shared by
// the authenticated and public layouts so the toggle is on every page.
const themeToggleButton = `<button type="button" class="theme-toggle" onclick="tapirToggleTheme()" aria-label="Toggle light or dark theme" title="Toggle light/dark">◐</button>`
// The design system is one charm-reader layout in two palettes (ADR-032, #17):
// a warm "reader" light theme (sketch B) and a "cozy terminal" dark theme
// (sketch C), both expressed as CSS custom properties. The palette is selected
// three ways, in cascade order: the :root light default; an OS-preference dark
// block that applies only when the visitor has made no explicit choice
// (:root:not([data-theme])); and an explicit :root[data-theme="dark"|"light"]
// set by the persisted toggle, which outranks the media query by specificity.
// charmDarkVars is declared once and injected in both dark selectors so the two
// never drift. Muted clears WCAG AA on each background (#5b5968 on cream,
// #a59cc0 on the dark card). Error/danger shades are tokens so they follow the
// theme too, instead of needing per-block dark overrides.
const charmDarkVars = `
--bg:#16131d; --card:#1e1a28; --fg:#ece7f5; --muted:#a59cc0; --line:#322b44;
--mint:#2ee6b6; --purple:#9d7bff; --pink:#ff6bdb; --cream:#f3ead8;
--accent:#9d7bff; --accent-fg:#16131d; --accent-weak:#2a2340;
--badge-bg:#caa24a; --badge-fg:#1a1300;
--err-bg:#3a1714; --err-fg:#f3b5ae; --err-line:#a6362e;`
const stylesheet = `
:root {
color-scheme: light dark;
--bg:#fbfbfa; --card:#ffffff; --fg:#1a1a1a; --muted:#595959; --line:#e4e4e1;
--accent:#2b6cb0; --accent-fg:#ffffff; --accent-weak:#eaf1f8;
--bg:#faf7f2; --card:#ffffff; --fg:#1c1b22; --muted:#5b5968; --line:#e7e2d8;
--mint:#0bbf8c; --purple:#6a4cf0; --pink:#e0379a; --cream:#1c1b22;
--accent:#6a4cf0; --accent-fg:#ffffff; --accent-weak:#efebfd;
--badge-bg:#e7a13a; --badge-fg:#2a1d00;
--err-bg:#fce8e6; --err-fg:#8a1c10; --err-line:#d9534f;
--s1:.25rem; --s2:.5rem; --s3:1rem; --s4:1.5rem; --s5:2.5rem; --radius:.5rem;
--mono: ui-monospace, SFMono-Regular, Menlo, "Cascadia Code", monospace;
}
@media (prefers-color-scheme: dark) {
:root {
--bg:#15171b; --card:#1e2128; --fg:#e7e7e4; --muted:#9aa0a8; --line:#2d313a;
--accent:#76aae6; --accent-fg:#0c0f13; --accent-weak:#222b38;
--badge-bg:#caa24a; --badge-fg:#1a1300;
:root:not([data-theme]) {` + charmDarkVars + `
}
}
:root[data-theme="dark"] {` + charmDarkVars + `
}
* { box-sizing: border-box; }
body { font: 15px/1.6 system-ui, -apple-system, sans-serif; margin: 0; color: var(--fg); background: var(--bg); }
a { color: var(--accent); text-decoration: none; }
a:hover, a:focus-visible { text-decoration: underline; }
a:visited { color: var(--accent); }
header { padding: var(--s3) var(--s4); border-bottom: 1px solid var(--line); background: var(--card); display: flex; align-items: center; justify-content: space-between; gap: var(--s3); }
.brand { font-weight: 700; font-size: 1.05rem; color: var(--accent); }
.nav { display: flex; gap: var(--s3); font-size: .9rem; }
.brand { font-weight: 800; font-size: 1.05rem; letter-spacing: -.01em; color: var(--accent); }
.nav { display: flex; gap: var(--s3); align-items: center; font-size: .9rem; }
.theme-toggle { font: inherit; font-size: 1rem; line-height: 1; padding: .3rem .5rem; border: 1px solid var(--line); border-radius: 999px; background: var(--card); color: var(--muted); cursor: pointer; }
.theme-toggle:hover { border-color: var(--accent); color: var(--accent); }
.theme-toggle:focus-visible { outline: 2px solid var(--accent); outline-offset: 1px; }
main { max-width: 60rem; margin: 0 auto; padding: var(--s4) var(--s3); }
.muted { color: var(--muted); }
@@ -603,7 +653,7 @@ main { max-width: 60rem; margin: 0 auto; padding: var(--s4) var(--s3); }
.filters input:focus-visible { outline: 2px solid var(--accent); outline-offset: 1px; border-color: var(--accent); }
.filter-check { flex-direction: row !important; align-items: center; gap: var(--s2) !important; padding-bottom: .45rem; }
.filter-check input[type=checkbox] { width: 1rem; height: 1rem; min-width: 0; padding: 0; accent-color: var(--accent); cursor: pointer; }
.btn { font: inherit; font-weight: 600; padding: .45rem 1rem; border: 1px solid var(--accent); border-radius: var(--radius); background: var(--accent); color: var(--accent-fg); cursor: pointer; }
.btn { font: inherit; font-weight: 600; padding: .45rem 1.1rem; border: 1px solid var(--accent); border-radius: 999px; background: var(--accent); color: var(--accent-fg); cursor: pointer; }
/* anchors styled as buttons: the generic a{} / a:visited{} colour rules outrank
.btn on <a>, painting the label accent-on-accent (invisible). Restore the
button foreground for anchor buttons, visited included. */
@@ -615,14 +665,19 @@ a.btn, a.btn:visited { color: var(--accent-fg); }
.cards { list-style: none; margin: 0; padding: 0; display: grid; gap: var(--s3); }
.card { background: var(--card); border: 1px solid var(--line); border-radius: var(--radius); padding: var(--s3) var(--s4); display: flex; flex-direction: column; gap: var(--s2); }
.card-title { font-size: 1.1rem; font-weight: 600; line-height: 1.3; }
.card-meta { color: var(--muted); font-size: .85rem; }
.card-meta { color: var(--muted); font-size: .8rem; font-family: var(--mono); }
.card-preview { color: var(--muted); font-size: .9rem; line-height: 1.5; display: -webkit-box; -webkit-line-clamp: 1; line-clamp: 1; -webkit-box-orient: vertical; overflow: hidden; }
.card-foot { display: flex; gap: var(--s2); align-items: center; flex-wrap: wrap; margin-top: var(--s1); }
/* Inline-expanded card (ADR-031/032): lifted, with a charm accent edge so the
open card reads as the focused one in the feed (sketch B/C). */
.card-expanded { border-color: var(--accent); border-left: 3px solid var(--mint); box-shadow: 0 6px 24px color-mix(in srgb, var(--accent) 14%, transparent); }
.card-expanded-head { display: flex; justify-content: space-between; align-items: baseline; gap: var(--s2); }
.card-collapse { font-size: .8rem; font-family: var(--mono); white-space: nowrap; }
.chip { display: inline-block; padding: .15rem .55rem; border-radius: 999px; background: var(--accent-weak); color: var(--accent); font-size: .72rem; font-weight: 600; }
/* passive "retrying later" chip: dim/grey (CharmDim), not the accent it is a
status, not an action the user can take. */
.chip-retry { background: rgba(108, 108, 108, .16); color: #6c6c6c; }
.pipeline-bar { display: flex; gap: var(--s3); align-items: center; flex-wrap: wrap; margin-bottom: var(--s3); font-size: .8rem; color: var(--muted); }
.pipeline-bar { display: flex; gap: var(--s3); align-items: center; flex-wrap: wrap; margin-bottom: var(--s3); font-size: .78rem; font-family: var(--mono); color: var(--muted); }
.pipeline-bar span { display: flex; align-items: center; gap: var(--s1); }
.pipeline-bar span + span::before { content: "·"; margin-right: var(--s1); }
.pipeline-note { margin: calc(-1 * var(--s2)) 0 var(--s3); font-size: .8rem; line-height: 1.5; max-width: 40rem; }
@@ -708,10 +763,7 @@ a.btn, a.btn:visited { color: var(--accent-fg); }
/* flash / notification banner */
.flash { padding: var(--s2) var(--s3); border-radius: var(--radius); margin-bottom: var(--s4); font-size: .92rem; border: 1px solid var(--line); }
.flash-success { background: var(--accent-weak); color: var(--accent); border-color: var(--accent); }
.flash-error { background: #fce8e6; color: #8a1c10; border-color: #d9534f; }
@media (prefers-color-scheme: dark) {
.flash-error { background: #3a1714; color: #f3b5ae; border-color: #a6362e; }
}
.flash-error { background: var(--err-bg); color: var(--err-fg); border-color: var(--err-line); }
/* htmx loading feedback */
.htmx-indicator { opacity: 0; transition: opacity .2s; color: var(--muted); font-size: .8rem; }
@@ -721,12 +773,13 @@ a.btn, a.btn:visited { color: var(--accent-fg); }
.detail { max-width: 38rem; }
.detail .back { margin: 0 0 var(--s3); font-size: .85rem; }
.detail h1 { font-size: 1.7rem; line-height: 1.25; margin: 0 0 var(--s2); }
.detail .meta { color: var(--muted); font-size: .9rem; margin: 0 0 var(--s2); display: flex; gap: var(--s2); align-items: center; flex-wrap: wrap; }
.detail .meta { color: var(--muted); font-size: .82rem; font-family: var(--mono); margin: 0 0 var(--s2); display: flex; gap: var(--s2); align-items: center; flex-wrap: wrap; }
.detail .source { margin: 0 0 var(--s4); font-size: .9rem; }
.detail .embed { margin: 0 0 var(--s4); aspect-ratio: 16 / 9; border-radius: var(--radius); overflow: hidden; background: #000; border: 1px solid var(--line); }
.detail .embed iframe { display: block; width: 100%; height: 100%; border: 0; }
.detail section { margin-top: var(--s4); }
.detail section h2 { font-size: .78rem; text-transform: uppercase; letter-spacing: .05em; color: var(--muted); border-top: 1px solid var(--line); padding-top: var(--s3); margin: 0 0 var(--s2); }
.detail section h2 { font-size: .72rem; text-transform: uppercase; letter-spacing: .12em; color: var(--accent); display: flex; align-items: center; gap: var(--s2); margin: var(--s4) 0 var(--s2); }
.detail section h2::after { content: ""; flex: 1; height: 1px; background: var(--line); }
.detail .body { white-space: pre-wrap; line-height: 1.7; margin: 0; }
.detail ul { margin: 0; padding-left: 1.2rem; line-height: 1.6; }
.detail li { margin-bottom: var(--s1); }
@@ -744,8 +797,7 @@ a.btn, a.btn:visited { color: var(--accent-fg); }
.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-error { margin: 0; color: var(--err-fg); font-size: .9rem; }
.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); }
@@ -795,17 +847,13 @@ a.btn, a.btn:visited { color: var(--accent-fg); }
.danger-zone h2 { border-top-color: #d9534f; }
.confirm-delete > summary { display: inline-block; list-style: none; cursor: pointer; font: inherit; font-weight: 600; padding: .45rem 1rem; border: 1px solid #d9534f; border-radius: var(--radius); background: transparent; color: #c0392b; }
.confirm-delete > summary::-webkit-details-marker { display: none; }
.confirm-delete > summary:hover { background: #fce8e6; }
.confirm-delete > summary:hover { background: var(--err-bg); }
.confirm-delete[open] > summary { margin-bottom: var(--s3); }
.confirm-body { border: 1px solid #d9534f; border-radius: var(--radius); padding: var(--s3); background: #fce8e6; color: #8a1c10; }
.confirm-body { border: 1px solid #d9534f; border-radius: var(--radius); padding: var(--s3); background: var(--err-bg); color: var(--err-fg); }
.btn-danger { font: inherit; font-weight: 600; padding: .45rem 1rem; border: 1px solid #d9534f; border-radius: var(--radius); background: #d9534f; color: #fff; cursor: pointer; }
.btn-danger:hover { filter: brightness(1.05); }
.btn-danger:focus-visible { outline: 2px solid #d9534f; outline-offset: 1px; }
@media (prefers-color-scheme: dark) {
.confirm-body { background: #3a1714; color: #f3b5ae; }
.confirm-delete > summary { color: #f3b5ae; }
.confirm-delete > summary:hover { background: #3a1714; }
}
.confirm-delete > summary { color: var(--err-fg); }
/* public landing page (/welcome) — the Charm-box mascot hero plus the sign-in CTA */
.welcome { text-align: center; padding: var(--s5) var(--s3); display: flex; flex-direction: column; align-items: center; gap: var(--s4); }
+40 -2
View File
@@ -17,13 +17,14 @@ templ Layout(title string) {
<meta charset="utf-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1"/>
<title>{ title }</title>
@templ.Raw(themeScriptTag)
<script src="/static/htmx.min.js" defer></script>
@templ.Raw(styleTag)
</head>
<body>
<header>
<a href="/" class="brand">Tapir</a>
<nav class="nav"><a href="/account">Account</a><a href="/auth/logout">Log out</a></nav>
<nav class="nav"><a href="/account">Account</a><a href="/auth/logout">Log out</a>@templ.Raw(themeToggleButton)</nav>
</header>
<main>
{ children... }
@@ -42,12 +43,14 @@ templ PublicLayout(title string) {
<meta charset="utf-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1"/>
<title>{ title }</title>
@templ.Raw(themeScriptTag)
<script src="/static/htmx.min.js" defer></script>
@templ.Raw(styleTag)
</head>
<body>
<header>
<a href="/" class="brand">Tapir</a>
<nav class="nav">@templ.Raw(themeToggleButton)</nav>
</header>
<main>
{ children... }
@@ -264,7 +267,16 @@ templ summaryList(b listBuckets, hasConnected bool, autoSummarize bool) {
templ VideoCard(r store.SummaryRow) {
<li class={ "card", templ.KV("card-pending", !r.Summarized) } id={ "video-" + r.VideoID }>
if r.Summarized {
<div class="card-title"><a href={ videoURL(r.VideoID) }>{ displayTitle(r) }</a></div>
// Expand the full summary + Q&A in place (ADR-031); href is the no-JS
// fallback to the detail page, so nothing becomes JS-only.
<div class="card-title">
<a
href={ videoURL(r.VideoID) }
hx-get={ string(expandURL(r.VideoID)) }
hx-target={ "#video-" + r.VideoID }
hx-swap="outerHTML"
>{ displayTitle(r) }</a>
</div>
} else {
<div class="card-title">{ displayTitle(r) }</div>
}
@@ -327,6 +339,32 @@ templ VideoCard(r store.SummaryRow) {
</li>
}
// expandedCard is a summarized list card opened IN PLACE (ADR-031): the full
// summary body + the deeper-dive chat dock, with a collapse control back to the
// compact card. It shares the <li id> with VideoCard so HTMX swaps it outerHTML,
// and reuses summaryBody + chatReveal so it never drifts from the detail page.
// Note: chatReveal uses a single #chat-section id, so this assumes one card open
// at a time; a per-video chat id is a follow-up if simultaneous expansion is wanted.
templ expandedCard(r store.SummaryRow, chatEnabled bool) {
<li class="card card-expanded" id={ "video-" + r.VideoID }>
<div class="card-expanded-head">
<span class="card-title">{ displayTitle(r) }</span>
<a
href={ videoURL(r.VideoID) }
hx-get={ string(cardURL(r.VideoID)) }
hx-target={ "#video-" + r.VideoID }
hx-swap="outerHTML"
class="card-collapse"
title="Collapse"
>collapse </a>
</div>
@summaryBody(r)
if chatEnabled {
@chatReveal(r.VideoID)
}
</li>
}
// TapirSpinner is the summarizing animation: a Charmbracelet-style TUI panel —
// three richly coloured ASCII tapir frames (inline span colours, snout wiggling
// ∩→∪→~) cross-faded by CSS, plus a lipgloss-style progress bar whose mint fill
File diff suppressed because it is too large Load Diff
+66
View File
@@ -0,0 +1,66 @@
package web_test
import (
"context"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"github.com/stretchr/testify/require"
)
// page renders any GET path's full HTML (helper for theme/structure assertions).
func page(t *testing.T, app interface {
Router() http.Handler
}, path string) string {
t.Helper()
rec := httptest.NewRecorder()
app.Router().ServeHTTP(rec, httptest.NewRequest(http.MethodGet, path, nil))
return rec.Body.String()
}
// TestThemeHasLightAndDarkPalettes: one layout, two palettes via CSS variables —
// a root light palette and a data-theme="dark" dark palette (ADR-032).
func TestThemeHasLightAndDarkPalettes(t *testing.T) {
app := newApp(t)
resetDB(t, rawPool(t))
html := page(t, app, "/welcome") // public, no DB needed
require.Contains(t, html, ":root", "light palette on the root")
require.Contains(t, html, `[data-theme="dark"]`, "explicit dark palette override for the toggle")
}
// TestThemeFollowsOSPreference: with no stored choice, the OS preference applies
// the dark palette automatically.
func TestThemeFollowsOSPreference(t *testing.T) {
app := newApp(t)
html := page(t, app, "/welcome")
require.Contains(t, html, "prefers-color-scheme: dark", "OS-preference dark default")
}
// TestThemeTogglePersists: every page carries a theme toggle control and a small
// script that flips data-theme and persists the choice.
func TestThemeTogglePersists(t *testing.T) {
app := newApp(t)
html := page(t, app, "/welcome")
require.Contains(t, html, "theme-toggle", "a theme toggle control")
require.Contains(t, html, "localStorage", "the choice is persisted")
require.Contains(t, html, "data-theme", "the toggle flips data-theme")
}
// TestExpandedCardEmbedsVideo: expanding a summarized card with a valid provider id
// includes the embedded video player (ADR-031/032).
func TestExpandedCardEmbedsVideo(t *testing.T) {
ctx := context.Background()
app := newApp(t)
p := rawPool(t)
resetDB(t, p)
require.NoError(t, deliver(ctx, app, videoX, "summary text"))
seedVideo(t, p, videoX, "X Title", "https://x", time.Time{})
html := body(t, do(t, app, httptest.NewRequest(http.MethodGet, "/v/"+videoX+"/expand", nil)))
require.Contains(t, html, "<iframe", "expanded card embeds a player")
require.True(t, strings.Contains(html, "youtube.com/embed") || strings.Contains(html, "youtube-nocookie.com/embed"),
"the embed is the YouTube player")
}
+1 -1
View File
@@ -7,7 +7,7 @@ import (
"github.com/stretchr/testify/require"
"gitea.d-ma.be/mathias/tapir/internal/web"
"git.d-ma.be/mathias/tapir/internal/web"
)
// fakeAuth is a configurable web.Auth for the landing-page tests: it reports a
+72
View File
@@ -0,0 +1,72 @@
# spike-media — the `/bygge` toolchain, recovered
Throwaway spike tooling for #28 (upload → transcript → takeaways). Committed
because it was found in `tmpfs` one reboot from gone, not because it is finished.
Nothing here is wired into tapir. No database, no HTTP handler, no ADR.
## What produced `/bygge`
The prototype at `tapir.d-ma.be/bygge` was **not** hand-built, which the epic and
the first round of spike issues both got wrong. It came from these three scripts:
```
IMG_1233.mov --transcode.yaml--> IMG_1233.web.mp4 (k3s Job, h264, +faststart)
IMG_1233.wav --whisper---------> IMG_1233.sv.srt (interactive — the one manual step)
IMG_1233.sv.srt --analyze_srt.py--> analys.json (berget/mistral-medium, temperature=0)
analys.json + video --build_page.py--> page HTML
```
The reference analysis was produced by **`berget/mistral-medium`** — a mid-tier
cloud model, not a frontier one. That matters when judging what a local model
has to clear: the bar is mistral-medium, and it should be re-run as the control
arm rather than excluded.
## The validator is the valuable part
`analyze_srt.py` grades its own output on four checks. Two of them catch classes
of error that timestamp- and citation-checking cannot:
| Check | Catches |
|---|---|
| citation exact / **partial** / absent | Paraphrase presented as a quote. Three tiers, because `alternativ` citations legitimately stitch non-contiguous passages — a strict substring test fails the reference output. |
| `TALET … FINNS INTE I CITATET` | A wrong number carried by a *verbatim* citation and a *real* timestamp. Converts Swedish number-words to digits first. It caught `45×230` where the transcript said `220`. |
| `TACKNINGSLUCKA` | Spans of the clip no section covers. **The recall term** — without it an empty analysis scores perfectly on every other check. |
| timestamp exists in the SRT | Fabricated seek targets. Near-solved: 74/75 on the reference, because the model copies the cue starts it was handed. |
## Known defects — read before running
- **`max_tokens: 6000` is too low.** The 4-minute reference analysis is ~4,700
output tokens, i.e. ~78% of budget. A 10-minute clip truncates for every model,
and truncation reads as a JSON-discipline failure if you are not counting
tokens. Record `finish_reason` and separate `truncated` from `malformed`.
- **`build_page.py` hardcodes the title** (`"Jonas genomgång av tillbyggnad"`).
- **The schema carries fields nothing renders**`projekt.sammanfattning`,
`projekt.skede`, `avsnitt.bildtid`, `avsnitt.bildmotiv`, `material[]`,
`oklart[]` — left over from an earlier still-frame/markdown output that
`build_page.py` replaced. They are paid for in output tokens on every run.
- **Speaker attribution is inferred, not diarized.** The output attributes
statements by name from an SRT with no speaker labels. All four checks above
are blind to a misattribution.
- **The web encode does not downscale.** The source is 720×1280; `-preset
veryfast -crf 24` on handheld motion is what makes it ~5 Mbps, not the
resolution. The lever is the preset.
## Fixtures
`fixtures/fake.sv.srt` + `fixtures/fake.analys.json` — a synthetic 7-cue Swedish
construction walkthrough with planted numbers. No real people, no real project.
It exercises the parser and all four validator checks **without a model call**,
which is what makes it usable in a test.
## Why the real artifacts are not here
**This repo is public.** The prototype's transcript and analysis are a verbatim
recording of a named person discussing a client's building project — private
third-party content, and exactly the class of data #28 calls out as the reason
uploaded transcripts cannot live in the shared `transcripts` table.
So `IMG_1233.sv.srt`, `payload.json` and the model-comparison runs stay out of
git here. That leaves `/bygge` without a versioned source of truth, which is a
real gap and a decision for #28: a private repo, an encrypted blob, or accepting
that the reference artifacts live outside version control.
+500
View File
@@ -0,0 +1,500 @@
#!/usr/bin/env python3
"""Analysera en svensk SRT-transkription av ett byggprojekt.
Steg 1 av 2: producerar tidsstämplad, källbelagd JSON som steg 2
(ffmpeg-bildutdrag + markdown-montering) konsumerar.
Varje påstående måste bära sitt ordagranna citat och sin tidsstämpel.
Det gör hallucination synlig i stället för trolig, och ger samtidigt
bildtiderna gratis.
Körning:
export DMABE_LLMAPI_KEY=... # redan satt på koala
python3 analyze_srt.py IMG_1233.sv.srt > analys.json
"""
from __future__ import annotations
import argparse
import json
import os
import re
import sys
import time
import urllib.error
import urllib.request
from dataclasses import dataclass, asdict
GATEWAY = os.environ.get("LITELLM_BASE", "http://koala:30401/v1")
DEFAULT_MODEL = "berget/mistral-medium"
# ---------------------------------------------------------------- SRT-parsning
TS = re.compile(
r"(\d{2}):(\d{2}):(\d{2})[,.](\d{3})\s*-->\s*(\d{2}):(\d{2}):(\d{2})[,.](\d{3})"
)
@dataclass
class Cue:
idx: int
start: float # sekunder
end: float
text: str
@property
def start_hms(self) -> str:
return secs_to_hms(self.start)
def secs_to_hms(s: float) -> str:
h, rem = divmod(int(s), 3600)
m, sec = divmod(rem, 60)
return f"{h:02d}:{m:02d}:{sec:02d}"
def hms_to_secs(v: str) -> float:
"""Tål '00:01:23', '01:23', '83' och '00:01:23,450'."""
v = v.strip().replace(",", ".")
parts = v.split(":")
try:
nums = [float(p) for p in parts]
except ValueError as e:
raise ValueError(f"ogiltig tidsstämpel: {v!r}") from e
if len(nums) == 3:
return nums[0] * 3600 + nums[1] * 60 + nums[2]
if len(nums) == 2:
return nums[0] * 60 + nums[1]
if len(nums) == 1:
return nums[0]
raise ValueError(f"ogiltig tidsstämpel: {v!r}")
def parse_srt(raw: str) -> list[Cue]:
"""Blockbaserad parsning. Tål saknade index och CRLF."""
raw = raw.replace("\r\n", "\n").replace("", "")
cues: list[Cue] = []
for block in re.split(r"\n\s*\n", raw.strip()):
lines = [ln for ln in block.split("\n") if ln.strip()]
if not lines:
continue
m = None
text_from = 0
for i, ln in enumerate(lines[:2]):
m = TS.search(ln)
if m:
text_from = i + 1
break
if not m:
continue
g = [int(x) for x in m.groups()]
start = g[0] * 3600 + g[1] * 60 + g[2] + g[3] / 1000
end = g[4] * 3600 + g[5] * 60 + g[6] + g[7] / 1000
text = " ".join(lines[text_from:]).strip()
if text:
cues.append(Cue(len(cues) + 1, start, end, text))
return cues
def as_numbered_transcript(cues: list[Cue]) -> str:
"""Tidsstämplad text som modellen kan citera exakt ur."""
return "\n".join(f"[{c.start_hms}] {c.text}" for c in cues)
# ------------------------------------------------------------------ LLM-anrop
SYSTEM = """Du är byggteknisk analytiker. Du analyserar en transkription från ett \
platsbesök där Jonas beskriver ett PLANERAT byggprojekt för minst en annan person.
Detta är ett SAMTAL, inte en monolog. Korta inpass som "Hela vägen?", "Vilken höjd?" \
eller "Måste det bli det?" är motpartens frågor - de driver samtalet men är inte \
Jonas påståenden. Attribuera inte en fråga som ett beslut.
Det mesta som sägs är FÖRSLAG under diskussion, inte färdiga beslut. Signalord som \
"vi tänker", "alternativt", "kanske", "förmodligen", "eventuellt", "om möjligt", \
"troligast" och "beror på" markerar något ÖPPET. Blanda aldrig ihop ett övervägt \
alternativ med ett fattat beslut - den skillnaden är hela poängen med analysen.
ABSOLUTA KRAV:
- Rapportera ENDAST det som faktiskt sägs. Dra inga egna slutsatser, lägg inte till \
byggmoment, material eller mått som inte nämns explicit.
- Varje påstående MÅSTE bära ett ordagrant citat ur transkriptionen samt dess tidsstämpel.
- Mått revideras under samtalets gång ("nio meter" -> "9,40"). Återge alla varianter \
som nämns, med citat, i stället för att välja en.
- Är något oklart, ohörbart eller avbrutet: skriv det i fältet "oklart" i stället för \
att gissa.
- Svara ENDAST med giltig JSON. Ingen markdown, inga kodstaket, ingen förklarande text."""
USER_TMPL = """Dela in transkriptionen i tematiska avsnitt (typiskt 4-8 stycken) som \
följer hur Jonas rör sig genom projektet.
TACKNINGSKRAV: avsnitten maste tillsammans tacka HELA klippet, fran 00:00:00 till \
{slut}. Forsta avsnittet borjar 00:00:00, sista avsnittet slutar {slut}, och varje \
avsnitts "start" ar lika med foregaende avsnitts "slut". Lamna inga luckor - ocksa \
korta avslutande kommentarer (tidplan, kostnader, nasta steg) ska tillhora ett avsnitt.
Svara med exakt denna JSON-struktur:
{{
"projekt": {{
"sammanfattning": "2-3 meningar om vad projektet är",
"typ": "t.ex. nybyggnad villa / tillbyggnad / renovering - eller null om det inte framgår",
"skede": "vilket byggskede som visas - eller null"
}},
"avsnitt": [
{{
"rubrik": "kort beskrivande rubrik",
"start": "HH:MM:SS",
"slut": "HH:MM:SS",
"sammanfattning": "vad Jonas beskriver i detta avsnitt, 1-3 meningar",
"nyckelpunkter": [
{{"pastaende": "vad som sags", "citat": "ordagrant citat", "tid": "HH:MM:SS"}}
],
"bildtid": "HH:MM:SS - ogonblicket dar det Jonas beskriver syns tydligast",
"bildmotiv": "vad som bor synas i bilden vid bildtid"
}}
],
"beslut": [{{"vad": "nagot som uttalas som bestamt", "citat": "ordagrant citat", "tid": "HH:MM:SS"}}],
"alternativ": [{{
"fraga": "vad som vags mot varandra",
"optioner": ["alternativ A", "alternativ B"],
"lutar_at": "det alternativ som forespraakas - eller null om oavgjort",
"citat": "ordagrant citat", "tid": "HH:MM:SS"
}}],
"oppna_fragor": [{{
"fraga": "det som inte ar avgjort",
"beror_pa": "vad avgorandet hanger pa - eller null",
"citat": "ordagrant citat", "tid": "HH:MM:SS"
}}],
"matt": [{{"vad": "matt/dimension", "citat": "ordagrant citat", "tid": "HH:MM:SS"}}],
"material": [{{"vad": "material/produkt", "citat": "ordagrant citat", "tid": "HH:MM:SS"}}],
"oklart": ["saker som ar ohorbara, avbrutna eller tvetydiga i transkriptionen"]
}}
Valj "bildtid" omsorgsfullt. I detta samtal pekar Jonas standigt pa saker med ord som \
"har", "hit", "den typ sa" - orden ar obegripliga utan bilden, sa bildtiden ska ligga \
dar foremalet han syftar pa syns, inte dar han byter amne. Tomma listor ar helt i sin \
ordning om inget relevant namns.
TRANSKRIPTION:
{transcript}"""
def call_llm(
model: str, transcript: str, slut: str, timeout: int = 300, retries: int = 2
) -> str:
key = os.environ.get("DMABE_LLMAPI_KEY") or os.environ.get("LITELLM_API_KEY")
if not key:
sys.exit("DMABE_LLMAPI_KEY (eller LITELLM_API_KEY) saknas i miljon.")
body = json.dumps(
{
"model": model,
"temperature": 0,
"max_tokens": 6000,
"messages": [
{"role": "system", "content": SYSTEM},
{"role": "user", "content": USER_TMPL.format(transcript=transcript, slut=slut)},
],
}
).encode()
# Gatewayen begransar burst (limit_req burst=5) och berget-anropet kan
# spika. TimeoutError arvs INTE av URLError, sa den maste fangas separat -
# annars blir felet en rasptrace efter flera minuters vantan.
last = ""
for attempt in range(retries + 1):
req = urllib.request.Request(
f"{GATEWAY}/chat/completions",
data=body,
headers={"Authorization": f"Bearer {key}", "Content-Type": "application/json"},
)
try:
with urllib.request.urlopen(req, timeout=timeout) as r:
payload = json.load(r)
break
except urllib.error.HTTPError as e:
detail = e.read()[:400].decode(errors="replace")
if e.code == 429 and attempt < retries:
last = f"HTTP 429 (rate limit)"
elif e.code >= 500 and attempt < retries:
last = f"HTTP {e.code}"
else:
sys.exit(f"gateway HTTP {e.code}: {detail}")
except (TimeoutError, urllib.error.URLError, OSError) as e:
reason = getattr(e, "reason", e)
if attempt < retries:
last = f"{type(e).__name__}: {reason}"
else:
sys.exit(
f"nadde inte gateway {GATEWAY} efter {retries + 1} forsok "
f"({type(e).__name__}: {reason}). Kontrollera att koala:30401 svarar."
)
backoff = 3 * (attempt + 1)
print(f"[!] {last} - forsok {attempt + 2}/{retries + 1} om {backoff}s", file=sys.stderr)
time.sleep(backoff)
else:
sys.exit(f"gav upp efter {retries + 1} forsok: {last}")
choice = payload["choices"][0]
content = (choice.get("message") or {}).get("content") or ""
if not content.strip():
# Kant lage for thinking-modeller: resonemanget ater hela budgeten.
sys.exit(
f"tomt svar (finish_reason={choice.get('finish_reason')}). "
"Valj en icke-resonerande modell for strukturerad utdata."
)
return content
def extract_json(s: str) -> dict:
s = s.strip()
if s.startswith("```"):
s = re.sub(r"^```(?:json)?\s*", "", s)
s = re.sub(r"\s*```$", "", s)
try:
return json.loads(s)
except json.JSONDecodeError:
# Sista utvag: forsta {...sista }
i, j = s.find("{"), s.rfind("}")
if i >= 0 and j > i:
return json.loads(s[i : j + 1])
raise
# -------------------------------------------------------------- Verifiering
def norm(s: str) -> str:
return re.sub(r"[^\wåäöÅÄÖ]+", " ", (s or "").lower()).strip()
# --- svenska talord -> siffror -------------------------------------------
# Byggmatt sags i klartext ("fyrtiofem ganger tvahundratjugo") men refereras
# i siffror. Utan denna oversattning kan ett felaktigt matt bara ett giltigt
# ordagrant citat och passera oupptackt - den farligaste feltypen i ett
# byggdokument.
_SW_UNITS = {
"noll": 0, "en": 1, "ett": 1, "två": 2, "tva": 2, "tre": 3, "fyra": 4,
"fem": 5, "sex": 6, "sju": 7, "åtta": 8, "atta": 8, "nio": 9, "tio": 10,
"elva": 11, "tolv": 12, "tretton": 13, "fjorton": 14, "femton": 15,
"sexton": 16, "sjutton": 17, "arton": 18, "nitton": 19,
}
_SW_TENS = {
"tjugo": 20, "trettio": 30, "fyrtio": 40, "femtio": 50, "sextio": 60,
"sjuttio": 70, "åttio": 80, "attio": 80, "nittio": 90,
}
_SW_ATOMS = {**_SW_UNITS, **_SW_TENS, "hundra": 100, "tusen": 1000}
# Langsta forst sa att "sexton" vinner over "sex", "attio" over "atta".
_SW_ORDER = sorted(_SW_ATOMS, key=len, reverse=True)
def _sw_word_to_int(word: str) -> int | None:
"""'tvåhundratjugo' -> 220. None om ordet inte ar ett rent talord."""
w = word.lower()
toks: list[str] = []
while w:
for atom in _SW_ORDER:
if w.startswith(atom):
toks.append(atom)
w = w[len(atom) :]
break
else:
return None
if not toks:
return None
total = current = 0
for t in toks:
v = _SW_ATOMS[t]
if t == "hundra":
current = (current or 1) * 100
elif t == "tusen":
total += (current or 1) * 1000
current = 0
else:
current += v
return total + current
def numbers_in(text: str) -> set[float]:
"""Alla tal i texten, som siffror och som svenska talord.
Svenska anvander decimalkomma ("9,40" = 9.4). Naiv \\d+-matchning skulle
lasa det som tva separata heltal 9 och 40 - och just har revideras matten
live i samtalet ("nio meter" -> "9,40"), sa den skillnaden ar betydelsebarande.
"""
found: set[float] = set()
for m in re.finditer(r"\d+(?:[.,]\d+)?", text or ""):
found.add(float(m.group().replace(",", ".")))
for w in re.findall(r"[a-zåäöA-ZÅÄÖ]+", text or ""):
# "en"/"ett" ar obestamd artikel vida oftare an raknetalet 1
# ("ett sedumtak"), sa fristaende traffar ger falsklarm. Sammansatta
# former ("etthundra") fangas anda av den giriga tokeniseringen.
if len(w) < 3 or w.lower() in {"en", "ett"}:
continue
v = _sw_word_to_int(w)
if v is not None and v > 0:
found.add(float(v))
return found
def fmt_num(v: float) -> str:
return str(int(v)) if v == int(v) else f"{v:g}"
def unsupported(claimed: set[float], supported: set[float]) -> list[float]:
"""Tal i pastaendet som inte styrks av citatet (med liten tolerans)."""
return sorted(
n for n in claimed if not any(abs(n - s) < 0.01 for s in supported)
)
def verify(doc: dict, cues: list[Cue], duration: float) -> list[str]:
"""Varje citat ska ga att aterfinna; varje tid ska ligga inom klippet.
Detta ar sjalva poangen med strukturen - ett pastaende utan verifierbar
kalla ar en hallucination, och den ska synas har och inte i dokumentet.
"""
warnings: list[str] = []
haystack = norm(" ".join(c.text for c in cues))
def check_quote(where: str, quote: str) -> None:
n = norm(quote)
if not n:
warnings.append(f"{where}: tomt citat")
return
if n in haystack:
return
# Delvis traff: minst 60 % av orden i foljd nagonstans
words = n.split()
if len(words) >= 4:
for size in (len(words), max(4, int(len(words) * 0.6))):
for i in range(0, len(words) - size + 1):
if " ".join(words[i : i + size]) in haystack:
warnings.append(f"{where}: citat matchar bara delvis: {quote!r}")
return
warnings.append(f"{where}: CITAT SAKNAS I TRANSKRIPTION: {quote!r}")
def check_time(where: str, t: str) -> None:
try:
v = hms_to_secs(t)
except ValueError:
warnings.append(f"{where}: ogiltig tid {t!r}")
return
if not (0 <= v <= duration + 1):
warnings.append(f"{where}: tid {t} utanfor klippet (0-{secs_to_hms(duration)})")
for i, sec in enumerate(doc.get("avsnitt") or [], 1):
tag = f"avsnitt {i} ({sec.get('rubrik','?')})"
for f in ("start", "slut", "bildtid"):
if sec.get(f):
check_time(f"{tag}.{f}", sec[f])
for j, kp in enumerate(sec.get("nyckelpunkter") or [], 1):
check_quote(f"{tag}.nyckelpunkt {j}", kp.get("citat", ""))
if kp.get("tid"):
check_time(f"{tag}.nyckelpunkt {j}.tid", kp["tid"])
def check_numbers(where: str, claim: str, quote: str) -> None:
"""Varje tal i pastaendet maste finnas i dess eget citat."""
claimed = numbers_in(claim)
if not claimed:
return
supported = numbers_in(quote)
for n in unsupported(claimed, supported):
stod = ", ".join(fmt_num(s) for s in sorted(supported)) or "inga tal"
warnings.append(
f"{where}: TALET {fmt_num(n)} FINNS INTE I CITATET "
f"(citat stoder {stod}): {claim!r}"
)
for i, sec in enumerate(doc.get("avsnitt") or [], 1):
tag = f"avsnitt {i} ({sec.get('rubrik','?')})"
for j, kp in enumerate(sec.get("nyckelpunkter") or [], 1):
check_numbers(f"{tag}.nyckelpunkt {j}", kp.get("pastaende", ""), kp.get("citat", ""))
for key in ("material", "matt", "beslut"):
for j, it in enumerate(doc.get(key) or [], 1):
check_quote(f"{key}[{j}]", it.get("citat", ""))
if it.get("tid"):
check_time(f"{key}[{j}].tid", it["tid"])
check_numbers(f"{key}[{j}]", it.get("vad", ""), it.get("citat", ""))
# Tackningskontroll: avsnitten ska tacka hela klippet utan hal, annars
# tappas material tyst (och inget bildutdrag gors for den luckan).
spans: list[tuple[float, float]] = []
for sec in doc.get("avsnitt") or []:
try:
spans.append((hms_to_secs(sec["start"]), hms_to_secs(sec["slut"])))
except (KeyError, ValueError):
continue
spans.sort()
cursor = 0.0
for s, e in spans:
if s - cursor > 2.0:
gap_text = " ".join(
c.text for c in cues if c.start >= cursor - 0.5 and c.end <= s + 0.5
)
warnings.append(
f"TACKNINGSLUCKA {secs_to_hms(cursor)}-{secs_to_hms(s)} "
f"({s - cursor:.0f}s utan avsnitt): {gap_text[:90]!r}"
)
cursor = max(cursor, e)
if duration - cursor > 2.0:
warnings.append(
f"TACKNINGSLUCKA {secs_to_hms(cursor)}-{secs_to_hms(duration)} "
f"({duration - cursor:.0f}s i slutet utan avsnitt)"
)
return warnings
# ------------------------------------------------------------------- main
def main() -> None:
ap = argparse.ArgumentParser(description=__doc__)
ap.add_argument("srt")
ap.add_argument("--model", default=DEFAULT_MODEL)
ap.add_argument("--raw", action="store_true", help="skriv aven modellens rasvar till stderr")
args = ap.parse_args()
with open(args.srt, encoding="utf-8") as fh:
cues = parse_srt(fh.read())
if not cues:
sys.exit(f"inga cues hittades i {args.srt} - fel format?")
duration = cues[-1].end
print(
f"[i] {len(cues)} cues, langd {secs_to_hms(duration)}, modell {args.model}",
file=sys.stderr,
)
content = call_llm(args.model, as_numbered_transcript(cues), secs_to_hms(duration))
if args.raw:
print(content, file=sys.stderr)
try:
doc = extract_json(content)
except json.JSONDecodeError as e:
sys.exit(f"modellen returnerade ogiltig JSON: {e}\n---\n{content[:800]}")
warnings = verify(doc, cues, duration)
doc["_meta"] = {
"modell": args.model,
"kallfil": os.path.basename(args.srt),
"antal_cues": len(cues),
"langd": secs_to_hms(duration),
"varningar": warnings,
}
print(json.dumps(doc, ensure_ascii=False, indent=2))
if warnings:
print(f"\n[!] {len(warnings)} verifieringsvarningar:", file=sys.stderr)
for w in warnings:
print(f" - {w}", file=sys.stderr)
else:
print("[ok] alla citat aterfunna, alla tider inom klippet", file=sys.stderr)
if __name__ == "__main__":
main()
+357
View File
@@ -0,0 +1,357 @@
#!/usr/bin/env python3
"""Bygg en fristaende HTML-sida: video + SRT + tidssynkad analys.
Sidan ar sjalvbarande sa nar som pa videofilen - transkription och analys
bakas in i HTML:en. Det gor att den fungerar aven over file:// (fetch mot
en lokal JSON blockeras av CORS).
python3 build_page.py payload.json IMG_1233.web.mp4 > index.html
"""
from __future__ import annotations
import html
import json
import sys
TEMPLATE = """<!doctype html>
<html lang="sv">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>__TITLE__</title>
<style>
:root {
--bg:#f6f7f9; --panel:#fff; --ink:#16181d; --muted:#6b7280; --line:#e3e6ea;
--accent:#2563eb; --accent-soft:#eff4ff;
--beslut:#0f9d58; --alt:#d97706; --oppen:#dc2626; --matt:#7c3aed;
}
@media (prefers-color-scheme: dark) {
:root {
--bg:#0e1116; --panel:#161a21; --ink:#e6e9ef; --muted:#9aa3b2; --line:#262c36;
--accent:#60a5fa; --accent-soft:#17233b;
--beslut:#34d399; --alt:#fbbf24; --oppen:#f87171; --matt:#a78bfa;
}
}
:root[data-theme="dark"] {
--bg:#0e1116; --panel:#161a21; --ink:#e6e9ef; --muted:#9aa3b2; --line:#262c36;
--accent:#60a5fa; --accent-soft:#17233b;
--beslut:#34d399; --alt:#fbbf24; --oppen:#f87171; --matt:#a78bfa;
}
:root[data-theme="light"] {
--bg:#f6f7f9; --panel:#fff; --ink:#16181d; --muted:#6b7280; --line:#e3e6ea;
--accent:#2563eb; --accent-soft:#eff4ff;
--beslut:#0f9d58; --alt:#d97706; --oppen:#dc2626; --matt:#7c3aed;
}
* { box-sizing:border-box; }
body {
margin:0; background:var(--bg); color:var(--ink);
font:15px/1.55 -apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif;
}
header {
padding:14px 20px; border-bottom:1px solid var(--line); background:var(--panel);
display:flex; align-items:baseline; gap:14px; flex-wrap:wrap;
}
header h1 { margin:0; font-size:17px; font-weight:650; }
header .sub { color:var(--muted); font-size:13px; }
.wrap {
display:grid; grid-template-columns: minmax(0,1.35fr) minmax(320px,1fr);
gap:20px; padding:20px; align-items:start; max-width:1600px; margin:0 auto;
}
@media (max-width: 950px) { .wrap { grid-template-columns:1fr; } }
/* Videon star stilla; analysen ror sig bredvid. */
.videocol { position:sticky; top:20px; }
video { width:100%; border-radius:10px; background:#000; display:block; }
.nowbar {
margin-top:10px; padding:12px 14px; background:var(--panel);
border:1px solid var(--line); border-radius:10px; min-height:64px;
}
.nowbar .label { font-size:11px; text-transform:uppercase; letter-spacing:.07em; color:var(--muted); }
.nowbar .text { margin-top:4px; font-size:16px; }
.tabs { display:flex; gap:6px; margin-bottom:12px; flex-wrap:wrap; }
.tab {
padding:6px 12px; border:1px solid var(--line); background:var(--panel);
border-radius:999px; cursor:pointer; font-size:13px; color:var(--ink);
}
.tab[aria-selected="true"] { background:var(--accent); border-color:var(--accent); color:#fff; }
.pane { display:none; }
.pane.active { display:block; }
.scroller {
max-height: calc(100vh - 190px); overflow-y:auto; padding-right:6px;
scroll-behavior:smooth;
}
.card {
background:var(--panel); border:1px solid var(--line); border-left:3px solid var(--line);
border-radius:8px; padding:11px 13px; margin-bottom:9px; cursor:pointer;
transition:border-color .15s, background .15s;
}
.card:hover { border-color:var(--accent); }
.card.on { background:var(--accent-soft); border-left-color:var(--accent); }
.card .k {
font-size:10px; text-transform:uppercase; letter-spacing:.07em;
font-weight:700; margin-bottom:5px;
}
.card .t { font-size:11px; color:var(--muted); font-variant-numeric:tabular-nums; }
.card .cit { display:block; margin-top:6px; color:var(--muted); font-size:13px; font-style:italic; }
.k.beslut{color:var(--beslut)} .k.alt{color:var(--alt)}
.k.oppen{color:var(--oppen)} .k.matt{color:var(--matt)}
.card.beslut{border-left-color:var(--beslut)} .card.alt{border-left-color:var(--alt)}
.card.oppen{border-left-color:var(--oppen)} .card.matt{border-left-color:var(--matt)}
.opts { margin:6px 0 0; padding-left:18px; font-size:13px; }
.lutar { font-size:12px; color:var(--muted); margin-top:5px; }
.cue {
padding:5px 9px; border-radius:6px; cursor:pointer; display:flex; gap:10px;
font-size:14px; border-left:2px solid transparent;
}
.cue:hover { background:var(--accent-soft); }
.cue.on { background:var(--accent-soft); border-left-color:var(--accent); font-weight:600; }
.cue .ts {
color:var(--muted); font-variant-numeric:tabular-nums; font-size:12px;
flex:0 0 46px; padding-top:2px;
}
.sec-head {
margin:16px 0 7px; font-size:12px; font-weight:700; text-transform:uppercase;
letter-spacing:.06em; color:var(--muted); border-bottom:1px solid var(--line);
padding-bottom:5px;
}
.sec-head:first-child { margin-top:0; }
.warn {
background:var(--panel); border:1px solid var(--line); border-left:3px solid var(--alt);
border-radius:8px; padding:11px 13px; font-size:13px; color:var(--muted);
}
.warn b { color:var(--ink); }
.warn ul { margin:6px 0 0; padding-left:18px; }
</style>
</head>
<body>
<header>
<h1>__TITLE__</h1>
<span class="sub">__SUB__</span>
</header>
<div class="wrap">
<div class="videocol">
<video id="v" controls preload="metadata" src="__VIDEO__"></video>
<div class="nowbar">
<div class="label">Sägs nu</div>
<div class="text" id="now"></div>
</div>
</div>
<div>
<div class="tabs" role="tablist">
<button class="tab" role="tab" aria-selected="true" data-pane="analys">Analys</button>
<button class="tab" role="tab" aria-selected="false" data-pane="text">Transkription</button>
<button class="tab" role="tab" aria-selected="false" data-pane="kvalitet">Kvalitet</button>
</div>
<div class="pane active" id="pane-analys"><div class="scroller" id="analys"></div></div>
<div class="pane" id="pane-text"><div class="scroller" id="text"></div></div>
<div class="pane" id="pane-kvalitet"><div class="scroller" id="kvalitet"></div></div>
</div>
</div>
<script>
const DATA = __DATA__;
const v = document.getElementById('v');
const hms = s => {
s = Math.max(0, Math.floor(s));
return String(Math.floor(s/60)).padStart(2,'0') + ':' + String(s%60).padStart(2,'0');
};
const toSec = t => {
if (t == null) return null;
const p = String(t).trim().split(':').map(Number);
if (p.some(isNaN)) return null;
return p.length === 3 ? p[0]*3600+p[1]*60+p[2] : p.length === 2 ? p[0]*60+p[1] : p[0];
};
const esc = s => { const d = document.createElement('div'); d.textContent = s ?? ''; return d.innerHTML; };
/* Varje kort bar sin tid; ett klick soker dit. Analysen ar tidsforankrad
hela vagen ned, sa synkningen ar bara en sortering pa den tiden. */
const items = [];
const A = DATA.analys;
(A.avsnitt || []).forEach((s, i) => {
const t = toSec(s.start) ?? 0;
items.push({t, end: toSec(s.slut) ?? 1e9, kind:'avsnitt', html:
`<div class="k">Avsnitt ${i+1} · ${esc(s.start)}${esc(s.slut)}</div>
<div style="font-weight:650;font-size:15px">${esc(s.rubrik)}</div>
<div style="margin-top:5px">${esc(s.sammanfattning)}</div>`});
(s.nyckelpunkter || []).forEach(k => {
const kt = toSec(k.tid); if (kt == null) return;
items.push({t:kt, end:kt+6, kind:'punkt', citat:k.citat, html:
`<div class="t">${esc(k.tid)}</div><div>${esc(k.pastaende)}</div>
<div class="cit">${esc(k.citat)}</div>`});
});
});
(A.beslut || []).forEach(b => {
const t = toSec(b.tid); if (t == null) return;
items.push({t, end:t+8, kind:'beslut', citat:b.citat, html:
`<div class="k beslut">Beslut</div><div class="t">${esc(b.tid)}</div>
<div style="font-weight:600">${esc(b.vad)}</div>
${b.skal ? `<div class="lutar">Skäl: ${esc(b.skal)}</div>` : ''}
<div class="cit">${esc(b.citat)}</div>`});
});
(A.alternativ || []).forEach(a => {
const t = toSec(a.tid); if (t == null) return;
items.push({t, end:t+8, kind:'alt', citat:a.citat, html:
`<div class="k alt">Alternativ</div><div class="t">${esc(a.tid)}</div>
<div style="font-weight:600">${esc(a.fraga)}</div>
<ul class="opts">${(a.optioner||[]).map(o=>`<li>${esc(o)}</li>`).join('')}</ul>
<div class="lutar">${a.lutar_at ? 'Lutar åt: <b>'+esc(a.lutar_at)+'</b>' : 'Oavgjort'}</div>
<div class="cit">${esc(a.citat)}</div>`});
});
(A.oppna_fragor || []).forEach(o => {
const t = toSec(o.tid); if (t == null) return;
items.push({t, end:t+8, kind:'oppen', citat:o.citat, html:
`<div class="k oppen">Öppen fråga</div><div class="t">${esc(o.tid)}</div>
<div style="font-weight:600">${esc(o.fraga)}</div>
${o.beror_pa ? `<div class="lutar">Beror : ${esc(o.beror_pa)}</div>` : ''}
<div class="cit">${esc(o.citat)}</div>`});
});
(A.matt || []).forEach(m => {
const t = toSec(m.tid); if (t == null) return;
items.push({t, end:t+6, kind:'matt', citat:m.citat, html:
`<div class="k matt">Mått</div><div class="t">${esc(m.tid)}</div>
<div>${esc(m.vad)}</div><div class="cit">${esc(m.citat)}</div>`});
});
const RANK = {avsnitt:0, beslut:1, oppen:2, alt:3, matt:4, punkt:5};
/* Samma replik klassas ofta bade som nyckelpunkt och som matt/beslut.
Visa den mest specifika varianten en gang i stallet for tva nastan
identiska kort - annars later flodet som ett eko. */
const bestByQuote = new Map();
items.forEach(it => {
const key = (it.citat||'').trim().toLowerCase() + '@' + it.t;
if (!it.citat) return;
const prev = bestByQuote.get(key);
if (prev === undefined || RANK[it.kind] < RANK[prev.kind]) bestByQuote.set(key, it);
});
const kept = items.filter(it => {
if (!it.citat) return true;
const key = (it.citat||'').trim().toLowerCase() + '@' + it.t;
return bestByQuote.get(key) === it;
});
items.length = 0; items.push(...kept);
items.sort((a,b) => a.t - b.t || RANK[a.kind] - RANK[b.kind]);
const analysEl = document.getElementById('analys');
analysEl.innerHTML = items.map((it,i) =>
`<div class="card ${it.kind}" data-i="${i}" data-t="${it.t}">${it.html}</div>`).join('');
const textEl = document.getElementById('text');
textEl.innerHTML = DATA.cues.map((c,i) =>
`<div class="cue" data-ci="${i}" data-t="${c.t}">
<span class="ts">${hms(c.t)}</span><span>${esc(c.text)}</span></div>`).join('');
const warns = (A._meta && A._meta.varningar) || [];
document.getElementById('kvalitet').innerHTML = `
<div class="warn">
<b>Automatisk källkontroll</b><br>
Modell: ${esc((A._meta&&A._meta.modell)||'')} · ${DATA.cues.length} repliker · längd ${esc((A._meta&&A._meta.langd)||'')}<br><br>
Varje påstående kontrolleras mot sitt eget citat: citatet måste återfinnas
ordagrant i transkriptionen, tiden måste ligga inom klippet, och varje tal i
påståendet måste styrkas av citatet.
${warns.length
? `<br><br><b>${warns.length} varningar:</b><ul>${warns.map(w=>`<li>${esc(w)}</li>`).join('')}</ul>`
: '<br><br>Inga avvikelser.'}
</div>`;
/* Klick söker i videon - åt båda håll: kort och replik. */
document.addEventListener('click', e => {
const el = e.target.closest('.card, .cue');
if (!el) return;
v.currentTime = parseFloat(el.dataset.t) + 0.05;
v.play().catch(()=>{});
});
document.querySelectorAll('.tab').forEach(tab => {
tab.addEventListener('click', () => {
document.querySelectorAll('.tab').forEach(t => t.setAttribute('aria-selected','false'));
tab.setAttribute('aria-selected','true');
document.querySelectorAll('.pane').forEach(p => p.classList.remove('active'));
document.getElementById('pane-' + tab.dataset.pane).classList.add('active');
});
});
/* Auto-scroll bara nar anvandaren inte sjalv scrollar - annars slits vyn
ur handerna pa den som lasar. */
let userScrolled = 0;
document.querySelectorAll('.scroller').forEach(s =>
s.addEventListener('wheel', () => { userScrolled = Date.now(); }, {passive:true}));
let lastCard = -1, lastCue = -1;
function sync() {
const t = v.currentTime;
let ci = -1;
for (let i = 0; i < DATA.cues.length; i++) {
if (t >= DATA.cues[i].t - 0.15) ci = i; else break;
}
if (ci !== lastCue) {
document.querySelectorAll('.cue.on').forEach(e => e.classList.remove('on'));
if (ci >= 0) {
const el = textEl.querySelector(`[data-ci="${ci}"]`);
if (el) {
el.classList.add('on');
if (Date.now() - userScrolled > 3000) el.scrollIntoView({block:'center'});
}
document.getElementById('now').textContent = DATA.cues[ci].text;
}
lastCue = ci;
}
let ai = -1;
for (let i = 0; i < items.length; i++) {
if (t >= items[i].t - 0.15) ai = i; else break;
}
if (ai !== lastCard) {
document.querySelectorAll('.card.on').forEach(e => e.classList.remove('on'));
if (ai >= 0) {
const el = analysEl.querySelector(`[data-i="${ai}"]`);
if (el) {
el.classList.add('on');
if (Date.now() - userScrolled > 3000 &&
document.getElementById('pane-analys').classList.contains('active')) {
el.scrollIntoView({block:'center'});
}
}
}
lastCard = ai;
}
requestAnimationFrame(sync);
}
requestAnimationFrame(sync);
</script>
</body>
</html>
"""
def main() -> None:
payload_path, video = sys.argv[1], sys.argv[2]
payload = json.load(open(payload_path, encoding="utf-8"))
a = payload["analys"]
proj = a.get("projekt") or {}
sub = " · ".join(
x for x in [proj.get("typ"), (a.get("_meta") or {}).get("langd"), video] if x
)
out = (
TEMPLATE.replace("__TITLE__", html.escape("Jonas genomgång av tillbyggnad"))
.replace("__SUB__", html.escape(sub))
.replace("__VIDEO__", html.escape(video))
.replace("__DATA__", json.dumps(payload, ensure_ascii=False))
)
sys.stdout.write(out)
if __name__ == "__main__":
main()
@@ -0,0 +1,173 @@
{
"projekt": {
"sammanfattning": "Projektet är en nybyggnad med grundläggning, betongplatta och limträstomme. Taket kommer att vara ett sedumtak.",
"typ": "nybyggnad",
"skede": "stomme"
},
"avsnitt": [
{
"rubrik": "Introduktion och grundläggning",
"start": "00:00:00",
"slut": "00:00:06",
"sammanfattning": "Jonas introducerar platsen och meddelar att grundläggningen är klar.",
"nyckelpunkter": [
{
"pastaende": "Grundläggningen är klar",
"citat": "Vi har precis blivit klara med grundläggningen.",
"tid": "00:00:03"
}
],
"bildtid": "00:00:03",
"bildmotiv": "tomten med färdig grundläggning"
},
{
"rubrik": "Dränering och grundvatten",
"start": "00:00:06",
"slut": "00:00:14",
"sammanfattning": "Jonas beskriver dräneringsarbetet som krävdes på grund av högt grundvatten.",
"nyckelpunkter": [
{
"pastaende": "Dränering utfördes på grund av högt grundvatten",
"citat": "Vi fick dränera ordentligt först, för grundvattnet stod väldigt högt här.",
"tid": "00:00:06"
}
],
"bildtid": "00:00:10",
"bildmotiv": "dräneringssystem eller markarbete"
},
{
"rubrik": "Betongplatta och isolering",
"start": "00:00:14",
"slut": "00:00:22",
"sammanfattning": "Jonas beskriver betongplattan och isoleringen under den.",
"nyckelpunkter": [
{
"pastaende": "Betongplatta på mark med cellplast under",
"citat": "det är en betongplatta på mark med tvåhundra millimeter cellplast under.",
"tid": "00:00:16"
}
],
"bildtid": "00:00:18",
"bildmotiv": "betongplattan med cellplast"
},
{
"rubrik": "Stomme och bjälklag",
"start": "00:00:22",
"slut": "00:00:31",
"sammanfattning": "Jonas beskriver stommen i limträ och bjälklagets dimensioner.",
"nyckelpunkter": [
{
"pastaende": "Stommen är i limträ",
"citat": "Här ser ni stommen, den är i limträ.",
"tid": "00:00:22"
},
{
"pastaende": "Pelarnas dimensioner",
"citat": "Pelarna är hundraåttio gånger hundraåttio.",
"tid": "00:00:25"
},
{
"pastaende": "Bjälklagets spännvidd och dimensioner",
"citat": "Bjälklaget spänner sex meter fritt, och vi har fyrtiofem gånger tvåhundratjugo som balkar.",
"tid": "00:00:31"
}
],
"bildtid": "00:00:28",
"bildmotiv": "limträstommen med pelare och bjälklag"
},
{
"rubrik": "Tak och tidplan",
"start": "00:00:40",
"slut": "00:00:48",
"sammanfattning": "Jonas nämner taktypen och förklarar att projektet är försenat på grund av leveransproblem.",
"nyckelpunkter": [
{
"pastaende": "Taket kommer att vara sedumtak",
"citat": "Taket blir sedumtak, det var byggherrens önskemål.",
"tid": "00:00:40"
},
{
"pastaende": "Sedumtaket ställer krav på bärigheten",
"citat": "Det ställer krav på bärigheten.",
"tid": "00:00:44"
},
{
"pastaende": "Projektet är två veckor efter tidplanen",
"citat": "Vi ligger ungefär två veckor efter tidplanen på grund av leveransproblem på limträet.",
"tid": "00:00:48"
}
],
"bildtid": "00:00:44",
"bildmotiv": "stommen med eventuella förberedelser för taket"
}
],
"material": [
{
"vad": "cellplast",
"citat": "med tvåhundra millimeter cellplast under",
"tid": "00:00:18"
},
{
"vad": "betongplatta",
"citat": "det är en betongplatta på mark",
"tid": "00:00:16"
},
{
"vad": "limträ",
"citat": "den är i limträ",
"tid": "00:00:22"
},
{
"vad": "sedumtak",
"citat": "Taket blir sedumtak",
"tid": "00:00:40"
}
],
"matt": [
{
"vad": "200 mm cellplast",
"citat": "tvåhundra millimeter cellplast",
"tid": "00:00:18"
},
{
"vad": "180x180 mm pelare",
"citat": "Pelarna är hundraåttio gånger hundraåttio",
"tid": "00:00:25"
},
{
"vad": "6 meter spännvidd för bjälklag",
"citat": "Bjälklaget spänner sex meter fritt",
"tid": "00:00:31"
},
{
"vad": "45x230 mm balkar",
"citat": "fyrtiofem gånger tvåhundratjugo som balkar",
"tid": "00:00:31"
}
],
"problem": [
{
"vad": "högt grundvatten",
"citat": "grundvattnet stod väldigt högt här",
"tid": "00:00:10"
},
{
"vad": "leveransproblem med limträ",
"citat": "leveransproblem på limträet",
"tid": "00:00:48"
},
{
"vad": "försenat projekt",
"citat": "Vi ligger ungefär två veckor efter tidplanen",
"tid": "00:00:48"
}
],
"oklart": [],
"_meta": {
"modell": "berget/mistral-medium",
"kallfil": "fake.sv.srt",
"antal_cues": 7,
"langd": "00:00:56",
"varningar": []
}
}
+27
View File
@@ -0,0 +1,27 @@
1
00:00:00,000 --> 00:00:06,500
Ja, då står vi här på tomten. Vi har precis blivit klara med grundläggningen.
2
00:00:06,500 --> 00:00:14,000
Vi fick dränera ordentligt först, för grundvattnet stod väldigt högt här.
3
00:00:14,000 --> 00:00:22,000
Sen göt vi plattan, det är en betongplatta på mark med tvåhundra millimeter cellplast under.
4
00:00:22,000 --> 00:00:31,000
Här ser ni stommen, den är i limträ. Pelarna är hundraåttio gånger hundraåttio.
5
00:00:31,000 --> 00:00:40,000
Bjälklaget spänner sex meter fritt, och vi har fyrtiofem gånger tvåhundratjugo som balkar.
6
00:00:40,000 --> 00:00:48,000
Taket blir sedumtak, det var byggherrens önskemål. Det ställer krav på bärigheten.
7
00:00:48,000 --> 00:00:56,000
Vi ligger ungefär två veckor efter tidplanen på grund av leveransproblem på limträet.
+51
View File
@@ -0,0 +1,51 @@
apiVersion: batch/v1
kind: Job
metadata:
name: jonas-transcode
namespace: default
spec:
ttlSecondsAfterFinished: 600
activeDeadlineSeconds: 1800
backoffLimit: 0
template:
spec:
restartPolicy: Never
nodeSelector:
kubernetes.io/hostname: koala
securityContext:
runAsUser: 1000
runAsGroup: 1000
fsGroup: 1000
containers:
- name: ffmpeg
image: mwader/static-ffmpeg:7.1
args:
- "-nostdin"
- "-i"
- "/work/IMG_1233.mov"
- "-c:v"
- "libx264"
- "-preset"
- "veryfast"
- "-crf"
- "24"
- "-vf"
- "scale='min(1280,iw)':-2"
- "-c:a"
- "aac"
- "-b:a"
- "128k"
- "-movflags"
- "+faststart"
- "-y"
- "/work/IMG_1233.web.mp4"
volumeMounts:
- name: work
mountPath: /work
resources:
limits: {cpu: "6", memory: "4Gi"}
volumes:
- name: work
hostPath:
path: /home/mathias/dev/AGENTS/agentsquad
type: Directory
+24
View File
@@ -25,6 +25,30 @@ import (
// fails if a scenario is unmapped, a mapped test is missing, or an entry no
// longer matches a real non-pending scenario.
var scenarioCoverage = map[string]string{
// inline_expand.feature (ADR-031, #16)
"A summarized card expands to the full summary in place": "TestExpandReturnsSummaryBodyFragment",
"An expanded card collapses back to the compact card": "TestCollapseReturnsCompactCard",
"The expanded card offers the Q&A dock": "TestExpandedCardOffersChatDock",
"Only a summarized card offers expand": "TestCompactCardExpandOnlyWhenSummarized",
"With JS off the card still reaches the full summary": "TestCompactCardHasNoJSDetailFallback",
"The detail page and the expanded card show the same summary": "TestDetailAndExpandShareSummaryBody",
// visual_theme.feature (ADR-032, #17)
"Light and dark themes share one layout via CSS variables": "TestThemeHasLightAndDarkPalettes",
"Without a stored choice the theme follows the OS preference": "TestThemeFollowsOSPreference",
"A persisted toggle switches light and dark": "TestThemeTogglePersists",
"The expanded card embeds the video player": "TestExpandedCardEmbedsVideo",
// observability.feature (ADR-030, #15)
"Summarization latency is recorded per endpoint": "TestSummarizerRecordsMetric",
"A failing summarizer endpoint records its failure outcome": "TestObserveSummarizeRecordsFailureOutcomes",
"Caption fetch latency is recorded by outcome": "TestObserveCaptionFetchByOutcome",
"LLM token usage is recorded from the completion": "TestClient_UsageHookRecordsTokens",
"Q&A answer latency is recorded": "TestChatAnswerLatencyRecorded",
"HTTP requests are counted by route, method, and status": "TestHTTPMiddlewareRecordsByRoutePattern",
"A successful login is counted": "TestLoginCounted",
"The metrics endpoint is not on the public app port": "TestMetricsNotOnPublicMux",
// ai_routing.feature
"Local AI produces the summary": "TestSummarize_LocalSucceeds",
"Local AI fails and the user has a BYO provider configured": "TestSummarize_FallsBackToBYO",
+2 -2
View File
@@ -12,8 +12,8 @@ import (
"testing"
"time"
"gitea.d-ma.be/mathias/tapir/internal/domain"
"gitea.d-ma.be/mathias/tapir/internal/usecase"
"git.d-ma.be/mathias/tapir/internal/domain"
"git.d-ma.be/mathias/tapir/internal/usecase"
)
// --- fake adapters ---------------------------------------------------------