chore(spike): recover the /bygge toolchain from tmpfs (#28)
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
This commit is contained in:
+11
@@ -11,3 +11,14 @@
|
|||||||
.env.*
|
.env.*
|
||||||
!.env.example
|
!.env.example
|
||||||
*.local
|
*.local
|
||||||
|
|
||||||
|
# Spike media (#28): real recordings, their transcripts and derived analyses are
|
||||||
|
# private third-party content and this repo is public. Only synthetic fixtures
|
||||||
|
# are committed — see scripts/spike-media/README.md.
|
||||||
|
/scripts/spike-media/*.mov
|
||||||
|
/scripts/spike-media/*.mp4
|
||||||
|
/scripts/spike-media/*.wav
|
||||||
|
/scripts/spike-media/*.srt
|
||||||
|
/scripts/spike-media/*.json
|
||||||
|
/scripts/spike-media/*.html
|
||||||
|
!/scripts/spike-media/fixtures/
|
||||||
|
|||||||
@@ -0,0 +1,72 @@
|
|||||||
|
# spike-media — the `/bygge` toolchain, recovered
|
||||||
|
|
||||||
|
Throwaway spike tooling for #28 (upload → transcript → takeaways). Committed
|
||||||
|
because it was found in `tmpfs` one reboot from gone, not because it is finished.
|
||||||
|
|
||||||
|
Nothing here is wired into tapir. No database, no HTTP handler, no ADR.
|
||||||
|
|
||||||
|
## What produced `/bygge`
|
||||||
|
|
||||||
|
The prototype at `tapir.d-ma.be/bygge` was **not** hand-built, which the epic and
|
||||||
|
the first round of spike issues both got wrong. It came from these three scripts:
|
||||||
|
|
||||||
|
```
|
||||||
|
IMG_1233.mov --transcode.yaml--> IMG_1233.web.mp4 (k3s Job, h264, +faststart)
|
||||||
|
IMG_1233.wav --whisper---------> IMG_1233.sv.srt (interactive — the one manual step)
|
||||||
|
IMG_1233.sv.srt --analyze_srt.py--> analys.json (berget/mistral-medium, temperature=0)
|
||||||
|
analys.json + video --build_page.py--> page HTML
|
||||||
|
```
|
||||||
|
|
||||||
|
The reference analysis was produced by **`berget/mistral-medium`** — a mid-tier
|
||||||
|
cloud model, not a frontier one. That matters when judging what a local model
|
||||||
|
has to clear: the bar is mistral-medium, and it should be re-run as the control
|
||||||
|
arm rather than excluded.
|
||||||
|
|
||||||
|
## The validator is the valuable part
|
||||||
|
|
||||||
|
`analyze_srt.py` grades its own output on four checks. Two of them catch classes
|
||||||
|
of error that timestamp- and citation-checking cannot:
|
||||||
|
|
||||||
|
| Check | Catches |
|
||||||
|
|---|---|
|
||||||
|
| citation exact / **partial** / absent | Paraphrase presented as a quote. Three tiers, because `alternativ` citations legitimately stitch non-contiguous passages — a strict substring test fails the reference output. |
|
||||||
|
| `TALET … FINNS INTE I CITATET` | A wrong number carried by a *verbatim* citation and a *real* timestamp. Converts Swedish number-words to digits first. It caught `45×230` where the transcript said `220`. |
|
||||||
|
| `TACKNINGSLUCKA` | Spans of the clip no section covers. **The recall term** — without it an empty analysis scores perfectly on every other check. |
|
||||||
|
| timestamp exists in the SRT | Fabricated seek targets. Near-solved: 74/75 on the reference, because the model copies the cue starts it was handed. |
|
||||||
|
|
||||||
|
## Known defects — read before running
|
||||||
|
|
||||||
|
- **`max_tokens: 6000` is too low.** The 4-minute reference analysis is ~4,700
|
||||||
|
output tokens, i.e. ~78% of budget. A 10-minute clip truncates for every model,
|
||||||
|
and truncation reads as a JSON-discipline failure if you are not counting
|
||||||
|
tokens. Record `finish_reason` and separate `truncated` from `malformed`.
|
||||||
|
- **`build_page.py` hardcodes the title** (`"Jonas – genomgång av tillbyggnad"`).
|
||||||
|
- **The schema carries fields nothing renders** — `projekt.sammanfattning`,
|
||||||
|
`projekt.skede`, `avsnitt.bildtid`, `avsnitt.bildmotiv`, `material[]`,
|
||||||
|
`oklart[]` — left over from an earlier still-frame/markdown output that
|
||||||
|
`build_page.py` replaced. They are paid for in output tokens on every run.
|
||||||
|
- **Speaker attribution is inferred, not diarized.** The output attributes
|
||||||
|
statements by name from an SRT with no speaker labels. All four checks above
|
||||||
|
are blind to a misattribution.
|
||||||
|
- **The web encode does not downscale.** The source is 720×1280; `-preset
|
||||||
|
veryfast -crf 24` on handheld motion is what makes it ~5 Mbps, not the
|
||||||
|
resolution. The lever is the preset.
|
||||||
|
|
||||||
|
## Fixtures
|
||||||
|
|
||||||
|
`fixtures/fake.sv.srt` + `fixtures/fake.analys.json` — a synthetic 7-cue Swedish
|
||||||
|
construction walkthrough with planted numbers. No real people, no real project.
|
||||||
|
It exercises the parser and all four validator checks **without a model call**,
|
||||||
|
which is what makes it usable in a test.
|
||||||
|
|
||||||
|
## Why the real artifacts are not here
|
||||||
|
|
||||||
|
**This repo is public.** The prototype's transcript and analysis are a verbatim
|
||||||
|
recording of a named person discussing a client's building project — private
|
||||||
|
third-party content, and exactly the class of data #28 calls out as the reason
|
||||||
|
uploaded transcripts cannot live in the shared `transcripts` table.
|
||||||
|
|
||||||
|
So `IMG_1233.sv.srt`, `payload.json` and the model-comparison runs stay out of
|
||||||
|
git here. That leaves `/bygge` without a versioned source of truth, which is a
|
||||||
|
real gap and a decision for #28: a private repo, an encrypted blob, or accepting
|
||||||
|
that the reference artifacts live outside version control.
|
||||||
@@ -0,0 +1,500 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Analysera en svensk SRT-transkription av ett byggprojekt.
|
||||||
|
|
||||||
|
Steg 1 av 2: producerar tidsstämplad, källbelagd JSON som steg 2
|
||||||
|
(ffmpeg-bildutdrag + markdown-montering) konsumerar.
|
||||||
|
|
||||||
|
Varje påstående måste bära sitt ordagranna citat och sin tidsstämpel.
|
||||||
|
Det gör hallucination synlig i stället för trolig, och ger samtidigt
|
||||||
|
bildtiderna gratis.
|
||||||
|
|
||||||
|
Körning:
|
||||||
|
export DMABE_LLMAPI_KEY=... # redan satt på koala
|
||||||
|
python3 analyze_srt.py IMG_1233.sv.srt > analys.json
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
import urllib.error
|
||||||
|
import urllib.request
|
||||||
|
from dataclasses import dataclass, asdict
|
||||||
|
|
||||||
|
GATEWAY = os.environ.get("LITELLM_BASE", "http://koala:30401/v1")
|
||||||
|
DEFAULT_MODEL = "berget/mistral-medium"
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------- SRT-parsning
|
||||||
|
|
||||||
|
TS = re.compile(
|
||||||
|
r"(\d{2}):(\d{2}):(\d{2})[,.](\d{3})\s*-->\s*(\d{2}):(\d{2}):(\d{2})[,.](\d{3})"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Cue:
|
||||||
|
idx: int
|
||||||
|
start: float # sekunder
|
||||||
|
end: float
|
||||||
|
text: str
|
||||||
|
|
||||||
|
@property
|
||||||
|
def start_hms(self) -> str:
|
||||||
|
return secs_to_hms(self.start)
|
||||||
|
|
||||||
|
|
||||||
|
def secs_to_hms(s: float) -> str:
|
||||||
|
h, rem = divmod(int(s), 3600)
|
||||||
|
m, sec = divmod(rem, 60)
|
||||||
|
return f"{h:02d}:{m:02d}:{sec:02d}"
|
||||||
|
|
||||||
|
|
||||||
|
def hms_to_secs(v: str) -> float:
|
||||||
|
"""Tål '00:01:23', '01:23', '83' och '00:01:23,450'."""
|
||||||
|
v = v.strip().replace(",", ".")
|
||||||
|
parts = v.split(":")
|
||||||
|
try:
|
||||||
|
nums = [float(p) for p in parts]
|
||||||
|
except ValueError as e:
|
||||||
|
raise ValueError(f"ogiltig tidsstämpel: {v!r}") from e
|
||||||
|
if len(nums) == 3:
|
||||||
|
return nums[0] * 3600 + nums[1] * 60 + nums[2]
|
||||||
|
if len(nums) == 2:
|
||||||
|
return nums[0] * 60 + nums[1]
|
||||||
|
if len(nums) == 1:
|
||||||
|
return nums[0]
|
||||||
|
raise ValueError(f"ogiltig tidsstämpel: {v!r}")
|
||||||
|
|
||||||
|
|
||||||
|
def parse_srt(raw: str) -> list[Cue]:
|
||||||
|
"""Blockbaserad parsning. Tål saknade index och CRLF."""
|
||||||
|
raw = raw.replace("\r\n", "\n").replace("", "")
|
||||||
|
cues: list[Cue] = []
|
||||||
|
for block in re.split(r"\n\s*\n", raw.strip()):
|
||||||
|
lines = [ln for ln in block.split("\n") if ln.strip()]
|
||||||
|
if not lines:
|
||||||
|
continue
|
||||||
|
m = None
|
||||||
|
text_from = 0
|
||||||
|
for i, ln in enumerate(lines[:2]):
|
||||||
|
m = TS.search(ln)
|
||||||
|
if m:
|
||||||
|
text_from = i + 1
|
||||||
|
break
|
||||||
|
if not m:
|
||||||
|
continue
|
||||||
|
g = [int(x) for x in m.groups()]
|
||||||
|
start = g[0] * 3600 + g[1] * 60 + g[2] + g[3] / 1000
|
||||||
|
end = g[4] * 3600 + g[5] * 60 + g[6] + g[7] / 1000
|
||||||
|
text = " ".join(lines[text_from:]).strip()
|
||||||
|
if text:
|
||||||
|
cues.append(Cue(len(cues) + 1, start, end, text))
|
||||||
|
return cues
|
||||||
|
|
||||||
|
|
||||||
|
def as_numbered_transcript(cues: list[Cue]) -> str:
|
||||||
|
"""Tidsstämplad text som modellen kan citera exakt ur."""
|
||||||
|
return "\n".join(f"[{c.start_hms}] {c.text}" for c in cues)
|
||||||
|
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------ LLM-anrop
|
||||||
|
|
||||||
|
SYSTEM = """Du är byggteknisk analytiker. Du analyserar en transkription från ett \
|
||||||
|
platsbesök där Jonas beskriver ett PLANERAT byggprojekt för minst en annan person.
|
||||||
|
|
||||||
|
Detta är ett SAMTAL, inte en monolog. Korta inpass som "Hela vägen?", "Vilken höjd?" \
|
||||||
|
eller "Måste det bli det?" är motpartens frågor - de driver samtalet men är inte \
|
||||||
|
Jonas påståenden. Attribuera inte en fråga som ett beslut.
|
||||||
|
|
||||||
|
Det mesta som sägs är FÖRSLAG under diskussion, inte färdiga beslut. Signalord som \
|
||||||
|
"vi tänker", "alternativt", "kanske", "förmodligen", "eventuellt", "om möjligt", \
|
||||||
|
"troligast" och "beror på" markerar något ÖPPET. Blanda aldrig ihop ett övervägt \
|
||||||
|
alternativ med ett fattat beslut - den skillnaden är hela poängen med analysen.
|
||||||
|
|
||||||
|
ABSOLUTA KRAV:
|
||||||
|
- Rapportera ENDAST det som faktiskt sägs. Dra inga egna slutsatser, lägg inte till \
|
||||||
|
byggmoment, material eller mått som inte nämns explicit.
|
||||||
|
- Varje påstående MÅSTE bära ett ordagrant citat ur transkriptionen samt dess tidsstämpel.
|
||||||
|
- Mått revideras under samtalets gång ("nio meter" -> "9,40"). Återge alla varianter \
|
||||||
|
som nämns, med citat, i stället för att välja en.
|
||||||
|
- Är något oklart, ohörbart eller avbrutet: skriv det i fältet "oklart" i stället för \
|
||||||
|
att gissa.
|
||||||
|
- Svara ENDAST med giltig JSON. Ingen markdown, inga kodstaket, ingen förklarande text."""
|
||||||
|
|
||||||
|
USER_TMPL = """Dela in transkriptionen i tematiska avsnitt (typiskt 4-8 stycken) som \
|
||||||
|
följer hur Jonas rör sig genom projektet.
|
||||||
|
|
||||||
|
TACKNINGSKRAV: avsnitten maste tillsammans tacka HELA klippet, fran 00:00:00 till \
|
||||||
|
{slut}. Forsta avsnittet borjar 00:00:00, sista avsnittet slutar {slut}, och varje \
|
||||||
|
avsnitts "start" ar lika med foregaende avsnitts "slut". Lamna inga luckor - ocksa \
|
||||||
|
korta avslutande kommentarer (tidplan, kostnader, nasta steg) ska tillhora ett avsnitt.
|
||||||
|
|
||||||
|
Svara med exakt denna JSON-struktur:
|
||||||
|
|
||||||
|
{{
|
||||||
|
"projekt": {{
|
||||||
|
"sammanfattning": "2-3 meningar om vad projektet är",
|
||||||
|
"typ": "t.ex. nybyggnad villa / tillbyggnad / renovering - eller null om det inte framgår",
|
||||||
|
"skede": "vilket byggskede som visas - eller null"
|
||||||
|
}},
|
||||||
|
"avsnitt": [
|
||||||
|
{{
|
||||||
|
"rubrik": "kort beskrivande rubrik",
|
||||||
|
"start": "HH:MM:SS",
|
||||||
|
"slut": "HH:MM:SS",
|
||||||
|
"sammanfattning": "vad Jonas beskriver i detta avsnitt, 1-3 meningar",
|
||||||
|
"nyckelpunkter": [
|
||||||
|
{{"pastaende": "vad som sags", "citat": "ordagrant citat", "tid": "HH:MM:SS"}}
|
||||||
|
],
|
||||||
|
"bildtid": "HH:MM:SS - ogonblicket dar det Jonas beskriver syns tydligast",
|
||||||
|
"bildmotiv": "vad som bor synas i bilden vid bildtid"
|
||||||
|
}}
|
||||||
|
],
|
||||||
|
"beslut": [{{"vad": "nagot som uttalas som bestamt", "citat": "ordagrant citat", "tid": "HH:MM:SS"}}],
|
||||||
|
"alternativ": [{{
|
||||||
|
"fraga": "vad som vags mot varandra",
|
||||||
|
"optioner": ["alternativ A", "alternativ B"],
|
||||||
|
"lutar_at": "det alternativ som forespraakas - eller null om oavgjort",
|
||||||
|
"citat": "ordagrant citat", "tid": "HH:MM:SS"
|
||||||
|
}}],
|
||||||
|
"oppna_fragor": [{{
|
||||||
|
"fraga": "det som inte ar avgjort",
|
||||||
|
"beror_pa": "vad avgorandet hanger pa - eller null",
|
||||||
|
"citat": "ordagrant citat", "tid": "HH:MM:SS"
|
||||||
|
}}],
|
||||||
|
"matt": [{{"vad": "matt/dimension", "citat": "ordagrant citat", "tid": "HH:MM:SS"}}],
|
||||||
|
"material": [{{"vad": "material/produkt", "citat": "ordagrant citat", "tid": "HH:MM:SS"}}],
|
||||||
|
"oklart": ["saker som ar ohorbara, avbrutna eller tvetydiga i transkriptionen"]
|
||||||
|
}}
|
||||||
|
|
||||||
|
Valj "bildtid" omsorgsfullt. I detta samtal pekar Jonas standigt pa saker med ord som \
|
||||||
|
"har", "hit", "den typ sa" - orden ar obegripliga utan bilden, sa bildtiden ska ligga \
|
||||||
|
dar foremalet han syftar pa syns, inte dar han byter amne. Tomma listor ar helt i sin \
|
||||||
|
ordning om inget relevant namns.
|
||||||
|
|
||||||
|
TRANSKRIPTION:
|
||||||
|
{transcript}"""
|
||||||
|
|
||||||
|
|
||||||
|
def call_llm(
|
||||||
|
model: str, transcript: str, slut: str, timeout: int = 300, retries: int = 2
|
||||||
|
) -> str:
|
||||||
|
key = os.environ.get("DMABE_LLMAPI_KEY") or os.environ.get("LITELLM_API_KEY")
|
||||||
|
if not key:
|
||||||
|
sys.exit("DMABE_LLMAPI_KEY (eller LITELLM_API_KEY) saknas i miljon.")
|
||||||
|
|
||||||
|
body = json.dumps(
|
||||||
|
{
|
||||||
|
"model": model,
|
||||||
|
"temperature": 0,
|
||||||
|
"max_tokens": 6000,
|
||||||
|
"messages": [
|
||||||
|
{"role": "system", "content": SYSTEM},
|
||||||
|
{"role": "user", "content": USER_TMPL.format(transcript=transcript, slut=slut)},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
).encode()
|
||||||
|
|
||||||
|
# Gatewayen begransar burst (limit_req burst=5) och berget-anropet kan
|
||||||
|
# spika. TimeoutError arvs INTE av URLError, sa den maste fangas separat -
|
||||||
|
# annars blir felet en rasptrace efter flera minuters vantan.
|
||||||
|
last = ""
|
||||||
|
for attempt in range(retries + 1):
|
||||||
|
req = urllib.request.Request(
|
||||||
|
f"{GATEWAY}/chat/completions",
|
||||||
|
data=body,
|
||||||
|
headers={"Authorization": f"Bearer {key}", "Content-Type": "application/json"},
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
with urllib.request.urlopen(req, timeout=timeout) as r:
|
||||||
|
payload = json.load(r)
|
||||||
|
break
|
||||||
|
except urllib.error.HTTPError as e:
|
||||||
|
detail = e.read()[:400].decode(errors="replace")
|
||||||
|
if e.code == 429 and attempt < retries:
|
||||||
|
last = f"HTTP 429 (rate limit)"
|
||||||
|
elif e.code >= 500 and attempt < retries:
|
||||||
|
last = f"HTTP {e.code}"
|
||||||
|
else:
|
||||||
|
sys.exit(f"gateway HTTP {e.code}: {detail}")
|
||||||
|
except (TimeoutError, urllib.error.URLError, OSError) as e:
|
||||||
|
reason = getattr(e, "reason", e)
|
||||||
|
if attempt < retries:
|
||||||
|
last = f"{type(e).__name__}: {reason}"
|
||||||
|
else:
|
||||||
|
sys.exit(
|
||||||
|
f"nadde inte gateway {GATEWAY} efter {retries + 1} forsok "
|
||||||
|
f"({type(e).__name__}: {reason}). Kontrollera att koala:30401 svarar."
|
||||||
|
)
|
||||||
|
backoff = 3 * (attempt + 1)
|
||||||
|
print(f"[!] {last} - forsok {attempt + 2}/{retries + 1} om {backoff}s", file=sys.stderr)
|
||||||
|
time.sleep(backoff)
|
||||||
|
else:
|
||||||
|
sys.exit(f"gav upp efter {retries + 1} forsok: {last}")
|
||||||
|
|
||||||
|
choice = payload["choices"][0]
|
||||||
|
content = (choice.get("message") or {}).get("content") or ""
|
||||||
|
if not content.strip():
|
||||||
|
# Kant lage for thinking-modeller: resonemanget ater hela budgeten.
|
||||||
|
sys.exit(
|
||||||
|
f"tomt svar (finish_reason={choice.get('finish_reason')}). "
|
||||||
|
"Valj en icke-resonerande modell for strukturerad utdata."
|
||||||
|
)
|
||||||
|
return content
|
||||||
|
|
||||||
|
|
||||||
|
def extract_json(s: str) -> dict:
|
||||||
|
s = s.strip()
|
||||||
|
if s.startswith("```"):
|
||||||
|
s = re.sub(r"^```(?:json)?\s*", "", s)
|
||||||
|
s = re.sub(r"\s*```$", "", s)
|
||||||
|
try:
|
||||||
|
return json.loads(s)
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
# Sista utvag: forsta {...sista }
|
||||||
|
i, j = s.find("{"), s.rfind("}")
|
||||||
|
if i >= 0 and j > i:
|
||||||
|
return json.loads(s[i : j + 1])
|
||||||
|
raise
|
||||||
|
|
||||||
|
|
||||||
|
# -------------------------------------------------------------- Verifiering
|
||||||
|
|
||||||
|
def norm(s: str) -> str:
|
||||||
|
return re.sub(r"[^\wåäöÅÄÖ]+", " ", (s or "").lower()).strip()
|
||||||
|
|
||||||
|
|
||||||
|
# --- svenska talord -> siffror -------------------------------------------
|
||||||
|
# Byggmatt sags i klartext ("fyrtiofem ganger tvahundratjugo") men refereras
|
||||||
|
# i siffror. Utan denna oversattning kan ett felaktigt matt bara ett giltigt
|
||||||
|
# ordagrant citat och passera oupptackt - den farligaste feltypen i ett
|
||||||
|
# byggdokument.
|
||||||
|
|
||||||
|
_SW_UNITS = {
|
||||||
|
"noll": 0, "en": 1, "ett": 1, "två": 2, "tva": 2, "tre": 3, "fyra": 4,
|
||||||
|
"fem": 5, "sex": 6, "sju": 7, "åtta": 8, "atta": 8, "nio": 9, "tio": 10,
|
||||||
|
"elva": 11, "tolv": 12, "tretton": 13, "fjorton": 14, "femton": 15,
|
||||||
|
"sexton": 16, "sjutton": 17, "arton": 18, "nitton": 19,
|
||||||
|
}
|
||||||
|
_SW_TENS = {
|
||||||
|
"tjugo": 20, "trettio": 30, "fyrtio": 40, "femtio": 50, "sextio": 60,
|
||||||
|
"sjuttio": 70, "åttio": 80, "attio": 80, "nittio": 90,
|
||||||
|
}
|
||||||
|
_SW_ATOMS = {**_SW_UNITS, **_SW_TENS, "hundra": 100, "tusen": 1000}
|
||||||
|
# Langsta forst sa att "sexton" vinner over "sex", "attio" over "atta".
|
||||||
|
_SW_ORDER = sorted(_SW_ATOMS, key=len, reverse=True)
|
||||||
|
|
||||||
|
|
||||||
|
def _sw_word_to_int(word: str) -> int | None:
|
||||||
|
"""'tvåhundratjugo' -> 220. None om ordet inte ar ett rent talord."""
|
||||||
|
w = word.lower()
|
||||||
|
toks: list[str] = []
|
||||||
|
while w:
|
||||||
|
for atom in _SW_ORDER:
|
||||||
|
if w.startswith(atom):
|
||||||
|
toks.append(atom)
|
||||||
|
w = w[len(atom) :]
|
||||||
|
break
|
||||||
|
else:
|
||||||
|
return None
|
||||||
|
if not toks:
|
||||||
|
return None
|
||||||
|
|
||||||
|
total = current = 0
|
||||||
|
for t in toks:
|
||||||
|
v = _SW_ATOMS[t]
|
||||||
|
if t == "hundra":
|
||||||
|
current = (current or 1) * 100
|
||||||
|
elif t == "tusen":
|
||||||
|
total += (current or 1) * 1000
|
||||||
|
current = 0
|
||||||
|
else:
|
||||||
|
current += v
|
||||||
|
return total + current
|
||||||
|
|
||||||
|
|
||||||
|
def numbers_in(text: str) -> set[float]:
|
||||||
|
"""Alla tal i texten, som siffror och som svenska talord.
|
||||||
|
|
||||||
|
Svenska anvander decimalkomma ("9,40" = 9.4). Naiv \\d+-matchning skulle
|
||||||
|
lasa det som tva separata heltal 9 och 40 - och just har revideras matten
|
||||||
|
live i samtalet ("nio meter" -> "9,40"), sa den skillnaden ar betydelsebarande.
|
||||||
|
"""
|
||||||
|
found: set[float] = set()
|
||||||
|
for m in re.finditer(r"\d+(?:[.,]\d+)?", text or ""):
|
||||||
|
found.add(float(m.group().replace(",", ".")))
|
||||||
|
for w in re.findall(r"[a-zåäöA-ZÅÄÖ]+", text or ""):
|
||||||
|
# "en"/"ett" ar obestamd artikel vida oftare an raknetalet 1
|
||||||
|
# ("ett sedumtak"), sa fristaende traffar ger falsklarm. Sammansatta
|
||||||
|
# former ("etthundra") fangas anda av den giriga tokeniseringen.
|
||||||
|
if len(w) < 3 or w.lower() in {"en", "ett"}:
|
||||||
|
continue
|
||||||
|
v = _sw_word_to_int(w)
|
||||||
|
if v is not None and v > 0:
|
||||||
|
found.add(float(v))
|
||||||
|
return found
|
||||||
|
|
||||||
|
|
||||||
|
def fmt_num(v: float) -> str:
|
||||||
|
return str(int(v)) if v == int(v) else f"{v:g}"
|
||||||
|
|
||||||
|
|
||||||
|
def unsupported(claimed: set[float], supported: set[float]) -> list[float]:
|
||||||
|
"""Tal i pastaendet som inte styrks av citatet (med liten tolerans)."""
|
||||||
|
return sorted(
|
||||||
|
n for n in claimed if not any(abs(n - s) < 0.01 for s in supported)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def verify(doc: dict, cues: list[Cue], duration: float) -> list[str]:
|
||||||
|
"""Varje citat ska ga att aterfinna; varje tid ska ligga inom klippet.
|
||||||
|
|
||||||
|
Detta ar sjalva poangen med strukturen - ett pastaende utan verifierbar
|
||||||
|
kalla ar en hallucination, och den ska synas har och inte i dokumentet.
|
||||||
|
"""
|
||||||
|
warnings: list[str] = []
|
||||||
|
haystack = norm(" ".join(c.text for c in cues))
|
||||||
|
|
||||||
|
def check_quote(where: str, quote: str) -> None:
|
||||||
|
n = norm(quote)
|
||||||
|
if not n:
|
||||||
|
warnings.append(f"{where}: tomt citat")
|
||||||
|
return
|
||||||
|
if n in haystack:
|
||||||
|
return
|
||||||
|
# Delvis traff: minst 60 % av orden i foljd nagonstans
|
||||||
|
words = n.split()
|
||||||
|
if len(words) >= 4:
|
||||||
|
for size in (len(words), max(4, int(len(words) * 0.6))):
|
||||||
|
for i in range(0, len(words) - size + 1):
|
||||||
|
if " ".join(words[i : i + size]) in haystack:
|
||||||
|
warnings.append(f"{where}: citat matchar bara delvis: {quote!r}")
|
||||||
|
return
|
||||||
|
warnings.append(f"{where}: CITAT SAKNAS I TRANSKRIPTION: {quote!r}")
|
||||||
|
|
||||||
|
def check_time(where: str, t: str) -> None:
|
||||||
|
try:
|
||||||
|
v = hms_to_secs(t)
|
||||||
|
except ValueError:
|
||||||
|
warnings.append(f"{where}: ogiltig tid {t!r}")
|
||||||
|
return
|
||||||
|
if not (0 <= v <= duration + 1):
|
||||||
|
warnings.append(f"{where}: tid {t} utanfor klippet (0-{secs_to_hms(duration)})")
|
||||||
|
|
||||||
|
for i, sec in enumerate(doc.get("avsnitt") or [], 1):
|
||||||
|
tag = f"avsnitt {i} ({sec.get('rubrik','?')})"
|
||||||
|
for f in ("start", "slut", "bildtid"):
|
||||||
|
if sec.get(f):
|
||||||
|
check_time(f"{tag}.{f}", sec[f])
|
||||||
|
for j, kp in enumerate(sec.get("nyckelpunkter") or [], 1):
|
||||||
|
check_quote(f"{tag}.nyckelpunkt {j}", kp.get("citat", ""))
|
||||||
|
if kp.get("tid"):
|
||||||
|
check_time(f"{tag}.nyckelpunkt {j}.tid", kp["tid"])
|
||||||
|
|
||||||
|
def check_numbers(where: str, claim: str, quote: str) -> None:
|
||||||
|
"""Varje tal i pastaendet maste finnas i dess eget citat."""
|
||||||
|
claimed = numbers_in(claim)
|
||||||
|
if not claimed:
|
||||||
|
return
|
||||||
|
supported = numbers_in(quote)
|
||||||
|
for n in unsupported(claimed, supported):
|
||||||
|
stod = ", ".join(fmt_num(s) for s in sorted(supported)) or "inga tal"
|
||||||
|
warnings.append(
|
||||||
|
f"{where}: TALET {fmt_num(n)} FINNS INTE I CITATET "
|
||||||
|
f"(citat stoder {stod}): {claim!r}"
|
||||||
|
)
|
||||||
|
|
||||||
|
for i, sec in enumerate(doc.get("avsnitt") or [], 1):
|
||||||
|
tag = f"avsnitt {i} ({sec.get('rubrik','?')})"
|
||||||
|
for j, kp in enumerate(sec.get("nyckelpunkter") or [], 1):
|
||||||
|
check_numbers(f"{tag}.nyckelpunkt {j}", kp.get("pastaende", ""), kp.get("citat", ""))
|
||||||
|
|
||||||
|
for key in ("material", "matt", "beslut"):
|
||||||
|
for j, it in enumerate(doc.get(key) or [], 1):
|
||||||
|
check_quote(f"{key}[{j}]", it.get("citat", ""))
|
||||||
|
if it.get("tid"):
|
||||||
|
check_time(f"{key}[{j}].tid", it["tid"])
|
||||||
|
check_numbers(f"{key}[{j}]", it.get("vad", ""), it.get("citat", ""))
|
||||||
|
|
||||||
|
# Tackningskontroll: avsnitten ska tacka hela klippet utan hal, annars
|
||||||
|
# tappas material tyst (och inget bildutdrag gors for den luckan).
|
||||||
|
spans: list[tuple[float, float]] = []
|
||||||
|
for sec in doc.get("avsnitt") or []:
|
||||||
|
try:
|
||||||
|
spans.append((hms_to_secs(sec["start"]), hms_to_secs(sec["slut"])))
|
||||||
|
except (KeyError, ValueError):
|
||||||
|
continue
|
||||||
|
spans.sort()
|
||||||
|
cursor = 0.0
|
||||||
|
for s, e in spans:
|
||||||
|
if s - cursor > 2.0:
|
||||||
|
gap_text = " ".join(
|
||||||
|
c.text for c in cues if c.start >= cursor - 0.5 and c.end <= s + 0.5
|
||||||
|
)
|
||||||
|
warnings.append(
|
||||||
|
f"TACKNINGSLUCKA {secs_to_hms(cursor)}-{secs_to_hms(s)} "
|
||||||
|
f"({s - cursor:.0f}s utan avsnitt): {gap_text[:90]!r}"
|
||||||
|
)
|
||||||
|
cursor = max(cursor, e)
|
||||||
|
if duration - cursor > 2.0:
|
||||||
|
warnings.append(
|
||||||
|
f"TACKNINGSLUCKA {secs_to_hms(cursor)}-{secs_to_hms(duration)} "
|
||||||
|
f"({duration - cursor:.0f}s i slutet utan avsnitt)"
|
||||||
|
)
|
||||||
|
return warnings
|
||||||
|
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------- main
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
ap = argparse.ArgumentParser(description=__doc__)
|
||||||
|
ap.add_argument("srt")
|
||||||
|
ap.add_argument("--model", default=DEFAULT_MODEL)
|
||||||
|
ap.add_argument("--raw", action="store_true", help="skriv aven modellens rasvar till stderr")
|
||||||
|
args = ap.parse_args()
|
||||||
|
|
||||||
|
with open(args.srt, encoding="utf-8") as fh:
|
||||||
|
cues = parse_srt(fh.read())
|
||||||
|
if not cues:
|
||||||
|
sys.exit(f"inga cues hittades i {args.srt} - fel format?")
|
||||||
|
duration = cues[-1].end
|
||||||
|
|
||||||
|
print(
|
||||||
|
f"[i] {len(cues)} cues, langd {secs_to_hms(duration)}, modell {args.model}",
|
||||||
|
file=sys.stderr,
|
||||||
|
)
|
||||||
|
|
||||||
|
content = call_llm(args.model, as_numbered_transcript(cues), secs_to_hms(duration))
|
||||||
|
if args.raw:
|
||||||
|
print(content, file=sys.stderr)
|
||||||
|
|
||||||
|
try:
|
||||||
|
doc = extract_json(content)
|
||||||
|
except json.JSONDecodeError as e:
|
||||||
|
sys.exit(f"modellen returnerade ogiltig JSON: {e}\n---\n{content[:800]}")
|
||||||
|
|
||||||
|
warnings = verify(doc, cues, duration)
|
||||||
|
doc["_meta"] = {
|
||||||
|
"modell": args.model,
|
||||||
|
"kallfil": os.path.basename(args.srt),
|
||||||
|
"antal_cues": len(cues),
|
||||||
|
"langd": secs_to_hms(duration),
|
||||||
|
"varningar": warnings,
|
||||||
|
}
|
||||||
|
|
||||||
|
print(json.dumps(doc, ensure_ascii=False, indent=2))
|
||||||
|
|
||||||
|
if warnings:
|
||||||
|
print(f"\n[!] {len(warnings)} verifieringsvarningar:", file=sys.stderr)
|
||||||
|
for w in warnings:
|
||||||
|
print(f" - {w}", file=sys.stderr)
|
||||||
|
else:
|
||||||
|
print("[ok] alla citat aterfunna, alla tider inom klippet", file=sys.stderr)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,357 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Bygg en fristaende HTML-sida: video + SRT + tidssynkad analys.
|
||||||
|
|
||||||
|
Sidan ar sjalvbarande sa nar som pa videofilen - transkription och analys
|
||||||
|
bakas in i HTML:en. Det gor att den fungerar aven over file:// (fetch mot
|
||||||
|
en lokal JSON blockeras av CORS).
|
||||||
|
|
||||||
|
python3 build_page.py payload.json IMG_1233.web.mp4 > index.html
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import html
|
||||||
|
import json
|
||||||
|
import sys
|
||||||
|
|
||||||
|
TEMPLATE = """<!doctype html>
|
||||||
|
<html lang="sv">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>__TITLE__</title>
|
||||||
|
<style>
|
||||||
|
:root {
|
||||||
|
--bg:#f6f7f9; --panel:#fff; --ink:#16181d; --muted:#6b7280; --line:#e3e6ea;
|
||||||
|
--accent:#2563eb; --accent-soft:#eff4ff;
|
||||||
|
--beslut:#0f9d58; --alt:#d97706; --oppen:#dc2626; --matt:#7c3aed;
|
||||||
|
}
|
||||||
|
@media (prefers-color-scheme: dark) {
|
||||||
|
:root {
|
||||||
|
--bg:#0e1116; --panel:#161a21; --ink:#e6e9ef; --muted:#9aa3b2; --line:#262c36;
|
||||||
|
--accent:#60a5fa; --accent-soft:#17233b;
|
||||||
|
--beslut:#34d399; --alt:#fbbf24; --oppen:#f87171; --matt:#a78bfa;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
:root[data-theme="dark"] {
|
||||||
|
--bg:#0e1116; --panel:#161a21; --ink:#e6e9ef; --muted:#9aa3b2; --line:#262c36;
|
||||||
|
--accent:#60a5fa; --accent-soft:#17233b;
|
||||||
|
--beslut:#34d399; --alt:#fbbf24; --oppen:#f87171; --matt:#a78bfa;
|
||||||
|
}
|
||||||
|
:root[data-theme="light"] {
|
||||||
|
--bg:#f6f7f9; --panel:#fff; --ink:#16181d; --muted:#6b7280; --line:#e3e6ea;
|
||||||
|
--accent:#2563eb; --accent-soft:#eff4ff;
|
||||||
|
--beslut:#0f9d58; --alt:#d97706; --oppen:#dc2626; --matt:#7c3aed;
|
||||||
|
}
|
||||||
|
* { box-sizing:border-box; }
|
||||||
|
body {
|
||||||
|
margin:0; background:var(--bg); color:var(--ink);
|
||||||
|
font:15px/1.55 -apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif;
|
||||||
|
}
|
||||||
|
header {
|
||||||
|
padding:14px 20px; border-bottom:1px solid var(--line); background:var(--panel);
|
||||||
|
display:flex; align-items:baseline; gap:14px; flex-wrap:wrap;
|
||||||
|
}
|
||||||
|
header h1 { margin:0; font-size:17px; font-weight:650; }
|
||||||
|
header .sub { color:var(--muted); font-size:13px; }
|
||||||
|
|
||||||
|
.wrap {
|
||||||
|
display:grid; grid-template-columns: minmax(0,1.35fr) minmax(320px,1fr);
|
||||||
|
gap:20px; padding:20px; align-items:start; max-width:1600px; margin:0 auto;
|
||||||
|
}
|
||||||
|
@media (max-width: 950px) { .wrap { grid-template-columns:1fr; } }
|
||||||
|
|
||||||
|
/* Videon star stilla; analysen ror sig bredvid. */
|
||||||
|
.videocol { position:sticky; top:20px; }
|
||||||
|
video { width:100%; border-radius:10px; background:#000; display:block; }
|
||||||
|
|
||||||
|
.nowbar {
|
||||||
|
margin-top:10px; padding:12px 14px; background:var(--panel);
|
||||||
|
border:1px solid var(--line); border-radius:10px; min-height:64px;
|
||||||
|
}
|
||||||
|
.nowbar .label { font-size:11px; text-transform:uppercase; letter-spacing:.07em; color:var(--muted); }
|
||||||
|
.nowbar .text { margin-top:4px; font-size:16px; }
|
||||||
|
|
||||||
|
.tabs { display:flex; gap:6px; margin-bottom:12px; flex-wrap:wrap; }
|
||||||
|
.tab {
|
||||||
|
padding:6px 12px; border:1px solid var(--line); background:var(--panel);
|
||||||
|
border-radius:999px; cursor:pointer; font-size:13px; color:var(--ink);
|
||||||
|
}
|
||||||
|
.tab[aria-selected="true"] { background:var(--accent); border-color:var(--accent); color:#fff; }
|
||||||
|
|
||||||
|
.pane { display:none; }
|
||||||
|
.pane.active { display:block; }
|
||||||
|
|
||||||
|
.scroller {
|
||||||
|
max-height: calc(100vh - 190px); overflow-y:auto; padding-right:6px;
|
||||||
|
scroll-behavior:smooth;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card {
|
||||||
|
background:var(--panel); border:1px solid var(--line); border-left:3px solid var(--line);
|
||||||
|
border-radius:8px; padding:11px 13px; margin-bottom:9px; cursor:pointer;
|
||||||
|
transition:border-color .15s, background .15s;
|
||||||
|
}
|
||||||
|
.card:hover { border-color:var(--accent); }
|
||||||
|
.card.on { background:var(--accent-soft); border-left-color:var(--accent); }
|
||||||
|
.card .k {
|
||||||
|
font-size:10px; text-transform:uppercase; letter-spacing:.07em;
|
||||||
|
font-weight:700; margin-bottom:5px;
|
||||||
|
}
|
||||||
|
.card .t { font-size:11px; color:var(--muted); font-variant-numeric:tabular-nums; }
|
||||||
|
.card .cit { display:block; margin-top:6px; color:var(--muted); font-size:13px; font-style:italic; }
|
||||||
|
.k.beslut{color:var(--beslut)} .k.alt{color:var(--alt)}
|
||||||
|
.k.oppen{color:var(--oppen)} .k.matt{color:var(--matt)}
|
||||||
|
.card.beslut{border-left-color:var(--beslut)} .card.alt{border-left-color:var(--alt)}
|
||||||
|
.card.oppen{border-left-color:var(--oppen)} .card.matt{border-left-color:var(--matt)}
|
||||||
|
.opts { margin:6px 0 0; padding-left:18px; font-size:13px; }
|
||||||
|
.lutar { font-size:12px; color:var(--muted); margin-top:5px; }
|
||||||
|
|
||||||
|
.cue {
|
||||||
|
padding:5px 9px; border-radius:6px; cursor:pointer; display:flex; gap:10px;
|
||||||
|
font-size:14px; border-left:2px solid transparent;
|
||||||
|
}
|
||||||
|
.cue:hover { background:var(--accent-soft); }
|
||||||
|
.cue.on { background:var(--accent-soft); border-left-color:var(--accent); font-weight:600; }
|
||||||
|
.cue .ts {
|
||||||
|
color:var(--muted); font-variant-numeric:tabular-nums; font-size:12px;
|
||||||
|
flex:0 0 46px; padding-top:2px;
|
||||||
|
}
|
||||||
|
.sec-head {
|
||||||
|
margin:16px 0 7px; font-size:12px; font-weight:700; text-transform:uppercase;
|
||||||
|
letter-spacing:.06em; color:var(--muted); border-bottom:1px solid var(--line);
|
||||||
|
padding-bottom:5px;
|
||||||
|
}
|
||||||
|
.sec-head:first-child { margin-top:0; }
|
||||||
|
.warn {
|
||||||
|
background:var(--panel); border:1px solid var(--line); border-left:3px solid var(--alt);
|
||||||
|
border-radius:8px; padding:11px 13px; font-size:13px; color:var(--muted);
|
||||||
|
}
|
||||||
|
.warn b { color:var(--ink); }
|
||||||
|
.warn ul { margin:6px 0 0; padding-left:18px; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<header>
|
||||||
|
<h1>__TITLE__</h1>
|
||||||
|
<span class="sub">__SUB__</span>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="wrap">
|
||||||
|
<div class="videocol">
|
||||||
|
<video id="v" controls preload="metadata" src="__VIDEO__"></video>
|
||||||
|
<div class="nowbar">
|
||||||
|
<div class="label">Sägs nu</div>
|
||||||
|
<div class="text" id="now">—</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<div class="tabs" role="tablist">
|
||||||
|
<button class="tab" role="tab" aria-selected="true" data-pane="analys">Analys</button>
|
||||||
|
<button class="tab" role="tab" aria-selected="false" data-pane="text">Transkription</button>
|
||||||
|
<button class="tab" role="tab" aria-selected="false" data-pane="kvalitet">Kvalitet</button>
|
||||||
|
</div>
|
||||||
|
<div class="pane active" id="pane-analys"><div class="scroller" id="analys"></div></div>
|
||||||
|
<div class="pane" id="pane-text"><div class="scroller" id="text"></div></div>
|
||||||
|
<div class="pane" id="pane-kvalitet"><div class="scroller" id="kvalitet"></div></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
const DATA = __DATA__;
|
||||||
|
const v = document.getElementById('v');
|
||||||
|
|
||||||
|
const hms = s => {
|
||||||
|
s = Math.max(0, Math.floor(s));
|
||||||
|
return String(Math.floor(s/60)).padStart(2,'0') + ':' + String(s%60).padStart(2,'0');
|
||||||
|
};
|
||||||
|
const toSec = t => {
|
||||||
|
if (t == null) return null;
|
||||||
|
const p = String(t).trim().split(':').map(Number);
|
||||||
|
if (p.some(isNaN)) return null;
|
||||||
|
return p.length === 3 ? p[0]*3600+p[1]*60+p[2] : p.length === 2 ? p[0]*60+p[1] : p[0];
|
||||||
|
};
|
||||||
|
const esc = s => { const d = document.createElement('div'); d.textContent = s ?? ''; return d.innerHTML; };
|
||||||
|
|
||||||
|
/* Varje kort bar sin tid; ett klick soker dit. Analysen ar tidsforankrad
|
||||||
|
hela vagen ned, sa synkningen ar bara en sortering pa den tiden. */
|
||||||
|
const items = [];
|
||||||
|
const A = DATA.analys;
|
||||||
|
|
||||||
|
(A.avsnitt || []).forEach((s, i) => {
|
||||||
|
const t = toSec(s.start) ?? 0;
|
||||||
|
items.push({t, end: toSec(s.slut) ?? 1e9, kind:'avsnitt', html:
|
||||||
|
`<div class="k">Avsnitt ${i+1} · ${esc(s.start)}–${esc(s.slut)}</div>
|
||||||
|
<div style="font-weight:650;font-size:15px">${esc(s.rubrik)}</div>
|
||||||
|
<div style="margin-top:5px">${esc(s.sammanfattning)}</div>`});
|
||||||
|
(s.nyckelpunkter || []).forEach(k => {
|
||||||
|
const kt = toSec(k.tid); if (kt == null) return;
|
||||||
|
items.push({t:kt, end:kt+6, kind:'punkt', citat:k.citat, html:
|
||||||
|
`<div class="t">${esc(k.tid)}</div><div>${esc(k.pastaende)}</div>
|
||||||
|
<div class="cit">”${esc(k.citat)}”</div>`});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
(A.beslut || []).forEach(b => {
|
||||||
|
const t = toSec(b.tid); if (t == null) return;
|
||||||
|
items.push({t, end:t+8, kind:'beslut', citat:b.citat, html:
|
||||||
|
`<div class="k beslut">Beslut</div><div class="t">${esc(b.tid)}</div>
|
||||||
|
<div style="font-weight:600">${esc(b.vad)}</div>
|
||||||
|
${b.skal ? `<div class="lutar">Skäl: ${esc(b.skal)}</div>` : ''}
|
||||||
|
<div class="cit">”${esc(b.citat)}”</div>`});
|
||||||
|
});
|
||||||
|
(A.alternativ || []).forEach(a => {
|
||||||
|
const t = toSec(a.tid); if (t == null) return;
|
||||||
|
items.push({t, end:t+8, kind:'alt', citat:a.citat, html:
|
||||||
|
`<div class="k alt">Alternativ</div><div class="t">${esc(a.tid)}</div>
|
||||||
|
<div style="font-weight:600">${esc(a.fraga)}</div>
|
||||||
|
<ul class="opts">${(a.optioner||[]).map(o=>`<li>${esc(o)}</li>`).join('')}</ul>
|
||||||
|
<div class="lutar">${a.lutar_at ? 'Lutar åt: <b>'+esc(a.lutar_at)+'</b>' : 'Oavgjort'}</div>
|
||||||
|
<div class="cit">”${esc(a.citat)}”</div>`});
|
||||||
|
});
|
||||||
|
(A.oppna_fragor || []).forEach(o => {
|
||||||
|
const t = toSec(o.tid); if (t == null) return;
|
||||||
|
items.push({t, end:t+8, kind:'oppen', citat:o.citat, html:
|
||||||
|
`<div class="k oppen">Öppen fråga</div><div class="t">${esc(o.tid)}</div>
|
||||||
|
<div style="font-weight:600">${esc(o.fraga)}</div>
|
||||||
|
${o.beror_pa ? `<div class="lutar">Beror på: ${esc(o.beror_pa)}</div>` : ''}
|
||||||
|
<div class="cit">”${esc(o.citat)}”</div>`});
|
||||||
|
});
|
||||||
|
(A.matt || []).forEach(m => {
|
||||||
|
const t = toSec(m.tid); if (t == null) return;
|
||||||
|
items.push({t, end:t+6, kind:'matt', citat:m.citat, html:
|
||||||
|
`<div class="k matt">Mått</div><div class="t">${esc(m.tid)}</div>
|
||||||
|
<div>${esc(m.vad)}</div><div class="cit">”${esc(m.citat)}”</div>`});
|
||||||
|
});
|
||||||
|
|
||||||
|
const RANK = {avsnitt:0, beslut:1, oppen:2, alt:3, matt:4, punkt:5};
|
||||||
|
/* Samma replik klassas ofta bade som nyckelpunkt och som matt/beslut.
|
||||||
|
Visa den mest specifika varianten en gang i stallet for tva nastan
|
||||||
|
identiska kort - annars later flodet som ett eko. */
|
||||||
|
const bestByQuote = new Map();
|
||||||
|
items.forEach(it => {
|
||||||
|
const key = (it.citat||'').trim().toLowerCase() + '@' + it.t;
|
||||||
|
if (!it.citat) return;
|
||||||
|
const prev = bestByQuote.get(key);
|
||||||
|
if (prev === undefined || RANK[it.kind] < RANK[prev.kind]) bestByQuote.set(key, it);
|
||||||
|
});
|
||||||
|
const kept = items.filter(it => {
|
||||||
|
if (!it.citat) return true;
|
||||||
|
const key = (it.citat||'').trim().toLowerCase() + '@' + it.t;
|
||||||
|
return bestByQuote.get(key) === it;
|
||||||
|
});
|
||||||
|
items.length = 0; items.push(...kept);
|
||||||
|
items.sort((a,b) => a.t - b.t || RANK[a.kind] - RANK[b.kind]);
|
||||||
|
|
||||||
|
const analysEl = document.getElementById('analys');
|
||||||
|
analysEl.innerHTML = items.map((it,i) =>
|
||||||
|
`<div class="card ${it.kind}" data-i="${i}" data-t="${it.t}">${it.html}</div>`).join('');
|
||||||
|
|
||||||
|
const textEl = document.getElementById('text');
|
||||||
|
textEl.innerHTML = DATA.cues.map((c,i) =>
|
||||||
|
`<div class="cue" data-ci="${i}" data-t="${c.t}">
|
||||||
|
<span class="ts">${hms(c.t)}</span><span>${esc(c.text)}</span></div>`).join('');
|
||||||
|
|
||||||
|
const warns = (A._meta && A._meta.varningar) || [];
|
||||||
|
document.getElementById('kvalitet').innerHTML = `
|
||||||
|
<div class="warn">
|
||||||
|
<b>Automatisk källkontroll</b><br>
|
||||||
|
Modell: ${esc((A._meta&&A._meta.modell)||'–')} · ${DATA.cues.length} repliker · längd ${esc((A._meta&&A._meta.langd)||'–')}<br><br>
|
||||||
|
Varje påstående kontrolleras mot sitt eget citat: citatet måste återfinnas
|
||||||
|
ordagrant i transkriptionen, tiden måste ligga inom klippet, och varje tal i
|
||||||
|
påståendet måste styrkas av citatet.
|
||||||
|
${warns.length
|
||||||
|
? `<br><br><b>${warns.length} varningar:</b><ul>${warns.map(w=>`<li>${esc(w)}</li>`).join('')}</ul>`
|
||||||
|
: '<br><br>Inga avvikelser.'}
|
||||||
|
</div>`;
|
||||||
|
|
||||||
|
/* Klick söker i videon - åt båda håll: kort och replik. */
|
||||||
|
document.addEventListener('click', e => {
|
||||||
|
const el = e.target.closest('.card, .cue');
|
||||||
|
if (!el) return;
|
||||||
|
v.currentTime = parseFloat(el.dataset.t) + 0.05;
|
||||||
|
v.play().catch(()=>{});
|
||||||
|
});
|
||||||
|
|
||||||
|
document.querySelectorAll('.tab').forEach(tab => {
|
||||||
|
tab.addEventListener('click', () => {
|
||||||
|
document.querySelectorAll('.tab').forEach(t => t.setAttribute('aria-selected','false'));
|
||||||
|
tab.setAttribute('aria-selected','true');
|
||||||
|
document.querySelectorAll('.pane').forEach(p => p.classList.remove('active'));
|
||||||
|
document.getElementById('pane-' + tab.dataset.pane).classList.add('active');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
/* Auto-scroll bara nar anvandaren inte sjalv scrollar - annars slits vyn
|
||||||
|
ur handerna pa den som lasar. */
|
||||||
|
let userScrolled = 0;
|
||||||
|
document.querySelectorAll('.scroller').forEach(s =>
|
||||||
|
s.addEventListener('wheel', () => { userScrolled = Date.now(); }, {passive:true}));
|
||||||
|
|
||||||
|
let lastCard = -1, lastCue = -1;
|
||||||
|
function sync() {
|
||||||
|
const t = v.currentTime;
|
||||||
|
|
||||||
|
let ci = -1;
|
||||||
|
for (let i = 0; i < DATA.cues.length; i++) {
|
||||||
|
if (t >= DATA.cues[i].t - 0.15) ci = i; else break;
|
||||||
|
}
|
||||||
|
if (ci !== lastCue) {
|
||||||
|
document.querySelectorAll('.cue.on').forEach(e => e.classList.remove('on'));
|
||||||
|
if (ci >= 0) {
|
||||||
|
const el = textEl.querySelector(`[data-ci="${ci}"]`);
|
||||||
|
if (el) {
|
||||||
|
el.classList.add('on');
|
||||||
|
if (Date.now() - userScrolled > 3000) el.scrollIntoView({block:'center'});
|
||||||
|
}
|
||||||
|
document.getElementById('now').textContent = DATA.cues[ci].text;
|
||||||
|
}
|
||||||
|
lastCue = ci;
|
||||||
|
}
|
||||||
|
|
||||||
|
let ai = -1;
|
||||||
|
for (let i = 0; i < items.length; i++) {
|
||||||
|
if (t >= items[i].t - 0.15) ai = i; else break;
|
||||||
|
}
|
||||||
|
if (ai !== lastCard) {
|
||||||
|
document.querySelectorAll('.card.on').forEach(e => e.classList.remove('on'));
|
||||||
|
if (ai >= 0) {
|
||||||
|
const el = analysEl.querySelector(`[data-i="${ai}"]`);
|
||||||
|
if (el) {
|
||||||
|
el.classList.add('on');
|
||||||
|
if (Date.now() - userScrolled > 3000 &&
|
||||||
|
document.getElementById('pane-analys').classList.contains('active')) {
|
||||||
|
el.scrollIntoView({block:'center'});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
lastCard = ai;
|
||||||
|
}
|
||||||
|
requestAnimationFrame(sync);
|
||||||
|
}
|
||||||
|
requestAnimationFrame(sync);
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
payload_path, video = sys.argv[1], sys.argv[2]
|
||||||
|
payload = json.load(open(payload_path, encoding="utf-8"))
|
||||||
|
a = payload["analys"]
|
||||||
|
proj = a.get("projekt") or {}
|
||||||
|
sub = " · ".join(
|
||||||
|
x for x in [proj.get("typ"), (a.get("_meta") or {}).get("langd"), video] if x
|
||||||
|
)
|
||||||
|
out = (
|
||||||
|
TEMPLATE.replace("__TITLE__", html.escape("Jonas – genomgång av tillbyggnad"))
|
||||||
|
.replace("__SUB__", html.escape(sub))
|
||||||
|
.replace("__VIDEO__", html.escape(video))
|
||||||
|
.replace("__DATA__", json.dumps(payload, ensure_ascii=False))
|
||||||
|
)
|
||||||
|
sys.stdout.write(out)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,173 @@
|
|||||||
|
{
|
||||||
|
"projekt": {
|
||||||
|
"sammanfattning": "Projektet är en nybyggnad med grundläggning, betongplatta och limträstomme. Taket kommer att vara ett sedumtak.",
|
||||||
|
"typ": "nybyggnad",
|
||||||
|
"skede": "stomme"
|
||||||
|
},
|
||||||
|
"avsnitt": [
|
||||||
|
{
|
||||||
|
"rubrik": "Introduktion och grundläggning",
|
||||||
|
"start": "00:00:00",
|
||||||
|
"slut": "00:00:06",
|
||||||
|
"sammanfattning": "Jonas introducerar platsen och meddelar att grundläggningen är klar.",
|
||||||
|
"nyckelpunkter": [
|
||||||
|
{
|
||||||
|
"pastaende": "Grundläggningen är klar",
|
||||||
|
"citat": "Vi har precis blivit klara med grundläggningen.",
|
||||||
|
"tid": "00:00:03"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"bildtid": "00:00:03",
|
||||||
|
"bildmotiv": "tomten med färdig grundläggning"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"rubrik": "Dränering och grundvatten",
|
||||||
|
"start": "00:00:06",
|
||||||
|
"slut": "00:00:14",
|
||||||
|
"sammanfattning": "Jonas beskriver dräneringsarbetet som krävdes på grund av högt grundvatten.",
|
||||||
|
"nyckelpunkter": [
|
||||||
|
{
|
||||||
|
"pastaende": "Dränering utfördes på grund av högt grundvatten",
|
||||||
|
"citat": "Vi fick dränera ordentligt först, för grundvattnet stod väldigt högt här.",
|
||||||
|
"tid": "00:00:06"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"bildtid": "00:00:10",
|
||||||
|
"bildmotiv": "dräneringssystem eller markarbete"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"rubrik": "Betongplatta och isolering",
|
||||||
|
"start": "00:00:14",
|
||||||
|
"slut": "00:00:22",
|
||||||
|
"sammanfattning": "Jonas beskriver betongplattan och isoleringen under den.",
|
||||||
|
"nyckelpunkter": [
|
||||||
|
{
|
||||||
|
"pastaende": "Betongplatta på mark med cellplast under",
|
||||||
|
"citat": "det är en betongplatta på mark med tvåhundra millimeter cellplast under.",
|
||||||
|
"tid": "00:00:16"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"bildtid": "00:00:18",
|
||||||
|
"bildmotiv": "betongplattan med cellplast"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"rubrik": "Stomme och bjälklag",
|
||||||
|
"start": "00:00:22",
|
||||||
|
"slut": "00:00:31",
|
||||||
|
"sammanfattning": "Jonas beskriver stommen i limträ och bjälklagets dimensioner.",
|
||||||
|
"nyckelpunkter": [
|
||||||
|
{
|
||||||
|
"pastaende": "Stommen är i limträ",
|
||||||
|
"citat": "Här ser ni stommen, den är i limträ.",
|
||||||
|
"tid": "00:00:22"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"pastaende": "Pelarnas dimensioner",
|
||||||
|
"citat": "Pelarna är hundraåttio gånger hundraåttio.",
|
||||||
|
"tid": "00:00:25"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"pastaende": "Bjälklagets spännvidd och dimensioner",
|
||||||
|
"citat": "Bjälklaget spänner sex meter fritt, och vi har fyrtiofem gånger tvåhundratjugo som balkar.",
|
||||||
|
"tid": "00:00:31"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"bildtid": "00:00:28",
|
||||||
|
"bildmotiv": "limträstommen med pelare och bjälklag"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"rubrik": "Tak och tidplan",
|
||||||
|
"start": "00:00:40",
|
||||||
|
"slut": "00:00:48",
|
||||||
|
"sammanfattning": "Jonas nämner taktypen och förklarar att projektet är försenat på grund av leveransproblem.",
|
||||||
|
"nyckelpunkter": [
|
||||||
|
{
|
||||||
|
"pastaende": "Taket kommer att vara sedumtak",
|
||||||
|
"citat": "Taket blir sedumtak, det var byggherrens önskemål.",
|
||||||
|
"tid": "00:00:40"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"pastaende": "Sedumtaket ställer krav på bärigheten",
|
||||||
|
"citat": "Det ställer krav på bärigheten.",
|
||||||
|
"tid": "00:00:44"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"pastaende": "Projektet är två veckor efter tidplanen",
|
||||||
|
"citat": "Vi ligger ungefär två veckor efter tidplanen på grund av leveransproblem på limträet.",
|
||||||
|
"tid": "00:00:48"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"bildtid": "00:00:44",
|
||||||
|
"bildmotiv": "stommen med eventuella förberedelser för taket"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"material": [
|
||||||
|
{
|
||||||
|
"vad": "cellplast",
|
||||||
|
"citat": "med tvåhundra millimeter cellplast under",
|
||||||
|
"tid": "00:00:18"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"vad": "betongplatta",
|
||||||
|
"citat": "det är en betongplatta på mark",
|
||||||
|
"tid": "00:00:16"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"vad": "limträ",
|
||||||
|
"citat": "den är i limträ",
|
||||||
|
"tid": "00:00:22"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"vad": "sedumtak",
|
||||||
|
"citat": "Taket blir sedumtak",
|
||||||
|
"tid": "00:00:40"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"matt": [
|
||||||
|
{
|
||||||
|
"vad": "200 mm cellplast",
|
||||||
|
"citat": "tvåhundra millimeter cellplast",
|
||||||
|
"tid": "00:00:18"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"vad": "180x180 mm pelare",
|
||||||
|
"citat": "Pelarna är hundraåttio gånger hundraåttio",
|
||||||
|
"tid": "00:00:25"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"vad": "6 meter spännvidd för bjälklag",
|
||||||
|
"citat": "Bjälklaget spänner sex meter fritt",
|
||||||
|
"tid": "00:00:31"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"vad": "45x230 mm balkar",
|
||||||
|
"citat": "fyrtiofem gånger tvåhundratjugo som balkar",
|
||||||
|
"tid": "00:00:31"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"problem": [
|
||||||
|
{
|
||||||
|
"vad": "högt grundvatten",
|
||||||
|
"citat": "grundvattnet stod väldigt högt här",
|
||||||
|
"tid": "00:00:10"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"vad": "leveransproblem med limträ",
|
||||||
|
"citat": "leveransproblem på limträet",
|
||||||
|
"tid": "00:00:48"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"vad": "försenat projekt",
|
||||||
|
"citat": "Vi ligger ungefär två veckor efter tidplanen",
|
||||||
|
"tid": "00:00:48"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"oklart": [],
|
||||||
|
"_meta": {
|
||||||
|
"modell": "berget/mistral-medium",
|
||||||
|
"kallfil": "fake.sv.srt",
|
||||||
|
"antal_cues": 7,
|
||||||
|
"langd": "00:00:56",
|
||||||
|
"varningar": []
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
1
|
||||||
|
00:00:00,000 --> 00:00:06,500
|
||||||
|
Ja, då står vi här på tomten. Vi har precis blivit klara med grundläggningen.
|
||||||
|
|
||||||
|
2
|
||||||
|
00:00:06,500 --> 00:00:14,000
|
||||||
|
Vi fick dränera ordentligt först, för grundvattnet stod väldigt högt här.
|
||||||
|
|
||||||
|
3
|
||||||
|
00:00:14,000 --> 00:00:22,000
|
||||||
|
Sen göt vi plattan, det är en betongplatta på mark med tvåhundra millimeter cellplast under.
|
||||||
|
|
||||||
|
4
|
||||||
|
00:00:22,000 --> 00:00:31,000
|
||||||
|
Här ser ni stommen, den är i limträ. Pelarna är hundraåttio gånger hundraåttio.
|
||||||
|
|
||||||
|
5
|
||||||
|
00:00:31,000 --> 00:00:40,000
|
||||||
|
Bjälklaget spänner sex meter fritt, och vi har fyrtiofem gånger tvåhundratjugo som balkar.
|
||||||
|
|
||||||
|
6
|
||||||
|
00:00:40,000 --> 00:00:48,000
|
||||||
|
Taket blir sedumtak, det var byggherrens önskemål. Det ställer krav på bärigheten.
|
||||||
|
|
||||||
|
7
|
||||||
|
00:00:48,000 --> 00:00:56,000
|
||||||
|
Vi ligger ungefär två veckor efter tidplanen på grund av leveransproblem på limträet.
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
apiVersion: batch/v1
|
||||||
|
kind: Job
|
||||||
|
metadata:
|
||||||
|
name: jonas-transcode
|
||||||
|
namespace: default
|
||||||
|
spec:
|
||||||
|
ttlSecondsAfterFinished: 600
|
||||||
|
activeDeadlineSeconds: 1800
|
||||||
|
backoffLimit: 0
|
||||||
|
template:
|
||||||
|
spec:
|
||||||
|
restartPolicy: Never
|
||||||
|
nodeSelector:
|
||||||
|
kubernetes.io/hostname: koala
|
||||||
|
securityContext:
|
||||||
|
runAsUser: 1000
|
||||||
|
runAsGroup: 1000
|
||||||
|
fsGroup: 1000
|
||||||
|
containers:
|
||||||
|
- name: ffmpeg
|
||||||
|
image: mwader/static-ffmpeg:7.1
|
||||||
|
args:
|
||||||
|
- "-nostdin"
|
||||||
|
- "-i"
|
||||||
|
- "/work/IMG_1233.mov"
|
||||||
|
- "-c:v"
|
||||||
|
- "libx264"
|
||||||
|
- "-preset"
|
||||||
|
- "veryfast"
|
||||||
|
- "-crf"
|
||||||
|
- "24"
|
||||||
|
- "-vf"
|
||||||
|
- "scale='min(1280,iw)':-2"
|
||||||
|
- "-c:a"
|
||||||
|
- "aac"
|
||||||
|
- "-b:a"
|
||||||
|
- "128k"
|
||||||
|
- "-movflags"
|
||||||
|
- "+faststart"
|
||||||
|
- "-y"
|
||||||
|
- "/work/IMG_1233.web.mp4"
|
||||||
|
volumeMounts:
|
||||||
|
- name: work
|
||||||
|
mountPath: /work
|
||||||
|
resources:
|
||||||
|
limits: {cpu: "6", memory: "4Gi"}
|
||||||
|
volumes:
|
||||||
|
- name: work
|
||||||
|
hostPath:
|
||||||
|
path: /home/mathias/dev/AGENTS/agentsquad
|
||||||
|
type: Directory
|
||||||
Reference in New Issue
Block a user