feat(erindringer): samtalestyring med forberedte spørgsmål, talt udgave med lydtags, spørgsmålets lyd gemmes med svaret
CD / deploy (push) Successful in 17s
CI / check (push) Successful in 23s

Intervieweren når de forberedte spørgsmål, følger op højst N gange pr. spørgsmål
(indstilles pr. emne), trækker tilbage til emnet og afrunder. 'Spring over' i UI.
Spørgsmålet oplæses med ElevenLabs-tags ([warmly], [chuckles] …) som ikke vises
i teksten; oplæsningen gemmes som <id>-sp.mp3 ved siden af svaret og følger med i
zip-eksporten, så spørgsmål + svar kan klippes ind i en podcast.

Claude-Session: https://claude.ai/code/session_01JmhJG6JnhXfzNiKv8f2vHu
This commit is contained in:
root
2026-10-06 22:15:46 +02:00
parent 7f8ddcf7af
commit 3dee428a37
6 changed files with 182 additions and 48 deletions
+113 -31
View File
@@ -93,13 +93,16 @@ def projekt(slug: str) -> dict | None:
return {**p, "slug": slug} if p else None
def gem_projekt(slug: str, titel: str, beskrivelse: str, kontekst: str, start: list[str]) -> bool:
def gem_projekt(slug: str, titel: str, beskrivelse: str, kontekst: str, start: list[str], opfoelg: int = 2) -> bool:
"""start = de forberedte spørgsmål, som intervieweren skal nå igennem; opfoelg = højst så mange
opfølgende spørgsmål pr. forberedt spørgsmål, før den går videre."""
if not SLUG.match(slug or "") or not titel.strip():
return False
p = projekter()
gammel = p.get(slug, {})
p[slug] = {"titel": titel.strip(), "beskrivelse": beskrivelse.strip(), "kontekst": kontekst.strip(),
"start": [s.strip() for s in start if s.strip()], "oprettet": gammel.get("oprettet") or _nu()}
"start": [s.strip() for s in start if s.strip()], "opfoelg": max(0, min(6, int(opfoelg or 0))),
"oprettet": gammel.get("oprettet") or _nu()}
_skriv(PROJEKTER, p)
return True
@@ -267,15 +270,21 @@ def _varighed(p: Path) -> int:
def gem_svar(pid: str, projekt_slug: str, samtale: str, spoergsmaal: str, lyd: bytes | None, filnavn: str,
tekst: str | None) -> dict:
tekst: str | None, spoergsmaal_talt: str = "", lyd_sha: str = "", kilde: str = "") -> dict:
"""Gem et svar: lyd konverteres til mp3 og transskriberes; ren tekst gemmes som det er.
Returnerer klip-metadata. Kaster ValueError med besked til brugeren."""
Spørgsmålets oplæsning (lyd_sha fra tts-cachen) kopieres med som <id>-sp.mp3, så spørgsmål og
svar kan klippes sammen i en podcast. Returnerer klip-metadata. Kaster ValueError med besked."""
if not PID.match(pid or "") or not SLUG.match(projekt_slug or ""):
raise ValueError("Ugyldig person eller projekt.")
d = _klipmappe(pid)
d.mkdir(parents=True, exist_ok=True)
kid = time.strftime("%Y%m%d-%H%M%S") + "-" + secrets.token_hex(3)
mp3 = d / f"{kid}.mp3"
sp = tts_fil(lyd_sha) if lyd_sha else None
if sp is None and spoergsmaal_talt:
sp = tts_sti(spoergsmaal_talt)
if sp:
(d / f"{kid}-sp.mp3").write_bytes(sp.read_bytes())
varighed = 0
if lyd:
if len(lyd) > MAX_UPLOAD:
@@ -304,6 +313,8 @@ def gem_svar(pid: str, projekt_slug: str, samtale: str, spoergsmaal: str, lyd: b
subprocess.run(["ffmpeg", "-y", "-v", "error", "-f", "lavfi", "-i", "anullsrc=r=44100:cl=mono", "-t", "1",
"-c:a", "libmp3lame", "-b:a", "32k", str(mp3)], capture_output=True, timeout=60)
meta = {"id": kid, "tid": _nu(), "projekt": projekt_slug, "samtale": samtale, "spoergsmaal": spoergsmaal.strip()[:600],
"spoergsmaal_talt": (spoergsmaal_talt or "").strip()[:800], "spoergsmaal_lyd": bool(sp),
"kilde": kilde if kilde in ("forberedt", "opfoelgning", "afrunding") else "",
"tekst": (tekst or "").strip(), "varighed": varighed, "favorit": False, "skrevet": not lyd}
_skriv(d / f"{kid}.json", meta)
return meta
@@ -333,11 +344,18 @@ def slet_klip(pid: str, kid: str) -> bool:
if not k:
return False
d = _klipmappe(pid)
(d / f"{kid}.json").unlink(missing_ok=True)
(d / f"{kid}.mp3").unlink(missing_ok=True)
for navn in (f"{kid}.json", f"{kid}.mp3", f"{kid}-sp.mp3"):
(d / navn).unlink(missing_ok=True)
return True
def spoergsmaal_fil(pid: str, kid: str) -> Path | None:
if not PID.match(pid or "") or not KLIPID.match(kid or ""):
return None
p = _klipmappe(pid) / f"{kid}-sp.mp3"
return p if p.exists() else None
# ---------------- ElevenLabs: tale ----------------
def transskriber(mp3: Path) -> str:
@@ -389,17 +407,53 @@ def tts_fil(sha: str) -> Path | None:
# ---------------- Claude: interviewer ----------------
SYSTEM = """Du er en varm, nysgerrig interviewer for en dansk familie. Du hjælper et familiemedlem med at
fortælle sine minder (eller føre dagbog) ved at stille ÉT kort spørgsmål ad gangen på dansk.
# Lyd-tags, som ElevenLabs v4 udfører i stedet for at læse dem op. Alt andet i [klammer] fjernes.
AUDIO_TAGS = {"warmly", "softly", "curious", "gently", "chuckles", "laughs", "pause", "thoughtful", "amused",
"sighs", "whispers", "excited", "surprised", "sincere", "calm", "slowly"}
_TAG = re.compile(r"\[([a-zA-Z ]+)\]")
Regler:
- Spørg konkret og hverdagsnært: steder, personer, lyde, lugte, hvad man lavede, hvad man følte. Et spørgsmål er på højst to sætninger.
- Følg op på det, personen lige har fortalt (navne, steder, ting de kun nævnte kort), før du skifter emne. Brug personens egne ord.
- Led ikke vidnet: foreslå ikke svar, og ret ikke personen. Hvis noget i det sagte strider mod baggrunden, så spørg nysgerrigt i stedet for at påpege det.
- Hold en rolig, respektfuld tone, ingen smiger, ingen udråbstegn, ingen "fantastisk". Skriv som man taler. Brug aldrig tankestreger.
- Hvis personen virker træt, skriver kort eller siger de vil stoppe, så stil et let, afrundende spørgsmål og sæt "afslut" til true.
- Gentag ikke et spørgsmål, der allerede er stillet i samtalen. Vend tilbage til startspørgsmålene, hvis samtalen går i stå.
- Svar KUN med JSON: {"spoergsmaal": "...", "afslut": false}"""
def keep_tags(text: str) -> str:
return _TAG.sub(lambda m: m.group(0) if m.group(1).strip().lower() in AUDIO_TAGS else "", text or "")
def untag(text: str) -> str:
return re.sub(r"\s{2,}", " ", _TAG.sub("", text or "")).strip()
SYSTEM = """Du er en varm, rolig interviewer for en dansk familie. Du hjælper et familiemedlem med at fortælle
sine minder (eller føre dagbog) ved at stille ÉT spørgsmål ad gangen på dansk. Samtalen skal føles som en
stille snak over kaffen, ikke som et forhør.
Sådan styrer du samtalen:
- Emnet har en liste FORBEREDTE spørgsmål. Dem skal du nå igennem i løbet af samtalen, i den rækkefølge der
passer. "tilbage" viser, hvilke der endnu ikke er stillet (eller er sprunget over; dem lader du ligge).
- Når personen lige har svaret, må du stille højst "opfoelg_max" OPFØLGENDE spørgsmål til det svar, og kun hvis
der er noget konkret at spørge ind til: et navn, et sted, en ting, en pudsig detalje, noget personen selv
griner af eller skynder sig forbi. Det pudsige er guld; spørg ind til det med et smil. "opfoelg_brugt" fortæller,
hvor mange du allerede har brugt på det aktuelle forberedte spørgsmål.
- Er der ikke noget konkret at følge op på, eller er opfølgningerne brugt, så gå videre til næste forberedte
spørgsmål. Kobl gerne overgangen til noget, personen har sagt ("Du nævnte …, det minder mig om …").
- Hvis svaret stikker af fra emnet, så tag venligt tråden op igen med næste forberedte spørgsmål, uden at
påpege afvigelsen.
- Når alle forberedte spørgsmål er stillet og opfølgningerne brugt, stiller du ét let, afrundende spørgsmål
("Er der noget om …, vi ikke har fået med?") og sætter "afslut" til true. Det samme hvis personen virker
træt, svarer meget kort flere gange eller siger de vil stoppe.
- Er "denne_samtale" tom, åbner du med det første forberedte spørgsmål i "tilbage", ordret.
Tone og form:
- Konkret og hverdagsnært: steder, personer, lyde, lugte, hvad man lavede. Højst to sætninger pr. spørgsmål, ét spørgsmål ad gangen.
- Brug personens egne ord. Foreslå ikke svar, ret ikke personen; strider noget mod baggrunden, så spørg nysgerrigt.
- Ingen smiger, ingen udråbstegn, ingen "fantastisk", ingen tankestreger. Skriv som man taler. Navnet bruges sjældent.
- Gentag aldrig et spørgsmål, der allerede er stillet.
Du svarer KUN med JSON med disse felter:
- "spoergsmaal": spørgsmålet som ren tekst, til skærmen.
- "talt": samme spørgsmål som det skal læses op, med lydtags i klammer, der gør stemmen levende: [warmly] [softly]
[curious] [gently] [chuckles] [laughs] [pause] [thoughtful] [amused] [sighs] [slowly]. Højst to-tre tags, lagt
naturligt ind, fx "[warmly] Du nævnte motorcyklen. [chuckles] Hvor stod den egentlig henne?". Ingen andre tags.
- "kilde": "forberedt", "opfoelgning" eller "afrunding".
- "afslut": true/false."""
def _historik(pid: str, projekt_slug: str, samtale: str) -> tuple[list[dict], list[dict]]:
@@ -411,23 +465,43 @@ def _historik(pid: str, projekt_slug: str, samtale: str) -> tuple[list[dict], li
def _kort(k: dict, n: int = 700) -> dict:
return {"dato": (k.get("tid") or "")[:10], "spoergsmaal": k.get("spoergsmaal", ""),
return {"dato": (k.get("tid") or "")[:10], "spoergsmaal": k.get("spoergsmaal", ""), "kilde": k.get("kilde", ""),
"svar": (k.get("tekst") or "")[:n]}
async def naeste_spoergsmaal(p: dict, pr: dict, samtale: str) -> tuple[str, bool]:
"""Næste spørgsmål til personen i projektet. Falder tilbage til startspørgsmålene uden nøgle/ved fejl."""
def _opfoelg_brugt(denne: list[dict], forberedte: list[str]) -> int:
"""Hvor mange opfølgende spørgsmål er stillet siden sidste forberedte spørgsmål i denne samtale."""
n = 0
for k in reversed(denne):
if k.get("spoergsmaal") in forberedte or k.get("kilde") == "forberedt":
break
n += 1
return n
async def naeste_spoergsmaal(p: dict, pr: dict, samtale: str, sprunget: list[str] | None = None) -> dict:
"""Næste spørgsmål: {"spoergsmaal", "talt", "afslut", "kilde"}. Claude styrer samtalen ud fra de forberedte
spørgsmål; uden nøgle (eller ved fejl) tages de forberedte i rækkefølge."""
denne, tidligere = _historik(p["pid"], pr["slug"], samtale)
stillede = {k.get("spoergsmaal") for k in denne + tidligere}
start = [s for s in (pr.get("start") or DAGBOG["start"]) if s not in stillede] or list(pr.get("start") or DAGBOG["start"])
if not denne:
return start[0], False # samtalen åbner altid med et af projektets egne spørgsmål
forberedte = list(pr.get("start") or DAGBOG["start"])
stillede = {k.get("spoergsmaal") for k in denne + tidligere} | set(sprunget or [])
tilbage = [s for s in forberedte if s not in stillede]
opfoelg_max = int(pr.get("opfoelg", 2) if pr.get("opfoelg") is not None else 2)
brugt = _opfoelg_brugt(denne, forberedte)
def fallback() -> dict:
if tilbage:
return {"spoergsmaal": tilbage[0], "talt": tilbage[0], "afslut": False, "kilde": "forberedt"}
q = "Er der noget, vi ikke har fået med, som du gerne vil have med?"
return {"spoergsmaal": q, "talt": "[warmly] " + q, "afslut": True, "kilde": "afrunding"}
if not ANTHROPIC_KEY:
return start[len(denne) % len(start)], False
return fallback()
payload = {
"person": {"navn": p.get("navn")},
"projekt": {"titel": pr.get("titel"), "beskrivelse": pr.get("beskrivelse"), "baggrund": pr.get("kontekst", "")[:6000],
"startspoergsmaal": pr.get("start") or []},
"projekt": {"titel": pr.get("titel"), "beskrivelse": pr.get("beskrivelse"), "baggrund": pr.get("kontekst", "")[:6000]},
"forberedte": forberedte, "tilbage": tilbage, "sprunget_over": sprunget or [],
"opfoelg_max": opfoelg_max, "opfoelg_brugt": brugt,
"tidligere_samtaler": [_kort(k, 300) for k in tidligere],
"denne_samtale": [_kort(k) for k in denne],
"antal_svar_i_dag": len(denne),
@@ -435,7 +509,7 @@ async def naeste_spoergsmaal(p: dict, pr: dict, samtale: str) -> tuple[str, bool
headers = {"x-api-key": ANTHROPIC_KEY, "anthropic-version": "2023-06-01", "content-type": "application/json"}
if ANTHROPIC_WORKSPACE:
headers["anthropic-workspace-id"] = ANTHROPIC_WORKSPACE
body = {"model": MODEL, "max_tokens": 300, "system": SYSTEM,
body = {"model": MODEL, "max_tokens": 400, "system": SYSTEM,
"messages": [{"role": "user", "content": "Her er samtalen indtil nu som JSON. Stil næste spørgsmål.\n\n"
+ json.dumps(payload, ensure_ascii=False)}]}
try:
@@ -446,12 +520,16 @@ async def naeste_spoergsmaal(p: dict, pr: dict, samtale: str) -> tuple[str, bool
m = re.search(r"\{.*\}", tekst, re.S)
if m:
svar = json.loads(m.group(0))
q = (svar.get("spoergsmaal") or "").strip().replace("—", ",").replace(" - ", ", ")
q = untag(svar.get("spoergsmaal") or "").replace("—", ",").replace(" - ", ", ")
talt = keep_tags((svar.get("talt") or q).replace("—", ",").replace(" - ", ", "))
if q:
return q[:400], bool(svar.get("afslut"))
if untag(talt) != q and len(untag(talt)) < 5:
talt = q
kilde = svar.get("kilde") if svar.get("kilde") in ("forberedt", "opfoelgning", "afrunding") else ("forberedt" if q in forberedte else "opfoelgning")
return {"spoergsmaal": q[:400], "talt": talt[:600], "afslut": bool(svar.get("afslut")), "kilde": kilde}
except (httpx.HTTPError, ValueError):
pass
return start[len(denne) % len(start)], False
return fallback()
def ny_samtale() -> str:
@@ -470,8 +548,12 @@ def eksport(pid: str) -> bytes:
f = klip_fil(pid, x["id"])
if f:
z.write(f, f"{x['id']}.mp3")
sp = spoergsmaal_fil(pid, x["id"])
if sp:
z.write(sp, f"{x['id']}-sp.mp3")
z.writestr(f"{x['id']}.json", json.dumps(x, ensure_ascii=False, indent=1))
linjer.append(f"## {x['id']} · {x.get('tid', '')[:16]} · {x.get('projekt')}{' · favorit' if x.get('favorit') else ''}\n"
f"Spørgsmål: {x.get('spoergsmaal', '')}\n\n{x.get('tekst', '')}\n")
f"Spørgsmål ({x.get('kilde') or 'ukendt'}{', oplæsning i ' + x['id'] + '-sp.mp3' if sp else ''}): {x.get('spoergsmaal', '')}\n\n"
f"{x.get('tekst', '')}\n")
z.writestr("alle_svar.md", "\n".join(linjer))
return buf.getvalue()
+25 -6
View File
@@ -680,6 +680,8 @@ async def erindringer_tts(sha: str):
def _klip_svar(pid: str, kid: str):
if kid.endswith("-sp"): # spørgsmålets oplæsning hører til svaret: <id>-sp.mp3
return _spoergsmaal_svar(pid, kid[:-3])
f = erindringer.klip_fil(pid, kid)
if not f:
raise HTTPException(404)
@@ -698,6 +700,15 @@ async def erindringer_gaest_klip(request: Request, key: str, kid: str):
return _klip_svar(p["pid"], kid)
def _spoergsmaal_svar(pid: str, kid: str):
f = erindringer.spoergsmaal_fil(pid, kid)
if not f:
raise HTTPException(404)
return FileResponse(f, media_type="audio/mpeg", headers={"Cache-Control": "private, max-age=86400"})
def _fejl(status: int, tekst: str):
return JSONResponse({"fejl": tekst}, status_code=status)
@@ -716,10 +727,12 @@ async def erindringer_naeste(request: Request):
samtale = str(data.get("samtale") or "")
if not re.match(r"^[0-9]{8}-[a-f0-9]{6}$", samtale):
samtale = erindringer.ny_samtale()
q, afslut = await erindringer.naeste_spoergsmaal(p, pr, samtale)
lyd = await asyncio.to_thread(erindringer.tts_sti, q)
return {"samtale": samtale, "spoergsmaal": q, "afslut": afslut,
"lyd_url": f"/erindringer/tts/{lyd.stem}.mp3" if lyd else None,
sprunget = [str(x)[:400] for x in (data.get("sprunget") or []) if isinstance(x, str)][:50]
q = await erindringer.naeste_spoergsmaal(p, pr, samtale, sprunget)
lyd = await asyncio.to_thread(erindringer.tts_sti, q["talt"])
return {"samtale": samtale, "spoergsmaal": q["spoergsmaal"], "talt": q["talt"], "afslut": q["afslut"],
"kilde": q["kilde"], "lyd_url": f"/erindringer/tts/{lyd.stem}.mp3" if lyd else None,
"lyd_sha": lyd.stem if lyd else "", "tilbage": len([s for s in (pr.get("start") or []) if s not in sprunget]),
"nr": len(erindringer.klip_for(p["pid"], slug, samtale)) + 1}
@@ -743,7 +756,8 @@ async def erindringer_svar(request: Request):
tekst = str(form.get("tekst") or "")
try:
meta = await asyncio.to_thread(erindringer.gem_svar, p["pid"], slug, samtale, str(form.get("spoergsmaal") or ""),
data, filnavn, tekst or None)
data, filnavn, tekst or None, str(form.get("spoergsmaal_talt") or ""),
str(form.get("lyd_sha") or ""), str(form.get("kilde") or ""))
except ValueError as e:
return _fejl(400, str(e))
return {"id": meta["id"], "tekst": meta["tekst"], "varighed": meta["varighed"], "samtale": samtale}
@@ -782,8 +796,12 @@ async def admin_erindringer_projekt(request: Request):
erindringer.slet_projekt(slug)
return RedirectResponse("/admin/erindringer?saved=Projektet+er+slettet", status_code=303)
start = [s for s in str(form.get("start") or "").splitlines()]
try:
opfoelg = int(str(form.get("opfoelg") or "2"))
except ValueError:
opfoelg = 2
if not erindringer.gem_projekt(slug, str(form.get("titel") or ""), str(form.get("beskrivelse") or ""),
str(form.get("kontekst") or ""), start):
str(form.get("kontekst") or ""), start, opfoelg):
return RedirectResponse("/admin/erindringer?saved=Projektet+skal+have+et+navn+(a-z,+0-9,+bindestreg)+og+en+titel", status_code=303)
return RedirectResponse(f"/admin/erindringer?saved=Projektet+{urllib.parse.quote(slug)}+er+gemt", status_code=303)
@@ -839,6 +857,7 @@ async def admin_erindringer_klip(request: Request, pid: str, kid: str):
return _klip_svar(pid, kid)
@app.post("/admin/erindringer/p/{pid}/klip/{kid}")
async def admin_erindringer_klip_post(request: Request, pid: str, kid: str):
require_admin(request)
+5 -3
View File
@@ -52,7 +52,7 @@
</form>
<h2 style="font-size:1.15rem;margin-top:2rem">Emner (projekter)</h2>
<p class="wt-muted" style="margin:.3rem 0 .8rem">Baggrunden gives til intervieweren, så spørgsmålene bliver konkrete (navne, steder, årstal, hvad vi allerede ved). Startspørgsmålene åbner hver samtale; derefter improviserer intervieweren ({{ model }}). "dagbog" er indbygget og kan ikke slettes, men teksten kan rettes.</p>
<p class="wt-muted" style="margin:.3rem 0 .8rem">Baggrunden gives til intervieweren, så spørgsmålene bliver konkrete (navne, steder, årstal, hvad vi allerede ved). De forberedte spørgsmål er samtalens rygrad: intervieweren ({{ model }}) stiller dem alle, følger op på det konkrete og pudsige i svarene (højst det antal, du sætter), trækker venligt tilbage til emnet, hvis det stikker af, og afrunder, når listen er tømt. Personen kan springe et spørgsmål over og holde pause når som helst. Spørgsmålets oplæsning gemmes sammen med svaret, så spørgsmål og svar kan klippes ind i en podcast. "dagbog" er indbygget og kan ikke slettes, men teksten kan rettes.</p>
{% for pr in projekter %}
<details class="wt-card" style="padding:.8rem 1.2rem;max-width:48rem;margin:.5rem 0">
<summary style="cursor:pointer"><strong>{{ pr.titel }}</strong> <span class="wt-muted">({{ pr.slug }}) · {{ pr.start|length }} startspørgsmål</span></summary>
@@ -61,7 +61,8 @@
<label>Titel<br><input class="wt-input" name="titel" value="{{ pr.titel }}" required style="width:100%"></label>
<label style="display:block;margin-top:.6rem">Beskrivelse (vises til personen)<br><textarea class="wt-input" name="beskrivelse" rows="2" style="width:100%">{{ pr.beskrivelse }}</textarea></label>
<label style="display:block;margin-top:.6rem">Baggrund til intervieweren (vises ikke til personen)<br><textarea class="wt-input" name="kontekst" rows="8" style="width:100%">{{ pr.kontekst }}</textarea></label>
<label style="display:block;margin-top:.6rem">Startspørgsmål, ét pr. linje<br><textarea class="wt-input" name="start" rows="5" style="width:100%">{{ pr.start|join('\n') }}</textarea></label>
<label style="display:block;margin-top:.6rem">Forberedte spørgsmål, ét pr. linje (intervieweren når dem alle, i den rækkefølge der passer, og afrunder derefter)<br><textarea class="wt-input" name="start" rows="5" style="width:100%">{{ pr.start|join('\n') }}</textarea></label>
<label style="display:block;margin-top:.6rem">Højst så mange opfølgende spørgsmål pr. forberedt spørgsmål (0-6)<br><input class="wt-input" name="opfoelg" type="number" min="0" max="6" value="{{ pr.opfoelg if pr.opfoelg is not none else 2 }}" style="width:6rem"></label>
<p style="margin:.8rem 0 0;display:flex;gap:.5rem;flex-wrap:wrap">
<button class="wt-btn wt-btn--primary" type="submit">Gem</button>
{% if pr.slug != 'dagbog' %}<button class="wt-btn wt-btn--danger" type="submit" name="handling" value="slet" onclick="return confirm('Slet emnet {{ pr.slug }}? Svarene bliver liggende hos personerne.')">Slet emne</button>{% endif %}
@@ -76,7 +77,8 @@
<label style="display:block;margin-top:.6rem">Titel<br><input class="wt-input" name="titel" required style="width:100%"></label>
<label style="display:block;margin-top:.6rem">Beskrivelse (vises til personen)<br><textarea class="wt-input" name="beskrivelse" rows="2" style="width:100%"></textarea></label>
<label style="display:block;margin-top:.6rem">Baggrund til intervieweren<br><textarea class="wt-input" name="kontekst" rows="8" style="width:100%"></textarea></label>
<label style="display:block;margin-top:.6rem">Startspørgsmål, ét pr. linje<br><textarea class="wt-input" name="start" rows="5" style="width:100%"></textarea></label>
<label style="display:block;margin-top:.6rem">Forberedte spørgsmål, ét pr. linje<br><textarea class="wt-input" name="start" rows="5" style="width:100%"></textarea></label>
<label style="display:block;margin-top:.6rem">Højst så mange opfølgende spørgsmål pr. forberedt spørgsmål (0-6)<br><input class="wt-input" name="opfoelg" type="number" min="0" max="6" value="2" style="width:6rem"></label>
<p style="margin:.8rem 0 0"><button class="wt-btn wt-btn--primary" type="submit">Opret</button></p>
</form>
</details>
+4 -2
View File
@@ -36,8 +36,10 @@
{% for k in klip %}
<div class="wt-card" id="k-{{ k.id }}" style="padding:.9rem 1.2rem;margin:.6rem 0;max-width:48rem{% if k.favorit %};border-color:var(--color-accent){% endif %}">
<div class="wt-muted" style="font-size:.85rem">{{ k.tid[:16]|replace('T',' ') }} · {{ k.projekt_titel }} · {% if k.skrevet %}skrevet{% else %}{{ k.varighed // 60 }}:{{ '%02d' % (k.varighed % 60) }}{% endif %}{% if k.redigeret %} · tekst rettet{% endif %} · <span style="font-family:ui-monospace,monospace">{{ k.id }}</span></div>
<p style="margin:.4rem 0"><strong>{{ k.spoergsmaal }}</strong></p>
{% if not k.skrevet %}<audio controls preload="none" src="/admin/erindringer/p/{{ p.pid }}/klip/{{ k.id }}.mp3" style="width:100%;max-width:100%"></audio>{% endif %}
<p style="margin:.4rem 0"><strong>{{ k.spoergsmaal }}</strong>{% if k.kilde %} <span class="wt-tag muted">{{ k.kilde }}</span>{% endif %}</p>
{% if k.spoergsmaal_talt and k.spoergsmaal_talt != k.spoergsmaal %}<p class="wt-muted" style="margin:0 0 .3rem;font-size:.85rem">Oplæst som: {{ k.spoergsmaal_talt }}</p>{% endif %}
{% if k.spoergsmaal_lyd %}<p style="margin:.2rem 0"><span class="wt-muted" style="font-size:.85rem">Spørgsmålet:</span> <audio controls preload="none" src="/admin/erindringer/p/{{ p.pid }}/klip/{{ k.id }}-sp.mp3" style="width:100%;max-width:100%"></audio></p>{% endif %}
{% if not k.skrevet %}<p style="margin:.2rem 0"><span class="wt-muted" style="font-size:.85rem">Svaret:</span> <audio controls preload="none" src="/admin/erindringer/p/{{ p.pid }}/klip/{{ k.id }}.mp3" style="width:100%;max-width:100%"></audio></p>{% endif %}
<form method="post" action="/admin/erindringer/p/{{ p.pid }}/klip/{{ k.id }}" style="margin:.5rem 0 0">
<textarea class="wt-input" name="tekst" rows="4" style="width:100%">{{ k.tekst }}</textarea>
<p style="margin:.5rem 0 0;display:flex;gap:.5rem;flex-wrap:wrap">
+27 -4
View File
@@ -94,7 +94,11 @@
<div class="wt-card er-kort" id="er-spoergsmaal-kort">
<p class="wt-muted" style="margin:0 0 .2rem">Spørgsmål <span id="er-nr"></span></p>
<p class="er-spoergsmaal" id="er-spoergsmaal">Henter spørgsmålet…</p>
<button type="button" class="wt-btn er-stor" id="er-hoer" hidden aria-label="Hør spørgsmålet læst op">🔊 Hør spørgsmålet</button>
<p class="wt-muted" id="er-afrunding" hidden style="margin:0 0 .6rem">Det var det sidste spørgsmål om emnet for nu. Bagefter kan du holde pause, eller fortælle videre, hvis du har lyst.</p>
<div class="er-knapper" style="justify-content:flex-start">
<button type="button" class="wt-btn er-stor" id="er-hoer" hidden aria-label="Hør spørgsmålet læst op">🔊 Hør spørgsmålet</button>
<button type="button" class="wt-btn" id="er-spring" aria-label="Spring dette spørgsmål over">Spring over</button>
</div>
<audio id="er-lyd" preload="auto"></audio>
</div>
@@ -180,12 +184,12 @@
tekstfelt: el('er-tekstfelt'), sendTekst: el('er-send-tekst'),
svarKort: el('er-svar-kort'), svarTekst: el('er-svar-tekst'), svarRet: el('er-svar-ret'),
naeste: el('er-naeste'), pause: el('er-pause'), dagens: el('er-dagens'), dagensTom: el('er-dagens-tom'),
flash: el('er-flash')
flash: el('er-flash'), afrunding: el('er-afrunding'), spring: el('er-spring')
};
// Tilstand for den igangværende samtale
var st = {
projekt: null, titel: '', samtale: null, spoergsmaal: '', lydUrl: null,
projekt: null, titel: '', samtale: null, spoergsmaal: '', talt: '', lydSha: '', kilde: '', lydUrl: null,
recorder: null, stream: null, chunks: [], blob: null, mime: '', ext: 'webm',
timer: null, sekunder: 0, startet: 0, sender: false, sendtIDag: 0, lydAfspillet: false
};
@@ -344,12 +348,16 @@
nulstilOptagelse();
ui.optagelse.hidden = false;
status('');
kald('/naeste', { projekt: st.projekt, samtale: st.samtale || null }).then(function (j) {
kald('/naeste', { projekt: st.projekt, samtale: st.samtale || null, sprunget: hentSprunget() }).then(function (j) {
st.samtale = j.samtale;
husk(j.samtale);
st.spoergsmaal = j.spoergsmaal || '';
st.talt = j.talt || st.spoergsmaal;
st.lydSha = j.lyd_sha || '';
st.kilde = j.kilde || '';
st.lydUrl = j.lyd_url || null;
ui.spoergsmaal.textContent = st.spoergsmaal;
ui.afrunding.hidden = !j.afslut;
ui.nr.textContent = j.nr ? String(j.nr) : '';
ui.hoer.hidden = !st.lydUrl;
if (st.lydUrl) {
@@ -375,6 +383,18 @@
});
ui.naeste.addEventListener('click', function () { hentSpoergsmaal(false); });
// Sprungne spørgsmål huskes pr. projekt, så intervieweren ikke stiller dem igen
function sprungetNoegle() { return 'erindringer:sprunget:' + st.projekt; }
function hentSprunget() { try { return JSON.parse(sessionStorage.getItem(sprungetNoegle()) || '[]'); } catch (e) { return []; } }
ui.spring.addEventListener('click', function () {
if (st.sender || !st.spoergsmaal) return;
try { ui.lyd.pause(); } catch (e) {}
var liste = hentSprunget();
if (liste.indexOf(st.spoergsmaal) < 0) liste.push(st.spoergsmaal);
try { sessionStorage.setItem(sprungetNoegle(), JSON.stringify(liste.slice(-50))); } catch (e) {}
hentSpoergsmaal(false);
});
/* ---------- Optagelse ---------- */
function vaelgMime() {
@@ -533,6 +553,9 @@
form.append('projekt', st.projekt);
form.append('samtale', st.samtale || '');
form.append('spoergsmaal', st.spoergsmaal);
form.append('spoergsmaal_talt', st.talt || '');
form.append('lyd_sha', st.lydSha || '');
form.append('kilde', st.kilde || '');
kald('/svar', form).then(function (j) {
st.sendtIDag++;
visSvar(j);
+8 -2
View File
@@ -32,12 +32,18 @@ if q1["lyd_url"]:
r = c.post("/erindringer/api/svar", data={"projekt": "test-ep", "samtale": q1["samtale"], "spoergsmaal": q1["spoergsmaal"], "noegle": noegle, "tekst": "Jeg husker min morfar Gustav i Villa Monika i Hannerup. Han havde en stor have og en motorcykel, som han aldrig kørte på."})
assert r.status_code == 200, r.text; s1 = r.json(); print("svar1", s1["id"])
r = c.post("/erindringer/api/naeste", json={"projekt": "test-ep", "samtale": q1["samtale"], "noegle": noegle}); assert r.status_code == 200, r.text
q2 = r.json(); print("Q2 (Claude)", q2["spoergsmaal"], "| afslut:", q2["afslut"])
q2 = r.json(); print("Q2 (Claude)", q2["spoergsmaal"], "| talt:", q2["talt"], "| kilde:", q2["kilde"], "| afslut:", q2["afslut"])
assert "[" not in q2["spoergsmaal"] and q2["lyd_sha"]
# spring over: det sprungne må ikke komme igen
r = c.post("/erindringer/api/naeste", json={"projekt": "test-ep", "samtale": q1["samtale"], "noegle": noegle, "sprunget": [q2["spoergsmaal"]]}); assert r.status_code == 200
assert r.json()["spoergsmaal"] != q2["spoergsmaal"]; print("Q3 efter spring", r.json()["spoergsmaal"], "| kilde:", r.json()["kilde"])
# lydsvar: 2 s tone som mp3 lavet med ffmpeg
os.system("ffmpeg -y -v error -f lavfi -i 'sine=frequency=440:duration=2' -c:a libmp3lame /tmp/ef-test/tone.mp3")
r = c.post("/erindringer/api/svar", data={"projekt": "test-ep", "samtale": q1["samtale"], "spoergsmaal": q2["spoergsmaal"], "noegle": noegle}, files={"lyd": ("svar.mp3", open("/tmp/ef-test/tone.mp3", "rb"), "audio/mpeg")})
r = c.post("/erindringer/api/svar", data={"projekt": "test-ep", "samtale": q1["samtale"], "spoergsmaal": q2["spoergsmaal"], "spoergsmaal_talt": q2["talt"], "lyd_sha": q2["lyd_sha"], "kilde": q2["kilde"], "noegle": noegle}, files={"lyd": ("svar.mp3", open("/tmp/ef-test/tone.mp3", "rb"), "audio/mpeg")})
assert r.status_code == 200, r.text; s2 = r.json(); print("svar2 lyd", s2["id"], "varighed", s2["varighed"], "tekst:", repr(s2["tekst"])[:60])
r = c.get(f"/erindringer/g/{noegle}/klip/{s2['id']}.mp3"); assert r.status_code == 200 and r.headers["content-type"].startswith("audio")
r = c.get(f"/erindringer/g/{noegle}/klip/{s2['id']}-sp.mp3"); assert r.status_code == 200, "spørgsmålets lyd mangler"
k2 = erindringer.klip(pid, s2["id"]); assert k2["spoergsmaal_lyd"] and k2["kilde"] == q2["kilde"] and k2["spoergsmaal_talt"] == q2["talt"]
r = c.post("/erindringer/api/ret", json={"id": s1["id"], "tekst": "Rettet tekst.", "noegle": noegle}); assert r.status_code == 200
assert erindringer.klip(pid, s1["id"])["tekst"] == "Rettet tekst."
# forkert nøgle -> 404