Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
489b4bb2a1 | ||
|
|
21e7d6c74b | ||
|
|
8814ba6673 | ||
|
|
4b557a4325 | ||
|
|
72cb25111f | ||
|
|
38f222c931 | ||
|
|
e042b6d26d | ||
|
|
c85a32e770 | ||
|
|
b246c0e688 | ||
|
|
c7896cb3cc | ||
|
|
b79fb892c8 | ||
|
|
c315e3e003 | ||
|
|
64e3368f5f | ||
|
|
b01f8d5783 | ||
|
|
8ca767c144 | ||
|
|
f98b640531 | ||
|
|
607a8cbe8d | ||
|
|
54d60e53b9 | ||
|
|
9298e0c686 | ||
|
|
cd461b95f8 | ||
|
|
9c2a04406b | ||
|
|
a5a8cf6f6d | ||
|
|
64d11af9ef | ||
|
|
b590d2708d | ||
|
|
7314895ec4 | ||
|
|
36dd182fb5 |
+137
-5
@@ -78,16 +78,148 @@ jobs:
|
|||||||
${REGISTRY}/${{ env.IMAGE }}:${{ steps.meta.outputs.version-tag }} || true
|
${REGISTRY}/${{ env.IMAGE }}:${{ steps.meta.outputs.version-tag }} || true
|
||||||
echo "Image pushed to ${REF}"
|
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
|
- name: Smoke test
|
||||||
run: |
|
run: |
|
||||||
REGISTRY="localhost:5000"
|
REGISTRY="localhost:5000"
|
||||||
REF="${REGISTRY}/${{ env.IMAGE }}:${{ steps.meta.outputs.sha-tag }}"
|
REF="${REGISTRY}/${{ env.IMAGE }}:${{ steps.meta.outputs.sha-tag }}"
|
||||||
CNAME="smoke-${{ steps.meta.outputs.sha-tag }}"
|
CONTAINER=$(buildah from ${REF})
|
||||||
sudo k3s ctr images pull --plain-http ${REF}
|
LOG=$(mktemp)
|
||||||
OUTPUT=$(timeout 5 sudo k3s ctr run --rm ${REF} ${CNAME} /tapir 2>&1 || true)
|
buildah run "$CONTAINER" -- /tapir > "$LOG" 2>&1 &
|
||||||
sudo k3s ctr containers delete ${CNAME} 2>/dev/null || true
|
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 "$OUTPUT" | grep -q "tapir" \
|
||||||
&& echo "Smoke test passed" \
|
&& echo "Smoke test passed" \
|
||||||
|| echo "Smoke test inconclusive: $OUTPUT"
|
|| 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
@@ -11,3 +11,14 @@
|
|||||||
.env.*
|
.env.*
|
||||||
!.env.example
|
!.env.example
|
||||||
*.local
|
*.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/
|
||||||
|
|||||||
@@ -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}"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -12,6 +12,7 @@ docs it indexes.
|
|||||||
3. `DECISIONS.md` — the ADRs. Decisions are settled here; do not re-litigate without a new ADR.
|
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`.
|
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.
|
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
|
## How to work in this repo
|
||||||
|
|
||||||
|
|||||||
+186
@@ -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
|
## Rejected alternatives
|
||||||
|
|
||||||
Approaches considered during the 2026-06-02 planning + grill session and **deliberately not
|
Approaches considered during the 2026-06-02 planning + grill session and **deliberately not
|
||||||
|
|||||||
+26
@@ -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`
|
||||||
@@ -4,7 +4,7 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"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
|
// TestChatModelsReuseTheChainLocalFirst: the switcher offers the ADR-022 chain in
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import (
|
|||||||
"log/slog"
|
"log/slog"
|
||||||
"sync"
|
"sync"
|
||||||
|
|
||||||
"gitea.d-ma.be/mathias/tapir/internal/runner"
|
"git.d-ma.be/mathias/tapir/internal/runner"
|
||||||
)
|
)
|
||||||
|
|
||||||
// discoveryRunner runs one user's discovery pass.
|
// discoveryRunner runs one user's discovery pass.
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import (
|
|||||||
|
|
||||||
"github.com/stretchr/testify/require"
|
"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
|
// serialize must guarantee at most one discovery pass runs at a time, so a
|
||||||
|
|||||||
+1
-1
@@ -5,7 +5,7 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"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
|
// Env var names for the read-only CLI. DSN and user id are never hardcoded — the
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import (
|
|||||||
|
|
||||||
"github.com/stretchr/testify/require"
|
"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) {
|
func TestFormatListColumnsAndOrdering(t *testing.T) {
|
||||||
|
|||||||
+1
-1
@@ -9,7 +9,7 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"text/tabwriter"
|
"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.
|
// runList prints the user's stored summaries as a table, most recent first.
|
||||||
|
|||||||
+28
-9
@@ -23,14 +23,15 @@ import (
|
|||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"gitea.d-ma.be/mathias/tapir/internal/adapters/secrets"
|
"git.d-ma.be/mathias/tapir/internal/adapters/secrets"
|
||||||
"gitea.d-ma.be/mathias/tapir/internal/adapters/store"
|
"git.d-ma.be/mathias/tapir/internal/adapters/store"
|
||||||
"gitea.d-ma.be/mathias/tapir/internal/adapters/youtube"
|
"git.d-ma.be/mathias/tapir/internal/adapters/youtube"
|
||||||
"gitea.d-ma.be/mathias/tapir/internal/auth"
|
"git.d-ma.be/mathias/tapir/internal/auth"
|
||||||
"gitea.d-ma.be/mathias/tapir/internal/config"
|
"git.d-ma.be/mathias/tapir/internal/config"
|
||||||
"gitea.d-ma.be/mathias/tapir/internal/runner"
|
"git.d-ma.be/mathias/tapir/internal/metrics"
|
||||||
"gitea.d-ma.be/mathias/tapir/internal/web"
|
"git.d-ma.be/mathias/tapir/internal/runner"
|
||||||
"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"
|
||||||
)
|
)
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
@@ -310,16 +311,34 @@ func cmdServe(ctx context.Context, log *slog.Logger) error {
|
|||||||
|
|
||||||
srv := &http.Server{
|
srv := &http.Server{
|
||||||
Addr: cfg.HTTPAddr,
|
Addr: cfg.HTTPAddr,
|
||||||
Handler: app.Router(),
|
Handler: metrics.HTTPMiddleware(app.Router()),
|
||||||
ReadHeaderTimeout: 10 * time.Second,
|
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.
|
// Graceful shutdown on signal: stop accepting, drain in-flight requests.
|
||||||
go func() {
|
go func() {
|
||||||
<-ctx.Done()
|
<-ctx.Done()
|
||||||
shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
_ = srv.Shutdown(shutdownCtx)
|
_ = srv.Shutdown(shutdownCtx)
|
||||||
|
if metricsSrv != nil {
|
||||||
|
_ = metricsSrv.Shutdown(shutdownCtx)
|
||||||
|
}
|
||||||
}()
|
}()
|
||||||
|
|
||||||
log.Info("serving web ui", "addr", cfg.HTTPAddr, "user", cfg.UserID)
|
log.Info("serving web ui", "addr", cfg.HTTPAddr, "user", cfg.UserID)
|
||||||
|
|||||||
+14
-13
@@ -5,17 +5,18 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"gitea.d-ma.be/mathias/tapir/internal/adapters/chat"
|
"git.d-ma.be/mathias/tapir/internal/adapters/chat"
|
||||||
"gitea.d-ma.be/mathias/tapir/internal/adapters/llm"
|
"git.d-ma.be/mathias/tapir/internal/adapters/llm"
|
||||||
"gitea.d-ma.be/mathias/tapir/internal/adapters/secrets"
|
"git.d-ma.be/mathias/tapir/internal/adapters/secrets"
|
||||||
"gitea.d-ma.be/mathias/tapir/internal/adapters/store"
|
"git.d-ma.be/mathias/tapir/internal/adapters/store"
|
||||||
"gitea.d-ma.be/mathias/tapir/internal/adapters/summarizer"
|
"git.d-ma.be/mathias/tapir/internal/adapters/summarizer"
|
||||||
"gitea.d-ma.be/mathias/tapir/internal/adapters/youtube"
|
"git.d-ma.be/mathias/tapir/internal/adapters/youtube"
|
||||||
"gitea.d-ma.be/mathias/tapir/internal/config"
|
"git.d-ma.be/mathias/tapir/internal/config"
|
||||||
"gitea.d-ma.be/mathias/tapir/internal/domain"
|
"git.d-ma.be/mathias/tapir/internal/domain"
|
||||||
"gitea.d-ma.be/mathias/tapir/internal/ports"
|
"git.d-ma.be/mathias/tapir/internal/metrics"
|
||||||
"gitea.d-ma.be/mathias/tapir/internal/usecase"
|
"git.d-ma.be/mathias/tapir/internal/ports"
|
||||||
"gitea.d-ma.be/mathias/tapir/internal/web"
|
"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
|
// 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 {
|
func summarizerEndpoint(cfg config.Config) func(model string) summarizer.Endpoint {
|
||||||
return func(model string) summarizer.Endpoint {
|
return func(model string) summarizer.Endpoint {
|
||||||
return 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),
|
Provider: providerOf(model),
|
||||||
Model: model,
|
Model: model,
|
||||||
}
|
}
|
||||||
@@ -150,7 +151,7 @@ func buildChat(cfg config.Config) *chat.Service {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
newClient := func(model string) chat.Completer {
|
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)
|
return chat.New(newClient, models, cfg.MaxTranscriptChars)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ package main
|
|||||||
import (
|
import (
|
||||||
"testing"
|
"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
|
// TestBuildProcessorNilOnIncompleteConfig asserts the queue-only fallback: when a
|
||||||
|
|||||||
+34
-4
@@ -6,14 +6,36 @@ import (
|
|||||||
"io"
|
"io"
|
||||||
"os"
|
"os"
|
||||||
"text/tabwriter"
|
"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
|
// gateThreshold is the Stage-0 gate (VISION/ADR-016): usage in >= 2 distinct
|
||||||
// weeks. The gate passes when any user reaches it.
|
// weeks. The gate passes when any user reaches it.
|
||||||
const gateThreshold = 2
|
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
|
// 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
|
// 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).
|
// 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()
|
defer s.Close()
|
||||||
|
|
||||||
rows, err := s.ActiveWeeks(ctx)
|
since, err := gateStart()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
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,
|
// 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.
|
// 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 {
|
if len(rows) == 0 {
|
||||||
_, err := fmt.Fprintln(w, "no users yet")
|
_, err := fmt.Fprintln(w, "no users yet")
|
||||||
return err
|
return err
|
||||||
|
|||||||
@@ -3,12 +3,15 @@ package main
|
|||||||
import (
|
import (
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/stretchr/testify/require"
|
"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) {
|
func TestFormatReportColumnsAndGatePass(t *testing.T) {
|
||||||
rows := []store.UserActiveWeeks{
|
rows := []store.UserActiveWeeks{
|
||||||
{UserID: "user-a", DisplayName: "Ada", ActiveWeeks: 3},
|
{UserID: "user-a", DisplayName: "Ada", ActiveWeeks: 3},
|
||||||
@@ -16,9 +19,10 @@ func TestFormatReportColumnsAndGatePass(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
var b strings.Builder
|
var b strings.Builder
|
||||||
require.NoError(t, formatReport(&b, rows))
|
require.NoError(t, formatReport(&b, rows, testSince))
|
||||||
out := b.String()
|
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, "USER")
|
||||||
require.Contains(t, out, "ACTIVE_WEEKS")
|
require.Contains(t, out, "ACTIVE_WEEKS")
|
||||||
require.Contains(t, out, "Ada")
|
require.Contains(t, out, "Ada")
|
||||||
@@ -34,12 +38,12 @@ func TestFormatReportGateNotMet(t *testing.T) {
|
|||||||
rows := []store.UserActiveWeeks{{UserID: "user-a", ActiveWeeks: 1}}
|
rows := []store.UserActiveWeeks{{UserID: "user-a", ActiveWeeks: 1}}
|
||||||
|
|
||||||
var b strings.Builder
|
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")
|
require.Contains(t, b.String(), "NOT YET MET", "no user at >= 2 weeks fails the gate")
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestFormatReportEmpty(t *testing.T) {
|
func TestFormatReportEmpty(t *testing.T) {
|
||||||
var b strings.Builder
|
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")
|
require.Contains(t, b.String(), "no users yet")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,13 +6,13 @@ import (
|
|||||||
"log/slog"
|
"log/slog"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"gitea.d-ma.be/mathias/tapir/internal/adapters/store"
|
"git.d-ma.be/mathias/tapir/internal/adapters/store"
|
||||||
"gitea.d-ma.be/mathias/tapir/internal/adapters/youtube"
|
"git.d-ma.be/mathias/tapir/internal/adapters/youtube"
|
||||||
"gitea.d-ma.be/mathias/tapir/internal/config"
|
"git.d-ma.be/mathias/tapir/internal/config"
|
||||||
"gitea.d-ma.be/mathias/tapir/internal/ports"
|
"git.d-ma.be/mathias/tapir/internal/ports"
|
||||||
"gitea.d-ma.be/mathias/tapir/internal/runner"
|
"git.d-ma.be/mathias/tapir/internal/runner"
|
||||||
"gitea.d-ma.be/mathias/tapir/internal/usecase"
|
"git.d-ma.be/mathias/tapir/internal/usecase"
|
||||||
"gitea.d-ma.be/mathias/tapir/internal/web"
|
"git.d-ma.be/mathias/tapir/internal/web"
|
||||||
)
|
)
|
||||||
|
|
||||||
// buildUserRunner constructs a runner.Runner for one user, reusing the same
|
// buildUserRunner constructs a runner.Runner for one user, reusing the same
|
||||||
|
|||||||
@@ -11,8 +11,8 @@ import (
|
|||||||
|
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
|
|
||||||
"gitea.d-ma.be/mathias/tapir/internal/adapters/store"
|
"git.d-ma.be/mathias/tapir/internal/adapters/store"
|
||||||
"gitea.d-ma.be/mathias/tapir/internal/runner"
|
"git.d-ma.be/mathias/tapir/internal/runner"
|
||||||
)
|
)
|
||||||
|
|
||||||
func quietLog() *slog.Logger {
|
func quietLog() *slog.Logger {
|
||||||
|
|||||||
+1
-1
@@ -8,7 +8,7 @@ import (
|
|||||||
"os"
|
"os"
|
||||||
"strings"
|
"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,
|
// runShow prints the full summary for one video: text, highlights, takeaways,
|
||||||
|
|||||||
@@ -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
|
- `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).
|
discovery pass for every registered user (run-once-on-startup, then every interval).
|
||||||
**Unset or `0` = disabled** (dev/tests never auto-fetch).
|
**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
|
- `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"
|
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`
|
click-path — serialises through this one limiter so the pod cannot collectively trip 429s. `0`
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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`) |
|
| **"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`) |
|
| **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`) |
|
| **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` |
|
| **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` |
|
||||||
|
|||||||
@@ -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)
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
module gitea.d-ma.be/mathias/tapir
|
module git.d-ma.be/mathias/tapir
|
||||||
|
|
||||||
go 1.26.1
|
go 1.26.1
|
||||||
|
|
||||||
@@ -9,23 +9,33 @@ require (
|
|||||||
github.com/go-jose/go-jose/v4 v4.1.4
|
github.com/go-jose/go-jose/v4 v4.1.4
|
||||||
github.com/golang-migrate/migrate/v4 v4.19.1
|
github.com/golang-migrate/migrate/v4 v4.19.1
|
||||||
github.com/jackc/pgx/v5 v5.9.2
|
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
|
github.com/stretchr/testify v1.11.1
|
||||||
golang.org/x/crypto v0.45.0
|
|
||||||
golang.org/x/oauth2 v0.36.0
|
golang.org/x/oauth2 v0.36.0
|
||||||
golang.org/x/time v0.15.0
|
golang.org/x/time v0.15.0
|
||||||
)
|
)
|
||||||
|
|
||||||
require (
|
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/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
|
||||||
github.com/jackc/pgerrcode v0.0.0-20220416144525-469b46aa5efa // indirect
|
github.com/jackc/pgerrcode v0.0.0-20220416144525-469b46aa5efa // indirect
|
||||||
github.com/jackc/pgpassfile v1.0.0 // indirect
|
github.com/jackc/pgpassfile v1.0.0 // indirect
|
||||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
|
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
|
||||||
github.com/jackc/puddle/v2 v2.2.2 // 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/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/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/rogpeppe/go-internal v1.15.0 // indirect
|
||||||
github.com/xi2/xz v0.0.0-20171230120015-48954b6210f8 // 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/sync v0.18.0 // indirect
|
||||||
|
golang.org/x/sys v0.41.0 // indirect
|
||||||
golang.org/x/text v0.31.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
|
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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/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 h1:ypAT/L5ySWEnZ6Zft/5yfoWXYYkhFNvEFOeeqecg4tw=
|
||||||
github.com/a-h/templ v0.3.1020/go.mod h1:A2DlK61v+K+NRoGnhmYbNYVmtYHcFO5/AisMvBdDxTM=
|
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 h1:tg5yIfIlQIrxYtu9ajqY42W3lpS19XqdxRQeEwYG8PI=
|
||||||
github.com/containerd/errdefs v1.0.0/go.mod h1:+YBYIdtsnF4Iw6nWZhJcqGSg/dwvV7tyJ/kCkyJ2k+M=
|
github.com/containerd/errdefs v1.0.0/go.mod h1:+YBYIdtsnF4Iw6nWZhJcqGSg/dwvV7tyJ/kCkyJ2k+M=
|
||||||
github.com/containerd/errdefs/pkg v0.3.0 h1:9IKJ06FvyNlexW690DXuQNx2KA2cUJXx151Xdx3ZPPE=
|
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/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 h1:OCyb44lFuQfYXYLx1SCxPZQGU7mcaZ7gH9yH4jSFbBA=
|
||||||
github.com/golang-migrate/migrate/v4 v4.19.1/go.mod h1:CTcgfjxhaUtsLipnLoQRWCrjYXycRz/g5+RWDuYgPrE=
|
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.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||||
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
|
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 h1:s+4MhCQ6YrzisK6hFJUX53drDT4UsSW3DEhKn0ifuHw=
|
||||||
github.com/jackc/pgerrcode v0.0.0-20220416144525-469b46aa5efa/go.mod h1:a/s9Lp5W7n/DD0VrVoyJ00FbP2ytTPDVOivvn2bMlds=
|
github.com/jackc/pgerrcode v0.0.0-20220416144525-469b46aa5efa/go.mod h1:a/s9Lp5W7n/DD0VrVoyJ00FbP2ytTPDVOivvn2bMlds=
|
||||||
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
|
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/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 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
|
||||||
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
|
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
|
||||||
github.com/kr/pretty v0.3.0 h1:WgNl7dwNpEZ6jJ9k1snq4pZsg7DOEN8hP9Xw0Tsjwk0=
|
github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo=
|
||||||
github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk=
|
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 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||||
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
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 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw=
|
||||||
github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
|
github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
|
||||||
github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0=
|
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/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 h1:nP9CBfwrvYnBRgY6qfDQkygYDmYwOilePFkwzv4dU8A=
|
||||||
github.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc=
|
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 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U=
|
||||||
github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM=
|
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=
|
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.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 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
|
||||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
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 h1:D0RCU5rMAp+SpgkiNdrjfJ+LX4J1M32V2NeCY7EJ6hc=
|
||||||
github.com/rogpeppe/go-internal v1.15.0/go.mod h1:DrUVZyrJU+txYW5/1kwtXQSMFio52ZOxX7yM1VHvnxs=
|
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=
|
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.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 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
|
||||||
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
|
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
|
||||||
golang.org/x/crypto v0.45.0 h1:jMBrvKuj23MTlT0bQEOBcAE0mjg8mK9RXFhRH6nyF3Q=
|
go.yaml.in/yaml/v2 v2.4.2 h1:DzmwEr2rDGHl7lsFgAHxmNz/1NlQ7xLIrlN2h5d1eGI=
|
||||||
golang.org/x/crypto v0.45.0/go.mod h1:XTGrrkGJve7CYK7J8PEww4aY7gM3qMCElcJQ8n8JdX4=
|
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 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs=
|
||||||
golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q=
|
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=
|
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/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 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U=
|
||||||
golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno=
|
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 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 h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
||||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
|
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
|
||||||
|
|||||||
@@ -13,8 +13,12 @@ package chat
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"log/slog"
|
||||||
"strings"
|
"strings"
|
||||||
|
"time"
|
||||||
"unicode/utf8"
|
"unicode/utf8"
|
||||||
|
|
||||||
|
"git.d-ma.be/mathias/tapir/internal/metrics"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Completer is the minimal LLM chat surface the Service needs. *llm.Client
|
// 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)
|
system := buildSystem(transcript, truncated)
|
||||||
user := buildUser(req.History, req.Question)
|
user := buildUser(req.History, req.Question)
|
||||||
|
|
||||||
|
start := time.Now()
|
||||||
out, err := s.newClient(model).Complete(ctx, system, user)
|
out, err := s.newClient(model).Complete(ctx, system, user)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return Reply{}, fmt.Errorf("chat: %s: %w", model, err)
|
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)
|
answer := strings.TrimSpace(out)
|
||||||
if answer == "" {
|
if answer == "" {
|
||||||
return Reply{}, fmt.Errorf("chat: %s returned an empty answer", model)
|
return Reply{}, fmt.Errorf("chat: %s returned an empty answer", model)
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ type Client struct {
|
|||||||
model string
|
model string
|
||||||
maxTokens int
|
maxTokens int
|
||||||
httpClient *http.Client
|
httpClient *http.Client
|
||||||
|
usageHook func(model string, prompt, completion int)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Option configures a Client at construction. Variadic so the existing 4-arg
|
// 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.
|
// New constructs a Client.
|
||||||
func New(baseURL, apiKey, model string, timeout time.Duration, opts ...Option) *Client {
|
func New(baseURL, apiKey, model string, timeout time.Duration, opts ...Option) *Client {
|
||||||
c := &Client{
|
c := &Client{
|
||||||
@@ -81,6 +90,10 @@ type chatResponse struct {
|
|||||||
Choices []struct {
|
Choices []struct {
|
||||||
Message message `json:"message"`
|
Message message `json:"message"`
|
||||||
} `json:"choices"`
|
} `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.
|
// 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 {
|
if len(cr.Choices) == 0 {
|
||||||
return "", fmt.Errorf("LLM returned no choices")
|
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
|
return cr.Choices[0].Message.Content, nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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) {
|
func TestClient_ReturnsErrorOnNon200(t *testing.T) {
|
||||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
http.Error(w, "overloaded", http.StatusServiceUnavailable)
|
http.Error(w, "overloaded", http.StatusServiceUnavailable)
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ import (
|
|||||||
"path/filepath"
|
"path/filepath"
|
||||||
"sync"
|
"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
|
// FileStore is a SecretStore backed by a single 0600 JSON file mapping opaque
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import (
|
|||||||
"path/filepath"
|
"path/filepath"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"gitea.d-ma.be/mathias/tapir/internal/adapters/secrets"
|
"git.d-ma.be/mathias/tapir/internal/adapters/secrets"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestPutThenGet(t *testing.T) {
|
func TestPutThenGet(t *testing.T) {
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import (
|
|||||||
|
|
||||||
"github.com/stretchr/testify/require"
|
"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
|
// seedUserRow inserts a bare users row (FK target for a connection) as the
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import (
|
|||||||
|
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
|
|
||||||
"gitea.d-ma.be/mathias/tapir/internal/adapters/store"
|
"git.d-ma.be/mathias/tapir/internal/adapters/store"
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package store_test
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"database/sql"
|
"database/sql"
|
||||||
|
"errors"
|
||||||
"os"
|
"os"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
@@ -34,6 +35,30 @@ func fileMigrator(t *testing.T) *migrate.Migrate {
|
|||||||
return m
|
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.
|
// loginEventsExists reports whether the login_events relation is present.
|
||||||
func loginEventsExists(t *testing.T) bool {
|
func loginEventsExists(t *testing.T) bool {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
@@ -53,25 +78,15 @@ func TestMigration010LoginEventsUpDown(t *testing.T) {
|
|||||||
require.True(t, loginEventsExists(t), "login_events must exist at latest migration")
|
require.True(t, loginEventsExists(t), "login_events must exist at latest migration")
|
||||||
|
|
||||||
m := fileMigrator(t)
|
m := fileMigrator(t)
|
||||||
// 011..016 sit above 010; step them down first so 010 is exercised in isolation.
|
head := headVersion(t, m)
|
||||||
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")
|
|
||||||
|
|
||||||
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.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")
|
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
|
// 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")
|
require.Equal(t, "true", autoSummarizeDefault(t), "011 sets the default to TRUE")
|
||||||
|
|
||||||
m := fileMigrator(t)
|
m := fileMigrator(t)
|
||||||
require.NoError(t, m.Steps(-1), "down 016 drops channel_caption_state")
|
head := headVersion(t, m)
|
||||||
require.NoError(t, m.Steps(-1), "down 015 reshapes transcripts")
|
|
||||||
require.NoError(t, m.Steps(-1), "down 014 drops channel_title")
|
migrateTo(t, m, 10) // just below 011 — reverts the column default
|
||||||
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")
|
|
||||||
require.Equal(t, "false", autoSummarizeDefault(t), "default is FALSE after the down migration")
|
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.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")
|
migrateTo(t, m, head) // restore to HEAD for sibling tests
|
||||||
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)")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// channelTitleExists reports whether videos.channel_title is present.
|
// 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")
|
require.True(t, channelTitleExists(t), "channel_title exists at latest migration")
|
||||||
|
|
||||||
m := fileMigrator(t)
|
m := fileMigrator(t)
|
||||||
require.NoError(t, m.Steps(-1), "down 016 drops channel_caption_state, channel_title intact")
|
head := headVersion(t, m)
|
||||||
require.True(t, channelTitleExists(t), "016 down leaves channel_title intact")
|
|
||||||
require.NoError(t, m.Steps(-1), "down 015 reshapes transcripts, channel_title intact")
|
migrateTo(t, m, 13) // just below 014 — drops channel_title
|
||||||
require.True(t, channelTitleExists(t), "015 down leaves channel_title intact")
|
|
||||||
require.NoError(t, m.Steps(-1), "down 014 must drop channel_title")
|
|
||||||
require.False(t, channelTitleExists(t), "channel_title must be gone after the down migration")
|
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.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
|
// 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.
|
// Round-trip: down 012, then up 012 — must be idempotent.
|
||||||
m := fileMigrator(t)
|
m := fileMigrator(t)
|
||||||
require.NoError(t, m.Steps(-1), "down 012 must not error")
|
head := headVersion(t, m)
|
||||||
require.NoError(t, m.Steps(1), "up 012 must re-apply cleanly")
|
|
||||||
|
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")
|
require.Equal(t, "true", autoSummarizeDefault(t), "default still TRUE after 012 re-applied")
|
||||||
|
|
||||||
|
migrateTo(t, m, head) // restore to HEAD for sibling tests
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import (
|
|||||||
"github.com/jackc/pgx/v5/pgxpool"
|
"github.com/jackc/pgx/v5/pgxpool"
|
||||||
"github.com/stretchr/testify/require"
|
"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
|
// seedVideo inserts a videos row whose id matches a summary's video_id, so the
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
"sort"
|
"sort"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/jackc/pgx/v5"
|
"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
|
// 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
|
// 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.
|
// 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)
|
userIDs, err := s.identityUserIDs(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -40,7 +46,7 @@ func (s *Store) ActiveWeeks(ctx context.Context) ([]UserActiveWeeks, error) {
|
|||||||
|
|
||||||
out := make([]UserActiveWeeks, 0, len(userIDs))
|
out := make([]UserActiveWeeks, 0, len(userIDs))
|
||||||
for _, uid := range userIDs {
|
for _, uid := range userIDs {
|
||||||
row, err := s.activeWeeksFor(ctx, uid)
|
row, err := s.activeWeeksFor(ctx, uid, since)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
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
|
// 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
|
// 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.
|
// 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}
|
res := UserActiveWeeks{UserID: userID}
|
||||||
if err := s.withUser(ctx, userID, func(tx pgx.Tx) error {
|
if err := s.withUser(ctx, userID, func(tx pgx.Tx) error {
|
||||||
if err := tx.QueryRow(ctx,
|
if err := tx.QueryRow(ctx,
|
||||||
`WITH weeks AS (
|
`WITH weeks AS (
|
||||||
SELECT date_trunc('week', seen_at) AS wk
|
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
|
UNION
|
||||||
SELECT date_trunc('week', acted_at)
|
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)
|
return fmt.Errorf("store: count active weeks: %w", err)
|
||||||
}
|
}
|
||||||
if err := tx.QueryRow(ctx,
|
if err := tx.QueryRow(ctx,
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package store_test
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"testing"
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/jackc/pgx/v5/pgxpool"
|
"github.com/jackc/pgx/v5/pgxpool"
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
@@ -54,7 +55,7 @@ func TestActiveWeeksCountsDistinctWeeksAcrossReadsAndActs(t *testing.T) {
|
|||||||
($1, 'vid-2', 'saved', '2026-01-19T18:00:00Z')`, userA)
|
($1, 'vid-2', 'saved', '2026-01-19T18:00:00Z')`, userA)
|
||||||
require.NoError(t, err)
|
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.NoError(t, err)
|
||||||
require.Len(t, got, 2, "both identity users must appear")
|
require.Len(t, got, 2, "both identity users must appear")
|
||||||
|
|
||||||
@@ -72,7 +73,33 @@ func TestActiveWeeksEmptyWhenNoUsers(t *testing.T) {
|
|||||||
s := newStore(t)
|
s := newStore(t)
|
||||||
resetDB(t, rawPool(t))
|
resetDB(t, rawPool(t))
|
||||||
|
|
||||||
got, err := s.ActiveWeeks(ctx)
|
got, err := s.ActiveWeeks(ctx, time.Time{})
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
require.Empty(t, got)
|
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")
|
||||||
|
}
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ import (
|
|||||||
|
|
||||||
_ "github.com/jackc/pgx/v5/stdlib" // register the "pgx" database/sql driver for migrate
|
_ "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
|
//go:embed migrations/*.sql
|
||||||
|
|||||||
@@ -11,9 +11,9 @@ import (
|
|||||||
"github.com/jackc/pgx/v5/pgxpool"
|
"github.com/jackc/pgx/v5/pgxpool"
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
|
|
||||||
"gitea.d-ma.be/mathias/tapir/internal/adapters/store"
|
"git.d-ma.be/mathias/tapir/internal/adapters/store"
|
||||||
"gitea.d-ma.be/mathias/tapir/internal/domain"
|
"git.d-ma.be/mathias/tapir/internal/domain"
|
||||||
"gitea.d-ma.be/mathias/tapir/internal/ports"
|
"git.d-ma.be/mathias/tapir/internal/ports"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Static check: Store satisfies the Sink port.
|
// Static check: Store satisfies the Sink port.
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import (
|
|||||||
"github.com/jackc/pgx/v5/pgxpool"
|
"github.com/jackc/pgx/v5/pgxpool"
|
||||||
"github.com/stretchr/testify/require"
|
"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
|
// seedBareVideo inserts a videos row with no summary, so the all-videos read and
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import (
|
|||||||
|
|
||||||
"github.com/jackc/pgx/v5"
|
"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
|
// GetTranscript returns the shared, stored transcript for a video keyed by the
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import (
|
|||||||
|
|
||||||
"github.com/stretchr/testify/require"
|
"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) {
|
func TestSetTranscriptStatus_RoundTrip(t *testing.T) {
|
||||||
|
|||||||
@@ -6,9 +6,9 @@ import (
|
|||||||
|
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
|
|
||||||
"gitea.d-ma.be/mathias/tapir/internal/adapters/store"
|
"git.d-ma.be/mathias/tapir/internal/adapters/store"
|
||||||
"gitea.d-ma.be/mathias/tapir/internal/domain"
|
"git.d-ma.be/mathias/tapir/internal/domain"
|
||||||
"gitea.d-ma.be/mathias/tapir/internal/ports"
|
"git.d-ma.be/mathias/tapir/internal/ports"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Static check: Store satisfies the shared TranscriptStore port (ADR-021).
|
// Static check: Store satisfies the shared TranscriptStore port (ADR-021).
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import (
|
|||||||
|
|
||||||
"github.com/jackc/pgx/v5"
|
"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
|
// UpsertVideo persists a video's metadata and returns its durable store id (the
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import (
|
|||||||
|
|
||||||
"github.com/stretchr/testify/require"
|
"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 {
|
func ytVideo(userID, provVideoID, title string) domain.Video {
|
||||||
|
|||||||
@@ -13,11 +13,13 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"log/slog"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
"unicode/utf8"
|
"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.
|
// 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
|
var errs []error
|
||||||
for i, ep := range s.endpoints {
|
for i, ep := range s.endpoints {
|
||||||
|
fallback := i > 0
|
||||||
|
start := time.Now()
|
||||||
out, err := ep.Client.Complete(ctx, systemPrompt, user)
|
out, err := ep.Client.Complete(ctx, systemPrompt, user)
|
||||||
|
dur := time.Since(start)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
metrics.ObserveSummarize(ep.Model, "error", fallback, dur)
|
||||||
errs = append(errs, fmt.Errorf("%s/%s call: %w", ep.Provider, ep.Model, err))
|
errs = append(errs, fmt.Errorf("%s/%s call: %w", ep.Provider, ep.Model, err))
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
sum, perr := s.build(v, ep, i > 0, out)
|
sum, perr := s.build(v, ep, fallback, out)
|
||||||
if perr != nil {
|
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))
|
errs = append(errs, fmt.Errorf("%s/%s output: %w", ep.Provider, ep.Model, perr))
|
||||||
continue
|
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 sum, nil
|
||||||
}
|
}
|
||||||
return domain.Summary{}, fmt.Errorf("summarize: all %d endpoint(s) failed: %w", len(s.endpoints), errors.Join(errs...))
|
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 (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"gitea.d-ma.be/mathias/tapir/internal/domain"
|
"git.d-ma.be/mathias/tapir/internal/domain"
|
||||||
"gitea.d-ma.be/mathias/tapir/internal/ports"
|
"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.
|
// compile-time check: Summarizer satisfies the port.
|
||||||
var _ ports.Summarizer = (*Summarizer)(nil)
|
var _ ports.Summarizer = (*Summarizer)(nil)
|
||||||
|
|
||||||
|
|||||||
@@ -7,10 +7,13 @@ import (
|
|||||||
"encoding/xml"
|
"encoding/xml"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
|
"log/slog"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strings"
|
"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
|
// 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
|
// fetch, or an unparseable body all yield SourceNone rather than an error. Only
|
||||||
// genuine transport (network) faults return an error. Audio download and
|
// genuine transport (network) faults return an error. Audio download and
|
||||||
// speech-to-text remain absent (ADR-007).
|
// 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) {
|
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()
|
client := a.plainClient()
|
||||||
|
|
||||||
tracks, err := a.captionTracks(ctx, client, v.ProviderVideoID)
|
tracks, err := a.captionTracks(ctx, client, v.ProviderVideoID)
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import (
|
|||||||
"net/http"
|
"net/http"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"gitea.d-ma.be/mathias/tapir/internal/domain"
|
"git.d-ma.be/mathias/tapir/internal/domain"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestVideoByID(t *testing.T) {
|
func TestVideoByID(t *testing.T) {
|
||||||
|
|||||||
@@ -27,8 +27,8 @@ import (
|
|||||||
|
|
||||||
"golang.org/x/oauth2"
|
"golang.org/x/oauth2"
|
||||||
|
|
||||||
"gitea.d-ma.be/mathias/tapir/internal/domain"
|
"git.d-ma.be/mathias/tapir/internal/domain"
|
||||||
"gitea.d-ma.be/mathias/tapir/internal/ports"
|
"git.d-ma.be/mathias/tapir/internal/ports"
|
||||||
)
|
)
|
||||||
|
|
||||||
// defaultBaseURL is the YouTube Data API v3 root. Overridable via Config.BaseURL
|
// defaultBaseURL is the YouTube Data API v3 root. Overridable via Config.BaseURL
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import (
|
|||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"gitea.d-ma.be/mathias/tapir/internal/domain"
|
"git.d-ma.be/mathias/tapir/internal/domain"
|
||||||
)
|
)
|
||||||
|
|
||||||
// --- fakes ------------------------------------------------------------------
|
// --- fakes ------------------------------------------------------------------
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ import (
|
|||||||
|
|
||||||
"golang.org/x/oauth2"
|
"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).
|
// fakeWriter is a TokenWriter capturing the persisted (ref, value).
|
||||||
|
|||||||
@@ -143,6 +143,11 @@ type Config struct {
|
|||||||
// HTTPAddr is the listen address for `tapir serve` (the Stage-0 web UI).
|
// HTTPAddr is the listen address for `tapir serve` (the Stage-0 web UI).
|
||||||
HTTPAddr string
|
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,
|
// 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
|
// 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.
|
// (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"
|
defaultYTConnectRedirectURL = "https://tapir.d-ma.be/oauth/youtube/callback"
|
||||||
defaultOAuthRedirectAddr = "localhost:8080"
|
defaultOAuthRedirectAddr = "localhost:8080"
|
||||||
defaultHTTPAddr = ":8080"
|
defaultHTTPAddr = ":8080"
|
||||||
|
defaultMetricsAddr = ":9090"
|
||||||
defaultFetchBackoff = time.Hour
|
defaultFetchBackoff = time.Hour
|
||||||
defaultFetchRate = 2 * time.Second
|
defaultFetchRate = 2 * time.Second
|
||||||
defaultPublicURL = "https://tapir.d-ma.be"
|
defaultPublicURL = "https://tapir.d-ma.be"
|
||||||
@@ -209,6 +215,7 @@ func Load() (Config, error) {
|
|||||||
SecretsFile: envOr("TAPIR_SECRETS_FILE", defaultSecretsFile()),
|
SecretsFile: envOr("TAPIR_SECRETS_FILE", defaultSecretsFile()),
|
||||||
OAuthRedirectAddr: envOr("TAPIR_OAUTH_REDIRECT_ADDR", defaultOAuthRedirectAddr),
|
OAuthRedirectAddr: envOr("TAPIR_OAUTH_REDIRECT_ADDR", defaultOAuthRedirectAddr),
|
||||||
HTTPAddr: envOr("TAPIR_HTTP_ADDR", defaultHTTPAddr),
|
HTTPAddr: envOr("TAPIR_HTTP_ADDR", defaultHTTPAddr),
|
||||||
|
MetricsAddr: lookupOr("TAPIR_METRICS_ADDR", defaultMetricsAddr),
|
||||||
PublicURL: envOr("TAPIR_PUBLIC_URL", defaultPublicURL),
|
PublicURL: envOr("TAPIR_PUBLIC_URL", defaultPublicURL),
|
||||||
OIDCIssuer: os.Getenv("TAPIR_OIDC_ISSUER"),
|
OIDCIssuer: os.Getenv("TAPIR_OIDC_ISSUER"),
|
||||||
DexClientID: os.Getenv("TAPIR_DEX_CLIENT_ID"),
|
DexClientID: os.Getenv("TAPIR_DEX_CLIENT_ID"),
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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")
|
||||||
|
}
|
||||||
@@ -7,7 +7,7 @@ package ports
|
|||||||
import (
|
import (
|
||||||
"context"
|
"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).
|
// VideoSource is a video platform Tapir watches (YouTube, Vimeo).
|
||||||
|
|||||||
@@ -19,9 +19,9 @@ import (
|
|||||||
"slices"
|
"slices"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"gitea.d-ma.be/mathias/tapir/internal/domain"
|
"git.d-ma.be/mathias/tapir/internal/domain"
|
||||||
"gitea.d-ma.be/mathias/tapir/internal/ports"
|
"git.d-ma.be/mathias/tapir/internal/ports"
|
||||||
"gitea.d-ma.be/mathias/tapir/internal/usecase"
|
"git.d-ma.be/mathias/tapir/internal/usecase"
|
||||||
)
|
)
|
||||||
|
|
||||||
// passCandidate is a video that passed all pre-filters (seen/manual/backoff)
|
// passCandidate is a video that passed all pre-filters (seen/manual/backoff)
|
||||||
|
|||||||
@@ -9,9 +9,9 @@ import (
|
|||||||
|
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
|
|
||||||
"gitea.d-ma.be/mathias/tapir/internal/domain"
|
"git.d-ma.be/mathias/tapir/internal/domain"
|
||||||
"gitea.d-ma.be/mathias/tapir/internal/runner"
|
"git.d-ma.be/mathias/tapir/internal/runner"
|
||||||
"gitea.d-ma.be/mathias/tapir/internal/usecase"
|
"git.d-ma.be/mathias/tapir/internal/usecase"
|
||||||
)
|
)
|
||||||
|
|
||||||
const testUser = "11111111-1111-1111-1111-111111111111"
|
const testUser = "11111111-1111-1111-1111-111111111111"
|
||||||
|
|||||||
@@ -14,8 +14,8 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"sync"
|
"sync"
|
||||||
|
|
||||||
"gitea.d-ma.be/mathias/tapir/internal/domain"
|
"git.d-ma.be/mathias/tapir/internal/domain"
|
||||||
"gitea.d-ma.be/mathias/tapir/internal/ports"
|
"git.d-ma.be/mathias/tapir/internal/ports"
|
||||||
)
|
)
|
||||||
|
|
||||||
// ErrNotImplemented marks scaffold methods awaiting implementation.
|
// ErrNotImplemented marks scaffold methods awaiting implementation.
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"testing"
|
"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:
|
// These tests pin the ADR-021 read-stored-first behaviour at the engine core:
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ package web
|
|||||||
import (
|
import (
|
||||||
"net/http"
|
"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
|
// handleAccount renders the account page: the user's display name, the
|
||||||
|
|||||||
@@ -9,8 +9,8 @@ import (
|
|||||||
|
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
|
|
||||||
"gitea.d-ma.be/mathias/tapir/internal/adapters/store"
|
"git.d-ma.be/mathias/tapir/internal/adapters/store"
|
||||||
"gitea.d-ma.be/mathias/tapir/internal/web"
|
"git.d-ma.be/mathias/tapir/internal/web"
|
||||||
)
|
)
|
||||||
|
|
||||||
// fakeSecrets is a SecretRemover that records the refs it was asked to delete, so
|
// fakeSecrets is a SecretRemover that records the refs it was asked to delete, so
|
||||||
|
|||||||
@@ -6,8 +6,8 @@ import (
|
|||||||
"net/http"
|
"net/http"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"gitea.d-ma.be/mathias/tapir/internal/adapters/chat"
|
"git.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/store"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Chatter is the per-video chat backend (ADR-027). *chat.Service satisfies it;
|
// Chatter is the per-video chat backend (ADR-027). *chat.Service satisfies it;
|
||||||
|
|||||||
@@ -12,10 +12,10 @@ import (
|
|||||||
"github.com/jackc/pgx/v5/pgxpool"
|
"github.com/jackc/pgx/v5/pgxpool"
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
|
|
||||||
"gitea.d-ma.be/mathias/tapir/internal/adapters/chat"
|
"git.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/store"
|
||||||
"gitea.d-ma.be/mathias/tapir/internal/domain"
|
"git.d-ma.be/mathias/tapir/internal/domain"
|
||||||
"gitea.d-ma.be/mathias/tapir/internal/web"
|
"git.d-ma.be/mathias/tapir/internal/web"
|
||||||
)
|
)
|
||||||
|
|
||||||
// videoZ is a video id used by the isolation test for a DIFFERENT user's video.
|
// videoZ is a video id used by the isolation test for a DIFFERENT user's video.
|
||||||
|
|||||||
@@ -12,8 +12,8 @@ import (
|
|||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"gitea.d-ma.be/mathias/tapir/internal/adapters/store"
|
"git.d-ma.be/mathias/tapir/internal/adapters/store"
|
||||||
"gitea.d-ma.be/mathias/tapir/internal/auth"
|
"git.d-ma.be/mathias/tapir/internal/auth"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Connections is the narrow write port the connect flow depends on (Clean
|
// Connections is the narrow write port the connect flow depends on (Clean
|
||||||
|
|||||||
@@ -11,9 +11,9 @@ import (
|
|||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
"golang.org/x/oauth2"
|
"golang.org/x/oauth2"
|
||||||
|
|
||||||
"gitea.d-ma.be/mathias/tapir/internal/adapters/store"
|
"git.d-ma.be/mathias/tapir/internal/adapters/store"
|
||||||
"gitea.d-ma.be/mathias/tapir/internal/auth"
|
"git.d-ma.be/mathias/tapir/internal/auth"
|
||||||
"gitea.d-ma.be/mathias/tapir/internal/web"
|
"git.d-ma.be/mathias/tapir/internal/web"
|
||||||
)
|
)
|
||||||
|
|
||||||
// fakeWriter is a TokenWriter capturing the persisted (ref, value).
|
// fakeWriter is a TokenWriter capturing the persisted (ref, value).
|
||||||
|
|||||||
@@ -11,8 +11,8 @@ import (
|
|||||||
|
|
||||||
"github.com/a-h/templ"
|
"github.com/a-h/templ"
|
||||||
|
|
||||||
"gitea.d-ma.be/mathias/tapir/internal/adapters/store"
|
"git.d-ma.be/mathias/tapir/internal/adapters/store"
|
||||||
"gitea.d-ma.be/mathias/tapir/internal/domain"
|
"git.d-ma.be/mathias/tapir/internal/domain"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Store is the read/write surface the web handlers depend on — a narrow port over
|
// 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 := http.NewServeMux()
|
||||||
app.HandleFunc("GET /{$}", a.handleList)
|
app.HandleFunc("GET /{$}", a.handleList)
|
||||||
app.HandleFunc("GET /v/{videoId}", a.handleDetail)
|
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}/action", a.handleAction)
|
||||||
app.HandleFunc("POST /v/{videoId}/summarize", a.handleRequestSummarize)
|
app.HandleFunc("POST /v/{videoId}/summarize", a.handleRequestSummarize)
|
||||||
app.HandleFunc("POST /v/{videoId}/retry-now", a.handleRetryNow)
|
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))
|
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
|
// handleAction toggles one action: re-clicking an active verb clears it, else it
|
||||||
// is set (the store enforces watched↔skipped exclusion atomically). It returns
|
// is set (the store enforces watched↔skipped exclusion atomically). It returns
|
||||||
// the refreshed button-group fragment for HTMX; without JS it redirects back to
|
// the refreshed button-group fragment for HTMX; without JS it redirects back to
|
||||||
|
|||||||
@@ -16,9 +16,9 @@ import (
|
|||||||
"github.com/jackc/pgx/v5/pgxpool"
|
"github.com/jackc/pgx/v5/pgxpool"
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
|
|
||||||
"gitea.d-ma.be/mathias/tapir/internal/adapters/store"
|
"git.d-ma.be/mathias/tapir/internal/adapters/store"
|
||||||
"gitea.d-ma.be/mathias/tapir/internal/domain"
|
"git.d-ma.be/mathias/tapir/internal/domain"
|
||||||
"gitea.d-ma.be/mathias/tapir/internal/web"
|
"git.d-ma.be/mathias/tapir/internal/web"
|
||||||
)
|
)
|
||||||
|
|
||||||
// dsn points at the in-process Postgres started in TestMain. Handler tests run
|
// 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.Contains(t, html, "land gradually")
|
||||||
require.NotContains(t, html, "are not summarized automatically")
|
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")
|
||||||
|
}
|
||||||
|
|||||||
@@ -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
@@ -7,10 +7,11 @@
|
|||||||
// Authentication is real (Dex OIDC) and is the only gate: any Dex-authenticated
|
// 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).
|
// subject may sign in (ADR-012 dropped ADR-011's single-subject allowlist).
|
||||||
// Authorization/registration is layered on top in internal/web (an authenticated
|
// Authorization/registration is layered on top in internal/web (an authenticated
|
||||||
// subject with no tapir user is routed to registration). Sessions are server-side
|
// subject with no tapir user is routed to registration). Sessions are STATELESS
|
||||||
// (in-memory, fine for the single Stage-1 replica) addressed by an HMAC-signed
|
// (ADR-029): the identity + expiry live inside an HMAC-signed (HS256) HttpOnly
|
||||||
// (HS256) HttpOnly Secure SameSite=Lax cookie with a short TTL and sliding
|
// Secure SameSite=Lax persistent cookie with a long sliding TTL — no server-side
|
||||||
// refresh. Tokens are never logged.
|
// 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
|
// 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.
|
// inbound Bearer JWTs for MCP APIs; this is a browser session login.
|
||||||
@@ -26,7 +27,8 @@ import (
|
|||||||
"github.com/coreos/go-oidc/v3/oidc"
|
"github.com/coreos/go-oidc/v3/oidc"
|
||||||
"golang.org/x/oauth2"
|
"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
|
// Config is the OIDC + session configuration. cmd/tapir maps these from
|
||||||
@@ -47,7 +49,11 @@ type Config struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const (
|
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
|
pendingTTL = 10 * time.Minute
|
||||||
sessionCookie = "tapir_session"
|
sessionCookie = "tapir_session"
|
||||||
loginPath = "/auth/login"
|
loginPath = "/auth/login"
|
||||||
@@ -59,7 +65,6 @@ type DexAuth struct {
|
|||||||
oauth *oauth2.Config
|
oauth *oauth2.Config
|
||||||
verifier *oidc.IDTokenVerifier
|
verifier *oidc.IDTokenVerifier
|
||||||
|
|
||||||
sessions *sessionStore
|
|
||||||
pending *pendingStore
|
pending *pendingStore
|
||||||
secret []byte
|
secret []byte
|
||||||
sessionTTL time.Duration
|
sessionTTL time.Duration
|
||||||
@@ -127,7 +132,6 @@ func New(ctx context.Context, cfg Config, opts ...Option) (*DexAuth, error) {
|
|||||||
RedirectURL: cfg.RedirectURL,
|
RedirectURL: cfg.RedirectURL,
|
||||||
Scopes: []string{oidc.ScopeOpenID, "profile", "email"},
|
Scopes: []string{oidc.ScopeOpenID, "profile", "email"},
|
||||||
},
|
},
|
||||||
sessions: newSessionStore(),
|
|
||||||
pending: newPendingStore(),
|
pending: newPendingStore(),
|
||||||
secret: []byte(cfg.SessionSecret),
|
secret: []byte(cfg.SessionSecret),
|
||||||
sessionTTL: defaultSessionTTL,
|
sessionTTL: defaultSessionTTL,
|
||||||
@@ -159,31 +163,31 @@ func (d *DexAuth) Middleware(h http.Handler) http.Handler {
|
|||||||
h.ServeHTTP(w, r)
|
h.ServeHTTP(w, r)
|
||||||
return
|
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 {
|
if !ok {
|
||||||
d.redirectUnauthenticated(w, r)
|
d.redirectUnauthenticated(w, r)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if _, ok := d.sessions.get(sid, d.now()); !ok {
|
// Sliding refresh: re-issue the cookie with a fresh expiry so an active
|
||||||
d.redirectUnauthenticated(w, r)
|
// user never lapses (the expiry lives in the cookie, so sliding = re-sign).
|
||||||
return
|
d.setSessionCookie(w, d.encodeSession(user, d.now().Add(d.sessionTTL)))
|
||||||
}
|
|
||||||
d.sessions.refresh(sid, d.now().Add(d.sessionTTL)) // sliding refresh
|
|
||||||
h.ServeHTTP(w, r)
|
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) {
|
func (d *DexAuth) CurrentUser(r *http.Request) (web.User, bool) {
|
||||||
sid, ok := d.sessionID(r)
|
c, err := r.Cookie(sessionCookie)
|
||||||
if !ok {
|
if err != nil {
|
||||||
return web.User{}, false
|
return web.User{}, false
|
||||||
}
|
}
|
||||||
data, ok := d.sessions.get(sid, d.now())
|
user, _, ok := d.decodeSession(c.Value, d.now())
|
||||||
if !ok {
|
return user, ok
|
||||||
return web.User{}, false
|
|
||||||
}
|
|
||||||
return data.user, true
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (d *DexAuth) handleLogin(w http.ResponseWriter, r *http.Request) {
|
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
|
_ = idToken.Claims(&claims) // email is best-effort; subject is the identity
|
||||||
|
|
||||||
sid, err := randToken()
|
user := web.User{Subject: idToken.Subject, Email: claims.Email}
|
||||||
if err != nil {
|
d.setSessionCookie(w, d.encodeSession(user, d.now().Add(d.sessionTTL)))
|
||||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
metrics.IncLogin()
|
||||||
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)
|
|
||||||
http.Redirect(w, r, "/", http.StatusFound)
|
http.Redirect(w, r, "/", http.StatusFound)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (d *DexAuth) handleLogout(w http.ResponseWriter, r *http.Request) {
|
func (d *DexAuth) handleLogout(w http.ResponseWriter, r *http.Request) {
|
||||||
if sid, ok := d.sessionID(r); ok {
|
// Stateless sessions: clearing the cookie logs the browser out. There is no
|
||||||
d.sessions.delete(sid)
|
// 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)
|
d.clearSessionCookie(w)
|
||||||
// Land on the public landing page, not the login endpoint: a just-logged-out
|
// 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.
|
// 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)
|
http.Redirect(w, r, loginPath, http.StatusFound)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (d *DexAuth) sessionID(r *http.Request) (string, bool) {
|
// setSessionCookie writes the signed session value as a PERSISTENT cookie
|
||||||
c, err := r.Cookie(sessionCookie)
|
// (Max-Age set), so it survives the browser/app being closed — a session cookie
|
||||||
if err != nil {
|
// (no Max-Age) was dropped on iPhone Safari close, forcing re-login. value is the
|
||||||
return "", false
|
// already-signed payload from encodeSession.
|
||||||
}
|
func (d *DexAuth) setSessionCookie(w http.ResponseWriter, value string) {
|
||||||
return d.unsign(c.Value)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (d *DexAuth) setSessionCookie(w http.ResponseWriter, sid string) {
|
|
||||||
http.SetCookie(w, &http.Cookie{
|
http.SetCookie(w, &http.Cookie{
|
||||||
Name: sessionCookie,
|
Name: sessionCookie,
|
||||||
Value: d.sign(sid),
|
Value: value,
|
||||||
Path: "/",
|
Path: "/",
|
||||||
HttpOnly: true,
|
HttpOnly: true,
|
||||||
Secure: !d.insecure,
|
Secure: !d.insecure,
|
||||||
SameSite: http.SameSiteLaxMode,
|
SameSite: http.SameSiteLaxMode,
|
||||||
|
MaxAge: int(d.sessionTTL.Seconds()),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -14,8 +14,8 @@ import (
|
|||||||
josev4 "github.com/go-jose/go-jose/v4"
|
josev4 "github.com/go-jose/go-jose/v4"
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
|
|
||||||
"gitea.d-ma.be/mathias/tapir/internal/web"
|
"git.d-ma.be/mathias/tapir/internal/web"
|
||||||
"gitea.d-ma.be/mathias/tapir/internal/web/oidc"
|
"git.d-ma.be/mathias/tapir/internal/web/oidc"
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
@@ -306,13 +306,42 @@ func TestLogoutClearsSession(t *testing.T) {
|
|||||||
require.Equal(t, http.StatusFound, rec.Code)
|
require.Equal(t, http.StatusFound, rec.Code)
|
||||||
require.Equal(t, "/welcome", rec.Header().Get("Location"), "logout lands on the public page")
|
require.Equal(t, "/welcome", rec.Header().Get("Location"), "logout lands on the public page")
|
||||||
cleared := sessionCookie(t, rec.Result())
|
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 := httptest.NewRequest(http.MethodGet, "/", nil)
|
||||||
check.AddCookie(cookie)
|
check.AddCookie(cleared)
|
||||||
_, ok := auth.CurrentUser(check)
|
_, 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) {
|
func TestExpiredSessionRejected(t *testing.T) {
|
||||||
|
|||||||
@@ -6,12 +6,13 @@ import (
|
|||||||
"crypto/sha256"
|
"crypto/sha256"
|
||||||
"encoding/base64"
|
"encoding/base64"
|
||||||
"encoding/hex"
|
"encoding/hex"
|
||||||
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"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
|
// 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
|
return value, true
|
||||||
}
|
}
|
||||||
|
|
||||||
// sessionData is the server-side session record.
|
// sessionClaims is the self-contained session payload carried INSIDE the signed
|
||||||
type sessionData struct {
|
// cookie — there is no server-side session table. This is deliberate (ADR-029):
|
||||||
user web.User
|
// an in-memory store was wiped on every pod restart, logging every user out on
|
||||||
expiry time.Time
|
// 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
|
// encodeSession produces the signed cookie value for a user with the given expiry.
|
||||||
// in-process map is sufficient; it is safe for concurrent use.
|
func (d *DexAuth) encodeSession(u web.User, exp time.Time) string {
|
||||||
type sessionStore struct {
|
b, _ := json.Marshal(sessionClaims{Sub: u.Subject, Email: u.Email, Exp: exp.Unix()})
|
||||||
mu sync.Mutex
|
return d.sign(base64.RawURLEncoding.EncodeToString(b))
|
||||||
m map[string]sessionData
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func newSessionStore() *sessionStore { return &sessionStore{m: make(map[string]sessionData)} }
|
// decodeSession verifies the cookie's HMAC, parses the claims, and checks expiry.
|
||||||
|
// It returns the user and the absolute expiry on success.
|
||||||
func (s *sessionStore) put(id string, d sessionData) {
|
func (d *DexAuth) decodeSession(cookieValue string, now time.Time) (web.User, time.Time, bool) {
|
||||||
s.mu.Lock()
|
payload, ok := d.unsign(cookieValue)
|
||||||
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]
|
|
||||||
if !ok {
|
if !ok {
|
||||||
return sessionData{}, false
|
return web.User{}, time.Time{}, false
|
||||||
}
|
}
|
||||||
if !now.Before(d.expiry) {
|
raw, err := base64.RawURLEncoding.DecodeString(payload)
|
||||||
delete(s.m, id)
|
if err != nil {
|
||||||
return sessionData{}, false
|
return web.User{}, time.Time{}, false
|
||||||
}
|
}
|
||||||
return d, true
|
var c sessionClaims
|
||||||
}
|
if err := json.Unmarshal(raw, &c); err != nil {
|
||||||
|
return web.User{}, time.Time{}, false
|
||||||
// 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
|
|
||||||
}
|
}
|
||||||
}
|
exp := time.Unix(c.Exp, 0)
|
||||||
|
if !now.Before(exp) {
|
||||||
func (s *sessionStore) delete(id string) {
|
return web.User{}, time.Time{}, false // expired
|
||||||
s.mu.Lock()
|
}
|
||||||
defer s.mu.Unlock()
|
return web.User{Subject: c.Sub, Email: c.Email}, exp, true
|
||||||
delete(s.m, id)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// pendingData holds the nonce bound to an in-flight authorization request.
|
// pendingData holds the nonce bound to an in-flight authorization request.
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import (
|
|||||||
|
|
||||||
"github.com/stretchr/testify/require"
|
"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),
|
// fakeFetcher is a web.VideoFetcher returning a fixed video (or an error),
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ package web
|
|||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
"gitea.d-ma.be/mathias/tapir/internal/adapters/store"
|
"git.d-ma.be/mathias/tapir/internal/adapters/store"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"sync"
|
"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
|
// Processor runs the core summarization use case for a single already-discovered
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import (
|
|||||||
"github.com/jackc/pgx/v5/pgxpool"
|
"github.com/jackc/pgx/v5/pgxpool"
|
||||||
"github.com/stretchr/testify/require"
|
"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
|
// fakeProcessor records ProcessVideo calls. With block set it parks until the
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"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 {
|
func renderVideoCard(t *testing.T, r store.SummaryRow) string {
|
||||||
|
|||||||
+79
-31
@@ -9,7 +9,7 @@ import (
|
|||||||
|
|
||||||
"github.com/a-h/templ"
|
"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
|
// 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")
|
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) —
|
// Charmbracelet-inspired palette for the summarizing animation (TapirSpinner) —
|
||||||
// a charm purple box, pink tapir, mint snout/eyes/progress. Kept as named consts
|
// a charm purple box, pink tapir, mint snout/eyes/progress. Kept as named consts
|
||||||
// so the inline span colours and the CSS track/fill share one source of truth.
|
// so the inline span colours and the CSS track/fill share one source of truth.
|
||||||
@@ -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).
|
// not as template expressions — so the CSS is rendered as a raw node instead).
|
||||||
var styleTag = "<style>" + stylesheet + "</style>"
|
var styleTag = "<style>" + stylesheet + "</style>"
|
||||||
|
|
||||||
// The design system is a small set of CSS custom properties: one accent, a
|
// themeScript powers the light/dark toggle (ADR-032). The init runs in <head>
|
||||||
// 4/8px-derived spacing scale, a single radius, and full light+dark palettes so
|
// before paint: if the visitor has a stored choice it is applied as data-theme
|
||||||
// color-scheme: light dark is actually honoured (review #1). Muted is #595959
|
// immediately, so there is no flash of the wrong palette; with no stored choice
|
||||||
// (~7:1 on white) / #9aa0a8 on dark to clear WCAG AA (review #4).
|
// 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 = `
|
const stylesheet = `
|
||||||
:root {
|
:root {
|
||||||
color-scheme: light dark;
|
color-scheme: light dark;
|
||||||
--bg:#fbfbfa; --card:#ffffff; --fg:#1a1a1a; --muted:#595959; --line:#e4e4e1;
|
--bg:#faf7f2; --card:#ffffff; --fg:#1c1b22; --muted:#5b5968; --line:#e7e2d8;
|
||||||
--accent:#2b6cb0; --accent-fg:#ffffff; --accent-weak:#eaf1f8;
|
--mint:#0bbf8c; --purple:#6a4cf0; --pink:#e0379a; --cream:#1c1b22;
|
||||||
|
--accent:#6a4cf0; --accent-fg:#ffffff; --accent-weak:#efebfd;
|
||||||
--badge-bg:#e7a13a; --badge-fg:#2a1d00;
|
--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;
|
--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) {
|
@media (prefers-color-scheme: dark) {
|
||||||
:root {
|
:root:not([data-theme]) {` + charmDarkVars + `
|
||||||
--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[data-theme="dark"] {` + charmDarkVars + `
|
||||||
|
}
|
||||||
* { box-sizing: border-box; }
|
* { box-sizing: border-box; }
|
||||||
body { font: 15px/1.6 system-ui, -apple-system, sans-serif; margin: 0; color: var(--fg); background: var(--bg); }
|
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 { color: var(--accent); text-decoration: none; }
|
||||||
a:hover, a:focus-visible { text-decoration: underline; }
|
a:hover, a:focus-visible { text-decoration: underline; }
|
||||||
a:visited { color: var(--accent); }
|
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); }
|
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); }
|
.brand { font-weight: 800; font-size: 1.05rem; letter-spacing: -.01em; color: var(--accent); }
|
||||||
.nav { display: flex; gap: var(--s3); font-size: .9rem; }
|
.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); }
|
main { max-width: 60rem; margin: 0 auto; padding: var(--s4) var(--s3); }
|
||||||
.muted { color: var(--muted); }
|
.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); }
|
.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 { 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; }
|
.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
|
/* anchors styled as buttons: the generic a{} / a:visited{} colour rules outrank
|
||||||
.btn on <a>, painting the label accent-on-accent (invisible). Restore the
|
.btn on <a>, painting the label accent-on-accent (invisible). Restore the
|
||||||
button foreground for anchor buttons, visited included. */
|
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); }
|
.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 { 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-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-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); }
|
.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; }
|
.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
|
/* passive "retrying later" chip: dim/grey (CharmDim), not the accent — it is a
|
||||||
status, not an action the user can take. */
|
status, not an action the user can take. */
|
||||||
.chip-retry { background: rgba(108, 108, 108, .16); color: #6c6c6c; }
|
.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 { display: flex; align-items: center; gap: var(--s1); }
|
||||||
.pipeline-bar span + span::before { content: "·"; margin-right: 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; }
|
.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 / 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 { 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-success { background: var(--accent-weak); color: var(--accent); border-color: var(--accent); }
|
||||||
.flash-error { background: #fce8e6; color: #8a1c10; border-color: #d9534f; }
|
.flash-error { background: var(--err-bg); color: var(--err-fg); border-color: var(--err-line); }
|
||||||
@media (prefers-color-scheme: dark) {
|
|
||||||
.flash-error { background: #3a1714; color: #f3b5ae; border-color: #a6362e; }
|
|
||||||
}
|
|
||||||
|
|
||||||
/* htmx loading feedback */
|
/* htmx loading feedback */
|
||||||
.htmx-indicator { opacity: 0; transition: opacity .2s; color: var(--muted); font-size: .8rem; }
|
.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 { max-width: 38rem; }
|
||||||
.detail .back { margin: 0 0 var(--s3); font-size: .85rem; }
|
.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 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 .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 { 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 .embed iframe { display: block; width: 100%; height: 100%; border: 0; }
|
||||||
.detail section { margin-top: var(--s4); }
|
.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 .body { white-space: pre-wrap; line-height: 1.7; margin: 0; }
|
||||||
.detail ul { margin: 0; padding-left: 1.2rem; line-height: 1.6; }
|
.detail ul { margin: 0; padding-left: 1.2rem; line-height: 1.6; }
|
||||||
.detail li { margin-bottom: var(--s1); }
|
.detail li { margin-bottom: var(--s1); }
|
||||||
@@ -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 { background: var(--card); border: 1px solid var(--line); }
|
||||||
.chat-a .body { white-space: pre-wrap; line-height: 1.6; }
|
.chat-a .body { white-space: pre-wrap; line-height: 1.6; }
|
||||||
.chat-note { margin: 0; font-size: .82rem; font-style: italic; }
|
.chat-note { margin: 0; font-size: .82rem; font-style: italic; }
|
||||||
.chat-error { margin: 0; color: #8a1c10; font-size: .9rem; }
|
.chat-error { margin: 0; color: var(--err-fg); font-size: .9rem; }
|
||||||
@media (prefers-color-scheme: dark) { .chat-error { color: #f3b5ae; } }
|
|
||||||
.chat-form { display: flex; flex-direction: column; gap: var(--s2); margin: var(--s2) 0 0; }
|
.chat-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 { flex-direction: column; display: flex; gap: var(--s1); font-size: .78rem; text-transform: uppercase; letter-spacing: .04em; color: var(--muted); align-items: flex-start; }
|
||||||
.chat-model select { font: inherit; text-transform: none; letter-spacing: 0; padding: .4rem .55rem; border: 1px solid var(--line); border-radius: var(--radius); background: var(--card); color: var(--fg); }
|
.chat-model 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; }
|
.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 { 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::-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-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 { 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:hover { filter: brightness(1.05); }
|
||||||
.btn-danger:focus-visible { outline: 2px solid #d9534f; outline-offset: 1px; }
|
.btn-danger:focus-visible { outline: 2px solid #d9534f; outline-offset: 1px; }
|
||||||
@media (prefers-color-scheme: dark) {
|
.confirm-delete > summary { color: var(--err-fg); }
|
||||||
.confirm-body { background: #3a1714; color: #f3b5ae; }
|
|
||||||
.confirm-delete > summary { color: #f3b5ae; }
|
|
||||||
.confirm-delete > summary:hover { background: #3a1714; }
|
|
||||||
}
|
|
||||||
|
|
||||||
/* public landing page (/welcome) — the Charm-box mascot hero plus the sign-in CTA */
|
/* 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); }
|
.welcome { text-align: center; padding: var(--s5) var(--s3); display: flex; flex-direction: column; align-items: center; gap: var(--s4); }
|
||||||
|
|||||||
@@ -17,13 +17,14 @@ templ Layout(title string) {
|
|||||||
<meta charset="utf-8"/>
|
<meta charset="utf-8"/>
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1"/>
|
<meta name="viewport" content="width=device-width, initial-scale=1"/>
|
||||||
<title>{ title }</title>
|
<title>{ title }</title>
|
||||||
|
@templ.Raw(themeScriptTag)
|
||||||
<script src="/static/htmx.min.js" defer></script>
|
<script src="/static/htmx.min.js" defer></script>
|
||||||
@templ.Raw(styleTag)
|
@templ.Raw(styleTag)
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<header>
|
<header>
|
||||||
<a href="/" class="brand">Tapir</a>
|
<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>
|
</header>
|
||||||
<main>
|
<main>
|
||||||
{ children... }
|
{ children... }
|
||||||
@@ -42,12 +43,14 @@ templ PublicLayout(title string) {
|
|||||||
<meta charset="utf-8"/>
|
<meta charset="utf-8"/>
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1"/>
|
<meta name="viewport" content="width=device-width, initial-scale=1"/>
|
||||||
<title>{ title }</title>
|
<title>{ title }</title>
|
||||||
|
@templ.Raw(themeScriptTag)
|
||||||
<script src="/static/htmx.min.js" defer></script>
|
<script src="/static/htmx.min.js" defer></script>
|
||||||
@templ.Raw(styleTag)
|
@templ.Raw(styleTag)
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<header>
|
<header>
|
||||||
<a href="/" class="brand">Tapir</a>
|
<a href="/" class="brand">Tapir</a>
|
||||||
|
<nav class="nav">@templ.Raw(themeToggleButton)</nav>
|
||||||
</header>
|
</header>
|
||||||
<main>
|
<main>
|
||||||
{ children... }
|
{ children... }
|
||||||
@@ -264,7 +267,16 @@ templ summaryList(b listBuckets, hasConnected bool, autoSummarize bool) {
|
|||||||
templ VideoCard(r store.SummaryRow) {
|
templ VideoCard(r store.SummaryRow) {
|
||||||
<li class={ "card", templ.KV("card-pending", !r.Summarized) } id={ "video-" + r.VideoID }>
|
<li class={ "card", templ.KV("card-pending", !r.Summarized) } id={ "video-" + r.VideoID }>
|
||||||
if r.Summarized {
|
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 {
|
} else {
|
||||||
<div class="card-title">{ displayTitle(r) }</div>
|
<div class="card-title">{ displayTitle(r) }</div>
|
||||||
}
|
}
|
||||||
@@ -327,6 +339,32 @@ templ VideoCard(r store.SummaryRow) {
|
|||||||
</li>
|
</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 —
|
// TapirSpinner is the summarizing animation: a Charmbracelet-style TUI panel —
|
||||||
// three richly coloured ASCII tapir frames (inline span colours, snout wiggling
|
// three richly coloured ASCII tapir frames (inline span colours, snout wiggling
|
||||||
// ∩→∪→~) cross-faded by CSS, plus a lipgloss-style progress bar whose mint fill
|
// ∩→∪→~) cross-faded by CSS, plus a lipgloss-style progress bar whose mint fill
|
||||||
|
|||||||
+798
-626
File diff suppressed because it is too large
Load Diff
@@ -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")
|
||||||
|
}
|
||||||
@@ -7,7 +7,7 @@ import (
|
|||||||
|
|
||||||
"github.com/stretchr/testify/require"
|
"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
|
// fakeAuth is a configurable web.Auth for the landing-page tests: it reports a
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -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()
|
||||||
@@ -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 på: ${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": []
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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.
|
||||||
@@ -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
|
||||||
@@ -25,6 +25,30 @@ import (
|
|||||||
// fails if a scenario is unmapped, a mapped test is missing, or an entry no
|
// fails if a scenario is unmapped, a mapped test is missing, or an entry no
|
||||||
// longer matches a real non-pending scenario.
|
// longer matches a real non-pending scenario.
|
||||||
var scenarioCoverage = map[string]string{
|
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
|
// ai_routing.feature
|
||||||
"Local AI produces the summary": "TestSummarize_LocalSucceeds",
|
"Local AI produces the summary": "TestSummarize_LocalSucceeds",
|
||||||
"Local AI fails and the user has a BYO provider configured": "TestSummarize_FallsBackToBYO",
|
"Local AI fails and the user has a BYO provider configured": "TestSummarize_FallsBackToBYO",
|
||||||
|
|||||||
@@ -12,8 +12,8 @@ import (
|
|||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"gitea.d-ma.be/mathias/tapir/internal/domain"
|
"git.d-ma.be/mathias/tapir/internal/domain"
|
||||||
"gitea.d-ma.be/mathias/tapir/internal/usecase"
|
"git.d-ma.be/mathias/tapir/internal/usecase"
|
||||||
)
|
)
|
||||||
|
|
||||||
// --- fake adapters ---------------------------------------------------------
|
// --- fake adapters ---------------------------------------------------------
|
||||||
|
|||||||
Reference in New Issue
Block a user