From 4005860364178b30269b73043509754d167813ee Mon Sep 17 00:00:00 2001 From: root Date: Tue, 6 Oct 2026 22:04:20 +0200 Subject: [PATCH] =?UTF-8?q?feat(erindringer):=20familien=20fort=C3=A6ller?= =?UTF-8?q?=20minder=20og=20dagbog=20til=20en=20AI-interviewer?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Nyt modul erindringer.py: emner (projekter) med baggrund og startspørgsmål, personer (indloggede + gæstelinks), Claude stiller opfølgende spørgsmål, ElevenLabs læser op (cachet) og transskriberer, ffmpeg laver mp3, admin med klip, favoritter og zip-eksport. Forsidekort og admin-modul. Røgtest i tests/. Claude-Session: https://claude.ai/code/session_01JmhJG6JnhXfzNiKv8f2vHu --- README.md | 22 + config.py | 2 + deploy/portal.env.example | 6 + erindringer.py | 477 +++++++++++++++++++ main.py | 251 +++++++++- templates/admin_erindringer.html | 83 ++++ templates/admin_erindringer_person.html | 54 +++ templates/dashboard_body.html | 10 +- templates/erindringer.html | 606 ++++++++++++++++++++++++ tests_erindringer_smoke.py | 56 +++ 10 files changed, 1565 insertions(+), 2 deletions(-) create mode 100644 erindringer.py create mode 100644 templates/admin_erindringer.html create mode 100644 templates/admin_erindringer_person.html create mode 100644 templates/erindringer.html create mode 100644 tests_erindringer_smoke.py diff --git a/README.md b/README.md index cd35594..14f886b 100644 --- a/README.md +++ b/README.md @@ -14,6 +14,28 @@ FastAPI-app der: - Matomo-statistik - App-beskrivelser & grupper (skriver tilbage til Authentik) +## Erindringer (interview + dagbog) + +`/erindringer`: familien fortæller minder til en AI-interviewer. Claude (Anthropic +Messages API, `ANTHROPIC_API_KEY`, model `ERINDRINGER_MODEL`) stiller ét spørgsmål ad gangen +og følger op på det sidste svar; spørgsmålet læses op af ElevenLabs (`ELEVEN_API_KEY`, +stemme `ERINDRINGER_VOICE`, cachet pr. tekst), svaret optages i browseren (MediaRecorder, +webm/mp4), konverteres med ffmpeg til mp3 og transskriberes (ElevenLabs STT). Alt gemmes +pr. person under `PORTAL_DATA/erindringer/` (persondata, aldrig i git). + +- Indloggede i familiegrupperne har altid emnet "Dagbog"; admin inviterer til andre emner. +- Gæster uden login får et hemmeligt link `/erindringer/g//`, lavet i `/admin/erindringer` + (kan lukkes og fornys). Nøglen sendes med i API-kald som `noegle`. +- `/admin/erindringer`: emner (titel, beskrivelse, baggrund til intervieweren, startspørgsmål), + personer, klip med afspiller og transskription, favoritter, zip-eksport til podcast-produktion. +- Edge: gæstelink, `/erindringer/api/`, `/erindringer/tts/` og `/erindringer/klip/` skal uden om + bot-udfordringen og have `client_max_body_size 50m` (lange lydsvar). +- Uden nøgler virker siden stadig: intervieweren bruger emnets startspørgsmål, og svar gemmes + uden transskription. + +Røgtest uden OIDC: `PORTAL_DATA=/tmp/x python tests/erindringer_smoke.py` (kalder de rigtige +API'er, koster lidt credits). + ## Kør ```bash diff --git a/config.py b/config.py index d2210c4..4a5224a 100644 --- a/config.py +++ b/config.py @@ -111,6 +111,8 @@ ADMIN_MODULES = [ "desc": "Giv et token eller kodeord til serveren krypteret, uden at skrive det i chatten."}, {"key": "podcast", "title": "Podcast", "icon": "🎙️", "desc": "Slægtens Stemmer: udgiv kladder og se/træk lytternes personlige feed-adresser tilbage."}, + {"key": "erindringer", "title": "Erindringer", "icon": "🎤", + "desc": "Familien fortæller minder og dagbog til en AI-interviewer: projekter, gæstelinks, klip og eksport."}, {"key": "claude-login", "title": "Claude-login", "icon": "🤖", "desc": "Log Claude på serveren ind igen fra telefonen, uden terminal."}, {"key": "mcp-keys", "title": "MCP-nøgler", "icon": "🔑", diff --git a/deploy/portal.env.example b/deploy/portal.env.example index 6316d3b..586fcd1 100644 --- a/deploy/portal.env.example +++ b/deploy/portal.env.example @@ -11,3 +11,9 @@ MATOMO_TOKEN=__matomo-token__ MATOMO_SITE=2 IP_LABELS_REMOTE=user@edge-host:/path/to/ip_labels.json PORTAL_DATA=/opt/portal/data +# Erindringer (interview + dagbog): Claude stiller spørgsmål, ElevenLabs læser op og transskriberer. +ANTHROPIC_API_KEY=__sk-ant-…__ +ANTHROPIC_WORKSPACE_ID= # kun for bruger-nøgler (sk-ant-usr…), ellers tom +ERINDRINGER_MODEL=claude-sonnet-5 +ELEVEN_API_KEY=__xi-…__ +ERINDRINGER_VOICE=tJDFCHyviItsYF1qqToS diff --git a/erindringer.py b/erindringer.py new file mode 100644 index 0000000..1ced5c6 --- /dev/null +++ b/erindringer.py @@ -0,0 +1,477 @@ +"""Erindringer: familien fortæller sine minder (eller fører dagbog) til en AI-interviewer. + +Interviewer = Claude (Anthropic Messages API via httpx; ingen SDK), der stiller ét spørgsmål ad +gangen og følger op på det, personen lige har sagt. Spørgsmålet læses op af ElevenLabs (cachet pr. +tekst), svaret optages i browseren, transskriberes af ElevenLabs STT og gemmes som mp3 + tekst. +Klippene bruges som råstof til podcasten (Slægtens Stemmer) og som personlig dagbog. + +Data (ikke i git, persondata): + DATA/erindringer/projekter.json slug -> titel, beskrivelse, kontekst (til Claude), start (spørgsmål) + DATA/erindringer/personer.json pid -> navn, bruger | noegle, projekter, oprettet, sidst + DATA/erindringer/klip//.mp3 svaret (mono 64 kbit) + DATA/erindringer/klip//.json id, tid, projekt, samtale, spoergsmaal, tekst, varighed, favorit + DATA/erindringer/tts/.mp3 oplæste spørgsmål + +Personer: indloggede brugere har pid "u:" og kan altid føre dagbog; gæster (familie uden +login) får pid "g:" og en hemmelig nøgle i adressen, som podcasten gør det. Admin bestemmer, +hvilke projekter en person inviteres til; projektet "dagbog" er altid åbent for indloggede. +""" +import hashlib +import json +import os +import re +import secrets +import subprocess +import tempfile +import time +from pathlib import Path + +import httpx + +import config + +ROOT = config.DATA / "erindringer" +PROJEKTER = ROOT / "projekter.json" +PERSONER = ROOT / "personer.json" +KLIP = ROOT / "klip" +TTS = ROOT / "tts" +SLUG = re.compile(r"^[a-z0-9][a-z0-9-]{0,40}$") +KEY = re.compile(r"^[A-Za-z0-9_-]{32,64}$") +KLIPID = re.compile(r"^[0-9]{8}-[0-9]{6}-[a-f0-9]{6}$") +PID = re.compile(r"^[ug]:[A-Za-z0-9_.@-]{1,64}$") + +ANTHROPIC_KEY = os.environ.get("ANTHROPIC_API_KEY", "") +ANTHROPIC_WORKSPACE = os.environ.get("ANTHROPIC_WORKSPACE_ID", "") +MODEL = os.environ.get("ERINDRINGER_MODEL", "claude-sonnet-5") +ELEVEN_KEY = os.environ.get("ELEVEN_API_KEY", "") +VOICE = os.environ.get("ERINDRINGER_VOICE", "tJDFCHyviItsYF1qqToS") # Leif, podcastens fortæller +MAX_SVAR_SEK = 15 * 60 +MAX_UPLOAD = 40 * 1024 * 1024 + +DAGBOG = { + "titel": "Dagbog", + "beskrivelse": "Hvad der fylder lige nu. Et par minutter, når du har lyst; interviewerens spørgsmål hjælper dig i gang.", + "kontekst": "Dette er personens private dagbog. Spørg til hverdagen, til det der fylder, til små og store ting, " + "og følg op på det, personen selv bringer på bane. Vær nysgerrig uden at være nærgående.", + "start": ["Hvad har fyldt mest for dig i dag?", "Hvad har du tænkt på i denne uge?", + "Er der sket noget siden sidst, du gerne vil huske?"], +} + + +# ---------------- fil-hjælpere ---------------- + +def _læs(p: Path, standard): + try: + return json.loads(p.read_text()) + except (OSError, ValueError): + return standard + + +def _skriv(p: Path, data) -> None: + p.parent.mkdir(parents=True, exist_ok=True) + tmp = p.with_name("." + p.name + ".tmp") + tmp.write_text(json.dumps(data, ensure_ascii=False, indent=1)) + os.replace(tmp, p) + + +def _nu() -> str: + return time.strftime("%Y-%m-%dT%H:%M:%S%z") + + +# ---------------- projekter ---------------- + +def projekter() -> dict: + p = _læs(PROJEKTER, {}) + p.setdefault("dagbog", dict(DAGBOG)) + return p + + +def projekt(slug: str) -> dict | None: + if not SLUG.match(slug or ""): + return None + p = projekter().get(slug) + return {**p, "slug": slug} if p else None + + +def gem_projekt(slug: str, titel: str, beskrivelse: str, kontekst: str, start: list[str]) -> bool: + 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()} + _skriv(PROJEKTER, p) + return True + + +def slet_projekt(slug: str) -> bool: + p = projekter() + if slug == "dagbog" or slug not in p: + return False + del p[slug] + _skriv(PROJEKTER, p) + ps = personer() + for v in ps.values(): + if slug in v.get("projekter", []): + v["projekter"].remove(slug) + _skriv(PERSONER, ps) + return True + + +# ---------------- personer ---------------- + +def personer() -> dict: + return _læs(PERSONER, {}) + + +def person(pid: str) -> dict | None: + if not PID.match(pid or ""): + return None + v = personer().get(pid) + return {**v, "pid": pid} if v else None + + +def person_for_bruger(username: str, navn: str) -> dict: + """Indlogget bruger: oprettes første gang, dagbog er altid med.""" + pid = f"u:{username}" + ps = personer() + v = ps.get(pid) + if v is None: + v = {"navn": navn or username, "bruger": username, "projekter": ["dagbog"], "oprettet": _nu()} + ps[pid] = v + else: + if navn and v.get("navn") != navn: + v["navn"] = navn + if "dagbog" not in v.setdefault("projekter", []): + v["projekter"].insert(0, "dagbog") + v["sidst"] = _nu() + _skriv(PERSONER, ps) + return {**v, "pid": pid} + + +def person_for_noegle(key: str) -> dict | None: + if not KEY.match(key or ""): + return None + for pid, v in personer().items(): + if v.get("noegle") == key and not v.get("lukket"): + v["sidst"] = _nu() + ps = personer(); ps[pid]["sidst"] = v["sidst"]; _skriv(PERSONER, ps) + return {**v, "pid": pid} + return None + + +def ny_gaest(navn: str, projekt_slugs: list[str]) -> dict: + base = re.sub(r"[^a-z0-9]+", "-", navn.lower().replace("æ", "ae").replace("ø", "oe").replace("å", "aa")).strip("-")[:30] or "gaest" + ps = personer() + slug, n = base, 2 + while f"g:{slug}" in ps: + slug, n = f"{base}-{n}", n + 1 + pid = f"g:{slug}" + ps[pid] = {"navn": navn.strip(), "noegle": secrets.token_urlsafe(32), "projekter": list(projekt_slugs), + "oprettet": _nu()} + _skriv(PERSONER, ps) + return {**ps[pid], "pid": pid} + + +def saet_projekter(pid: str, projekt_slugs: list[str]) -> bool: + ps = personer() + if pid not in ps: + return False + ps[pid]["projekter"] = [s for s in projekt_slugs if s in projekter()] + if pid.startswith("u:") and "dagbog" not in ps[pid]["projekter"]: + ps[pid]["projekter"].insert(0, "dagbog") + _skriv(PERSONER, ps) + return True + + +def ny_noegle(pid: str) -> str | None: + ps = personer() + if pid not in ps or not pid.startswith("g:"): + return None + ps[pid]["noegle"] = secrets.token_urlsafe(32) + ps[pid].pop("lukket", None) + _skriv(PERSONER, ps) + return ps[pid]["noegle"] + + +def luk_gaest(pid: str, lukket: bool) -> bool: + ps = personer() + if pid not in ps or not pid.startswith("g:"): + return False + if lukket: + ps[pid]["lukket"] = _nu() + else: + ps[pid].pop("lukket", None) + _skriv(PERSONER, ps) + return True + + +def alle_personer() -> list[dict]: + ud = [] + for pid, v in personer().items(): + ud.append({**v, "pid": pid, "antal": len(klip_for(pid))}) + return sorted(ud, key=lambda v: (v.get("sidst") or v.get("oprettet") or ""), reverse=True) + + +def maa(p: dict, slug: str) -> bool: + return slug in (p.get("projekter") or []) and projekt(slug) is not None + + +# ---------------- klip ---------------- + +def _klipmappe(pid: str) -> Path: + return KLIP / pid.replace(":", "_") + + +def klip_for(pid: str, projekt_slug: str | None = None, samtale: str | None = None) -> list[dict]: + """Klip for en person, ældste først.""" + d = _klipmappe(pid) + if not PID.match(pid or "") or not d.exists(): + return [] + ud = [] + for meta in d.glob("*.json"): + if not KLIPID.match(meta.stem): + continue + k = _læs(meta, None) + if not k or not (d / f"{meta.stem}.mp3").exists(): + continue + if projekt_slug and k.get("projekt") != projekt_slug: + continue + if samtale and k.get("samtale") != samtale: + continue + ud.append(k) + return sorted(ud, key=lambda k: k["id"]) + + +def klip(pid: str, kid: str) -> dict | None: + if not PID.match(pid or "") or not KLIPID.match(kid or ""): + return None + k = _læs(_klipmappe(pid) / f"{kid}.json", None) + return k if k and k.get("id") == kid else None + + +def klip_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}.mp3" + return p if p.exists() else None + + +def _varighed(p: Path) -> int: + try: + ud = subprocess.run(["ffprobe", "-v", "error", "-show_entries", "format=duration", "-of", "csv=p=0", str(p)], + capture_output=True, text=True, timeout=30).stdout.strip() + return int(float(ud)) + except (OSError, ValueError, subprocess.SubprocessError): + return 0 + + +def gem_svar(pid: str, projekt_slug: str, samtale: str, spoergsmaal: str, lyd: bytes | None, filnavn: str, + tekst: str | None) -> 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.""" + 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" + varighed = 0 + if lyd: + if len(lyd) > MAX_UPLOAD: + raise ValueError("Optagelsen er for stor. Prøv at dele den op i to svar.") + suffix = "." + (re.sub(r"[^a-z0-9]", "", (filnavn or "").rsplit(".", 1)[-1].lower()) or "webm") + with tempfile.NamedTemporaryFile(suffix=suffix, delete=False) as tmp: + tmp.write(lyd) + raa = Path(tmp.name) + try: + r = subprocess.run(["ffmpeg", "-y", "-v", "error", "-i", str(raa), "-vn", "-ac", "1", "-ar", "44100", + "-c:a", "libmp3lame", "-b:a", "64k", str(mp3)], capture_output=True, text=True, timeout=300) + if r.returncode or not mp3.exists(): + raise ValueError("Optagelsen kunne ikke læses. Prøv igen, eller skriv svaret i stedet.") + varighed = _varighed(mp3) + if varighed > MAX_SVAR_SEK: + mp3.unlink(missing_ok=True) + raise ValueError("Svaret er længere end et kvarter. Del det op, så bliver det også nemmere at bruge.") + if tekst is None or not tekst.strip(): + tekst = transskriber(mp3) + finally: + raa.unlink(missing_ok=True) + else: + if not (tekst or "").strip(): + raise ValueError("Der kom hverken lyd eller tekst med.") + # Tekstsvar: en stille mp3 på 1 s, så alle klip har samme form (afspiller + tekst). + 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], + "tekst": (tekst or "").strip(), "varighed": varighed, "favorit": False, "skrevet": not lyd} + _skriv(d / f"{kid}.json", meta) + return meta + + +def ret_tekst(pid: str, kid: str, tekst: str) -> bool: + k = klip(pid, kid) + if not k: + return False + k["tekst"] = tekst.strip()[:20000] + k["redigeret"] = _nu() + _skriv(_klipmappe(pid) / f"{kid}.json", k) + return True + + +def saet_favorit(pid: str, kid: str, favorit: bool) -> bool: + k = klip(pid, kid) + if not k: + return False + k["favorit"] = favorit + _skriv(_klipmappe(pid) / f"{kid}.json", k) + return True + + +def slet_klip(pid: str, kid: str) -> bool: + k = klip(pid, kid) + if not k: + return False + d = _klipmappe(pid) + (d / f"{kid}.json").unlink(missing_ok=True) + (d / f"{kid}.mp3").unlink(missing_ok=True) + return True + + +# ---------------- ElevenLabs: tale ---------------- + +def transskriber(mp3: Path) -> str: + if not ELEVEN_KEY: + return "" + try: + with open(mp3, "rb") as f: + r = httpx.post("https://api.elevenlabs.io/v1/speech-to-text", headers={"xi-api-key": ELEVEN_KEY}, + data={"model_id": "scribe_v2", "language_code": "da"}, + files={"file": (mp3.name, f, "audio/mpeg")}, timeout=600) + if r.status_code == 200: + return (r.json().get("text") or "").strip() + except httpx.HTTPError: + pass + return "" + + +def tts_sti(tekst: str) -> Path | None: + """Oplæst spørgsmål, cachet på tekstens hash; None hvis det ikke kunne laves.""" + tekst = (tekst or "").strip() + if not tekst or not ELEVEN_KEY: + return None + sha = hashlib.sha256((VOICE + "|" + tekst).encode()).hexdigest()[:32] + p = TTS / f"{sha}.mp3" + if p.exists(): + return p + TTS.mkdir(parents=True, exist_ok=True) + body = {"text": tekst, "model_id": "eleven_v4", "language_code": "da", + "voice_settings": {"stability": 0.5, "similarity_boost": 0.8, "style": 0.2, "use_speaker_boost": True}} + try: + r = httpx.post(f"https://api.elevenlabs.io/v1/text-to-speech/{VOICE}?output_format=mp3_44100_64", + headers={"xi-api-key": ELEVEN_KEY}, json=body, timeout=120) + if r.status_code == 200 and r.content[:3] in (b"ID3", b"\xff\xfb", b"\xff\xf3"): + tmp = p.with_name("." + p.name + ".tmp") + tmp.write_bytes(r.content) + os.replace(tmp, p) + return p + except httpx.HTTPError: + pass + return None + + +def tts_fil(sha: str) -> Path | None: + if not re.match(r"^[a-f0-9]{32}$", sha or ""): + return None + p = TTS / f"{sha}.mp3" + return p if p.exists() else 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. + +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 _historik(pid: str, projekt_slug: str, samtale: str) -> tuple[list[dict], list[dict]]: + """(denne samtale, tidligere svar i projektet) som korte dicts til prompten.""" + alle = klip_for(pid, projekt_slug) + denne = [k for k in alle if k.get("samtale") == samtale] + tidligere = [k for k in alle if k.get("samtale") != samtale][-30:] + return denne, tidligere + + +def _kort(k: dict, n: int = 700) -> dict: + return {"dato": (k.get("tid") or "")[:10], "spoergsmaal": k.get("spoergsmaal", ""), + "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.""" + 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 + if not ANTHROPIC_KEY: + return start[len(denne) % len(start)], False + 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 []}, + "tidligere_samtaler": [_kort(k, 300) for k in tidligere], + "denne_samtale": [_kort(k) for k in denne], + "antal_svar_i_dag": len(denne), + } + 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, + "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: + async with httpx.AsyncClient(timeout=60) as c: + r = await c.post("https://api.anthropic.com/v1/messages", headers=headers, json=body) + if r.status_code == 200: + tekst = "".join(b.get("text", "") for b in r.json().get("content", []) if b.get("type") == "text") + m = re.search(r"\{.*\}", tekst, re.S) + if m: + svar = json.loads(m.group(0)) + q = (svar.get("spoergsmaal") or "").strip().replace("—", ",").replace(" - ", ", ") + if q: + return q[:400], bool(svar.get("afslut")) + except (httpx.HTTPError, ValueError): + pass + return start[len(denne) % len(start)], False + + +def ny_samtale() -> str: + return time.strftime("%Y%m%d") + "-" + secrets.token_hex(3) + + +def eksport(pid: str) -> bytes: + """Zip med alle klip (mp3 + json) og en samlet tekstfil, til podcast-produktionen.""" + import io + import zipfile + k = klip_for(pid) + buf = io.BytesIO() + with zipfile.ZipFile(buf, "w", zipfile.ZIP_DEFLATED) as z: + linjer = [] + for x in k: + f = klip_fil(pid, x["id"]) + if f: + z.write(f, f"{x['id']}.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") + z.writestr("alle_svar.md", "\n".join(linjer)) + return buf.getvalue() diff --git a/main.py b/main.py index 2bd7f9b..a2ff4ee 100644 --- a/main.py +++ b/main.py @@ -26,6 +26,7 @@ import mcp_gateway import secretdrop import claudelogin import podcast +import erindringer # Portalens egen MCP (FastMCP), monteret in-process på /mcp. json_response=True # => almindelig JSON (ingen SSE), så det passerer edge'en utunet. Bearer-gated. @@ -149,7 +150,8 @@ async def dashboard_content(request: Request): "fav_apps": [a for a in by_name if a["slug"] in fav_set], "favorites": list(fav_set), "view": prefs["view"], "mc_servers": mc_servers, "disco_url": config.DISCO_PANEL_URL, - "podcasts": _podcast_kort(user)}) + "podcasts": _podcast_kort(user), + "erindringer": bool(is_admin(user) or config.PODCAST_GROUPS & set(user.get("groups") or []))}) def _podcast_kort(user) -> list[dict]: @@ -615,6 +617,253 @@ async def admin_podcast_udgiv(request: Request, name: str, slug: str): return RedirectResponse(f"/admin/podcast?saved={besked}", status_code=303) +# ---------------- erindringer (interview + dagbog) ---------------- +# Indloggede (familie) bruger sessionen; gæster uden login bruger en hemmelig nøgle i +# adressen og sender den med i API-kald ("noegle"). Begge ender som en "person" i modulet. + +def _erindringer_person(request: Request, noegle: str = "") -> dict: + user = current_user(request) + if user and (is_admin(user) or config.PODCAST_GROUPS & set(user.get("groups") or [])): + return erindringer.person_for_bruger(user["username"], user.get("name") or user["username"]) + if noegle: + if podcast.blokeret(_klient_ip(request)): + raise HTTPException(404) + p = erindringer.person_for_noegle(noegle) + if p: + return p + podcast.forkert(_klient_ip(request)) + raise HTTPException(404) + if not user and request.method == "GET": + nxt = request.url.path + raise HTTPException(303, headers={"Location": "/auth/login?next=" + urllib.parse.quote(nxt, safe="")}) + raise HTTPException(403, "Kun for familien") + + +def _erindringer_side(request: Request, p: dict, rod: str, noegle: str, saved: str, valgt: str): + user = current_user(request) + pr = [] + for slug in p.get("projekter") or []: + x = erindringer.projekt(slug) + if x: + pr.append({**x, "antal": len(erindringer.klip_for(p["pid"], slug))}) + titler = {x["slug"]: x["titel"] for x in pr} + seneste = list(reversed(erindringer.klip_for(p["pid"])))[:20] + for k in seneste: + k["projekt_titel"] = titler.get(k.get("projekt"), k.get("projekt")) + return templates.TemplateResponse(request, "erindringer.html", { + "request": request, "user": user, "is_admin": is_admin(user), "navn": p.get("navn") or "", + "noegle": noegle, "rod": rod, "api": "/erindringer/api", "projekter": pr, "klip_seneste": seneste, + "saved": saved, "valgt": valgt if valgt in titler else ""}) + + +@app.get("/erindringer", response_class=HTMLResponse) +async def erindringer_side(request: Request, saved: str = "", valgt: str = ""): + p = _erindringer_person(request) + return _erindringer_side(request, p, "/erindringer", "", saved, valgt) + + +@app.get("/erindringer/g/{key}/", response_class=HTMLResponse) +async def erindringer_gaest(request: Request, key: str, saved: str = "", valgt: str = ""): + p = _erindringer_person(request, key) + r = _erindringer_side(request, p, f"/erindringer/g/{key}", key, saved, valgt) + r.headers["X-Robots-Tag"] = "noindex, nofollow, noarchive" + r.headers["Referrer-Policy"] = "no-referrer" + return r + + +@app.get("/erindringer/tts/{sha}.mp3") +async def erindringer_tts(sha: str): + p = erindringer.tts_fil(sha) + if not p: + raise HTTPException(404) + return FileResponse(p, media_type="audio/mpeg", headers={"Cache-Control": "private, max-age=86400"}) + + +def _klip_svar(pid: str, kid: str): + f = erindringer.klip_fil(pid, kid) + if not f: + raise HTTPException(404) + return FileResponse(f, media_type="audio/mpeg", headers={"Cache-Control": "private, no-store"}) + + +@app.get("/erindringer/klip/{kid}.mp3") +async def erindringer_klip(request: Request, kid: str): + p = _erindringer_person(request) + return _klip_svar(p["pid"], kid) + + +@app.get("/erindringer/g/{key}/klip/{kid}.mp3") +async def erindringer_gaest_klip(request: Request, key: str, kid: str): + p = _erindringer_person(request, key) + return _klip_svar(p["pid"], kid) + + +def _fejl(status: int, tekst: str): + return JSONResponse({"fejl": tekst}, status_code=status) + + +@app.post("/erindringer/api/naeste") +async def erindringer_naeste(request: Request): + try: + data = await request.json() + except ValueError: + return _fejl(400, "Ugyldigt kald.") + p = _erindringer_person(request, str(data.get("noegle") or "")) + slug = str(data.get("projekt") or "") + if not erindringer.maa(p, slug): + return _fejl(403, "Det emne er ikke åbent for dig.") + pr = erindringer.projekt(slug) + 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, + "nr": len(erindringer.klip_for(p["pid"], slug, samtale)) + 1} + + +@app.post("/erindringer/api/svar") +async def erindringer_svar(request: Request): + form = await request.form() + p = _erindringer_person(request, str(form.get("noegle") or "")) + slug = str(form.get("projekt") or "") + if not erindringer.maa(p, slug): + return _fejl(403, "Det emne er ikke åbent for dig.") + samtale = str(form.get("samtale") or "") + if not re.match(r"^[0-9]{8}-[a-f0-9]{6}$", samtale): + samtale = erindringer.ny_samtale() + lyd = form.get("lyd") + data, filnavn = None, "" + if lyd is not None and hasattr(lyd, "read"): + data = await lyd.read() + filnavn = getattr(lyd, "filename", "") or "" + if not data: + data = None + 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) + except ValueError as e: + return _fejl(400, str(e)) + return {"id": meta["id"], "tekst": meta["tekst"], "varighed": meta["varighed"], "samtale": samtale} + + +@app.post("/erindringer/api/ret") +async def erindringer_ret(request: Request): + try: + data = await request.json() + except ValueError: + return _fejl(400, "Ugyldigt kald.") + p = _erindringer_person(request, str(data.get("noegle") or "")) + if not erindringer.ret_tekst(p["pid"], str(data.get("id") or ""), str(data.get("tekst") or "")): + return _fejl(404, "Svaret findes ikke.") + return {"ok": True} + + +@app.get("/admin/erindringer", response_class=HTMLResponse) +async def admin_erindringer(request: Request, saved: str = "", ny: str = ""): + user = require_admin(request) + pr = erindringer.projekter() + return templates.TemplateResponse(request, "admin_erindringer.html", { + "request": request, "user": user, "is_admin": True, "saved": saved, "ny": ny, + "base": str(request.base_url).rstrip("/"), + "projekter": [{**v, "slug": k} for k, v in pr.items()], + "personer": erindringer.alle_personer(), + "har_claude": bool(erindringer.ANTHROPIC_KEY), "har_eleven": bool(erindringer.ELEVEN_KEY), "model": erindringer.MODEL}) + + +@app.post("/admin/erindringer/projekt") +async def admin_erindringer_projekt(request: Request): + require_admin(request) + form = await request.form() + slug = re.sub(r"[^a-z0-9-]", "-", str(form.get("slug") or "").strip().lower()).strip("-")[:40] + if str(form.get("handling") or "") == "slet": + 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()] + if not erindringer.gem_projekt(slug, str(form.get("titel") or ""), str(form.get("beskrivelse") or ""), + str(form.get("kontekst") or ""), start): + 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) + + +@app.post("/admin/erindringer/gaest") +async def admin_erindringer_gaest(request: Request): + require_admin(request) + form = await request.form() + navn = str(form.get("navn") or "").strip()[:80] + if not navn: + return RedirectResponse("/admin/erindringer?saved=Skriv+et+navn", status_code=303) + p = erindringer.ny_gaest(navn, [str(x) for x in form.getlist("projekter")]) + return RedirectResponse(f"/admin/erindringer?ny={urllib.parse.quote(p['pid'])}", status_code=303) + + +@app.post("/admin/erindringer/person/{pid}") +async def admin_erindringer_person_save(request: Request, pid: str): + require_admin(request) + form = await request.form() + handling = str(form.get("handling") or "") + if handling == "projekter": + erindringer.saet_projekter(pid, [str(x) for x in form.getlist("projekter")]) + besked = "Emnerne er gemt" + elif handling == "ny-noegle": + erindringer.ny_noegle(pid); besked = "Nyt link lavet; det gamle virker ikke mere" + elif handling == "luk": + erindringer.luk_gaest(pid, True); besked = "Linket er lukket" + elif handling == "aabn": + erindringer.luk_gaest(pid, False); besked = "Linket er åbnet igen" + else: + besked = "Ukendt handling" + return RedirectResponse(f"/admin/erindringer/p/{urllib.parse.quote(pid)}?saved={urllib.parse.quote(besked)}", status_code=303) + + +@app.get("/admin/erindringer/p/{pid}", response_class=HTMLResponse) +async def admin_erindringer_person(request: Request, pid: str, saved: str = ""): + user = require_admin(request) + p = erindringer.person(pid) + if not p: + raise HTTPException(404) + pr = erindringer.projekter() + k = list(reversed(erindringer.klip_for(pid))) + for x in k: + x["projekt_titel"] = (pr.get(x.get("projekt")) or {}).get("titel", x.get("projekt")) + return templates.TemplateResponse(request, "admin_erindringer_person.html", { + "request": request, "user": user, "is_admin": True, "saved": saved, "p": p, "klip": k, + "base": str(request.base_url).rstrip("/"), "projekter": [{**v, "slug": s} for s, v in pr.items()]}) + + +@app.get("/admin/erindringer/p/{pid}/klip/{kid}.mp3") +async def admin_erindringer_klip(request: Request, pid: str, kid: str): + require_admin(request) + 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) + form = await request.form() + handling = str(form.get("handling") or "") + if handling == "favorit": + erindringer.saet_favorit(pid, kid, str(form.get("favorit") or "") == "1") + elif handling == "slet": + erindringer.slet_klip(pid, kid) + elif handling == "ret": + erindringer.ret_tekst(pid, kid, str(form.get("tekst") or "")) + return RedirectResponse(f"/admin/erindringer/p/{urllib.parse.quote(pid)}#k-{kid}", status_code=303) + + +@app.get("/admin/erindringer/p/{pid}/eksport.zip") +async def admin_erindringer_eksport(request: Request, pid: str): + require_admin(request) + if not erindringer.person(pid): + raise HTTPException(404) + data = await asyncio.to_thread(erindringer.eksport, pid) + navn = pid.replace(":", "_") + return Response(data, media_type="application/zip", + headers={"Content-Disposition": f'attachment; filename="erindringer-{navn}.zip"'}) + + @app.get("/admin/claude-login", response_class=HTMLResponse) async def admin_claude_login(request: Request): user = require_admin(request) diff --git a/templates/admin_erindringer.html b/templates/admin_erindringer.html new file mode 100644 index 0000000..8121c25 --- /dev/null +++ b/templates/admin_erindringer.html @@ -0,0 +1,83 @@ +{% extends "base.html" %} +{% block title %}Admin — erindringer{% endblock %} +{% block body %} +

← Admin

+

Erindringer

+

Familien fortæller minder til en AI-interviewer, der stiller ét spørgsmål ad gangen og følger op. Svarene gemmes som lyd og tekst pr. person og kan hentes som zip til podcasten. Indloggede i familiegruppen har altid "Dagbog" på /erindringer; gæster uden login får et hemmeligt link, som du laver her.

+{% if saved %}
{{ saved }}
{% endif %} +{% if not har_claude or not har_eleven %} +
⚠️ {% if not har_claude %}ANTHROPIC_API_KEY mangler i /etc/portal.env: intervieweren falder tilbage til projektets startspørgsmål.{% endif %} +{% if not har_eleven %}ELEVEN_API_KEY mangler: ingen oplæsning og ingen transskription (svar gemmes kun som lyd).{% endif %}
+{% endif %} + +{% if ny %} +{% for p in personer if p.pid == ny %} +
+

Link til {{ p.navn }}, send det privat (Messenger, sms). Linket er personligt og kan lukkes igen.

+
+ + +
+
+{% endfor %} +{% endif %} + +

Personer

+ + + + {% for p in personer %} + + + + + + + + + {% else %} + + {% endfor %} + +
NavnAdgangEmnerSvarSidst
{{ p.navn }}{% if p.bruger %}login ({{ p.bruger }}){% elif p.lukket %}gæstelink lukket{% else %}gæstelink{% endif %}{{ p.projekter|join(', ') }}{{ p.antal }}{{ (p.sidst or p.oprettet or '')[:10] }}Åbn
Ingen endnu. Indloggede dukker op, når de åbner /erindringer første gang.
+ +

Nyt gæstelink (uden login)

+
+ +

Emner, personen må fortælle om:

+ {% for pr in projekter %} + + {% endfor %} +

+
+ +

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.

+{% for pr in projekter %} +
+ {{ pr.titel }} ({{ pr.slug }}) · {{ pr.start|length }} startspørgsmål +
+ + + + + +

+ + {% if pr.slug != 'dagbog' %}{% endif %} +

+
+
+{% endfor %} +
+ Nyt emne +
+ + + + + +

+
+
+{% endblock %} diff --git a/templates/admin_erindringer_person.html b/templates/admin_erindringer_person.html new file mode 100644 index 0000000..6cd7915 --- /dev/null +++ b/templates/admin_erindringer_person.html @@ -0,0 +1,54 @@ +{% extends "base.html" %} +{% block title %}Admin — erindringer: {{ p.navn }}{% endblock %} +{% block body %} +

← Erindringer

+

{{ p.navn }}

+{% if saved %}
{{ saved }}
{% endif %} + +
+ {% if p.bruger %} +

Logger ind som {{ p.bruger }} og bruger /erindringer.

+ {% else %} +

Gæstelink{% if p.lukket %} lukket{% endif %}

+ {% if not p.lukket %} +
+ + +
+ {% endif %} +
+ + {% if p.lukket %}{% else %}{% endif %} +
+ {% endif %} +
+ +

Emner:

+ {% for pr in projekter %} + + {% endfor %} +

+ Hent alt som zip

+
+
+ +

{{ klip|length }} svar

+{% 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 %} +
+ +

+ + + + +

+
+
+{% else %} +

Ingen svar endnu.

+{% endfor %} +{% endblock %} diff --git a/templates/dashboard_body.html b/templates/dashboard_body.html index 58dacc9..3d67502 100644 --- a/templates/dashboard_body.html +++ b/templates/dashboard_body.html @@ -12,7 +12,7 @@ {% endmacro %} {% if error %}
{{ error }}
{% endif %} -{% if podcasts %} +{% if podcasts or erindringer %}

🎙️ Familiens podcast

@@ -24,6 +24,14 @@
Lyt →
{% endfor %} + {% if erindringer %} +
+ +
🎤Erindringer
+
Fortæl dine minder eller før dagbog. En interviewer stiller spørgsmålene.
+
Fortæl →
+
+ {% endif %}
{% endif %} diff --git a/templates/erindringer.html b/templates/erindringer.html new file mode 100644 index 0000000..038ecfd --- /dev/null +++ b/templates/erindringer.html @@ -0,0 +1,606 @@ +{% extends "base.html" %} +{% block title %}Erindringer{% endblock %} +{% block body %} + + +
+ +

Erindringer

+

Hej {{ navn }}. Fortæl, så gemmer vi det. Du kan altid holde en pause og komme tilbage.

+ + {% if saved %}
{{ saved }}
{% endif %} + + + +
+

Vælg, hvad du vil fortælle om

+ {% for p in projekter %} +
+

{{ p.titel }}

+ {% if p.beskrivelse %}

{{ p.beskrivelse }}

{% endif %} +

{{ p.antal }} svar indtil nu

+ +
+ {% else %} +

Der er ingen emner endnu.

+ {% endfor %} +
+ + + + + +
+

Tidligere svar

+ {% if klip_seneste %} +
    + {% for k in klip_seneste[:20] %} +
  • +

    {{ k.tid[:10] }} · {{ k.projekt_titel or k.projekt }}{% if k.varighed %} · {{ k.varighed // 60 }}:{{ '%02d' % (k.varighed % 60) }}{% endif %}

    +

    {{ k.spoergsmaal }}

    +

    {{ k.tekst }}

    + +
    +
  • + {% endfor %} +
+ {% else %} +

Ingen svar endnu. Det første gemmes her, når du har fortalt noget.

+ {% endif %} +
+
+ + +{% endblock %} diff --git a/tests_erindringer_smoke.py b/tests_erindringer_smoke.py new file mode 100644 index 0000000..d5c36a6 --- /dev/null +++ b/tests_erindringer_smoke.py @@ -0,0 +1,56 @@ +# Røgtest af erindringer-modulet med FastAPI TestClient, uden OIDC: sessionen sættes direkte. +# Kør i CT151: PORTAL_DATA=/tmp/ef-test/data + env fra /etc/portal.env. Rører ikke prod-data. +import io, json, os, sys +from fastapi.testclient import TestClient +import main, erindringer + +c = TestClient(main.app, base_url="https://weircon.dk") +adm = {"username": "agw", "name": "Asger", "groups": [main.config.ADMIN_GROUP, "Familie"]} + + +def login(client, user): + # Starlette-session: skriv via en lille rute er omstændeligt; monkeypatch current_user i stedet. + main.current_user = lambda request: user + + +login(c, adm) +r = c.get("/admin/erindringer"); assert r.status_code == 200 and "Erindringer" in r.text, r.status_code +r = c.post("/admin/erindringer/projekt", data={"slug": "test-ep", "titel": "Testemne", "beskrivelse": "b", "kontekst": "Personen er Asger. Vi ved at han bor i Sønderlund.", "start": "Hvad husker du fra Rørbæk?\nHvem var din morfar?"}, follow_redirects=False) +assert r.status_code == 303, r.text +assert erindringer.projekt("test-ep")["start"][0].startswith("Hvad husker") +r = c.post("/admin/erindringer/gaest", data={"navn": "Tina Test", "projekter": ["test-ep"]}, follow_redirects=False) +assert r.status_code == 303 +pid = [p for p in erindringer.alle_personer() if p["navn"] == "Tina Test"][0]["pid"]; noegle = erindringer.person(pid)["noegle"] +print("gæst", pid) + +# API som gæst (uden session) +login(c, None) +r = c.post("/erindringer/api/naeste", json={"projekt": "test-ep", "samtale": None, "noegle": noegle}); assert r.status_code == 200, r.text +q1 = r.json(); print("Q1", q1["spoergsmaal"], "| lyd:", bool(q1["lyd_url"])) +if q1["lyd_url"]: + assert c.get(q1["lyd_url"]).status_code == 200 +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"]) +# 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")}) +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.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 +r = c.post("/erindringer/api/naeste", json={"projekt": "test-ep", "samtale": None, "noegle": "x" * 40}); assert r.status_code == 404 +# indlogget familie: dagbog findes, admin-person-side, eksport +login(c, adm) +r = c.post("/erindringer/api/naeste", json={"projekt": "dagbog", "samtale": None, "noegle": ""}); assert r.status_code == 200, r.text +print("dagbog Q", r.json()["spoergsmaal"]) +r = c.get(f"/admin/erindringer/p/{pid}"); assert r.status_code == 200 and "Rettet tekst." in r.text +r = c.get(f"/admin/erindringer/p/{pid}/eksport.zip"); assert r.status_code == 200 and r.headers["content-type"] == "application/zip" and len(r.content) > 1000 +r = c.post(f"/admin/erindringer/p/{pid}/klip/{s1['id']}", data={"handling": "favorit", "favorit": "1"}, follow_redirects=False); assert r.status_code == 303 +assert erindringer.klip(pid, s1["id"])["favorit"] is True +r = c.post(f"/admin/erindringer/person/{pid}", data={"handling": "luk"}, follow_redirects=False); assert r.status_code == 303 +login(c, None) +r = c.post("/erindringer/api/naeste", json={"projekt": "test-ep", "samtale": None, "noegle": noegle}); assert r.status_code == 404 +print("ALT OK")