From 489b4bb2a159baef0c35f3e22386f2e4f8af301e Mon Sep 17 00:00:00 2001 From: Mathias Date: Thu, 13 Aug 2026 11:36:24 +0200 Subject: [PATCH] chore(spike): recover the /bygge toolchain from tmpfs (#28) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_01BeAp5LTscnz2W7Ubt4eJ6m --- .gitignore | 11 + scripts/spike-media/README.md | 72 +++ scripts/spike-media/analyze_srt.py | 500 ++++++++++++++++++ scripts/spike-media/build_page.py | 357 +++++++++++++ scripts/spike-media/fixtures/fake.analys.json | 173 ++++++ scripts/spike-media/fixtures/fake.sv.srt | 27 + scripts/spike-media/transcode.yaml | 51 ++ 7 files changed, 1191 insertions(+) create mode 100644 scripts/spike-media/README.md create mode 100644 scripts/spike-media/analyze_srt.py create mode 100644 scripts/spike-media/build_page.py create mode 100644 scripts/spike-media/fixtures/fake.analys.json create mode 100644 scripts/spike-media/fixtures/fake.sv.srt create mode 100644 scripts/spike-media/transcode.yaml diff --git a/.gitignore b/.gitignore index 8589f72..566c625 100644 --- a/.gitignore +++ b/.gitignore @@ -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/ diff --git a/scripts/spike-media/README.md b/scripts/spike-media/README.md new file mode 100644 index 0000000..b341359 --- /dev/null +++ b/scripts/spike-media/README.md @@ -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. diff --git a/scripts/spike-media/analyze_srt.py b/scripts/spike-media/analyze_srt.py new file mode 100644 index 0000000..01c157a --- /dev/null +++ b/scripts/spike-media/analyze_srt.py @@ -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() diff --git a/scripts/spike-media/build_page.py b/scripts/spike-media/build_page.py new file mode 100644 index 0000000..095191b --- /dev/null +++ b/scripts/spike-media/build_page.py @@ -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 = """ + + + + +__TITLE__ + + + +
+

__TITLE__

+ __SUB__ +
+ +
+
+ +
+
Sägs nu
+
+
+
+ +
+
+ + + +
+
+
+
+
+
+ + + + +""" + + +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() diff --git a/scripts/spike-media/fixtures/fake.analys.json b/scripts/spike-media/fixtures/fake.analys.json new file mode 100644 index 0000000..73032e7 --- /dev/null +++ b/scripts/spike-media/fixtures/fake.analys.json @@ -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": [] + } +} diff --git a/scripts/spike-media/fixtures/fake.sv.srt b/scripts/spike-media/fixtures/fake.sv.srt new file mode 100644 index 0000000..7f5053e --- /dev/null +++ b/scripts/spike-media/fixtures/fake.sv.srt @@ -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. diff --git a/scripts/spike-media/transcode.yaml b/scripts/spike-media/transcode.yaml new file mode 100644 index 0000000..a31afe4 --- /dev/null +++ b/scripts/spike-media/transcode.yaml @@ -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