feat(erindringer): familien fortæller minder og dagbog til en AI-interviewer
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
This commit is contained in:
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user