Compare commits

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015QbdxXWxLefS5AwLN5eyze
2026-07-02 14:49:02 +02:00
9 changed files with 1376 additions and 42 deletions
+137 -5
View File
@@ -78,16 +78,148 @@ jobs:
${REGISTRY}/${{ env.IMAGE }}:${{ steps.meta.outputs.version-tag }} || true
echo "Image pushed to ${REF}"
# Run the just-built local image briefly via buildah, not k3s ctr —
# avoids sudo/host-containerd access so this still works once the
# runner itself is containerized (infra#132). Tests the local
# buildah-store image directly, no registry round-trip needed.
#
# Bare `/tapir` (no subcommand) runs the long-running server, same as
# the old ctr-based smoke test -- ctr's --rm reliably force-killed it,
# but a plain `timeout N buildah run` does NOT: it only signals the
# `buildah run` wrapper, and the actual container process can survive
# that and keep the output pipe open, hanging the whole job (hit this
# live: 14min hang, infra#132). Backgrounding the run + `buildah rm -f`
# decouples "is the script blocked" from "did the process exit" --
# rm -f forcibly tears down the container regardless of wrapper state.
- name: Smoke test
run: |
REGISTRY="localhost:5000"
REF="${REGISTRY}/${{ env.IMAGE }}:${{ steps.meta.outputs.sha-tag }}"
CNAME="smoke-${{ steps.meta.outputs.sha-tag }}"
sudo k3s ctr images pull --plain-http ${REF}
OUTPUT=$(timeout 5 sudo k3s ctr run --rm ${REF} ${CNAME} /tapir 2>&1 || true)
sudo k3s ctr containers delete ${CNAME} 2>/dev/null || true
CONTAINER=$(buildah from ${REF})
LOG=$(mktemp)
buildah run "$CONTAINER" -- /tapir > "$LOG" 2>&1 &
RUNPID=$!
sleep 5
kill -9 "$RUNPID" 2>/dev/null || true
wait "$RUNPID" 2>/dev/null || true
buildah rm -f "$CONTAINER" >/dev/null 2>&1 || true
OUTPUT=$(cat "$LOG"); rm -f "$LOG"
echo "$OUTPUT" | grep -q "tapir" \
&& echo "Smoke test passed" \
|| echo "Smoke test inconclusive: $OUTPUT"
# ── 3. Mirror to GitHub — skipped for now (SSH key rotation pending)
# ── 3. Deploy via infra repo + Flux ────────────────────────────────────────
# Flux native image-automation can't scan localhost:5000 from inside k3s pods
# (mathias/infra k3s/flux/flux-system/image-automation.yaml) — this job
# mirrors cobalt-dingo's proven pattern instead: patch the infra repo's
# manifest directly on every push to main, then annotate Flux for a fast
# reconcile. Fixes infra#111 (image built+pushed but manifest bump was
# manual, so merged features silently didn't deploy).
deploy:
name: Deploy via GitOps
needs: build
runs-on: self-hosted
if: github.ref == 'refs/heads/main' && github.event_name == 'push'
environment: staging
steps:
- name: Update image tag in infra repo
env:
IMAGE_TAG: ${{ needs.build.outputs.image-tag }}
DEPLOY_KEY: ${{ secrets.INFRA_DEPLOY_KEY }}
run: |
set -euo pipefail
# INFRA_DEPLOY_KEY is a Gitea org secret (mathias org), already
# configured per docs/cd-pipeline.md in the infra repo — same key
# cobalt-dingo and brain-gardener use, no new secret needed.
mkdir -p ~/.ssh
echo "$DEPLOY_KEY" > ~/.ssh/id_infra
chmod 600 ~/.ssh/id_infra
ssh-keyscan -p 30022 10.0.1.20 >> ~/.ssh/known_hosts 2>/dev/null
export GIT_SSH_COMMAND="ssh -i ~/.ssh/id_infra -o IdentitiesOnly=yes"
rm -rf /tmp/infra
git clone -b main ssh://git@10.0.1.20:30022/mathias/infra.git /tmp/infra
cd /tmp/infra
DEPLOYMENT="k3s/apps/tapir/deployment.yaml"
# In-place update of the image tag. sed (not yq) so we don't
# depend on additional tooling on the runner — same as cobalt-dingo.
sed -i "s|image: localhost:5000/tapir:.*|image: localhost:5000/tapir:${IMAGE_TAG}|" "$DEPLOYMENT"
# Verify the patch took effect.
grep -q "localhost:5000/tapir:${IMAGE_TAG}" "$DEPLOYMENT" \
|| { echo "✗ image tag patch failed"; exit 1; }
if git diff --quiet "$DEPLOYMENT"; then
echo " image tag unchanged — skipping push"
else
git -c user.name="tapir CI" \
-c user.email="ci@tapir.local" \
commit -m "chore(deploy): tapir → ${IMAGE_TAG}" "$DEPLOYMENT"
git push origin main
echo "✓ pushed to infra repo"
fi
shred -u ~/.ssh/id_infra
- name: Trigger Flux reconcile (immediate)
run: |
# Without these annotations, Flux would still pick up the change
# within 30s (the apps Kustomization interval). The annotations
# cut latency to ~1s.
kubectl -n flux-system annotate gitrepository flux-system \
reconcile.fluxcd.io/requestedAt="$(date +%s)" --overwrite
kubectl -n flux-system annotate kustomization apps \
reconcile.fluxcd.io/requestedAt="$(date +%s)" --overwrite
- name: Wait for Flux to apply new image
env:
IMAGE_TAG: ${{ needs.build.outputs.image-tag }}
run: |
# Poll the Deployment spec until it reflects the new tag.
# Bound to 60s so a stuck Flux doesn't hang CI.
EXPECTED="localhost:5000/tapir:${IMAGE_TAG}"
for i in $(seq 1 60); do
CURRENT=$(kubectl get deploy tapir -n tapir \
-o jsonpath='{.spec.template.spec.containers[0].image}' 2>/dev/null || echo "")
if [ "$CURRENT" = "$EXPECTED" ]; then
echo "✓ Flux applied new image after ${i}s"
break
fi
sleep 1
done
# Final assertion (in case the loop exited without matching).
kubectl get deploy tapir -n tapir \
-o jsonpath='{.spec.template.spec.containers[0].image}' \
| grep -qx "$EXPECTED" \
|| { echo "✗ Flux did not apply new image within 60s"; exit 1; }
- name: Verify rollout
run: |
kubectl rollout status deployment/tapir \
--namespace tapir \
--timeout=120s \
|| {
echo "── pod status ──"
kubectl get pods -n tapir -o wide
echo "── events ──"
kubectl get events -n tapir --sort-by='.lastTimestamp' | tail -20
echo "── describe ──"
kubectl describe pods -n tapir -l app=tapir | tail -40
exit 1
}
- name: Confirm pod running new image
env:
IMAGE_TAG: ${{ needs.build.outputs.image-tag }}
run: |
kubectl get pods -n tapir \
-l app=tapir \
--field-selector=status.phase=Running \
-o jsonpath='{.items[*].spec.containers[0].image}' \
| grep -q "localhost:5000/tapir:${IMAGE_TAG}" \
&& echo "✓ pod running new image" \
|| { echo "✗ pod image mismatch"; exit 1; }
# ── 4. Mirror to GitHub — skipped for now (SSH key rotation pending) ─
+11
View File
@@ -11,3 +11,14 @@
.env.*
!.env.example
*.local
# Spike media (#28): real recordings, their transcripts and derived analyses are
# private third-party content and this repo is public. Only synthetic fixtures
# are committed — see scripts/spike-media/README.md.
/scripts/spike-media/*.mov
/scripts/spike-media/*.mp4
/scripts/spike-media/*.wav
/scripts/spike-media/*.srt
/scripts/spike-media/*.json
/scripts/spike-media/*.html
!/scripts/spike-media/fixtures/
+48 -37
View File
@@ -3,6 +3,7 @@ package store_test
import (
"context"
"database/sql"
"errors"
"os"
"testing"
@@ -34,6 +35,30 @@ func fileMigrator(t *testing.T) *migrate.Migrate {
return m
}
// headVersion reports the current (HEAD) schema version so a test can restore
// to it after stepping down, without hard-coding what HEAD is. Adding a
// migration on top changes HEAD but no test that uses this needs editing.
func headVersion(t *testing.T, m *migrate.Migrate) uint {
t.Helper()
v, dirty, err := m.Version()
require.NoError(t, err)
require.False(t, dirty, "schema must not be dirty")
return v
}
// migrateTo drives the schema to an exact version *by version number*, not by
// step count. This is the whole point of the migrate-test design: a migration
// added above the target does not shift any count here, so unrelated tests stay
// green (see issue #8). ErrNoChange (already at that version) is not a failure.
func migrateTo(t *testing.T, m *migrate.Migrate, version uint) {
t.Helper()
err := m.Migrate(version)
if errors.Is(err, migrate.ErrNoChange) {
return
}
require.NoError(t, err)
}
// loginEventsExists reports whether the login_events relation is present.
func loginEventsExists(t *testing.T) bool {
t.Helper()
@@ -53,25 +78,15 @@ func TestMigration010LoginEventsUpDown(t *testing.T) {
require.True(t, loginEventsExists(t), "login_events must exist at latest migration")
m := fileMigrator(t)
// 011..016 sit above 010; step them down first so 010 is exercised in isolation.
require.NoError(t, m.Steps(-1), "down 016 drops channel_caption_state, login_events intact")
require.True(t, loginEventsExists(t), "016 down leaves login_events intact")
require.NoError(t, m.Steps(-1), "down 015 reshapes transcripts, login_events intact")
require.True(t, loginEventsExists(t), "015 down leaves login_events intact")
require.NoError(t, m.Steps(-1), "down 014 drops channel_title, login_events intact")
require.True(t, loginEventsExists(t), "014 down leaves login_events intact")
require.NoError(t, m.Steps(-1), "down 013 drops channel_errors, login_events intact")
require.True(t, loginEventsExists(t), "013 down leaves login_events intact")
require.NoError(t, m.Steps(-1), "down 012 is a no-op, login_events intact")
require.True(t, loginEventsExists(t), "012 down leaves login_events intact")
require.NoError(t, m.Steps(-1), "down 011 must not touch login_events")
require.True(t, loginEventsExists(t), "011 down leaves login_events intact")
head := headVersion(t, m)
require.NoError(t, m.Steps(-1), "down 010 must drop login_events")
migrateTo(t, m, 9) // just below 010 — everything above steps down
require.False(t, loginEventsExists(t), "login_events must be gone after the down migration")
require.NoError(t, m.Steps(7), "up must recreate 010 then re-apply 011..016")
migrateTo(t, m, 10) // up 010
require.True(t, loginEventsExists(t), "login_events must be restored after the up migration")
migrateTo(t, m, head) // restore to HEAD for sibling tests
}
// autoSummarizeDefault reads the users.auto_summarize column default as text
@@ -93,21 +108,15 @@ func TestMigration011AutoSummarizeDefaultUpDown(t *testing.T) {
require.Equal(t, "true", autoSummarizeDefault(t), "011 sets the default to TRUE")
m := fileMigrator(t)
require.NoError(t, m.Steps(-1), "down 016 drops channel_caption_state")
require.NoError(t, m.Steps(-1), "down 015 reshapes transcripts")
require.NoError(t, m.Steps(-1), "down 014 drops channel_title")
require.NoError(t, m.Steps(-1), "down 013 drops channel_errors")
require.NoError(t, m.Steps(-1), "down 012 is a no-op")
require.NoError(t, m.Steps(-1), "down 011 reverts the column default")
head := headVersion(t, m)
migrateTo(t, m, 10) // just below 011 — reverts the column default
require.Equal(t, "false", autoSummarizeDefault(t), "default is FALSE after the down migration")
require.NoError(t, m.Steps(1), "up 011 re-applies the TRUE default")
migrateTo(t, m, 11) // up 011 re-applies the TRUE default
require.Equal(t, "true", autoSummarizeDefault(t))
require.NoError(t, m.Steps(1), "up 012 runs clean (no FORCE RLS on fresh schema)")
require.NoError(t, m.Steps(1), "up 013 creates channel_errors")
require.NoError(t, m.Steps(1), "up 014 recreates channel_title")
require.NoError(t, m.Steps(1), "up 015 reshapes transcripts to shared")
require.NoError(t, m.Steps(1), "up 016 recreates channel_caption_state (HEAD)")
migrateTo(t, m, head) // restore to HEAD for sibling tests
}
// channelTitleExists reports whether videos.channel_title is present.
@@ -127,17 +136,15 @@ func TestMigration014VideoChannelTitleUpDown(t *testing.T) {
require.True(t, channelTitleExists(t), "channel_title exists at latest migration")
m := fileMigrator(t)
require.NoError(t, m.Steps(-1), "down 016 drops channel_caption_state, channel_title intact")
require.True(t, channelTitleExists(t), "016 down leaves channel_title intact")
require.NoError(t, m.Steps(-1), "down 015 reshapes transcripts, channel_title intact")
require.True(t, channelTitleExists(t), "015 down leaves channel_title intact")
require.NoError(t, m.Steps(-1), "down 014 must drop channel_title")
head := headVersion(t, m)
migrateTo(t, m, 13) // just below 014 — drops channel_title
require.False(t, channelTitleExists(t), "channel_title must be gone after the down migration")
require.NoError(t, m.Steps(1), "up 014 must recreate channel_title")
migrateTo(t, m, 14) // up 014 recreates channel_title
require.True(t, channelTitleExists(t), "channel_title must be restored after the up migration")
require.NoError(t, m.Steps(1), "up 015 restores the shared transcripts shape")
require.NoError(t, m.Steps(1), "up 016 recreates channel_caption_state (HEAD)")
migrateTo(t, m, head) // restore to HEAD for sibling tests
}
// TestMigration012FixAutoSummarizeRLS proves 012 runs cleanly and flips any
@@ -148,7 +155,11 @@ func TestMigration012FixAutoSummarizeRLS(t *testing.T) {
// Round-trip: down 012, then up 012 — must be idempotent.
m := fileMigrator(t)
require.NoError(t, m.Steps(-1), "down 012 must not error")
require.NoError(t, m.Steps(1), "up 012 must re-apply cleanly")
head := headVersion(t, m)
migrateTo(t, m, 11) // down 012 must not error
migrateTo(t, m, 12) // up 012 must re-apply cleanly
require.Equal(t, "true", autoSummarizeDefault(t), "default still TRUE after 012 re-applied")
migrateTo(t, m, head) // restore to HEAD for sibling tests
}
+72
View File
@@ -0,0 +1,72 @@
# spike-media — the `/bygge` toolchain, recovered
Throwaway spike tooling for #28 (upload → transcript → takeaways). Committed
because it was found in `tmpfs` one reboot from gone, not because it is finished.
Nothing here is wired into tapir. No database, no HTTP handler, no ADR.
## What produced `/bygge`
The prototype at `tapir.d-ma.be/bygge` was **not** hand-built, which the epic and
the first round of spike issues both got wrong. It came from these three scripts:
```
IMG_1233.mov --transcode.yaml--> IMG_1233.web.mp4 (k3s Job, h264, +faststart)
IMG_1233.wav --whisper---------> IMG_1233.sv.srt (interactive — the one manual step)
IMG_1233.sv.srt --analyze_srt.py--> analys.json (berget/mistral-medium, temperature=0)
analys.json + video --build_page.py--> page HTML
```
The reference analysis was produced by **`berget/mistral-medium`** — a mid-tier
cloud model, not a frontier one. That matters when judging what a local model
has to clear: the bar is mistral-medium, and it should be re-run as the control
arm rather than excluded.
## The validator is the valuable part
`analyze_srt.py` grades its own output on four checks. Two of them catch classes
of error that timestamp- and citation-checking cannot:
| Check | Catches |
|---|---|
| citation exact / **partial** / absent | Paraphrase presented as a quote. Three tiers, because `alternativ` citations legitimately stitch non-contiguous passages — a strict substring test fails the reference output. |
| `TALET … FINNS INTE I CITATET` | A wrong number carried by a *verbatim* citation and a *real* timestamp. Converts Swedish number-words to digits first. It caught `45×230` where the transcript said `220`. |
| `TACKNINGSLUCKA` | Spans of the clip no section covers. **The recall term** — without it an empty analysis scores perfectly on every other check. |
| timestamp exists in the SRT | Fabricated seek targets. Near-solved: 74/75 on the reference, because the model copies the cue starts it was handed. |
## Known defects — read before running
- **`max_tokens: 6000` is too low.** The 4-minute reference analysis is ~4,700
output tokens, i.e. ~78% of budget. A 10-minute clip truncates for every model,
and truncation reads as a JSON-discipline failure if you are not counting
tokens. Record `finish_reason` and separate `truncated` from `malformed`.
- **`build_page.py` hardcodes the title** (`"Jonas genomgång av tillbyggnad"`).
- **The schema carries fields nothing renders** — `projekt.sammanfattning`,
`projekt.skede`, `avsnitt.bildtid`, `avsnitt.bildmotiv`, `material[]`,
`oklart[]` — left over from an earlier still-frame/markdown output that
`build_page.py` replaced. They are paid for in output tokens on every run.
- **Speaker attribution is inferred, not diarized.** The output attributes
statements by name from an SRT with no speaker labels. All four checks above
are blind to a misattribution.
- **The web encode does not downscale.** The source is 720×1280; `-preset
veryfast -crf 24` on handheld motion is what makes it ~5 Mbps, not the
resolution. The lever is the preset.
## Fixtures
`fixtures/fake.sv.srt` + `fixtures/fake.analys.json` — a synthetic 7-cue Swedish
construction walkthrough with planted numbers. No real people, no real project.
It exercises the parser and all four validator checks **without a model call**,
which is what makes it usable in a test.
## Why the real artifacts are not here
**This repo is public.** The prototype's transcript and analysis are a verbatim
recording of a named person discussing a client's building project — private
third-party content, and exactly the class of data #28 calls out as the reason
uploaded transcripts cannot live in the shared `transcripts` table.
So `IMG_1233.sv.srt`, `payload.json` and the model-comparison runs stay out of
git here. That leaves `/bygge` without a versioned source of truth, which is a
real gap and a decision for #28: a private repo, an encrypted blob, or accepting
that the reference artifacts live outside version control.
+500
View File
@@ -0,0 +1,500 @@
#!/usr/bin/env python3
"""Analysera en svensk SRT-transkription av ett byggprojekt.
Steg 1 av 2: producerar tidsstämplad, källbelagd JSON som steg 2
(ffmpeg-bildutdrag + markdown-montering) konsumerar.
Varje påstående måste bära sitt ordagranna citat och sin tidsstämpel.
Det gör hallucination synlig i stället för trolig, och ger samtidigt
bildtiderna gratis.
Körning:
export DMABE_LLMAPI_KEY=... # redan satt på koala
python3 analyze_srt.py IMG_1233.sv.srt > analys.json
"""
from __future__ import annotations
import argparse
import json
import os
import re
import sys
import time
import urllib.error
import urllib.request
from dataclasses import dataclass, asdict
GATEWAY = os.environ.get("LITELLM_BASE", "http://koala:30401/v1")
DEFAULT_MODEL = "berget/mistral-medium"
# ---------------------------------------------------------------- SRT-parsning
TS = re.compile(
r"(\d{2}):(\d{2}):(\d{2})[,.](\d{3})\s*-->\s*(\d{2}):(\d{2}):(\d{2})[,.](\d{3})"
)
@dataclass
class Cue:
idx: int
start: float # sekunder
end: float
text: str
@property
def start_hms(self) -> str:
return secs_to_hms(self.start)
def secs_to_hms(s: float) -> str:
h, rem = divmod(int(s), 3600)
m, sec = divmod(rem, 60)
return f"{h:02d}:{m:02d}:{sec:02d}"
def hms_to_secs(v: str) -> float:
"""Tål '00:01:23', '01:23', '83' och '00:01:23,450'."""
v = v.strip().replace(",", ".")
parts = v.split(":")
try:
nums = [float(p) for p in parts]
except ValueError as e:
raise ValueError(f"ogiltig tidsstämpel: {v!r}") from e
if len(nums) == 3:
return nums[0] * 3600 + nums[1] * 60 + nums[2]
if len(nums) == 2:
return nums[0] * 60 + nums[1]
if len(nums) == 1:
return nums[0]
raise ValueError(f"ogiltig tidsstämpel: {v!r}")
def parse_srt(raw: str) -> list[Cue]:
"""Blockbaserad parsning. Tål saknade index och CRLF."""
raw = raw.replace("\r\n", "\n").replace("", "")
cues: list[Cue] = []
for block in re.split(r"\n\s*\n", raw.strip()):
lines = [ln for ln in block.split("\n") if ln.strip()]
if not lines:
continue
m = None
text_from = 0
for i, ln in enumerate(lines[:2]):
m = TS.search(ln)
if m:
text_from = i + 1
break
if not m:
continue
g = [int(x) for x in m.groups()]
start = g[0] * 3600 + g[1] * 60 + g[2] + g[3] / 1000
end = g[4] * 3600 + g[5] * 60 + g[6] + g[7] / 1000
text = " ".join(lines[text_from:]).strip()
if text:
cues.append(Cue(len(cues) + 1, start, end, text))
return cues
def as_numbered_transcript(cues: list[Cue]) -> str:
"""Tidsstämplad text som modellen kan citera exakt ur."""
return "\n".join(f"[{c.start_hms}] {c.text}" for c in cues)
# ------------------------------------------------------------------ LLM-anrop
SYSTEM = """Du är byggteknisk analytiker. Du analyserar en transkription från ett \
platsbesök där Jonas beskriver ett PLANERAT byggprojekt för minst en annan person.
Detta är ett SAMTAL, inte en monolog. Korta inpass som "Hela vägen?", "Vilken höjd?" \
eller "Måste det bli det?" är motpartens frågor - de driver samtalet men är inte \
Jonas påståenden. Attribuera inte en fråga som ett beslut.
Det mesta som sägs är FÖRSLAG under diskussion, inte färdiga beslut. Signalord som \
"vi tänker", "alternativt", "kanske", "förmodligen", "eventuellt", "om möjligt", \
"troligast" och "beror på" markerar något ÖPPET. Blanda aldrig ihop ett övervägt \
alternativ med ett fattat beslut - den skillnaden är hela poängen med analysen.
ABSOLUTA KRAV:
- Rapportera ENDAST det som faktiskt sägs. Dra inga egna slutsatser, lägg inte till \
byggmoment, material eller mått som inte nämns explicit.
- Varje påstående MÅSTE bära ett ordagrant citat ur transkriptionen samt dess tidsstämpel.
- Mått revideras under samtalets gång ("nio meter" -> "9,40"). Återge alla varianter \
som nämns, med citat, i stället för att välja en.
- Är något oklart, ohörbart eller avbrutet: skriv det i fältet "oklart" i stället för \
att gissa.
- Svara ENDAST med giltig JSON. Ingen markdown, inga kodstaket, ingen förklarande text."""
USER_TMPL = """Dela in transkriptionen i tematiska avsnitt (typiskt 4-8 stycken) som \
följer hur Jonas rör sig genom projektet.
TACKNINGSKRAV: avsnitten maste tillsammans tacka HELA klippet, fran 00:00:00 till \
{slut}. Forsta avsnittet borjar 00:00:00, sista avsnittet slutar {slut}, och varje \
avsnitts "start" ar lika med foregaende avsnitts "slut". Lamna inga luckor - ocksa \
korta avslutande kommentarer (tidplan, kostnader, nasta steg) ska tillhora ett avsnitt.
Svara med exakt denna JSON-struktur:
{{
"projekt": {{
"sammanfattning": "2-3 meningar om vad projektet är",
"typ": "t.ex. nybyggnad villa / tillbyggnad / renovering - eller null om det inte framgår",
"skede": "vilket byggskede som visas - eller null"
}},
"avsnitt": [
{{
"rubrik": "kort beskrivande rubrik",
"start": "HH:MM:SS",
"slut": "HH:MM:SS",
"sammanfattning": "vad Jonas beskriver i detta avsnitt, 1-3 meningar",
"nyckelpunkter": [
{{"pastaende": "vad som sags", "citat": "ordagrant citat", "tid": "HH:MM:SS"}}
],
"bildtid": "HH:MM:SS - ogonblicket dar det Jonas beskriver syns tydligast",
"bildmotiv": "vad som bor synas i bilden vid bildtid"
}}
],
"beslut": [{{"vad": "nagot som uttalas som bestamt", "citat": "ordagrant citat", "tid": "HH:MM:SS"}}],
"alternativ": [{{
"fraga": "vad som vags mot varandra",
"optioner": ["alternativ A", "alternativ B"],
"lutar_at": "det alternativ som forespraakas - eller null om oavgjort",
"citat": "ordagrant citat", "tid": "HH:MM:SS"
}}],
"oppna_fragor": [{{
"fraga": "det som inte ar avgjort",
"beror_pa": "vad avgorandet hanger pa - eller null",
"citat": "ordagrant citat", "tid": "HH:MM:SS"
}}],
"matt": [{{"vad": "matt/dimension", "citat": "ordagrant citat", "tid": "HH:MM:SS"}}],
"material": [{{"vad": "material/produkt", "citat": "ordagrant citat", "tid": "HH:MM:SS"}}],
"oklart": ["saker som ar ohorbara, avbrutna eller tvetydiga i transkriptionen"]
}}
Valj "bildtid" omsorgsfullt. I detta samtal pekar Jonas standigt pa saker med ord som \
"har", "hit", "den typ sa" - orden ar obegripliga utan bilden, sa bildtiden ska ligga \
dar foremalet han syftar pa syns, inte dar han byter amne. Tomma listor ar helt i sin \
ordning om inget relevant namns.
TRANSKRIPTION:
{transcript}"""
def call_llm(
model: str, transcript: str, slut: str, timeout: int = 300, retries: int = 2
) -> str:
key = os.environ.get("DMABE_LLMAPI_KEY") or os.environ.get("LITELLM_API_KEY")
if not key:
sys.exit("DMABE_LLMAPI_KEY (eller LITELLM_API_KEY) saknas i miljon.")
body = json.dumps(
{
"model": model,
"temperature": 0,
"max_tokens": 6000,
"messages": [
{"role": "system", "content": SYSTEM},
{"role": "user", "content": USER_TMPL.format(transcript=transcript, slut=slut)},
],
}
).encode()
# Gatewayen begransar burst (limit_req burst=5) och berget-anropet kan
# spika. TimeoutError arvs INTE av URLError, sa den maste fangas separat -
# annars blir felet en rasptrace efter flera minuters vantan.
last = ""
for attempt in range(retries + 1):
req = urllib.request.Request(
f"{GATEWAY}/chat/completions",
data=body,
headers={"Authorization": f"Bearer {key}", "Content-Type": "application/json"},
)
try:
with urllib.request.urlopen(req, timeout=timeout) as r:
payload = json.load(r)
break
except urllib.error.HTTPError as e:
detail = e.read()[:400].decode(errors="replace")
if e.code == 429 and attempt < retries:
last = f"HTTP 429 (rate limit)"
elif e.code >= 500 and attempt < retries:
last = f"HTTP {e.code}"
else:
sys.exit(f"gateway HTTP {e.code}: {detail}")
except (TimeoutError, urllib.error.URLError, OSError) as e:
reason = getattr(e, "reason", e)
if attempt < retries:
last = f"{type(e).__name__}: {reason}"
else:
sys.exit(
f"nadde inte gateway {GATEWAY} efter {retries + 1} forsok "
f"({type(e).__name__}: {reason}). Kontrollera att koala:30401 svarar."
)
backoff = 3 * (attempt + 1)
print(f"[!] {last} - forsok {attempt + 2}/{retries + 1} om {backoff}s", file=sys.stderr)
time.sleep(backoff)
else:
sys.exit(f"gav upp efter {retries + 1} forsok: {last}")
choice = payload["choices"][0]
content = (choice.get("message") or {}).get("content") or ""
if not content.strip():
# Kant lage for thinking-modeller: resonemanget ater hela budgeten.
sys.exit(
f"tomt svar (finish_reason={choice.get('finish_reason')}). "
"Valj en icke-resonerande modell for strukturerad utdata."
)
return content
def extract_json(s: str) -> dict:
s = s.strip()
if s.startswith("```"):
s = re.sub(r"^```(?:json)?\s*", "", s)
s = re.sub(r"\s*```$", "", s)
try:
return json.loads(s)
except json.JSONDecodeError:
# Sista utvag: forsta {...sista }
i, j = s.find("{"), s.rfind("}")
if i >= 0 and j > i:
return json.loads(s[i : j + 1])
raise
# -------------------------------------------------------------- Verifiering
def norm(s: str) -> str:
return re.sub(r"[^\wåäöÅÄÖ]+", " ", (s or "").lower()).strip()
# --- svenska talord -> siffror -------------------------------------------
# Byggmatt sags i klartext ("fyrtiofem ganger tvahundratjugo") men refereras
# i siffror. Utan denna oversattning kan ett felaktigt matt bara ett giltigt
# ordagrant citat och passera oupptackt - den farligaste feltypen i ett
# byggdokument.
_SW_UNITS = {
"noll": 0, "en": 1, "ett": 1, "två": 2, "tva": 2, "tre": 3, "fyra": 4,
"fem": 5, "sex": 6, "sju": 7, "åtta": 8, "atta": 8, "nio": 9, "tio": 10,
"elva": 11, "tolv": 12, "tretton": 13, "fjorton": 14, "femton": 15,
"sexton": 16, "sjutton": 17, "arton": 18, "nitton": 19,
}
_SW_TENS = {
"tjugo": 20, "trettio": 30, "fyrtio": 40, "femtio": 50, "sextio": 60,
"sjuttio": 70, "åttio": 80, "attio": 80, "nittio": 90,
}
_SW_ATOMS = {**_SW_UNITS, **_SW_TENS, "hundra": 100, "tusen": 1000}
# Langsta forst sa att "sexton" vinner over "sex", "attio" over "atta".
_SW_ORDER = sorted(_SW_ATOMS, key=len, reverse=True)
def _sw_word_to_int(word: str) -> int | None:
"""'tvåhundratjugo' -> 220. None om ordet inte ar ett rent talord."""
w = word.lower()
toks: list[str] = []
while w:
for atom in _SW_ORDER:
if w.startswith(atom):
toks.append(atom)
w = w[len(atom) :]
break
else:
return None
if not toks:
return None
total = current = 0
for t in toks:
v = _SW_ATOMS[t]
if t == "hundra":
current = (current or 1) * 100
elif t == "tusen":
total += (current or 1) * 1000
current = 0
else:
current += v
return total + current
def numbers_in(text: str) -> set[float]:
"""Alla tal i texten, som siffror och som svenska talord.
Svenska anvander decimalkomma ("9,40" = 9.4). Naiv \\d+-matchning skulle
lasa det som tva separata heltal 9 och 40 - och just har revideras matten
live i samtalet ("nio meter" -> "9,40"), sa den skillnaden ar betydelsebarande.
"""
found: set[float] = set()
for m in re.finditer(r"\d+(?:[.,]\d+)?", text or ""):
found.add(float(m.group().replace(",", ".")))
for w in re.findall(r"[a-zåäöA-ZÅÄÖ]+", text or ""):
# "en"/"ett" ar obestamd artikel vida oftare an raknetalet 1
# ("ett sedumtak"), sa fristaende traffar ger falsklarm. Sammansatta
# former ("etthundra") fangas anda av den giriga tokeniseringen.
if len(w) < 3 or w.lower() in {"en", "ett"}:
continue
v = _sw_word_to_int(w)
if v is not None and v > 0:
found.add(float(v))
return found
def fmt_num(v: float) -> str:
return str(int(v)) if v == int(v) else f"{v:g}"
def unsupported(claimed: set[float], supported: set[float]) -> list[float]:
"""Tal i pastaendet som inte styrks av citatet (med liten tolerans)."""
return sorted(
n for n in claimed if not any(abs(n - s) < 0.01 for s in supported)
)
def verify(doc: dict, cues: list[Cue], duration: float) -> list[str]:
"""Varje citat ska ga att aterfinna; varje tid ska ligga inom klippet.
Detta ar sjalva poangen med strukturen - ett pastaende utan verifierbar
kalla ar en hallucination, och den ska synas har och inte i dokumentet.
"""
warnings: list[str] = []
haystack = norm(" ".join(c.text for c in cues))
def check_quote(where: str, quote: str) -> None:
n = norm(quote)
if not n:
warnings.append(f"{where}: tomt citat")
return
if n in haystack:
return
# Delvis traff: minst 60 % av orden i foljd nagonstans
words = n.split()
if len(words) >= 4:
for size in (len(words), max(4, int(len(words) * 0.6))):
for i in range(0, len(words) - size + 1):
if " ".join(words[i : i + size]) in haystack:
warnings.append(f"{where}: citat matchar bara delvis: {quote!r}")
return
warnings.append(f"{where}: CITAT SAKNAS I TRANSKRIPTION: {quote!r}")
def check_time(where: str, t: str) -> None:
try:
v = hms_to_secs(t)
except ValueError:
warnings.append(f"{where}: ogiltig tid {t!r}")
return
if not (0 <= v <= duration + 1):
warnings.append(f"{where}: tid {t} utanfor klippet (0-{secs_to_hms(duration)})")
for i, sec in enumerate(doc.get("avsnitt") or [], 1):
tag = f"avsnitt {i} ({sec.get('rubrik','?')})"
for f in ("start", "slut", "bildtid"):
if sec.get(f):
check_time(f"{tag}.{f}", sec[f])
for j, kp in enumerate(sec.get("nyckelpunkter") or [], 1):
check_quote(f"{tag}.nyckelpunkt {j}", kp.get("citat", ""))
if kp.get("tid"):
check_time(f"{tag}.nyckelpunkt {j}.tid", kp["tid"])
def check_numbers(where: str, claim: str, quote: str) -> None:
"""Varje tal i pastaendet maste finnas i dess eget citat."""
claimed = numbers_in(claim)
if not claimed:
return
supported = numbers_in(quote)
for n in unsupported(claimed, supported):
stod = ", ".join(fmt_num(s) for s in sorted(supported)) or "inga tal"
warnings.append(
f"{where}: TALET {fmt_num(n)} FINNS INTE I CITATET "
f"(citat stoder {stod}): {claim!r}"
)
for i, sec in enumerate(doc.get("avsnitt") or [], 1):
tag = f"avsnitt {i} ({sec.get('rubrik','?')})"
for j, kp in enumerate(sec.get("nyckelpunkter") or [], 1):
check_numbers(f"{tag}.nyckelpunkt {j}", kp.get("pastaende", ""), kp.get("citat", ""))
for key in ("material", "matt", "beslut"):
for j, it in enumerate(doc.get(key) or [], 1):
check_quote(f"{key}[{j}]", it.get("citat", ""))
if it.get("tid"):
check_time(f"{key}[{j}].tid", it["tid"])
check_numbers(f"{key}[{j}]", it.get("vad", ""), it.get("citat", ""))
# Tackningskontroll: avsnitten ska tacka hela klippet utan hal, annars
# tappas material tyst (och inget bildutdrag gors for den luckan).
spans: list[tuple[float, float]] = []
for sec in doc.get("avsnitt") or []:
try:
spans.append((hms_to_secs(sec["start"]), hms_to_secs(sec["slut"])))
except (KeyError, ValueError):
continue
spans.sort()
cursor = 0.0
for s, e in spans:
if s - cursor > 2.0:
gap_text = " ".join(
c.text for c in cues if c.start >= cursor - 0.5 and c.end <= s + 0.5
)
warnings.append(
f"TACKNINGSLUCKA {secs_to_hms(cursor)}-{secs_to_hms(s)} "
f"({s - cursor:.0f}s utan avsnitt): {gap_text[:90]!r}"
)
cursor = max(cursor, e)
if duration - cursor > 2.0:
warnings.append(
f"TACKNINGSLUCKA {secs_to_hms(cursor)}-{secs_to_hms(duration)} "
f"({duration - cursor:.0f}s i slutet utan avsnitt)"
)
return warnings
# ------------------------------------------------------------------- main
def main() -> None:
ap = argparse.ArgumentParser(description=__doc__)
ap.add_argument("srt")
ap.add_argument("--model", default=DEFAULT_MODEL)
ap.add_argument("--raw", action="store_true", help="skriv aven modellens rasvar till stderr")
args = ap.parse_args()
with open(args.srt, encoding="utf-8") as fh:
cues = parse_srt(fh.read())
if not cues:
sys.exit(f"inga cues hittades i {args.srt} - fel format?")
duration = cues[-1].end
print(
f"[i] {len(cues)} cues, langd {secs_to_hms(duration)}, modell {args.model}",
file=sys.stderr,
)
content = call_llm(args.model, as_numbered_transcript(cues), secs_to_hms(duration))
if args.raw:
print(content, file=sys.stderr)
try:
doc = extract_json(content)
except json.JSONDecodeError as e:
sys.exit(f"modellen returnerade ogiltig JSON: {e}\n---\n{content[:800]}")
warnings = verify(doc, cues, duration)
doc["_meta"] = {
"modell": args.model,
"kallfil": os.path.basename(args.srt),
"antal_cues": len(cues),
"langd": secs_to_hms(duration),
"varningar": warnings,
}
print(json.dumps(doc, ensure_ascii=False, indent=2))
if warnings:
print(f"\n[!] {len(warnings)} verifieringsvarningar:", file=sys.stderr)
for w in warnings:
print(f" - {w}", file=sys.stderr)
else:
print("[ok] alla citat aterfunna, alla tider inom klippet", file=sys.stderr)
if __name__ == "__main__":
main()
+357
View File
@@ -0,0 +1,357 @@
#!/usr/bin/env python3
"""Bygg en fristaende HTML-sida: video + SRT + tidssynkad analys.
Sidan ar sjalvbarande sa nar som pa videofilen - transkription och analys
bakas in i HTML:en. Det gor att den fungerar aven over file:// (fetch mot
en lokal JSON blockeras av CORS).
python3 build_page.py payload.json IMG_1233.web.mp4 > index.html
"""
from __future__ import annotations
import html
import json
import sys
TEMPLATE = """<!doctype html>
<html lang="sv">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>__TITLE__</title>
<style>
:root {
--bg:#f6f7f9; --panel:#fff; --ink:#16181d; --muted:#6b7280; --line:#e3e6ea;
--accent:#2563eb; --accent-soft:#eff4ff;
--beslut:#0f9d58; --alt:#d97706; --oppen:#dc2626; --matt:#7c3aed;
}
@media (prefers-color-scheme: dark) {
:root {
--bg:#0e1116; --panel:#161a21; --ink:#e6e9ef; --muted:#9aa3b2; --line:#262c36;
--accent:#60a5fa; --accent-soft:#17233b;
--beslut:#34d399; --alt:#fbbf24; --oppen:#f87171; --matt:#a78bfa;
}
}
:root[data-theme="dark"] {
--bg:#0e1116; --panel:#161a21; --ink:#e6e9ef; --muted:#9aa3b2; --line:#262c36;
--accent:#60a5fa; --accent-soft:#17233b;
--beslut:#34d399; --alt:#fbbf24; --oppen:#f87171; --matt:#a78bfa;
}
:root[data-theme="light"] {
--bg:#f6f7f9; --panel:#fff; --ink:#16181d; --muted:#6b7280; --line:#e3e6ea;
--accent:#2563eb; --accent-soft:#eff4ff;
--beslut:#0f9d58; --alt:#d97706; --oppen:#dc2626; --matt:#7c3aed;
}
* { box-sizing:border-box; }
body {
margin:0; background:var(--bg); color:var(--ink);
font:15px/1.55 -apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif;
}
header {
padding:14px 20px; border-bottom:1px solid var(--line); background:var(--panel);
display:flex; align-items:baseline; gap:14px; flex-wrap:wrap;
}
header h1 { margin:0; font-size:17px; font-weight:650; }
header .sub { color:var(--muted); font-size:13px; }
.wrap {
display:grid; grid-template-columns: minmax(0,1.35fr) minmax(320px,1fr);
gap:20px; padding:20px; align-items:start; max-width:1600px; margin:0 auto;
}
@media (max-width: 950px) { .wrap { grid-template-columns:1fr; } }
/* Videon star stilla; analysen ror sig bredvid. */
.videocol { position:sticky; top:20px; }
video { width:100%; border-radius:10px; background:#000; display:block; }
.nowbar {
margin-top:10px; padding:12px 14px; background:var(--panel);
border:1px solid var(--line); border-radius:10px; min-height:64px;
}
.nowbar .label { font-size:11px; text-transform:uppercase; letter-spacing:.07em; color:var(--muted); }
.nowbar .text { margin-top:4px; font-size:16px; }
.tabs { display:flex; gap:6px; margin-bottom:12px; flex-wrap:wrap; }
.tab {
padding:6px 12px; border:1px solid var(--line); background:var(--panel);
border-radius:999px; cursor:pointer; font-size:13px; color:var(--ink);
}
.tab[aria-selected="true"] { background:var(--accent); border-color:var(--accent); color:#fff; }
.pane { display:none; }
.pane.active { display:block; }
.scroller {
max-height: calc(100vh - 190px); overflow-y:auto; padding-right:6px;
scroll-behavior:smooth;
}
.card {
background:var(--panel); border:1px solid var(--line); border-left:3px solid var(--line);
border-radius:8px; padding:11px 13px; margin-bottom:9px; cursor:pointer;
transition:border-color .15s, background .15s;
}
.card:hover { border-color:var(--accent); }
.card.on { background:var(--accent-soft); border-left-color:var(--accent); }
.card .k {
font-size:10px; text-transform:uppercase; letter-spacing:.07em;
font-weight:700; margin-bottom:5px;
}
.card .t { font-size:11px; color:var(--muted); font-variant-numeric:tabular-nums; }
.card .cit { display:block; margin-top:6px; color:var(--muted); font-size:13px; font-style:italic; }
.k.beslut{color:var(--beslut)} .k.alt{color:var(--alt)}
.k.oppen{color:var(--oppen)} .k.matt{color:var(--matt)}
.card.beslut{border-left-color:var(--beslut)} .card.alt{border-left-color:var(--alt)}
.card.oppen{border-left-color:var(--oppen)} .card.matt{border-left-color:var(--matt)}
.opts { margin:6px 0 0; padding-left:18px; font-size:13px; }
.lutar { font-size:12px; color:var(--muted); margin-top:5px; }
.cue {
padding:5px 9px; border-radius:6px; cursor:pointer; display:flex; gap:10px;
font-size:14px; border-left:2px solid transparent;
}
.cue:hover { background:var(--accent-soft); }
.cue.on { background:var(--accent-soft); border-left-color:var(--accent); font-weight:600; }
.cue .ts {
color:var(--muted); font-variant-numeric:tabular-nums; font-size:12px;
flex:0 0 46px; padding-top:2px;
}
.sec-head {
margin:16px 0 7px; font-size:12px; font-weight:700; text-transform:uppercase;
letter-spacing:.06em; color:var(--muted); border-bottom:1px solid var(--line);
padding-bottom:5px;
}
.sec-head:first-child { margin-top:0; }
.warn {
background:var(--panel); border:1px solid var(--line); border-left:3px solid var(--alt);
border-radius:8px; padding:11px 13px; font-size:13px; color:var(--muted);
}
.warn b { color:var(--ink); }
.warn ul { margin:6px 0 0; padding-left:18px; }
</style>
</head>
<body>
<header>
<h1>__TITLE__</h1>
<span class="sub">__SUB__</span>
</header>
<div class="wrap">
<div class="videocol">
<video id="v" controls preload="metadata" src="__VIDEO__"></video>
<div class="nowbar">
<div class="label">Sägs nu</div>
<div class="text" id="now">—</div>
</div>
</div>
<div>
<div class="tabs" role="tablist">
<button class="tab" role="tab" aria-selected="true" data-pane="analys">Analys</button>
<button class="tab" role="tab" aria-selected="false" data-pane="text">Transkription</button>
<button class="tab" role="tab" aria-selected="false" data-pane="kvalitet">Kvalitet</button>
</div>
<div class="pane active" id="pane-analys"><div class="scroller" id="analys"></div></div>
<div class="pane" id="pane-text"><div class="scroller" id="text"></div></div>
<div class="pane" id="pane-kvalitet"><div class="scroller" id="kvalitet"></div></div>
</div>
</div>
<script>
const DATA = __DATA__;
const v = document.getElementById('v');
const hms = s => {
s = Math.max(0, Math.floor(s));
return String(Math.floor(s/60)).padStart(2,'0') + ':' + String(s%60).padStart(2,'0');
};
const toSec = t => {
if (t == null) return null;
const p = String(t).trim().split(':').map(Number);
if (p.some(isNaN)) return null;
return p.length === 3 ? p[0]*3600+p[1]*60+p[2] : p.length === 2 ? p[0]*60+p[1] : p[0];
};
const esc = s => { const d = document.createElement('div'); d.textContent = s ?? ''; return d.innerHTML; };
/* Varje kort bar sin tid; ett klick soker dit. Analysen ar tidsforankrad
hela vagen ned, sa synkningen ar bara en sortering pa den tiden. */
const items = [];
const A = DATA.analys;
(A.avsnitt || []).forEach((s, i) => {
const t = toSec(s.start) ?? 0;
items.push({t, end: toSec(s.slut) ?? 1e9, kind:'avsnitt', html:
`<div class="k">Avsnitt ${i+1} · ${esc(s.start)}${esc(s.slut)}</div>
<div style="font-weight:650;font-size:15px">${esc(s.rubrik)}</div>
<div style="margin-top:5px">${esc(s.sammanfattning)}</div>`});
(s.nyckelpunkter || []).forEach(k => {
const kt = toSec(k.tid); if (kt == null) return;
items.push({t:kt, end:kt+6, kind:'punkt', citat:k.citat, html:
`<div class="t">${esc(k.tid)}</div><div>${esc(k.pastaende)}</div>
<div class="cit">”${esc(k.citat)}”</div>`});
});
});
(A.beslut || []).forEach(b => {
const t = toSec(b.tid); if (t == null) return;
items.push({t, end:t+8, kind:'beslut', citat:b.citat, html:
`<div class="k beslut">Beslut</div><div class="t">${esc(b.tid)}</div>
<div style="font-weight:600">${esc(b.vad)}</div>
${b.skal ? `<div class="lutar">Skäl: ${esc(b.skal)}</div>` : ''}
<div class="cit">”${esc(b.citat)}”</div>`});
});
(A.alternativ || []).forEach(a => {
const t = toSec(a.tid); if (t == null) return;
items.push({t, end:t+8, kind:'alt', citat:a.citat, html:
`<div class="k alt">Alternativ</div><div class="t">${esc(a.tid)}</div>
<div style="font-weight:600">${esc(a.fraga)}</div>
<ul class="opts">${(a.optioner||[]).map(o=>`<li>${esc(o)}</li>`).join('')}</ul>
<div class="lutar">${a.lutar_at ? 'Lutar åt: <b>'+esc(a.lutar_at)+'</b>' : 'Oavgjort'}</div>
<div class="cit">”${esc(a.citat)}”</div>`});
});
(A.oppna_fragor || []).forEach(o => {
const t = toSec(o.tid); if (t == null) return;
items.push({t, end:t+8, kind:'oppen', citat:o.citat, html:
`<div class="k oppen">Öppen fråga</div><div class="t">${esc(o.tid)}</div>
<div style="font-weight:600">${esc(o.fraga)}</div>
${o.beror_pa ? `<div class="lutar">Beror 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": []
}
}
+27
View File
@@ -0,0 +1,27 @@
1
00:00:00,000 --> 00:00:06,500
Ja, då står vi här på tomten. Vi har precis blivit klara med grundläggningen.
2
00:00:06,500 --> 00:00:14,000
Vi fick dränera ordentligt först, för grundvattnet stod väldigt högt här.
3
00:00:14,000 --> 00:00:22,000
Sen göt vi plattan, det är en betongplatta på mark med tvåhundra millimeter cellplast under.
4
00:00:22,000 --> 00:00:31,000
Här ser ni stommen, den är i limträ. Pelarna är hundraåttio gånger hundraåttio.
5
00:00:31,000 --> 00:00:40,000
Bjälklaget spänner sex meter fritt, och vi har fyrtiofem gånger tvåhundratjugo som balkar.
6
00:00:40,000 --> 00:00:48,000
Taket blir sedumtak, det var byggherrens önskemål. Det ställer krav på bärigheten.
7
00:00:48,000 --> 00:00:56,000
Vi ligger ungefär två veckor efter tidplanen på grund av leveransproblem på limträet.
+51
View File
@@ -0,0 +1,51 @@
apiVersion: batch/v1
kind: Job
metadata:
name: jonas-transcode
namespace: default
spec:
ttlSecondsAfterFinished: 600
activeDeadlineSeconds: 1800
backoffLimit: 0
template:
spec:
restartPolicy: Never
nodeSelector:
kubernetes.io/hostname: koala
securityContext:
runAsUser: 1000
runAsGroup: 1000
fsGroup: 1000
containers:
- name: ffmpeg
image: mwader/static-ffmpeg:7.1
args:
- "-nostdin"
- "-i"
- "/work/IMG_1233.mov"
- "-c:v"
- "libx264"
- "-preset"
- "veryfast"
- "-crf"
- "24"
- "-vf"
- "scale='min(1280,iw)':-2"
- "-c:a"
- "aac"
- "-b:a"
- "128k"
- "-movflags"
- "+faststart"
- "-y"
- "/work/IMG_1233.web.mp4"
volumeMounts:
- name: work
mountPath: /work
resources:
limits: {cpu: "6", memory: "4Gi"}
volumes:
- name: work
hostPath:
path: /home/mathias/dev/AGENTS/agentsquad
type: Directory