Files
diarization-ui/app.py

851 lines
33 KiB
Python
Raw Normal View History

import json
import os
import sqlite3
import threading
from concurrent.futures import ThreadPoolExecutor
from datetime import datetime
from pathlib import Path
from typing import Optional
import markdown as md
import requests
from fastapi import FastAPI, File, Form, HTTPException, UploadFile
from fastapi.responses import HTMLResponse, PlainTextResponse, Response, JSONResponse
API_BASE = os.getenv("API_BASE", "http://gx10.aquantico.lan:8093").rstrip("/")
OLLAMA_BASE_URL = os.getenv("OLLAMA_BASE_URL", "http://gx10.aquantico.lan:11434").rstrip("/")
OLLAMA_MODEL = os.getenv("OLLAMA_MODEL", "qwen3.5:9b")
DB_PATH = os.getenv("DB_PATH", "/data/ui.db")
app = FastAPI(title="Diarization UI")
JOB_DIR = Path(os.getenv("JOB_DIR", "/data/jobs"))
EXECUTOR = ThreadPoolExecutor(max_workers=2)
JOB_LOCK = threading.Lock()
def db():
conn = sqlite3.connect(DB_PATH)
conn.row_factory = sqlite3.Row
return conn
def now_iso() -> str:
return datetime.utcnow().isoformat()
def init_db():
os.makedirs(os.path.dirname(DB_PATH), exist_ok=True)
with db() as c:
c.execute(
"""
CREATE TABLE IF NOT EXISTS projects (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT UNIQUE NOT NULL,
created_at TEXT NOT NULL
)
"""
)
c.execute(
"""
CREATE TABLE IF NOT EXISTS prompts (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT UNIQUE NOT NULL,
prompt TEXT NOT NULL,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
)
"""
)
c.execute(
"""
CREATE TABLE IF NOT EXISTS documents (
id INTEGER PRIMARY KEY AUTOINCREMENT,
project_id INTEGER NOT NULL,
kind TEXT NOT NULL, -- transcript|analysis
title TEXT NOT NULL,
content_md TEXT NOT NULL,
source_document_id INTEGER,
prompt_id INTEGER,
raw_json TEXT,
created_at TEXT NOT NULL,
FOREIGN KEY(project_id) REFERENCES projects(id),
FOREIGN KEY(source_document_id) REFERENCES documents(id),
FOREIGN KEY(prompt_id) REFERENCES prompts(id)
)
"""
)
c.execute(
"""
CREATE TABLE IF NOT EXISTS jobs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
kind TEXT NOT NULL, -- upload|analysis
status TEXT NOT NULL, -- queued|running|done|error
project_id INTEGER,
document_id INTEGER,
prompt_id INTEGER,
title TEXT,
file_path TEXT,
error TEXT,
result_document_id INTEGER,
created_at TEXT NOT NULL,
started_at TEXT,
finished_at TEXT
)
"""
)
# defaults
c.execute("INSERT OR IGNORE INTO projects(name, created_at) VALUES (?,?)", ("Default", now_iso()))
c.execute(
"INSERT OR IGNORE INTO prompts(name, prompt, created_at, updated_at) VALUES (?,?,?,?)",
(
"Zusammenfassung",
"Erstelle eine prägnante Zusammenfassung des Gesprächs in Stichpunkten.",
now_iso(),
now_iso(),
),
)
c.execute(
"INSERT OR IGNORE INTO prompts(name, prompt, created_at, updated_at) VALUES (?,?,?,?)",
(
"Aufgaben",
"Extrahiere alle Aufgaben. Gib pro Aufgabe: Verantwortlich, Aufgabe, Deadline (falls vorhanden), Priorität.",
now_iso(),
now_iso(),
),
)
def layout(title: str, body: str) -> str:
return f"""
<!doctype html>
<html>
<head>
<meta charset='utf-8'>
<meta name='viewport' content='width=device-width, initial-scale=1, viewport-fit=cover'>
<meta name='theme-color' content='#0f172a'>
<link rel='manifest' href='/manifest.webmanifest'>
<link rel='icon' href='/icon.svg' type='image/svg+xml'>
<title>{title}</title>
<style>
:root{{--bg:#0b1020;--bg2:#111827;--card:#0f172a;--txt:#e5e7eb;--muted:#94a3b8;--acc:#22d3ee;--acc2:#38bdf8;--ok:#34d399;--border:#1f2937}}
*{{box-sizing:border-box}}
body{{font-family:Inter,system-ui,Arial;margin:0;background:linear-gradient(180deg,var(--bg),#020617);color:var(--txt);display:flex;min-height:100vh}}
nav{{width:250px;background:rgba(15,23,42,.92);backdrop-filter:blur(8px);border-right:1px solid var(--border);padding:16px;position:sticky;top:0;height:100vh}}
.brand{{font-weight:700;letter-spacing:.3px;margin:0 0 12px 0}}
nav a{{display:flex;gap:10px;align-items:center;color:var(--txt);text-decoration:none;padding:10px 12px;border-radius:12px;margin:6px 0;border:1px solid transparent}}
nav a:hover{{background:#0b1222;border-color:#243244}}
main{{flex:1;padding:18px;max-width:1200px}}
.card{{background:rgba(15,23,42,.88);border:1px solid var(--border);border-radius:16px;padding:14px;margin:10px 0;box-shadow:0 8px 30px rgba(0,0,0,.25)}}
input,select,textarea,button{{padding:10px 12px;font-size:14px;border-radius:12px;border:1px solid #334155;background:#0b1222;color:var(--txt)}}
button{{background:linear-gradient(90deg,var(--acc),var(--acc2));color:#001018;border:none;font-weight:700}}
button:hover{{filter:brightness(1.05)}}
textarea{{width:100%;min-height:150px}}
pre{{white-space:pre-wrap;background:#020617;color:#86efac;padding:12px;border-radius:12px;border:1px solid #1e293b}}
.mdview{{line-height:1.55}}
.mdview h1,.mdview h2,.mdview h3{{margin:16px 0 8px}}
.mdview p{{margin:10px 0}}
.mdview ul,.mdview ol{{padding-left:22px}}
.mdview code{{background:#0b1222;padding:2px 5px;border-radius:6px}}
.mdview pre code{{display:block;padding:10px}}
.mdview blockquote{{border-left:3px solid #334155;padding-left:10px;color:#cbd5e1}}
.row{{display:flex;gap:8px;flex-wrap:wrap;align-items:center}}
small{{color:var(--muted)}}
.hint{{color:var(--muted);font-size:13px}}
@media (max-width:900px){{nav{{width:86px;padding:10px}} nav a span{{display:none}} .brand{{font-size:12px}} main{{padding:12px}}}}
.modal-backdrop{{position:fixed;inset:0;background:rgba(0,0,0,.55);display:none;align-items:center;justify-content:center;z-index:2000}}
.modal{{width:min(92vw,520px);background:#0b1222;border:1px solid #334155;border-radius:16px;padding:14px;box-shadow:0 24px 60px rgba(0,0,0,.5)}}
.modal h4{{margin:0 0 8px 0}}
.modal .actions{{display:flex;gap:8px;justify-content:flex-end;margin-top:10px}}
.modal input,.modal select{{width:100%}}
.iconbtn{{text-decoration:none;font-size:18px;padding:2px 6px;border-radius:8px;border:1px solid #334155;background:#0b1222}}
</style>
<script>
if ('serviceWorker' in navigator) {{ window.addEventListener('load', () => navigator.serviceWorker.register('/sw.js').catch(()=>{{}})); }}
window.showModal = function({{title='Eingabe', html='', ok='OK', cancel='Abbrechen'}}) {{
return new Promise((resolve) => {{
const bd=document.getElementById('modal-backdrop');
const box=document.getElementById('modal-box');
box.innerHTML = `<h4>${{title}}</h4><div>${{html}}</div><div class='actions'><button id='m-cancel' type='button' style='background:#334155;color:#fff'>${{cancel}}</button><button id='m-ok' type='button'>${{ok}}</button></div>`;
bd.style.display='flex';
const close=(v)=>{{bd.style.display='none'; resolve(v);}};
box.querySelector('#m-cancel').onclick=()=>close(null);
box.querySelector('#m-ok').onclick=()=>{{
const inp=box.querySelector('[data-modal-input]');
if(inp) close(inp.value); else close(true);
}};
}});
}};
window.uiPrompt = async function(title, value='') {{
const v = await window.showModal({{title, html:`<input data-modal-input value="${{String(value).replace(/"/g,'&quot;')}}">`}});
return v;
}};
window.uiConfirm = async function(title) {{
const v = await window.showModal({{title, html:`<p class='hint'>Bitte bestätigen.</p>`, ok:'Ja', cancel:'Nein'}});
return v !== null;
}};
window.uiSelect = async function(title, options, placeholder='') {{
const opts = options.map(o=>`<option value="${{o.value}}">${{o.label}}</option>`).join('');
return await window.showModal({{title, html:`<select data-modal-input><option value="">${{placeholder}}</option>${{opts}}</select>`}});
}};
</script>
</head>
<body>
<div id='modal-backdrop' class='modal-backdrop'><div id='modal-box' class='modal'></div></div>
<nav>
<div class='brand'>🎙 Audio Copilot</div>
<a href='/'><span></span><span>Upload</span></a>
<a href='/library'><span>🗂</span><span>Datenbank</span></a>
<a href='/prompts'><span>🧩</span><span>Prompts</span></a>
<a href='/run'><span>🤖</span><span>Prompt ausführen</span></a>
<a href='/jobs'><span></span><span>Hintergrundjobs</span></a>
<a href='/healthz'><span>💚</span><span>Health</span></a>
</nav>
<main>{body}</main>
</body></html>
"""
def get_projects():
with db() as c:
return c.execute("SELECT id,name FROM projects ORDER BY name").fetchall()
def get_project_name(project_id: int) -> str:
with db() as c:
r = c.execute("SELECT name FROM projects WHERE id=?", (project_id,)).fetchone()
return r[0] if r else ""
def get_prompts():
with db() as c:
return c.execute("SELECT id,name,prompt FROM prompts ORDER BY name").fetchall()
def _job_get(job_id: int):
with db() as c:
return c.execute("SELECT * FROM jobs WHERE id=?", (job_id,)).fetchone()
def _job_set(job_id: int, **fields):
if not fields:
return
cols = ", ".join([f"{k}=?" for k in fields.keys()])
vals = list(fields.values()) + [job_id]
with db() as c:
c.execute(f"UPDATE jobs SET {cols} WHERE id=?", vals)
def _process_upload_job(job_id: int):
j = _job_get(job_id)
if not j:
return
if j["status"] == "cancelled":
return
_job_set(job_id, status="running", started_at=now_iso())
try:
j = _job_get(job_id)
if not j or j["status"] == "cancelled":
return
p = Path(j["file_path"])
data = p.read_bytes()
filename = p.name
files = {"file": (filename, data, "application/octet-stream")}
r = requests.post(f"{API_BASE}/transcribe-diarize", files=files, timeout=1800)
r.raise_for_status()
payload = r.json()
j = _job_get(job_id)
if not j or j["status"] == "cancelled":
return
content_md = payload.get("formatted_text", "")
doc_title = (j["title"] or "").strip() or filename
with db() as c:
cur = c.execute(
"INSERT INTO documents(project_id, kind, title, content_md, raw_json, created_at) VALUES (?,?,?,?,?,?)",
(j["project_id"], "transcript", doc_title, content_md, json.dumps(payload, ensure_ascii=False), now_iso()),
)
new_doc_id = cur.lastrowid
_job_set(job_id, status="done", result_document_id=new_doc_id, finished_at=now_iso())
except Exception as e:
_job_set(job_id, status="error", error=str(e), finished_at=now_iso())
def _process_analysis_job(job_id: int):
j = _job_get(job_id)
if not j:
return
if j["status"] == "cancelled":
return
_job_set(job_id, status="running", started_at=now_iso())
try:
j = _job_get(job_id)
if not j or j["status"] == "cancelled":
return
with db() as c:
doc = c.execute("SELECT * FROM documents WHERE id=?", (j["document_id"],)).fetchone()
prm = c.execute("SELECT * FROM prompts WHERE id=?", (j["prompt_id"],)).fetchone()
if not doc or not prm:
raise RuntimeError("Dokument oder Prompt nicht gefunden")
llm_prompt = (
"Du bist ein präziser Assistent. Antworte auf Deutsch.\\n"
f"AUFTRAG:\\n{prm['prompt']}\\n\\n"
f"TEXT:\\n{doc['content_md']}\\n"
)
r = requests.post(
f"{OLLAMA_BASE_URL}/api/generate",
json={"model": OLLAMA_MODEL, "prompt": llm_prompt, "stream": False},
timeout=1200,
)
r.raise_for_status()
answer = r.json().get("response", "")
j = _job_get(job_id)
if not j or j["status"] == "cancelled":
return
with db() as c:
cur = c.execute(
"""
INSERT INTO documents(project_id, kind, title, content_md, source_document_id, prompt_id, raw_json, created_at)
VALUES (?,?,?,?,?,?,?,?)
""",
(
doc["project_id"],
"analysis",
f"Analyse: {prm['name']} · {doc['title']}",
answer,
doc["id"],
prm["id"],
json.dumps({"ollama_response": r.json()}, ensure_ascii=False),
now_iso(),
),
)
new_doc_id = cur.lastrowid
_job_set(job_id, status="done", result_document_id=new_doc_id, finished_at=now_iso())
except Exception as e:
_job_set(job_id, status="error", error=str(e), finished_at=now_iso())
def enqueue_job(kind: str, **kwargs) -> int:
with JOB_LOCK:
with db() as c:
cur = c.execute(
"""
INSERT INTO jobs(kind,status,project_id,document_id,prompt_id,title,file_path,created_at)
VALUES (?,?,?,?,?,?,?,?)
""",
(
kind,
"queued",
kwargs.get("project_id"),
kwargs.get("document_id"),
kwargs.get("prompt_id"),
kwargs.get("title"),
kwargs.get("file_path"),
now_iso(),
),
)
job_id = cur.lastrowid
if kind == "upload":
EXECUTOR.submit(_process_upload_job, job_id)
elif kind == "analysis":
EXECUTOR.submit(_process_analysis_job, job_id)
return job_id
@app.on_event("startup")
def startup():
init_db()
JOB_DIR.mkdir(parents=True, exist_ok=True)
@app.get("/manifest.webmanifest")
def manifest():
return {
"name": "Audio Copilot",
"short_name": "Copilot",
"start_url": "/",
"display": "standalone",
"background_color": "#020617",
"theme_color": "#0f172a",
"icons": [
{"src": "/icon.svg", "sizes": "any", "type": "image/svg+xml", "purpose": "any maskable"}
],
}
@app.get("/icon.svg")
def icon_svg():
svg = """<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 128 128'><rect rx='24' width='128' height='128' fill='#0f172a'/><text x='64' y='82' font-size='72' text-anchor='middle'>🎙️</text></svg>"""
return Response(content=svg, media_type="image/svg+xml")
@app.get("/sw.js")
def sw_js():
js = """
self.addEventListener('install', (event) => { self.skipWaiting(); });
self.addEventListener('activate', (event) => { event.waitUntil(clients.claim()); });
self.addEventListener('fetch', (event) => {
if (event.request.method !== 'GET') return;
event.respondWith(fetch(event.request).catch(() => new Response('offline', {status: 503})));
});
"""
return Response(content=js, media_type="application/javascript")
@app.get("/healthz")
def healthz():
return {"ok": True, "api_base": API_BASE, "ollama_base_url": OLLAMA_BASE_URL, "ollama_model": OLLAMA_MODEL, "db_path": DB_PATH}
@app.get("/", response_class=HTMLResponse)
def upload_page(msg: str = ""):
projects = get_projects()
opts = "".join([f"<option value='{p['id']}'>{p['name']}</option>" for p in projects])
body = f"""
<h2>Audio Upload</h2>
<p>Audio wird transkribiert + mit Sprechern angereichert und als Dokument gespeichert.</p>
{f"<p><b>{msg}</b></p>" if msg else ""}
<form action='/upload' method='post' enctype='multipart/form-data' class='card'>
<div class='row'>
<label>Projekt:</label>
<select name='project_id'>{opts}</select>
<input name='title' placeholder='Titel (optional)'>
</div>
<div class='row' style='margin-top:8px'>
<input type='file' name='file' accept='audio/*' required>
<button type='submit'>Verarbeiten & speichern</button>
</div>
</form>
"""
return layout("Upload", body)
@app.post("/projects", response_class=HTMLResponse)
def add_project(name: str = Form(...)):
with db() as c:
c.execute("INSERT INTO projects(name, created_at) VALUES (?,?)", (name.strip(), now_iso()))
return HTMLResponse("<meta http-equiv='refresh' content='0; url=/prompts'>")
@app.post("/projects/update", response_class=HTMLResponse)
def rename_project(id: int = Form(...), name: str = Form(...)):
with db() as c:
c.execute("UPDATE projects SET name=? WHERE id=?", (name.strip(), id))
return HTMLResponse("<meta http-equiv='refresh' content='0; url=/prompts'>")
@app.post("/projects/{project_id}/delete", response_class=HTMLResponse)
def delete_project(project_id: int):
with db() as c:
default = c.execute("SELECT id FROM projects WHERE name='Default'").fetchone()
if not default:
c.execute("INSERT INTO projects(name, created_at) VALUES (?,?)", ("Default", now_iso()))
default_id = c.execute("SELECT id FROM projects WHERE name='Default'").fetchone()[0]
else:
default_id = default[0]
if project_id == default_id:
return HTMLResponse("<meta http-equiv='refresh' content='0; url=/prompts'>")
c.execute("UPDATE documents SET project_id=? WHERE project_id=?", (default_id, project_id))
c.execute("DELETE FROM projects WHERE id=?", (project_id,))
return HTMLResponse("<meta http-equiv='refresh' content='0; url=/prompts'>")
@app.post("/upload", response_class=HTMLResponse)
async def upload(project_id: int = Form(...), title: str = Form(""), file: UploadFile = File(...)):
data = await file.read()
if not data:
raise HTTPException(400, "Leere Datei")
JOB_DIR.mkdir(parents=True, exist_ok=True)
filename = (file.filename or "audio.bin").replace("/", "_")
temp_path = JOB_DIR / f"{now_iso().replace(':','-')}_{filename}"
temp_path.write_bytes(data)
job_id = enqueue_job(
"upload",
project_id=project_id,
title=(title or "").strip() or filename,
file_path=str(temp_path),
)
return HTMLResponse(f"<meta http-equiv='refresh' content='0; url=/jobs?queued={job_id}'>")
@app.get("/library", response_class=HTMLResponse)
def library(project_id: Optional[int] = None):
with db() as c:
projects = c.execute("SELECT id,name FROM projects ORDER BY name").fetchall()
if project_id:
docs = c.execute(
"""
SELECT d.id,d.kind,d.title,d.created_at,p.name AS project
FROM documents d JOIN projects p ON p.id=d.project_id
WHERE d.project_id=? ORDER BY d.id DESC
""",
(project_id,),
).fetchall()
else:
docs = c.execute(
"""
SELECT d.id,d.kind,d.title,d.created_at,p.name AS project
FROM documents d JOIN projects p ON p.id=d.project_id
ORDER BY d.id DESC LIMIT 200
"""
).fetchall()
p_opts = "<option value=''>Alle</option>" + "".join(
[f"<option value='{p['id']}' {'selected' if project_id==p['id'] else ''}>{p['name']}</option>" for p in projects]
)
items = "".join(
[
f"<div class='card'><b>#{d['id']}</b> [{d['kind']}] {d['title']}<br><small>{d['project']} · {d['created_at']}</small><br>"
f"<div class='row' style='margin-top:8px'>"
f"<a href='/document/{d['id']}' style='text-decoration:none'><button type='button'>👁️ Ansehen</button></a>"
f"<a href='/document/{d['id']}/download.md' class='iconbtn' title='Download'>⬇️</a>"
f"<a href='#' class='iconbtn' title='Umbenennen' onclick=\"libRename({d['id']}, {json.dumps(d['title'])});return false;\">✏️</a>"
f"<a href='#' class='iconbtn' title='Verschieben' onclick=\"libMove({d['id']});return false;\">📁</a>"
f"<a href='#' class='iconbtn' title='Löschen' onclick=\"libDelete({d['id']});return false;\">🗑️</a>"
f"</div></div>"
for d in docs
]
)
project_js = json.dumps([{"value": p["id"], "label": p["name"]} for p in projects], ensure_ascii=False)
body = f"""
<h2>Datenbank / Dokumente</h2>
<form method='get' class='row card'>
<label>Projekt:</label>
<select name='project_id'>{p_opts}</select>
<button type='submit'>Filtern</button>
</form>
{items or '<p>Keine Einträge.</p>'}
<script>
async function libPost(url, data) {{
const r = await fetch(url, {{method:'POST', headers:{{'Content-Type':'application/x-www-form-urlencoded'}}, body:new URLSearchParams(data)}});
if(!r.ok) {{ alert('Fehler '+r.status); return; }}
location.reload();
}}
window.libRename = async function(id, current) {{
const v = await window.uiPrompt('Dokument umbenennen', current || '');
if(v===null) return;
await libPost(`/document/${{id}}/rename`, {{title:v}});
}};
window.libMove = async function(id) {{
const options = {project_js};
const v = await window.uiSelect('In Projekt verschieben', options, 'Projekt wählen');
if(v===null || v==='') return;
await libPost(`/document/${{id}}/move`, {{project_id:v}});
}};
window.libDelete = async function(id) {{
const ok = await window.uiConfirm('Dokument löschen?');
if(!ok) return;
await libPost(`/document/${{id}}/delete`, {{}});
}};
</script>
"""
return layout("Library", body)
@app.get("/document/{doc_id}", response_class=HTMLResponse)
def view_document(doc_id: int):
with db() as c:
d = c.execute(
"""
SELECT d.*, p.name AS project, pr.name AS prompt_name
FROM documents d
JOIN projects p ON p.id=d.project_id
LEFT JOIN prompts pr ON pr.id=d.prompt_id
WHERE d.id=?
""",
(doc_id,),
).fetchone()
if not d:
raise HTTPException(404, "not found")
rendered = md.markdown(d["content_md"] or "", extensions=["fenced_code", "tables", "nl2br"])
projects = get_projects()
body = f"""
<h2>Dokument #{d['id']} {d['title']}</h2>
<p>
<small>Projekt: {d['project']} · Typ: {d['kind']} · {d['created_at']}</small>
&nbsp;&nbsp;
<a title='Download .md' href='/document/{doc_id}/download.md' style='text-decoration:none'></a>
<a title='Umbenennen' href='#' onclick='renameDoc();return false;' style='text-decoration:none'></a>
<a title='Verschieben' href='#' onclick='moveDoc();return false;' style='text-decoration:none'>📁</a>
<a title='Löschen' href='#' onclick='deleteDoc();return false;' style='text-decoration:none'>🗑</a>
</p>
<div class='card mdview'>{rendered}</div>
<script>
window.postForm = async function(url, data) {{
const body = new URLSearchParams(data);
const r = await fetch(url, {{method:'POST', headers:{{'Content-Type':'application/x-www-form-urlencoded'}}, body}});
if (!r.ok) {{ alert('Fehler: '+r.status); return; }}
location.href = '/library';
}};
window.renameDoc = async function() {{
const v = await window.uiPrompt('Neuer Dokumentname', {json.dumps(d['title'])});
if (v===null) return;
window.postForm('/document/{doc_id}/rename', {{title:v}});
}};
window.moveDoc = async function() {{
const options = {json.dumps([{"value": p['id'], "label": p['name']} for p in projects], ensure_ascii=False)};
const v = await window.uiSelect('In Projekt verschieben', options, 'Projekt wählen');
if (v===null || v==='') return;
window.postForm('/document/{doc_id}/move', {{project_id:v}});
}};
window.deleteDoc = async function() {{
const ok = await window.uiConfirm('Dokument wirklich löschen?');
if (!ok) return;
window.postForm('/document/{doc_id}/delete', {{}});
}};
</script>
"""
return layout("Dokument", body)
@app.get("/document/{doc_id}/download.md", response_class=PlainTextResponse)
def download_md(doc_id: int):
with db() as c:
d = c.execute("SELECT title,content_md FROM documents WHERE id=?", (doc_id,)).fetchone()
if not d:
raise HTTPException(404, "not found")
base = (d["title"] or f"document_{doc_id}").strip()
safe = "".join(ch if ch.isalnum() or ch in ("-", "_", " ") else "_" for ch in base).strip()
safe = safe.replace(" ", "_") or f"document_{doc_id}"
filename = f"{safe}.md"
return PlainTextResponse(
d["content_md"],
headers={"Content-Disposition": f"attachment; filename={filename}"},
)
@app.post("/document/{doc_id}/rename", response_class=HTMLResponse)
def rename_document(doc_id: int, title: str = Form(...)):
with db() as c:
c.execute("UPDATE documents SET title=? WHERE id=?", (title.strip(), doc_id))
return HTMLResponse("<meta http-equiv='refresh' content='0; url=/library'>")
@app.post("/document/{doc_id}/move", response_class=HTMLResponse)
def move_document(doc_id: int, project_id: int = Form(...)):
with db() as c:
c.execute("UPDATE documents SET project_id=? WHERE id=?", (project_id, doc_id))
return HTMLResponse("<meta http-equiv='refresh' content='0; url=/library'>")
@app.post("/document/{doc_id}/delete", response_class=HTMLResponse)
def delete_document(doc_id: int):
with db() as c:
c.execute("DELETE FROM documents WHERE id=?", (doc_id,))
return HTMLResponse("<meta http-equiv='refresh' content='0; url=/library'>")
@app.get("/prompts", response_class=HTMLResponse)
def prompts_page():
with db() as c:
prompts = c.execute("SELECT * FROM prompts ORDER BY name").fetchall()
projects = c.execute("SELECT id,name FROM projects ORDER BY name").fetchall()
p_list = "".join(
[
f"<div class='card'><b>{p['name']}</b><pre>{(p['prompt'] or '').replace('<','&lt;')}</pre>"
f"<form method='post' action='/prompts/update'><input type='hidden' name='id' value='{p['id']}'><input name='name' value='{p['name']}'><br><textarea name='prompt'>{p['prompt']}</textarea><br><button>Speichern</button></form>"
f"<form method='post' action='/prompts/{p['id']}/delete' onsubmit=\"return confirm('Prompt löschen?')\" style='margin-top:6px'><button>Löschen</button></form>"
f"</div>"
for p in prompts
]
)
project_opts = "".join([f"<option value='{p['name']}'>{p['name']}</option>" for p in projects])
project_list = "".join([
f"<div class='card'><b>{p['name']}</b>"
f"<form method='post' action='/projects/update' class='row' style='margin-top:6px'>"
f"<input type='hidden' name='id' value='{p['id']}'><input name='name' value='{p['name']}'><button>Umbenennen</button></form>"
f"<form method='post' action='/projects/{p['id']}/delete' onsubmit=\"return confirm('Projekt löschen? Dokumente werden auf Default verschoben.')\" style='margin-top:6px'><button>Löschen</button></form>"
f"</div>" for p in projects
])
body = f"""
<h2>Prompt-Konfiguration</h2>
<div class='card'>
<form method='post' action='/prompts/add'>
<h4>Neuer Prompt</h4>
<input name='name' placeholder='Name' required>
<br><textarea name='prompt' placeholder='Prompttext' required></textarea>
<br><button type='submit'>Anlegen</button>
</form>
</div>
<div class='card'>
<form method='post' action='/projects'>
<h4>Neues Projekt</h4>
<input name='name' list='projectNames' placeholder='Projektname' required>
<datalist id='projectNames'>{project_opts}</datalist>
<button type='submit'>Anlegen</button>
</form>
</div>
<h3>Projekte</h3>
{project_list}
<h3>Prompts</h3>
{p_list}
"""
return layout("Prompts", body)
@app.post("/prompts/add", response_class=HTMLResponse)
def prompt_add(name: str = Form(...), prompt: str = Form(...)):
with db() as c:
c.execute(
"INSERT INTO prompts(name,prompt,created_at,updated_at) VALUES (?,?,?,?)",
(name.strip(), prompt.strip(), now_iso(), now_iso()),
)
return HTMLResponse("<meta http-equiv='refresh' content='0; url=/prompts'>")
@app.post("/prompts/update", response_class=HTMLResponse)
def prompt_update(id: int = Form(...), name: str = Form(...), prompt: str = Form(...)):
with db() as c:
c.execute("UPDATE prompts SET name=?, prompt=?, updated_at=? WHERE id=?", (name.strip(), prompt.strip(), now_iso(), id))
return HTMLResponse("<meta http-equiv='refresh' content='0; url=/prompts'>")
@app.post("/prompts/{prompt_id}/delete", response_class=HTMLResponse)
def prompt_delete(prompt_id: int):
with db() as c:
c.execute("DELETE FROM prompts WHERE id=?", (prompt_id,))
return HTMLResponse("<meta http-equiv='refresh' content='0; url=/prompts'>")
def _jobs_payload(limit: int = 200):
with db() as c:
jobs = c.execute(
"""
SELECT j.*, p.name AS project_name, d.title AS document_title, pr.name AS prompt_name
FROM jobs j
LEFT JOIN projects p ON p.id=j.project_id
LEFT JOIN documents d ON d.id=j.document_id
LEFT JOIN prompts pr ON pr.id=j.prompt_id
ORDER BY j.id DESC LIMIT ?
""",
(limit,),
).fetchall()
return [dict(j) for j in jobs]
@app.get("/jobs/data")
def jobs_data(limit: int = 200):
return {"items": _jobs_payload(limit)}
@app.post("/jobs/{job_id}/cancel")
def jobs_cancel(job_id: int):
j = _job_get(job_id)
if not j:
raise HTTPException(404, "job not found")
if j["status"] in ("done", "error", "cancelled"):
return {"ok": True, "status": j["status"]}
_job_set(job_id, status="cancelled", finished_at=now_iso(), error="Cancelled by user")
return {"ok": True, "status": "cancelled"}
@app.post("/jobs/{job_id}/delete")
def jobs_delete(job_id: int):
with db() as c:
c.execute("DELETE FROM jobs WHERE id=?", (job_id,))
return {"ok": True}
@app.get("/jobs", response_class=HTMLResponse)
def jobs_page(queued: Optional[int] = None):
notice = f"<p><b>Job #{queued} wurde eingereiht.</b></p>" if queued else ""
body = f"""
<h2>Hintergrundverarbeitung</h2>
<p class='hint'>Maximal 2 Jobs gleichzeitig. Seite aktualisiert automatisch.</p>
{notice}
<div id='jobs-root'></div>
<script>
function since(ts) {{
if(!ts) return '-';
const s = Math.floor((Date.now()-Date.parse(ts))/1000);
if (s<60) return s+'s';
const m = Math.floor(s/60); if (m<60) return m+'m '+(s%60)+'s';
const h = Math.floor(m/60); return h+'h '+(m%60)+'m';
}}
async function post(url) {{
const r = await fetch(url, {{method:'POST'}});
if(!r.ok) alert('Fehler '+r.status);
}}
async function renderJobs() {{
const r = await fetch('/jobs/data');
const j = await r.json();
const root = document.getElementById('jobs-root');
root.innerHTML = '';
if(!j.items.length) {{ root.innerHTML = '<p>Keine Jobs.</p>'; return; }}
for(const it of j.items) {{
const d = document.createElement('div'); d.className='card';
const actions = [];
if(!['done','error','cancelled'].includes(it.status)) actions.push("<a href='#' class='iconbtn' onclick=\"cancelJob("+it.id+");return false;\">⛔</a>");
actions.push("<a href='#' class='iconbtn' onclick=\"deleteJob("+it.id+");return false;\">🗑️</a>");
const result = it.result_document_id ? ("<a href='/document/"+it.result_document_id+"'>Ergebnis öffnen</a>") : '';
const err = it.error ? ("<pre>"+String(it.error).replaceAll('<','&lt;')+"</pre>") : '';
d.innerHTML = "<b>Job #"+it.id+"</b> ["+it.kind+"] · <b>"+it.status+"</b> · läuft: "+since(it.started_at || it.created_at)+"<br><small>"+it.created_at+"</small><br>"
+(it.project_name?('Projekt: '+it.project_name+'<br>'):'')
+(it.document_title?('Dokument: '+it.document_title+'<br>'):'')
+(it.prompt_name?('Prompt: '+it.prompt_name+'<br>'):'')
+"<div class='row' style='margin-top:8px'>"+actions.join(' ')+" "+result+"</div>"+err;
root.appendChild(d);
}}
}}
async function cancelJob(id) {{ if(!confirm('Job abbrechen?')) return; await post('/jobs/'+id+'/cancel'); renderJobs(); }}
async function deleteJob(id) {{ if(!confirm('Job löschen?')) return; await post('/jobs/'+id+'/delete'); renderJobs(); }}
renderJobs(); setInterval(renderJobs, 5000);
</script>
"""
return layout("Jobs", body)
@app.get("/run", response_class=HTMLResponse)
def run_page():
with db() as c:
docs = c.execute("SELECT id,title,kind,created_at FROM documents ORDER BY id DESC LIMIT 200").fetchall()
prompts = c.execute("SELECT id,name FROM prompts ORDER BY name").fetchall()
d_opts = "".join([f"<option value='{d['id']}'>#{d['id']} [{d['kind']}] {d['title']}</option>" for d in docs])
p_opts = "".join([f"<option value='{p['id']}'>{p['name']}</option>" for p in prompts])
body = f"""
<h2>Prompt ausführen</h2>
<form method='post' action='/run' class='card'>
<label>Dokument:</label><br>
<select name='document_id' style='width:100%'>{d_opts}</select><br><br>
<label>Prompt:</label><br>
<select name='prompt_id' style='width:100%'>{p_opts}</select><br><br>
<button type='submit'>Ausführen (Qwen)</button>
</form>
"""
return layout("Run", body)
@app.post("/run", response_class=HTMLResponse)
def run_prompt(document_id: int = Form(...), prompt_id: int = Form(...)):
with db() as c:
doc = c.execute("SELECT id FROM documents WHERE id=?", (document_id,)).fetchone()
prm = c.execute("SELECT id FROM prompts WHERE id=?", (prompt_id,)).fetchone()
if not doc or not prm:
raise HTTPException(404, "Dokument oder Prompt nicht gefunden")
job_id = enqueue_job("analysis", document_id=document_id, prompt_id=prompt_id)
return HTMLResponse(f"<meta http-equiv='refresh' content='0; url=/jobs?queued={job_id}'>")