Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3dee428a37 | ||
|
|
7f8ddcf7af | ||
|
|
4005860364 |
@@ -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/<nøgle>/`, 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
|
||||
|
||||
@@ -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": "🔑",
|
||||
|
||||
@@ -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
|
||||
|
||||
+559
@@ -0,0 +1,559 @@
|
||||
"""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/<pid>/<id>.mp3 svaret (mono 64 kbit)
|
||||
DATA/erindringer/klip/<pid>/<id>.json id, tid, projekt, samtale, spoergsmaal, tekst, varighed, favorit
|
||||
DATA/erindringer/tts/<sha>.mp3 oplæste spørgsmål
|
||||
|
||||
Personer: indloggede brugere har pid "u:<username>" og kan altid føre dagbog; gæster (familie uden
|
||||
login) får pid "g:<slug>" 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], 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()], "opfoelg": max(0, min(6, int(opfoelg or 0))),
|
||||
"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, spoergsmaal_talt: str = "", lyd_sha: str = "", kilde: str = "") -> dict:
|
||||
"""Gem et svar: lyd konverteres til mp3 og transskriberes; ren tekst gemmes som det er.
|
||||
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:
|
||||
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],
|
||||
"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
|
||||
|
||||
|
||||
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)
|
||||
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:
|
||||
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 ----------------
|
||||
|
||||
# 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 ]+)\]")
|
||||
|
||||
|
||||
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]]:
|
||||
"""(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", ""), "kilde": k.get("kilde", ""),
|
||||
"svar": (k.get("tekst") or "")[:n]}
|
||||
|
||||
|
||||
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)
|
||||
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 fallback()
|
||||
payload = {
|
||||
"person": {"navn": p.get("navn")},
|
||||
"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),
|
||||
}
|
||||
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": 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:
|
||||
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 = untag(svar.get("spoergsmaal") or "").replace("—", ",").replace(" - ", ", ")
|
||||
talt = keep_tags((svar.get("talt") or q).replace("—", ",").replace(" - ", ", "))
|
||||
if q:
|
||||
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 fallback()
|
||||
|
||||
|
||||
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")
|
||||
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('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()
|
||||
@@ -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,272 @@ 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):
|
||||
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)
|
||||
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 _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)
|
||||
|
||||
|
||||
@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()
|
||||
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}
|
||||
|
||||
|
||||
@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, 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}
|
||||
|
||||
|
||||
@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()]
|
||||
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, 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)
|
||||
|
||||
|
||||
@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)
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Admin — erindringer{% endblock %}
|
||||
{% block body %}
|
||||
<p><a href="/admin" class="wt-muted">← Admin</a></p>
|
||||
<h1>Erindringer</h1>
|
||||
<p class="wt-lead">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å <a href="/erindringer">/erindringer</a>; gæster uden login får et hemmeligt link, som du laver her.</p>
|
||||
{% if saved %}<div class="wt-flash">{{ saved }}</div>{% endif %}
|
||||
{% if not har_claude or not har_eleven %}
|
||||
<div class="wt-flash">⚠️ {% 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 %}</div>
|
||||
{% endif %}
|
||||
|
||||
{% if ny %}
|
||||
{% for p in personer if p.pid == ny %}
|
||||
<div class="wt-card" style="padding:1rem 1.2rem;margin:1rem 0;border-color:var(--color-accent)">
|
||||
<p style="margin:0 0 .5rem"><strong>Link til {{ p.navn }}</strong>, send det privat (Messenger, sms). Linket er personligt og kan lukkes igen.</p>
|
||||
<div style="display:flex;gap:.5rem;flex-wrap:wrap">
|
||||
<input class="wt-input" readonly id="ny-link" value="{{ base }}/erindringer/g/{{ p.noegle }}/" style="flex:1 1 22rem;font-family:ui-monospace,monospace;font-size:.85rem" onclick="this.select()">
|
||||
<button class="wt-btn" type="button" onclick="var i=document.getElementById('ny-link');i.select();navigator.clipboard&&navigator.clipboard.writeText(i.value);this.textContent='Kopieret'">Kopiér</button>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
|
||||
<h2 style="font-size:1.15rem;margin-top:1.6rem">Personer</h2>
|
||||
<table class="wt-table">
|
||||
<thead><tr><th>Navn</th><th>Adgang</th><th>Emner</th><th>Svar</th><th>Sidst</th><th></th></tr></thead>
|
||||
<tbody>
|
||||
{% for p in personer %}
|
||||
<tr>
|
||||
<td><a href="/admin/erindringer/p/{{ p.pid }}">{{ p.navn }}</a></td>
|
||||
<td>{% if p.bruger %}login ({{ p.bruger }}){% elif p.lukket %}<span class="wt-tag muted">gæstelink lukket</span>{% else %}gæstelink{% endif %}</td>
|
||||
<td>{{ p.projekter|join(', ') }}</td>
|
||||
<td>{{ p.antal }}</td>
|
||||
<td>{{ (p.sidst or p.oprettet or '')[:10] }}</td>
|
||||
<td><a class="wt-btn" href="/admin/erindringer/p/{{ p.pid }}">Åbn</a></td>
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr><td colspan="6" class="wt-muted">Ingen endnu. Indloggede dukker op, når de åbner /erindringer første gang.</td></tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<h2 style="font-size:1.15rem;margin-top:2rem">Nyt gæstelink (uden login)</h2>
|
||||
<form method="post" action="/admin/erindringer/gaest" class="wt-card" style="padding:1rem 1.2rem;max-width:48rem;margin:0">
|
||||
<label>Navn (som intervieweren bruger)<br><input class="wt-input" name="navn" required maxlength="80" style="width:100%"></label>
|
||||
<p style="margin:.8rem 0 .3rem">Emner, personen må fortælle om:</p>
|
||||
{% for pr in projekter %}
|
||||
<label style="display:block;margin:.2rem 0"><input type="checkbox" name="projekter" value="{{ pr.slug }}" {% if pr.slug != 'dagbog' %}checked{% endif %}> {{ pr.titel }} <span class="wt-muted">({{ pr.slug }})</span></label>
|
||||
{% endfor %}
|
||||
<p style="margin:.8rem 0 0"><button class="wt-btn wt-btn--primary" type="submit">Lav link</button></p>
|
||||
</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). 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>
|
||||
<form method="post" action="/admin/erindringer/projekt" style="margin:.8rem 0 0">
|
||||
<input type="hidden" name="slug" value="{{ pr.slug }}">
|
||||
<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">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 %}
|
||||
</p>
|
||||
</form>
|
||||
</details>
|
||||
{% endfor %}
|
||||
<details class="wt-card" style="padding:.8rem 1.2rem;max-width:48rem;margin:.5rem 0">
|
||||
<summary style="cursor:pointer"><strong>Nyt emne</strong></summary>
|
||||
<form method="post" action="/admin/erindringer/projekt" style="margin:.8rem 0 0">
|
||||
<label>Kort navn (a-z, 0-9, bindestreg)<br><input class="wt-input" name="slug" required pattern="[a-z0-9][a-z0-9-]{0,40}" style="width:100%" placeholder="fx ep10-foraeldrene"></label>
|
||||
<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">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>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,56 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Admin — erindringer: {{ p.navn }}{% endblock %}
|
||||
{% block body %}
|
||||
<p><a href="/admin/erindringer" class="wt-muted">← Erindringer</a></p>
|
||||
<h1>{{ p.navn }}</h1>
|
||||
{% if saved %}<div class="wt-flash">{{ saved }}</div>{% endif %}
|
||||
|
||||
<div class="wt-card" style="padding:1rem 1.2rem;max-width:48rem;margin:0 0 1.2rem">
|
||||
{% if p.bruger %}
|
||||
<p style="margin:0 0 .6rem">Logger ind som <strong>{{ p.bruger }}</strong> og bruger <a href="/erindringer">/erindringer</a>.</p>
|
||||
{% else %}
|
||||
<p style="margin:0 0 .6rem"><strong>Gæstelink</strong>{% if p.lukket %} <span class="wt-tag muted">lukket</span>{% endif %}</p>
|
||||
{% if not p.lukket %}
|
||||
<div style="display:flex;gap:.5rem;flex-wrap:wrap;margin-bottom:.8rem">
|
||||
<input class="wt-input" readonly id="g-link" value="{{ base }}/erindringer/g/{{ p.noegle }}/" style="flex:1 1 22rem;font-family:ui-monospace,monospace;font-size:.85rem" onclick="this.select()">
|
||||
<button class="wt-btn" type="button" onclick="var i=document.getElementById('g-link');i.select();navigator.clipboard&&navigator.clipboard.writeText(i.value);this.textContent='Kopieret'">Kopiér</button>
|
||||
</div>
|
||||
{% endif %}
|
||||
<form method="post" action="/admin/erindringer/person/{{ p.pid }}" style="display:flex;gap:.5rem;flex-wrap:wrap;margin:0 0 .8rem">
|
||||
<button class="wt-btn" name="handling" value="ny-noegle" onclick="return confirm('Nyt link? Det gamle holder op med at virke.')">Nyt link</button>
|
||||
{% if p.lukket %}<button class="wt-btn" name="handling" value="aabn">Åbn igen</button>{% else %}<button class="wt-btn wt-btn--danger" name="handling" value="luk">Luk linket</button>{% endif %}
|
||||
</form>
|
||||
{% endif %}
|
||||
<form method="post" action="/admin/erindringer/person/{{ p.pid }}" style="margin:0">
|
||||
<input type="hidden" name="handling" value="projekter">
|
||||
<p style="margin:.2rem 0 .3rem">Emner:</p>
|
||||
{% for pr in projekter %}
|
||||
<label style="display:inline-block;margin:.2rem .8rem .2rem 0"><input type="checkbox" name="projekter" value="{{ pr.slug }}" {% if pr.slug in p.projekter %}checked{% endif %}{% if p.bruger and pr.slug == 'dagbog' %} disabled checked{% endif %}> {{ pr.titel }}</label>
|
||||
{% endfor %}
|
||||
<p style="margin:.6rem 0 0"><button class="wt-btn" type="submit">Gem emner</button>
|
||||
<a class="wt-btn" href="/admin/erindringer/p/{{ p.pid }}/eksport.zip">Hent alt som zip</a></p>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<h2 style="font-size:1.15rem">{{ klip|length }} svar</h2>
|
||||
{% 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>{% 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">
|
||||
<button class="wt-btn" name="handling" value="ret">Gem tekst</button>
|
||||
<button class="wt-btn{% if k.favorit %} wt-btn--primary{% endif %}" name="handling" value="favorit" onclick="this.form.favorit.value='{{ '0' if k.favorit else '1' }}'">{{ '★ Favorit' if k.favorit else '☆ Markér som favorit' }}</button>
|
||||
<input type="hidden" name="favorit" value="1">
|
||||
<button class="wt-btn wt-btn--danger" name="handling" value="slet" onclick="return confirm('Slet svaret for altid?')">Slet</button>
|
||||
</p>
|
||||
</form>
|
||||
</div>
|
||||
{% else %}
|
||||
<p class="wt-muted">Ingen svar endnu.</p>
|
||||
{% endfor %}
|
||||
{% endblock %}
|
||||
@@ -12,7 +12,7 @@
|
||||
</div>
|
||||
{% endmacro %}
|
||||
{% if error %}<div class="err">{{ error }}</div>{% endif %}
|
||||
{% if podcasts %}
|
||||
{% if podcasts or erindringer %}
|
||||
<section class="appgroup" id="podsec">
|
||||
<h2>🎙️ Familiens podcast</h2>
|
||||
<div class="grid">
|
||||
@@ -24,6 +24,14 @@
|
||||
<div class="go">Lyt →</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% if erindringer %}
|
||||
<div class="card" data-search="erindringer dagbog minder interview">
|
||||
<a class="card-link" href="/erindringer" aria-label="Erindringer"></a>
|
||||
<div class="head"><span class="ico">🎤</span><span class="name">Erindringer</span></div>
|
||||
<div class="sub">Fortæl dine minder eller før dagbog. En interviewer stiller spørgsmålene.</div>
|
||||
<div class="go">Fortæl →</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</section>
|
||||
{% endif %}
|
||||
|
||||
@@ -0,0 +1,629 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Erindringer{% endblock %}
|
||||
{% block body %}
|
||||
<style>
|
||||
/* Ingen vandret scroll på iPhone: alt holder sig inden for kortet, og felter
|
||||
har mindst 16 px skrift, ellers zoomer iOS ind. */
|
||||
html, body { overflow-x: hidden; }
|
||||
.er-side { max-width: 100%; overflow-wrap: anywhere; }
|
||||
.er-side audio, .er-side textarea, .er-side .wt-card { max-width: 100%; box-sizing: border-box; }
|
||||
.er-side audio { display: block; width: 100%; min-width: 0; }
|
||||
.er-side textarea { font-size: 16px !important; min-width: 0; min-height: 7rem; line-height: 1.5; }
|
||||
.er-side .wt-btn { white-space: normal; }
|
||||
|
||||
/* Store, rolige knapper til ældre fingre */
|
||||
.er-stor { font-size: 1.1rem; padding: .85rem 1.4rem; min-height: 3.2rem; }
|
||||
.er-knapper { display: flex; flex-wrap: wrap; gap: .7rem; justify-content: center; margin-top: 1rem; }
|
||||
.er-kort { margin: 1rem 0; padding: 1.1rem 1.2rem; }
|
||||
.er-emne { display: flex; flex-direction: column; gap: .5rem; }
|
||||
.er-emne h3 { margin: 0; font-size: 1.25rem; }
|
||||
.er-emne .wt-btn { align-self: flex-start; }
|
||||
|
||||
/* Spørgsmålet: stor skrift, god linjeafstand */
|
||||
.er-spoergsmaal { font-size: 1.3rem; line-height: 1.5; margin: .3rem 0 .8rem; font-weight: 600; }
|
||||
|
||||
/* Den runde optage-knap */
|
||||
.er-midt { display: flex; flex-direction: column; align-items: center; gap: .8rem; margin: 1.4rem 0; text-align: center; }
|
||||
.er-optag {
|
||||
width: 7.5rem; height: 7.5rem; min-width: 88px; min-height: 88px; border-radius: 50%;
|
||||
border: 3px solid var(--color-accent);
|
||||
background: color-mix(in srgb, var(--color-accent) 16%, transparent);
|
||||
color: inherit; font: inherit; font-size: 1.2rem; font-weight: 700; cursor: pointer;
|
||||
display: flex; flex-direction: column; align-items: center; justify-content: center; gap: .15rem;
|
||||
transition: transform .15s, background .2s;
|
||||
}
|
||||
.er-optag:hover { transform: scale(1.04); }
|
||||
.er-optag:disabled { opacity: .5; cursor: default; transform: none; }
|
||||
.er-optag.optager {
|
||||
background: var(--color-bad); border-color: var(--color-bad); color: #fff;
|
||||
animation: er-puls 1.3s ease-in-out infinite;
|
||||
}
|
||||
.er-optag .er-taeller { font-size: .95rem; font-weight: 500; font-variant-numeric: tabular-nums; }
|
||||
@keyframes er-puls {
|
||||
0%, 100% { box-shadow: 0 0 0 0 color-mix(in srgb, var(--color-bad) 55%, transparent); }
|
||||
50% { box-shadow: 0 0 0 18px transparent; }
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) { .er-optag.optager { animation: none; } }
|
||||
|
||||
.er-status { min-height: 1.5rem; font-size: 1.05rem; color: var(--color-muted); }
|
||||
.er-status.travl { color: var(--color-accent); font-weight: 600; }
|
||||
.er-link { background: none; border: 0; padding: .4rem; color: var(--color-accent); text-decoration: underline;
|
||||
cursor: pointer; font: inherit; font-size: 1rem; }
|
||||
|
||||
/* Svar-lister */
|
||||
.er-liste { list-style: none; padding: 0; margin: .6rem 0 0; }
|
||||
.er-liste li { border-top: 1px solid var(--color-border); padding: .9rem 0; }
|
||||
.er-liste li:first-child { border-top: 0; }
|
||||
.er-liste .er-q { font-weight: 700; margin: 0 0 .3rem; font-size: 1.05rem; }
|
||||
.er-liste .er-meta { color: var(--color-muted); font-size: .9rem; margin: 0 0 .3rem; }
|
||||
.er-tekst { margin: 0; white-space: pre-line; line-height: 1.55; font-size: 1.05rem; }
|
||||
.er-ret { margin-top: .5rem; }
|
||||
.er-ret .wt-btn { font-size: .95rem; }
|
||||
.er-dit-svar { background: color-mix(in srgb, var(--color-good) 8%, transparent); border-color: color-mix(in srgb, var(--color-good) 40%, transparent); }
|
||||
</style>
|
||||
|
||||
<div class="er-side" id="er-side"
|
||||
data-api="{{ api }}" data-rod="{{ rod }}" data-noegle="{{ noegle }}" data-valgt="{{ valgt }}">
|
||||
|
||||
<h1>Erindringer</h1>
|
||||
<p class="wt-lead">Hej {{ navn }}. Fortæl, så gemmer vi det. Du kan altid holde en pause og komme tilbage.</p>
|
||||
|
||||
{% if saved %}<div class="wt-flash">{{ saved }}</div>{% endif %}
|
||||
<div class="wt-flash" id="er-flash" hidden role="status" aria-live="polite"></div>
|
||||
|
||||
<!-- Trin 2: vælg emne -->
|
||||
<section id="er-emner">
|
||||
<h2>Vælg, hvad du vil fortælle om</h2>
|
||||
{% for p in projekter %}
|
||||
<div class="wt-card er-kort er-emne" data-slug="{{ p.slug }}" data-titel="{{ p.titel }}">
|
||||
<h3>{{ p.titel }}</h3>
|
||||
{% if p.beskrivelse %}<p style="margin:0">{{ p.beskrivelse }}</p>{% endif %}
|
||||
<p class="wt-muted" style="margin:0"><span class="er-antal">{{ p.antal }}</span> svar indtil nu</p>
|
||||
<button type="button" class="wt-btn wt-btn--primary er-stor" data-vaelg="{{ p.slug }}"
|
||||
aria-label="Fortæl om {{ p.titel }}">Fortæl</button>
|
||||
</div>
|
||||
{% else %}
|
||||
<p class="wt-muted">Der er ingen emner endnu.</p>
|
||||
{% endfor %}
|
||||
</section>
|
||||
|
||||
<!-- Trin 3: samtalen -->
|
||||
<section id="er-samtale" hidden>
|
||||
<h2 id="er-samtale-titel">Samtale</h2>
|
||||
|
||||
<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>
|
||||
<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>
|
||||
|
||||
<div class="er-midt" id="er-optagelse">
|
||||
<button type="button" class="er-optag" id="er-optag" aria-label="Optag dit svar" aria-pressed="false">
|
||||
<span id="er-optag-tekst">Optag</span>
|
||||
<span class="er-taeller" id="er-taeller" hidden>0:00</span>
|
||||
</button>
|
||||
<p class="er-status" id="er-status" aria-live="polite"></p>
|
||||
<p class="wt-muted" style="margin:0">Tal bare, som du ville fortælle det til et barnebarn.</p>
|
||||
|
||||
<div id="er-optaget" hidden style="width:100%;max-width:32rem">
|
||||
<audio id="er-afspil" controls></audio>
|
||||
<div class="er-knapper">
|
||||
<button type="button" class="wt-btn wt-btn--primary er-stor" id="er-send">Send svaret</button>
|
||||
<button type="button" class="wt-btn er-stor" id="er-om">Tag det om</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button type="button" class="er-link" id="er-skriv-link">Skriv i stedet</button>
|
||||
<div id="er-skriv" hidden style="width:100%;max-width:32rem;text-align:left">
|
||||
<label for="er-tekstfelt" class="wt-muted">Skriv dit svar her</label>
|
||||
<textarea id="er-tekstfelt" rows="5"></textarea>
|
||||
<div class="er-knapper">
|
||||
<button type="button" class="wt-btn wt-btn--primary er-stor" id="er-send-tekst">Send teksten</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="wt-card er-kort er-dit-svar" id="er-svar-kort" hidden>
|
||||
<p class="wt-muted" style="margin:0 0 .3rem">Dit svar</p>
|
||||
<p class="er-tekst" id="er-svar-tekst"></p>
|
||||
<div class="er-ret" id="er-svar-ret"></div>
|
||||
<div class="er-knapper">
|
||||
<button type="button" class="wt-btn wt-btn--primary er-stor" id="er-naeste">Næste spørgsmål</button>
|
||||
<button type="button" class="wt-btn er-stor" id="er-pause">Hold pause</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h2>Dine svar i dag</h2>
|
||||
<ul class="er-liste" id="er-dagens"></ul>
|
||||
<p class="wt-muted" id="er-dagens-tom">Ingen endnu. Dit første svar kommer her.</p>
|
||||
</section>
|
||||
|
||||
<!-- Trin 4: tidligere svar -->
|
||||
<section id="er-tidligere">
|
||||
<h2>Tidligere svar</h2>
|
||||
{% if klip_seneste %}
|
||||
<ul class="er-liste">
|
||||
{% for k in klip_seneste[:20] %}
|
||||
<li data-id="{{ k.id }}">
|
||||
<p class="er-meta"><span data-tid="{{ k.tid }}">{{ k.tid[:10] }}</span> · {{ k.projekt_titel or k.projekt }}{% if k.varighed %} · {{ k.varighed // 60 }}:{{ '%02d' % (k.varighed % 60) }}{% endif %}</p>
|
||||
<p class="er-q">{{ k.spoergsmaal }}</p>
|
||||
<p class="er-tekst" data-tekst>{{ k.tekst }}</p>
|
||||
<audio controls preload="none" src="{{ rod }}/klip/{{ k.id }}.mp3{% if noegle %}?noegle={{ noegle }}{% endif %}" style="margin-top:.5rem"></audio>
|
||||
<div class="er-ret"><button type="button" class="wt-btn" data-ret="{{ k.id }}">Ret teksten</button></div>
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% else %}
|
||||
<p class="wt-muted">Ingen svar endnu. Det første gemmes her, når du har fortalt noget.</p>
|
||||
{% endif %}
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
(function () {
|
||||
if (window.__erindringer) return;
|
||||
window.__erindringer = true;
|
||||
|
||||
var side = document.getElementById('er-side');
|
||||
var API = side.dataset.api, ROD = side.dataset.rod, NOEGLE = side.dataset.noegle || '';
|
||||
var MAKS_SEK = 600; // 10 minutter pr. svar
|
||||
var MIMER = ['audio/webm;codecs=opus', 'audio/webm', 'audio/mp4', 'audio/ogg;codecs=opus'];
|
||||
|
||||
function el(id) { return document.getElementById(id); }
|
||||
var ui = {
|
||||
emner: el('er-emner'), samtale: el('er-samtale'), titel: el('er-samtale-titel'),
|
||||
nr: el('er-nr'), spoergsmaal: el('er-spoergsmaal'), hoer: el('er-hoer'), lyd: el('er-lyd'),
|
||||
optagelse: el('er-optagelse'), optag: el('er-optag'), optagTekst: el('er-optag-tekst'), taeller: el('er-taeller'),
|
||||
status: el('er-status'), optaget: el('er-optaget'), afspil: el('er-afspil'),
|
||||
send: el('er-send'), om: el('er-om'), skrivLink: el('er-skriv-link'), skriv: el('er-skriv'),
|
||||
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'), afrunding: el('er-afrunding'), spring: el('er-spring')
|
||||
};
|
||||
|
||||
// Tilstand for den igangværende samtale
|
||||
var st = {
|
||||
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
|
||||
};
|
||||
|
||||
/* ---------- Hjælpere ---------- */
|
||||
|
||||
function visFlash(tekst) {
|
||||
ui.flash.textContent = tekst;
|
||||
ui.flash.hidden = !tekst;
|
||||
}
|
||||
function status(tekst, travl) {
|
||||
ui.status.textContent = tekst || '';
|
||||
ui.status.classList.toggle('travl', !!travl);
|
||||
}
|
||||
function mmss(s) {
|
||||
s = Math.max(0, Math.round(s));
|
||||
return Math.floor(s / 60) + ':' + ('0' + (s % 60)).slice(-2);
|
||||
}
|
||||
function datoTekst(iso) {
|
||||
try {
|
||||
var d = new Date(iso);
|
||||
if (isNaN(d)) return iso.slice(0, 10);
|
||||
return d.toLocaleDateString('da-DK', { day: 'numeric', month: 'long', year: 'numeric' });
|
||||
} catch (e) { return iso.slice(0, 10); }
|
||||
}
|
||||
function lagerNoegle() { return 'erindringer:samtale:' + st.projekt; }
|
||||
function husk(id) { try { sessionStorage.setItem(lagerNoegle(), id); } catch (e) {} }
|
||||
function glem() { try { sessionStorage.removeItem(lagerNoegle()); } catch (e) {} }
|
||||
function hentHusket() { try { return sessionStorage.getItem(lagerNoegle()); } catch (e) { return null; } }
|
||||
|
||||
// Fælles fetch: JSON-body eller FormData, altid med nøgle og marker-header.
|
||||
// Ingen timeout: transskription kan tage lang tid.
|
||||
function kald(sti, data) {
|
||||
var erForm = data instanceof FormData;
|
||||
if (erForm) data.append('noegle', NOEGLE); else data.noegle = NOEGLE;
|
||||
var opts = { method: 'POST', credentials: 'same-origin', headers: { 'X-Requested-With': 'fetch' } };
|
||||
if (erForm) { opts.body = data; }
|
||||
else { opts.headers['Content-Type'] = 'application/json'; opts.body = JSON.stringify(data); }
|
||||
return fetch(API + sti, opts).then(function (r) {
|
||||
return r.text().then(function (t) {
|
||||
var j = null;
|
||||
try { j = t ? JSON.parse(t) : null; } catch (e) {}
|
||||
if (!r.ok) throw new Error((j && j.fejl) || 'Noget gik galt (' + r.status + '). Prøv igen om lidt.');
|
||||
return j || {};
|
||||
});
|
||||
}, function () {
|
||||
throw new Error('Vi kunne ikke få forbindelse. Tjek, at du har net, og prøv igen.');
|
||||
});
|
||||
}
|
||||
|
||||
/* ---------- Ret teksten (bruges både i dag og i tidligere svar) ---------- */
|
||||
|
||||
// tekstEls: ét eller flere elementer, der viser samme tekst og skal følges ad
|
||||
function retTekst(id, tekstEls, holder) {
|
||||
if (holder.querySelector('textarea')) return;
|
||||
if (!Array.isArray(tekstEls)) tekstEls = [tekstEls];
|
||||
holder.innerHTML = '';
|
||||
var ta = document.createElement('textarea');
|
||||
ta.value = tekstEls[0].textContent;
|
||||
ta.rows = 6;
|
||||
var gem = document.createElement('button');
|
||||
gem.type = 'button'; gem.className = 'wt-btn wt-btn--primary'; gem.textContent = 'Gem';
|
||||
var fortryd = document.createElement('button');
|
||||
fortryd.type = 'button'; fortryd.className = 'wt-btn'; fortryd.textContent = 'Fortryd';
|
||||
var knapper = document.createElement('div');
|
||||
knapper.className = 'er-knapper'; knapper.style.justifyContent = 'flex-start';
|
||||
knapper.appendChild(gem); knapper.appendChild(fortryd);
|
||||
holder.appendChild(ta); holder.appendChild(knapper);
|
||||
ta.focus();
|
||||
|
||||
function tilbage() {
|
||||
holder.innerHTML = '';
|
||||
var b = document.createElement('button');
|
||||
b.type = 'button'; b.className = 'wt-btn'; b.textContent = 'Ret teksten';
|
||||
b.setAttribute('data-ret', id);
|
||||
holder.appendChild(b);
|
||||
}
|
||||
fortryd.addEventListener('click', tilbage);
|
||||
gem.addEventListener('click', function () {
|
||||
var ny = ta.value.trim();
|
||||
gem.disabled = true; fortryd.disabled = true; gem.textContent = 'Gemmer…';
|
||||
kald('/ret', { id: id, tekst: ny }).then(function () {
|
||||
tekstEls.forEach(function (t) { t.textContent = ny; });
|
||||
tilbage();
|
||||
visFlash('Teksten er rettet.');
|
||||
}).catch(function (e) {
|
||||
gem.disabled = false; fortryd.disabled = false; gem.textContent = 'Gem';
|
||||
visFlash(e.message);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// Alle "Ret teksten"-knapper, også dem der oprettes senere
|
||||
document.addEventListener('click', function (e) {
|
||||
var b = e.target.closest && e.target.closest('[data-ret]');
|
||||
if (!b) return;
|
||||
var li = b.closest('[data-id]');
|
||||
var tekstEl = li ? li.querySelector('[data-tekst]') : null;
|
||||
if (!li || !tekstEl) return;
|
||||
var els = [tekstEl];
|
||||
// Er det dagens seneste svar, skal "Dit svar"-kortet følge med
|
||||
if (!ui.svarKort.hidden && ui.svarKort.dataset.id === b.dataset.ret) els.push(ui.svarTekst);
|
||||
retTekst(b.dataset.ret, els, b.parentNode);
|
||||
});
|
||||
|
||||
// Datoer i "Tidligere svar" på læsbart dansk
|
||||
document.querySelectorAll('[data-tid]').forEach(function (s) { s.textContent = datoTekst(s.dataset.tid); });
|
||||
|
||||
/* ---------- Emnevalg ---------- */
|
||||
|
||||
function vaelg(slug) {
|
||||
var kort = ui.emner.querySelector('[data-slug="' + slug + '"]');
|
||||
if (!kort) return;
|
||||
st.projekt = slug;
|
||||
st.titel = kort.dataset.titel || slug;
|
||||
st.samtale = hentHusket();
|
||||
st.sendtIDag = 0;
|
||||
ui.titel.textContent = st.titel;
|
||||
ui.dagens.innerHTML = '';
|
||||
ui.dagensTom.hidden = false;
|
||||
visFlash('');
|
||||
ui.emner.hidden = true;
|
||||
ui.samtale.hidden = false;
|
||||
window.scrollTo({ top: 0, behavior: 'smooth' });
|
||||
hentSpoergsmaal(true);
|
||||
}
|
||||
|
||||
ui.emner.addEventListener('click', function (e) {
|
||||
var b = e.target.closest && e.target.closest('[data-vaelg]');
|
||||
if (b) vaelg(b.dataset.vaelg);
|
||||
});
|
||||
|
||||
function holdPause() {
|
||||
stopStream();
|
||||
stopTimer();
|
||||
nulstilOptagelse();
|
||||
ui.svarKort.hidden = true;
|
||||
ui.samtale.hidden = true;
|
||||
ui.emner.hidden = false;
|
||||
// Opdatér tællingen på emnekortet
|
||||
var kort = ui.emner.querySelector('[data-slug="' + st.projekt + '"] .er-antal');
|
||||
if (kort) kort.textContent = (parseInt(kort.textContent, 10) || 0) + st.sendtIDag;
|
||||
st.sendtIDag = 0;
|
||||
visFlash('Tak for i dag. Dine svar er gemt.');
|
||||
window.scrollTo({ top: 0, behavior: 'smooth' });
|
||||
}
|
||||
ui.pause.addEventListener('click', holdPause);
|
||||
|
||||
/* ---------- Spørgsmål ---------- */
|
||||
|
||||
function hentSpoergsmaal(foersteForsoeg) {
|
||||
ui.spoergsmaal.textContent = 'Henter spørgsmålet…';
|
||||
ui.nr.textContent = '';
|
||||
ui.hoer.hidden = true;
|
||||
ui.svarKort.hidden = true;
|
||||
nulstilOptagelse();
|
||||
ui.optagelse.hidden = false;
|
||||
status('');
|
||||
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) {
|
||||
ui.lyd.src = st.lydUrl;
|
||||
// Afspil automatisk, hvis browseren tillader det; ellers kun på klik
|
||||
var p = ui.lyd.play();
|
||||
if (p && p.catch) p.catch(function () {});
|
||||
}
|
||||
}).catch(function (e) {
|
||||
// Et gammelt samtale-id kan være udløbet: prøv én gang forfra
|
||||
if (foersteForsoeg && st.samtale) { st.samtale = null; glem(); return hentSpoergsmaal(false); }
|
||||
ui.spoergsmaal.textContent = 'Vi kunne ikke hente et spørgsmål.';
|
||||
visFlash(e.message + ' Du kan holde pause og prøve igen senere.');
|
||||
});
|
||||
}
|
||||
|
||||
ui.hoer.addEventListener('click', function () {
|
||||
try {
|
||||
ui.lyd.currentTime = 0;
|
||||
var p = ui.lyd.play();
|
||||
if (p && p.catch) p.catch(function () { visFlash('Lyden kunne ikke afspilles. Du kan læse spørgsmålet ovenfor.'); });
|
||||
} catch (e) {}
|
||||
});
|
||||
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() {
|
||||
if (!window.MediaRecorder || !MediaRecorder.isTypeSupported) return '';
|
||||
for (var i = 0; i < MIMER.length; i++) if (MediaRecorder.isTypeSupported(MIMER[i])) return MIMER[i];
|
||||
return '';
|
||||
}
|
||||
function endelse(mime) {
|
||||
if (mime.indexOf('mp4') > -1) return 'mp4';
|
||||
if (mime.indexOf('ogg') > -1) return 'ogg';
|
||||
return 'webm';
|
||||
}
|
||||
function stopStream() {
|
||||
if (st.stream) { st.stream.getTracks().forEach(function (t) { t.stop(); }); st.stream = null; }
|
||||
}
|
||||
function stopTimer() {
|
||||
if (st.timer) { clearInterval(st.timer); st.timer = null; }
|
||||
}
|
||||
function nulstilOptagelse() {
|
||||
st.blob = null; st.chunks = []; st.sekunder = 0;
|
||||
ui.optag.disabled = false;
|
||||
ui.optag.classList.remove('optager');
|
||||
ui.optag.setAttribute('aria-pressed', 'false');
|
||||
ui.optagTekst.textContent = 'Optag';
|
||||
ui.taeller.hidden = true; ui.taeller.textContent = '0:00';
|
||||
ui.optag.hidden = false;
|
||||
ui.optaget.hidden = true;
|
||||
if (ui.afspil.src) { try { URL.revokeObjectURL(ui.afspil.src); } catch (e) {} ui.afspil.removeAttribute('src'); }
|
||||
ui.skriv.hidden = true; ui.skrivLink.hidden = false;
|
||||
ui.tekstfelt.value = '';
|
||||
ui.send.disabled = false; ui.om.disabled = false; ui.sendTekst.disabled = false;
|
||||
st.sender = false;
|
||||
}
|
||||
|
||||
function startOptagelse() {
|
||||
visFlash('');
|
||||
var mime = vaelgMime();
|
||||
if (!window.MediaRecorder || !navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) {
|
||||
visFlash('Din browser kan ikke optage lyd. Brug "Skriv i stedet" herunder.');
|
||||
visSkriv();
|
||||
return;
|
||||
}
|
||||
ui.optag.disabled = true;
|
||||
status('Gør mikrofonen klar…');
|
||||
navigator.mediaDevices.getUserMedia({ audio: true }).then(function (stream) {
|
||||
st.stream = stream;
|
||||
st.chunks = [];
|
||||
st.mime = mime;
|
||||
st.ext = endelse(mime);
|
||||
try {
|
||||
st.recorder = mime ? new MediaRecorder(stream, { mimeType: mime }) : new MediaRecorder(stream);
|
||||
} catch (e) {
|
||||
stopStream();
|
||||
throw new Error('Optageren kunne ikke startes. Brug "Skriv i stedet", eller prøv en anden browser.');
|
||||
}
|
||||
if (!st.mime) st.mime = st.recorder.mimeType || '';
|
||||
st.ext = endelse(st.mime);
|
||||
st.recorder.addEventListener('dataavailable', function (e) { if (e.data && e.data.size) st.chunks.push(e.data); });
|
||||
st.recorder.addEventListener('stop', optagelseFaerdig);
|
||||
st.recorder.addEventListener('error', function () {
|
||||
visFlash('Optagelsen blev afbrudt. Prøv igen, eller brug "Skriv i stedet".');
|
||||
stopOptagelse();
|
||||
});
|
||||
st.recorder.start(1000);
|
||||
st.startet = Date.now();
|
||||
st.sekunder = 0;
|
||||
ui.optag.disabled = false;
|
||||
ui.optag.classList.add('optager');
|
||||
ui.optag.setAttribute('aria-pressed', 'true');
|
||||
ui.optag.setAttribute('aria-label', 'Stop optagelsen');
|
||||
ui.optagTekst.textContent = 'Stop';
|
||||
ui.taeller.hidden = false; ui.taeller.textContent = '0:00';
|
||||
ui.skrivLink.hidden = true;
|
||||
status('Vi optager. Tryk på knappen, når du er færdig.');
|
||||
st.timer = setInterval(function () {
|
||||
st.sekunder = Math.round((Date.now() - st.startet) / 1000);
|
||||
ui.taeller.textContent = mmss(st.sekunder);
|
||||
if (st.sekunder >= MAKS_SEK) {
|
||||
stopOptagelse();
|
||||
visFlash('Vi stoppede optagelsen efter 10 minutter. Du kan sende svaret nu og fortsætte på næste spørgsmål.');
|
||||
}
|
||||
}, 1000);
|
||||
}).catch(function (e) {
|
||||
stopStream();
|
||||
ui.optag.disabled = false;
|
||||
status('');
|
||||
var navn = e && e.name;
|
||||
if (navn === 'NotAllowedError' || navn === 'SecurityError' || navn === 'PermissionDeniedError') {
|
||||
visFlash('Vi fik ikke lov til at bruge mikrofonen. Tillad mikrofonen i browseren, eller brug "Skriv i stedet".');
|
||||
} else if (navn === 'NotFoundError' || navn === 'DevicesNotFoundError') {
|
||||
visFlash('Vi kunne ikke finde en mikrofon. Brug "Skriv i stedet".');
|
||||
} else {
|
||||
visFlash((e && e.message) || 'Mikrofonen kunne ikke startes. Prøv igen, eller brug "Skriv i stedet".');
|
||||
}
|
||||
visSkriv();
|
||||
});
|
||||
}
|
||||
|
||||
function stopOptagelse() {
|
||||
stopTimer();
|
||||
ui.optag.disabled = true;
|
||||
status('Gør optagelsen klar…');
|
||||
try {
|
||||
if (st.recorder && st.recorder.state !== 'inactive') st.recorder.stop();
|
||||
else optagelseFaerdig();
|
||||
} catch (e) { optagelseFaerdig(); }
|
||||
}
|
||||
|
||||
function optagelseFaerdig() {
|
||||
stopStream(); // ellers bliver den røde mikrofon-prik stående på iPhone
|
||||
ui.optag.classList.remove('optager');
|
||||
ui.optag.setAttribute('aria-pressed', 'false');
|
||||
ui.optag.setAttribute('aria-label', 'Optag dit svar');
|
||||
ui.optagTekst.textContent = 'Optag';
|
||||
ui.taeller.hidden = true;
|
||||
if (!st.chunks.length) {
|
||||
nulstilOptagelse();
|
||||
status('');
|
||||
visFlash('Der kom ingen lyd med. Prøv igen, og tal tæt på telefonen.');
|
||||
return;
|
||||
}
|
||||
st.blob = new Blob(st.chunks, { type: st.mime || 'application/octet-stream' });
|
||||
ui.afspil.src = URL.createObjectURL(st.blob);
|
||||
ui.optag.hidden = true;
|
||||
ui.optaget.hidden = false;
|
||||
status('Hør dit svar igennem, og send det, når du er tilfreds.');
|
||||
ui.send.focus();
|
||||
}
|
||||
|
||||
ui.optag.addEventListener('click', function () {
|
||||
if (st.recorder && st.recorder.state === 'recording') stopOptagelse(); else startOptagelse();
|
||||
});
|
||||
ui.om.addEventListener('click', function () {
|
||||
nulstilOptagelse();
|
||||
status('');
|
||||
visFlash('');
|
||||
});
|
||||
|
||||
/* ---------- Skriv i stedet ---------- */
|
||||
|
||||
function visSkriv() {
|
||||
ui.skriv.hidden = false;
|
||||
ui.skrivLink.hidden = true;
|
||||
ui.tekstfelt.focus();
|
||||
}
|
||||
ui.skrivLink.addEventListener('click', visSkriv);
|
||||
|
||||
/* ---------- Send svar ---------- */
|
||||
|
||||
function sendSvar(form) {
|
||||
if (st.sender) return;
|
||||
st.sender = true;
|
||||
ui.send.disabled = true; ui.om.disabled = true; ui.sendTekst.disabled = true; ui.optag.disabled = true;
|
||||
status('Gemmer og skriver af… Det kan tage op til et halvt minut.', true);
|
||||
visFlash('');
|
||||
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);
|
||||
}).catch(function (e) {
|
||||
// Behold det optagede, så det kan sendes igen
|
||||
st.sender = false;
|
||||
ui.send.disabled = false; ui.om.disabled = false; ui.sendTekst.disabled = false; ui.optag.disabled = false;
|
||||
status('Svaret blev ikke sendt. Det er stadig her, så prøv igen.');
|
||||
visFlash(e.message);
|
||||
});
|
||||
}
|
||||
|
||||
ui.send.addEventListener('click', function () {
|
||||
if (!st.blob) { visFlash('Der er ikke noget optaget endnu. Tryk på Optag først.'); return; }
|
||||
var form = new FormData();
|
||||
form.append('lyd', st.blob, 'svar.' + st.ext);
|
||||
sendSvar(form);
|
||||
});
|
||||
ui.sendTekst.addEventListener('click', function () {
|
||||
var t = ui.tekstfelt.value.trim();
|
||||
if (!t) { visFlash('Skriv lidt i feltet først.'); ui.tekstfelt.focus(); return; }
|
||||
var form = new FormData();
|
||||
form.append('tekst', t);
|
||||
sendSvar(form);
|
||||
});
|
||||
|
||||
function visSvar(j) {
|
||||
var tekst = j.tekst || '';
|
||||
ui.optagelse.hidden = true;
|
||||
status('');
|
||||
ui.svarTekst.textContent = tekst;
|
||||
ui.svarKort.dataset.id = j.id;
|
||||
ui.svarKort.hidden = false;
|
||||
var dagensTekst = tilfoejDagens(j, tekst);
|
||||
ui.svarRet.innerHTML = '';
|
||||
var ret = document.createElement('button');
|
||||
ret.type = 'button'; ret.className = 'wt-btn'; ret.textContent = 'Ret teksten';
|
||||
ret.addEventListener('click', function () { retTekst(j.id, [ui.svarTekst, dagensTekst], ui.svarRet); });
|
||||
ui.svarRet.appendChild(ret);
|
||||
ui.naeste.focus();
|
||||
nulstilOptagelse();
|
||||
}
|
||||
|
||||
// Sætter svaret øverst i "Dine svar i dag" og returnerer tekst-elementet
|
||||
function tilfoejDagens(j, tekst) {
|
||||
var li = document.createElement('li');
|
||||
li.setAttribute('data-id', j.id);
|
||||
var q = document.createElement('p'); q.className = 'er-q'; q.textContent = st.spoergsmaal;
|
||||
var t = document.createElement('p'); t.className = 'er-tekst'; t.setAttribute('data-tekst', ''); t.textContent = tekst;
|
||||
li.appendChild(q); li.appendChild(t);
|
||||
if (j.varighed) {
|
||||
var m = document.createElement('p'); m.className = 'er-meta'; m.textContent = 'Varighed ' + mmss(j.varighed);
|
||||
li.appendChild(m);
|
||||
}
|
||||
var ret = document.createElement('div'); ret.className = 'er-ret';
|
||||
var b = document.createElement('button');
|
||||
b.type = 'button'; b.className = 'wt-btn'; b.textContent = 'Ret teksten'; b.setAttribute('data-ret', j.id);
|
||||
ret.appendChild(b); li.appendChild(ret);
|
||||
ui.dagens.insertBefore(li, ui.dagens.firstChild);
|
||||
ui.dagensTom.hidden = true;
|
||||
return t;
|
||||
}
|
||||
|
||||
// Slip mikrofonen, hvis siden forlades midt i en optagelse
|
||||
window.addEventListener('pagehide', function () { stopTimer(); stopStream(); });
|
||||
|
||||
// Åbn et projekt med det samme, hvis serveren bad om det
|
||||
if (side.dataset.valgt) vaelg(side.dataset.valgt);
|
||||
})();
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,62 @@
|
||||
# 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"], "| 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"], "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
|
||||
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")
|
||||
Reference in New Issue
Block a user