From 3dee428a378f927dbe9c476b7a8bcdb8e15135d6 Mon Sep 17 00:00:00 2001 From: root Date: Tue, 6 Oct 2026 22:15:46 +0200 Subject: [PATCH] =?UTF-8?q?feat(erindringer):=20samtalestyring=20med=20for?= =?UTF-8?q?beredte=20sp=C3=B8rgsm=C3=A5l,=20talt=20udgave=20med=20lydtags,?= =?UTF-8?q?=20sp=C3=B8rgsm=C3=A5lets=20lyd=20gemmes=20med=20svaret?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 -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 --- erindringer.py | 144 +++++++++++++++++++----- main.py | 31 ++++- templates/admin_erindringer.html | 8 +- templates/admin_erindringer_person.html | 6 +- templates/erindringer.html | 31 ++++- tests/erindringer_smoke.py | 10 +- 6 files changed, 182 insertions(+), 48 deletions(-) diff --git a/erindringer.py b/erindringer.py index 1ced5c6..451ded9 100644 --- a/erindringer.py +++ b/erindringer.py @@ -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 -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() diff --git a/main.py b/main.py index a2ff4ee..7ff5439 100644 --- a/main.py +++ b/main.py @@ -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: -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) diff --git a/templates/admin_erindringer.html b/templates/admin_erindringer.html index 8121c25..4e9eb94 100644 --- a/templates/admin_erindringer.html +++ b/templates/admin_erindringer.html @@ -52,7 +52,7 @@

Emner (projekter)

-

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.

+

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.

{% for pr in projekter %}
{{ pr.titel }} ({{ pr.slug }}) · {{ pr.start|length }} startspørgsmål @@ -61,7 +61,8 @@ - + +

{% if pr.slug != 'dagbog' %}{% endif %} @@ -76,7 +77,8 @@ - + +

diff --git a/templates/admin_erindringer_person.html b/templates/admin_erindringer_person.html index 6cd7915..e25bcd8 100644 --- a/templates/admin_erindringer_person.html +++ b/templates/admin_erindringer_person.html @@ -36,8 +36,10 @@ {% for k in klip %}
{{ 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 %} · {{ k.id }}
-

{{ k.spoergsmaal }}

- {% if not k.skrevet %}{% endif %} +

{{ k.spoergsmaal }}{% if k.kilde %} {{ k.kilde }}{% endif %}

+ {% if k.spoergsmaal_talt and k.spoergsmaal_talt != k.spoergsmaal %}

Oplæst som: {{ k.spoergsmaal_talt }}

{% endif %} + {% if k.spoergsmaal_lyd %}

Spørgsmålet:

{% endif %} + {% if not k.skrevet %}

Svaret:

{% endif %}

diff --git a/templates/erindringer.html b/templates/erindringer.html index 038ecfd..ed82bd9 100644 --- a/templates/erindringer.html +++ b/templates/erindringer.html @@ -94,7 +94,11 @@

Spørgsmål

Henter spørgsmålet…

- + +
+ + +
@@ -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); diff --git a/tests/erindringer_smoke.py b/tests/erindringer_smoke.py index d5c36a6..4029f68 100644 --- a/tests/erindringer_smoke.py +++ b/tests/erindringer_smoke.py @@ -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