#!/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()