The fpdf2 built-in Helvetica font is Latin-1 only; characters outside that range caused "Not enough horizontal space" errors which silently aborted PDF generation. Fix: - _l1() helper encodes/decodes via latin-1 with replacement before any multi_cell call - _cell() wrapper catches per-line render errors so one bad character doesn't drop the whole document - PDF generation errors are now logged as warnings instead of swallowed Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2781 lines
120 KiB
Python
2781 lines
120 KiB
Python
import email as email_lib
|
||
import email.encoders
|
||
import email.mime.base
|
||
import email.mime.multipart
|
||
import email.mime.text
|
||
import json
|
||
import os
|
||
import re
|
||
import smtplib
|
||
import sqlite3
|
||
import threading
|
||
from concurrent.futures import ThreadPoolExecutor
|
||
from datetime import datetime
|
||
from pathlib import Path
|
||
from typing import List, Optional
|
||
|
||
import fpdf as fpdflib
|
||
import markdown as md
|
||
import requests
|
||
from fastapi import FastAPI, File, Form, HTTPException, UploadFile
|
||
from fastapi.responses import HTMLResponse, PlainTextResponse, Response, JSONResponse, RedirectResponse
|
||
|
||
API_BASE = os.getenv("API_BASE", "http://gx10.aquantico.lan:8093").rstrip("/")
|
||
# Kept for backward-compat seeding of the initial provider on first start
|
||
_SEED_PROVIDER_URL = os.getenv("OLLAMA_BASE_URL", "http://gx10.aquantico.lan:11434").rstrip("/")
|
||
_SEED_MODEL = os.getenv("OLLAMA_MODEL", "qwen3.5:9b")
|
||
_SEED_NUM_PREDICT = int(os.getenv("OLLAMA_NUM_PREDICT", "16384"))
|
||
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()
|
||
_JOB_STREAMS: dict = {}
|
||
_JOB_STREAM_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 _estimate_num_ctx(prompt: str) -> int:
|
||
needed = len(prompt) // 3 + 2048 # rough token estimate + response buffer
|
||
for ctx in (4096, 8192, 16384, 32768, 65536):
|
||
if needed <= ctx:
|
||
return ctx
|
||
return 65536
|
||
|
||
|
||
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
|
||
)
|
||
"""
|
||
)
|
||
|
||
# migrations
|
||
try:
|
||
c.execute("ALTER TABLE jobs ADD COLUMN user_prompt TEXT")
|
||
except Exception:
|
||
pass
|
||
try:
|
||
c.execute("ALTER TABLE jobs ADD COLUMN llm_prompt TEXT")
|
||
except Exception:
|
||
pass
|
||
try:
|
||
c.execute("ALTER TABLE jobs ADD COLUMN llm_response TEXT")
|
||
except Exception:
|
||
pass
|
||
try:
|
||
c.execute("ALTER TABLE jobs ADD COLUMN llm_thinking TEXT")
|
||
except Exception:
|
||
pass
|
||
try:
|
||
c.execute(
|
||
"CREATE TABLE IF NOT EXISTS settings (key TEXT PRIMARY KEY, value TEXT NOT NULL)"
|
||
)
|
||
except Exception:
|
||
pass
|
||
for col in [
|
||
"llm_use_default INTEGER NOT NULL DEFAULT 1",
|
||
"llm_model TEXT",
|
||
"llm_think TEXT",
|
||
"llm_num_ctx TEXT",
|
||
"llm_num_predict INTEGER",
|
||
"llm_repeat_penalty REAL",
|
||
"llm_repeat_last_n INTEGER",
|
||
]:
|
||
try:
|
||
c.execute(f"ALTER TABLE prompts ADD COLUMN {col}")
|
||
except Exception:
|
||
pass
|
||
|
||
for col in ["pipeline_prompt_ids TEXT", "email_to TEXT"]:
|
||
try:
|
||
c.execute(f"ALTER TABLE jobs ADD COLUMN {col}")
|
||
except Exception:
|
||
pass
|
||
|
||
try:
|
||
c.execute(
|
||
"""
|
||
CREATE TABLE IF NOT EXISTS providers (
|
||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||
name TEXT NOT NULL,
|
||
base_url TEXT NOT NULL,
|
||
api_key TEXT,
|
||
is_default INTEGER NOT NULL DEFAULT 0,
|
||
created_at TEXT NOT NULL
|
||
)
|
||
"""
|
||
)
|
||
except Exception:
|
||
pass
|
||
|
||
try:
|
||
c.execute("ALTER TABLE prompts ADD COLUMN provider_id INTEGER REFERENCES providers(id)")
|
||
except Exception:
|
||
pass
|
||
|
||
# Seed default provider from env if table is empty
|
||
count = c.execute("SELECT COUNT(*) FROM providers").fetchone()[0]
|
||
if count == 0:
|
||
c.execute(
|
||
"INSERT INTO providers(name, base_url, api_key, is_default, created_at) VALUES (?,?,?,1,?)",
|
||
("Ollama (Standard)", _SEED_PROVIDER_URL, None, now_iso()),
|
||
)
|
||
|
||
try:
|
||
c.execute(
|
||
"""
|
||
CREATE TABLE IF NOT EXISTS email_addresses (
|
||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||
label TEXT NOT NULL,
|
||
email TEXT UNIQUE NOT NULL,
|
||
created_at TEXT NOT NULL
|
||
)
|
||
"""
|
||
)
|
||
except Exception:
|
||
pass
|
||
|
||
# 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 get_setting(key: str, default: str) -> str:
|
||
with db() as c:
|
||
row = c.execute("SELECT value FROM settings WHERE key=?", (key,)).fetchone()
|
||
return row["value"] if row else default
|
||
|
||
|
||
def set_setting(key: str, value: str):
|
||
with db() as c:
|
||
c.execute("INSERT OR REPLACE INTO settings(key, value) VALUES (?,?)", (key, value))
|
||
|
||
|
||
def _get_default_provider() -> dict | None:
|
||
with db() as c:
|
||
row = c.execute("SELECT * FROM providers WHERE is_default=1 LIMIT 1").fetchone()
|
||
if not row:
|
||
row = c.execute("SELECT * FROM providers ORDER BY id LIMIT 1").fetchone()
|
||
return dict(row) if row else None
|
||
|
||
|
||
def _get_provider(provider_id: int | None) -> dict | None:
|
||
if not provider_id:
|
||
return _get_default_provider()
|
||
with db() as c:
|
||
row = c.execute("SELECT * FROM providers WHERE id=?", (provider_id,)).fetchone()
|
||
return dict(row) if row else _get_default_provider()
|
||
|
||
|
||
def _split_thinking(text: str) -> tuple[str, str]:
|
||
"""Split <think>…</think> prefix from the rest of the content."""
|
||
if text.startswith("<think>"):
|
||
end = text.find("</think>")
|
||
if end == -1:
|
||
return text[7:], ""
|
||
return text[7:end], text[end + 8:].lstrip("\n")
|
||
return "", text
|
||
|
||
|
||
def _md_to_pdf(title: str, content_md: str) -> bytes:
|
||
"""Render Markdown content to a PDF byte string."""
|
||
|
||
def _l1(text: str) -> str:
|
||
"""Sanitize text to Latin-1; fpdf2 built-in fonts require it."""
|
||
return text.encode("latin-1", errors="replace").decode("latin-1")
|
||
|
||
_title = _l1(title[:100])
|
||
|
||
class PDF(fpdflib.FPDF):
|
||
def header(self):
|
||
self.set_font("Helvetica", "B", 9)
|
||
self.set_text_color(100, 100, 100)
|
||
self.cell(0, 6, _title, align="L")
|
||
self.ln(2)
|
||
self.set_draw_color(200, 200, 200)
|
||
self.line(self.l_margin, self.get_y(), self.w - self.r_margin, self.get_y())
|
||
self.ln(4)
|
||
|
||
def footer(self):
|
||
self.set_y(-12)
|
||
self.set_font("Helvetica", "I", 8)
|
||
self.set_text_color(160, 160, 160)
|
||
self.cell(0, 6, f"Seite {self.page_no()}", align="C")
|
||
|
||
pdf = PDF(orientation="P", unit="mm", format="A4")
|
||
pdf.set_auto_page_break(auto=True, margin=15)
|
||
pdf.add_page()
|
||
|
||
def _cell(text: str, size: int = 10, bold: bool = False, indent: float = 0, h: float = 5):
|
||
try:
|
||
pdf.set_font("Helvetica", "B" if bold else "", size)
|
||
pdf.set_text_color(20 if bold else 30, 20 if bold else 30, 20 if bold else 30)
|
||
if indent:
|
||
pdf.set_x(pdf.l_margin + indent)
|
||
pdf.multi_cell(pdf.w - pdf.l_margin - pdf.r_margin - indent, h, _l1(text))
|
||
except Exception:
|
||
pass
|
||
|
||
for raw_line in content_md.split("\n"):
|
||
line = raw_line.rstrip()
|
||
if line.startswith("# "):
|
||
_cell(line[2:], size=14, bold=True, h=7)
|
||
pdf.ln(1)
|
||
elif line.startswith("## "):
|
||
_cell(line[3:], size=12, bold=True, h=6)
|
||
pdf.ln(1)
|
||
elif line.startswith("### "):
|
||
_cell(line[4:], size=11, bold=True, h=6)
|
||
elif line.startswith(("- ", "* ", "+ ")):
|
||
_cell("• " + line[2:], indent=5)
|
||
elif re.match(r"^\d+\. ", line):
|
||
_cell(line, indent=5)
|
||
elif line in ("", "---"):
|
||
pdf.ln(3)
|
||
else:
|
||
clean = re.sub(r"\*\*(.+?)\*\*", r"\1", line)
|
||
clean = re.sub(r"\*(.+?)\*", r"\1", clean)
|
||
clean = re.sub(r"`(.+?)`", r"\1", clean)
|
||
_cell(clean)
|
||
|
||
return bytes(pdf.output())
|
||
|
||
|
||
def _collect_doc_chain(input_doc) -> list[dict]:
|
||
"""Walk source_document_id links back to the original, return list oldest-first."""
|
||
chain = []
|
||
current = dict(input_doc)
|
||
chain.append(current)
|
||
while current.get("source_document_id"):
|
||
with db() as c:
|
||
row = c.execute("SELECT * FROM documents WHERE id=?", (current["source_document_id"],)).fetchone()
|
||
if not row:
|
||
break
|
||
current = dict(row)
|
||
chain.insert(0, current)
|
||
return chain
|
||
|
||
|
||
def _safe_fname(title: str) -> str:
|
||
return re.sub(r"[^a-zA-Z0-9äöüÄÖÜß\-_]", "_", title)[:60]
|
||
|
||
|
||
def _send_email(to_addrs: list[str], subject: str, body_md: str, attachments: list[tuple]):
|
||
"""
|
||
Send email with body_md as HTML/plain body and attachments as list of
|
||
(filename, bytes_content, mime_maintype, mime_subtype).
|
||
"""
|
||
host = get_setting("smtp_host", "")
|
||
if not host:
|
||
raise RuntimeError("SMTP-Host nicht konfiguriert")
|
||
port = int(get_setting("smtp_port", "587"))
|
||
user = get_setting("smtp_user", "")
|
||
password = get_setting("smtp_password", "")
|
||
from_addr = get_setting("smtp_from", user)
|
||
use_tls = get_setting("smtp_tls", "true") == "true"
|
||
|
||
outer = email.mime.multipart.MIMEMultipart("mixed")
|
||
outer["From"] = from_addr
|
||
outer["To"] = ", ".join(to_addrs)
|
||
outer["Subject"] = subject
|
||
|
||
# Multipart/alternative body (plain + HTML)
|
||
body_html = md.markdown(body_md, extensions=["fenced_code", "tables", "nl2br"])
|
||
full_html = f"""<html><body style="font-family:sans-serif;max-width:800px;margin:auto;padding:20px">
|
||
{body_html}
|
||
</body></html>"""
|
||
alt = email.mime.multipart.MIMEMultipart("alternative")
|
||
alt.attach(email.mime.text.MIMEText(body_md, "plain", "utf-8"))
|
||
alt.attach(email.mime.text.MIMEText(full_html, "html", "utf-8"))
|
||
outer.attach(alt)
|
||
|
||
for fname, data, maintype, subtype in attachments:
|
||
part = email.mime.base.MIMEBase(maintype, subtype)
|
||
part.set_payload(data)
|
||
email.encoders.encode_base64(part)
|
||
part.add_header("Content-Disposition", "attachment", filename=fname)
|
||
outer.attach(part)
|
||
|
||
if use_tls:
|
||
with smtplib.SMTP(host, port, timeout=30) as s:
|
||
s.starttls()
|
||
if user:
|
||
s.login(user, password)
|
||
s.sendmail(from_addr, to_addrs, outer.as_string())
|
||
else:
|
||
with smtplib.SMTP(host, port, timeout=30) as s:
|
||
if user:
|
||
s.login(user, password)
|
||
s.sendmail(from_addr, to_addrs, outer.as_string())
|
||
|
||
|
||
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'>
|
||
<link href='https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css' rel='stylesheet'>
|
||
<link href='https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.min.css' rel='stylesheet'>
|
||
<title>{title}</title>
|
||
<style>
|
||
:root{{--sidebar:#0b1220;--sidebar-border:#1e293b;--page:#f3f6fb;--card:#ffffff;--text:#0f172a;--muted:#64748b;--accent:#2563eb}}
|
||
*{{box-sizing:border-box}}
|
||
body{{font-family:Inter,system-ui,Arial,sans-serif;background:var(--page);color:var(--text);margin:0;display:flex;min-height:100vh}}
|
||
nav{{width:260px;background:linear-gradient(180deg,#0b1220,#0f172a);color:#e2e8f0;border-right:1px solid var(--sidebar-border);padding:14px 10px;position:sticky;top:0;height:100vh;overflow:auto;z-index:60}}
|
||
.brand{{font-weight:800;letter-spacing:.2px;margin:6px 10px 14px;color:#dbeafe;font-size:1.05rem}}
|
||
.navsection{{margin:14px 10px 8px;color:#94a3b8;font-size:.72rem;text-transform:uppercase;letter-spacing:.08em}}
|
||
nav a{{display:flex;gap:10px;align-items:center;color:#cbd5e1;text-decoration:none;padding:10px 12px;border-radius:12px;margin:4px 6px;border:1px solid transparent;font-weight:500}}
|
||
nav a i{{font-size:1rem;opacity:.95;width:18px;text-align:center}}
|
||
nav a:hover{{background:#16243d;color:#fff;border-color:#2c3d63}}
|
||
.app{{flex:1;display:flex;flex-direction:column;min-width:0}}
|
||
.topbar{{height:62px;background:rgba(255,255,255,.92);backdrop-filter:blur(8px);border-bottom:1px solid #e2e8f0;display:flex;align-items:center;padding:0 18px;gap:12px;position:sticky;top:0;z-index:20}}
|
||
.topbar h1{{font-size:1.02rem;margin:0;font-weight:700}}
|
||
.menu-btn{{display:none;border:1px solid #dbe3ef;background:#fff;color:#0f172a;border-radius:10px;padding:.35rem .55rem;font-size:1rem;line-height:1}}
|
||
main{{padding:18px;max-width:1400px;width:100%;margin:0 auto}}
|
||
.card{{background:var(--card);border:1px solid #e2e8f0;border-radius:14px;padding:14px;margin:10px 0;box-shadow:0 4px 16px rgba(15,23,42,.05)}}
|
||
pre{{white-space:pre-wrap;background:#0f172a;color:#c7f9cc;padding:12px;border-radius:10px;border:1px solid #1e293b}}
|
||
.mdview{{line-height:1.6}}
|
||
.mdview blockquote{{border-left:3px solid #94a3b8;padding-left:10px;color:#334155}}
|
||
.hint,small{{color:var(--muted)}}
|
||
.btn{{--bs-btn-padding-y:.34rem;--bs-btn-padding-x:.72rem;--bs-btn-font-size:.9rem;white-space:nowrap;width:auto;max-width:100%}}
|
||
.btn-group>.btn{{width:auto}}
|
||
.oc-modal-backdrop{{position:fixed;inset:0;background:rgba(2,6,23,.55);display:none;align-items:center;justify-content:center;z-index:2000}}
|
||
.oc-modal{{width:min(92vw,540px);background:#fff;border:1px solid #dbe3ef;border-radius:16px;padding:16px;box-shadow:0 24px 60px rgba(2,6,23,.3)}}
|
||
.oc-modal .actions{{display:flex;gap:8px;justify-content:flex-end;margin-top:12px}}
|
||
.iconbtn{{text-decoration:none}}
|
||
@media (max-width:980px){{
|
||
nav{{width:84px;padding:10px 6px}}
|
||
nav a span:last-child,.navsection{{display:none}}
|
||
.brand{{font-size:.78rem;margin:8px 8px 12px}}
|
||
}}
|
||
@media (max-width:760px){{
|
||
body{{display:block}}
|
||
nav{{position:fixed;left:0;top:0;bottom:0;width:270px;height:100vh;transform:translateX(-105%);transition:transform .22s ease;box-shadow:0 22px 42px rgba(2,6,23,.45)}}
|
||
body.nav-open nav{{transform:translateX(0)}}
|
||
.nav-backdrop{{display:none;position:fixed;inset:0;background:rgba(2,6,23,.45);z-index:55}}
|
||
body.nav-open .nav-backdrop{{display:block}}
|
||
.menu-btn{{display:inline-flex;align-items:center;justify-content:center}}
|
||
.topbar{{padding:0 12px}}
|
||
main{{padding:12px}}
|
||
nav a span:last-child,.navsection{{display:block}}
|
||
}}
|
||
</style>
|
||
<script>
|
||
if ('serviceWorker' in navigator) {{ window.addEventListener('load', () => navigator.serviceWorker.register('/sw.js').catch(()=>{{}})); }}
|
||
window.toggleNav = function() {{ document.body.classList.toggle('nav-open'); }};
|
||
window.closeNav = function() {{ document.body.classList.remove('nav-open'); }};
|
||
|
||
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,'"')}}">`}});
|
||
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>`}});
|
||
}};
|
||
|
||
window.applyBootstrap53 = function() {{
|
||
document.querySelectorAll('button').forEach(el => {{ if (!el.className.includes('btn') && !el.classList.contains('menu-btn')) el.classList.add('btn','btn-primary'); }});
|
||
document.querySelectorAll('input,select,textarea').forEach(el => {{
|
||
if (!el.classList.contains('form-control') && !el.classList.contains('form-select')) {{
|
||
if (el.tagName === 'SELECT') el.classList.add('form-select');
|
||
else el.classList.add('form-control');
|
||
}}
|
||
}});
|
||
document.querySelectorAll('table').forEach(el => {{ if (!el.className.includes('table')) el.classList.add('table','table-sm','table-striped','align-middle'); }});
|
||
document.querySelectorAll('.row').forEach(el => {{ if (!el.className.includes('g-2')) el.classList.add('g-2'); }});
|
||
}};
|
||
window.addEventListener('DOMContentLoaded', () => {{
|
||
window.applyBootstrap53();
|
||
const p = location.pathname;
|
||
document.querySelectorAll('nav a').forEach(a => {{
|
||
if (a.getAttribute('href') === p) {{
|
||
a.style.background = '#1d2d4a';
|
||
a.style.color = '#fff';
|
||
a.style.borderColor = '#34507c';
|
||
}}
|
||
}});
|
||
}});
|
||
</script>
|
||
</head>
|
||
<body>
|
||
<div id='modal-backdrop' class='oc-modal-backdrop'><div id='modal-box' class='oc-modal'></div></div>
|
||
<div class='nav-backdrop' onclick='closeNav()'></div>
|
||
<nav>
|
||
<div class='brand'>VoiceLog</div>
|
||
<div class='navsection'>Workspace</div>
|
||
<a href='/' onclick='closeNav()'><i class='bi bi-cloud-arrow-up'></i><span>Upload</span></a>
|
||
<a href='/library' onclick='closeNav()'><i class='bi bi-folder2-open'></i><span>Library</span></a>
|
||
<a href='/prompts' onclick='closeNav()'><i class='bi bi-sliders2'></i><span>Prompts & Projekte</span></a>
|
||
<div class='navsection'>Automation</div>
|
||
<a href='/run' onclick='closeNav()'><i class='bi bi-play-circle'></i><span>Prompt ausführen</span></a>
|
||
<a href='/jobs' onclick='closeNav()'><i class='bi bi-cpu'></i><span>Hintergrundjobs</span></a>
|
||
<div class='navsection'>System</div>
|
||
<a href='/settings' onclick='closeNav()'><i class='bi bi-gear'></i><span>Einstellungen</span></a>
|
||
<a href='/healthz' onclick='closeNav()'><i class='bi bi-heart-pulse'></i><span>Health</span></a>
|
||
</nav>
|
||
<div class='app'>
|
||
<div class='topbar'>
|
||
<button class='menu-btn' type='button' onclick='toggleNav()'><i class='bi bi-list'></i></button>
|
||
<h1>{title}</h1>
|
||
</div>
|
||
<main class='container-fluid py-2'>{body}</main>
|
||
</div>
|
||
<script src='https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js'></script>
|
||
</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:
|
||
row = c.execute("SELECT * FROM jobs WHERE id=?", (job_id,)).fetchone()
|
||
return dict(row) if row else None
|
||
|
||
|
||
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
|
||
|
||
pipeline_ids = json.loads(j.get("pipeline_prompt_ids") or "[]")
|
||
email_to = json.loads(j.get("email_to") or "[]")
|
||
_job_set(job_id, status="done", result_document_id=new_doc_id, finished_at=now_iso())
|
||
|
||
if pipeline_ids:
|
||
first_id, rest = pipeline_ids[0], pipeline_ids[1:]
|
||
enqueue_job(
|
||
"analysis",
|
||
project_id=j["project_id"],
|
||
document_id=new_doc_id,
|
||
prompt_id=first_id,
|
||
pipeline_prompt_ids=json.dumps(rest),
|
||
email_to=json.dumps(email_to),
|
||
)
|
||
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")
|
||
if not (doc["content_md"] or "").strip():
|
||
raise RuntimeError("Dokument hat keinen Inhalt – bitte zuerst das Transkript prüfen")
|
||
|
||
use_default = int(prm["llm_use_default"] if prm["llm_use_default"] is not None else 1)
|
||
if use_default:
|
||
provider = _get_default_provider()
|
||
model = get_setting("llm_model", _SEED_MODEL)
|
||
num_ctx_setting = get_setting("llm_num_ctx", "auto")
|
||
num_predict = int(get_setting("llm_num_predict", str(_SEED_NUM_PREDICT)))
|
||
repeat_penalty = float(get_setting("llm_repeat_penalty", "1.15"))
|
||
repeat_last_n = int(get_setting("llm_repeat_last_n", "128"))
|
||
else:
|
||
provider = _get_provider(prm["provider_id"] if "provider_id" in prm.keys() else None)
|
||
model = prm["llm_model"] or get_setting("llm_model", _SEED_MODEL)
|
||
num_ctx_setting = prm["llm_num_ctx"] or get_setting("llm_num_ctx", "auto")
|
||
num_predict = int(prm["llm_num_predict"] or get_setting("llm_num_predict", str(_SEED_NUM_PREDICT)))
|
||
repeat_penalty = float(prm["llm_repeat_penalty"] or get_setting("llm_repeat_penalty", "1.15"))
|
||
repeat_last_n = int(prm["llm_repeat_last_n"] or get_setting("llm_repeat_last_n", "128"))
|
||
|
||
if not provider:
|
||
raise RuntimeError("Kein LLM-Anbieter konfiguriert")
|
||
|
||
user_extra = (j.get("user_prompt") or "").strip()
|
||
system_msg = "Du bist ein präziser Assistent. Antworte auf Deutsch."
|
||
user_msg = (
|
||
f"AUFTRAG:\n{prm['prompt']}\n"
|
||
+ (f"\nZUSATZINFOS:\n{user_extra}\n" if user_extra else "")
|
||
+ f"\nTEXT:\n{doc['content_md']}\n"
|
||
)
|
||
|
||
ctx_hint = f"[num_ctx=auto]" if num_ctx_setting == "auto" else f"[num_ctx={num_ctx_setting}]"
|
||
_job_set(job_id, llm_prompt=f"{ctx_hint} [Anbieter: {provider['name']} | Modell: {model}]\n\nSYSTEM:\n{system_msg}\n\nUSER:\n{user_msg}")
|
||
|
||
with _JOB_STREAM_LOCK:
|
||
_JOB_STREAMS[job_id] = {"thinking": "", "response": ""}
|
||
|
||
headers = {"Content-Type": "application/json"}
|
||
if provider.get("api_key"):
|
||
headers["Authorization"] = f"Bearer {provider['api_key']}"
|
||
|
||
req_body: dict = {
|
||
"model": model,
|
||
"messages": [
|
||
{"role": "system", "content": system_msg},
|
||
{"role": "user", "content": user_msg},
|
||
],
|
||
"stream": True,
|
||
"max_tokens": num_predict,
|
||
}
|
||
if num_ctx_setting != "auto":
|
||
req_body["options"] = {
|
||
"num_ctx": int(num_ctx_setting),
|
||
"num_predict": num_predict,
|
||
"repeat_penalty": repeat_penalty,
|
||
"repeat_last_n": repeat_last_n,
|
||
}
|
||
|
||
r = requests.post(
|
||
f"{provider['base_url'].rstrip('/')}/v1/chat/completions",
|
||
json=req_body,
|
||
headers=headers,
|
||
stream=True,
|
||
timeout=1200,
|
||
)
|
||
r.raise_for_status()
|
||
|
||
acc = ""
|
||
chunk_count = 0
|
||
finish_reason = None
|
||
for line in r.iter_lines():
|
||
if not line:
|
||
continue
|
||
line_str = line.decode() if isinstance(line, bytes) else line
|
||
if not line_str.startswith("data: "):
|
||
continue
|
||
data_str = line_str[6:]
|
||
if data_str.strip() == "[DONE]":
|
||
break
|
||
try:
|
||
chunk = json.loads(data_str)
|
||
except Exception:
|
||
continue
|
||
choice = (chunk.get("choices") or [{}])[0]
|
||
acc += choice.get("delta", {}).get("content", "") or ""
|
||
finish_reason = choice.get("finish_reason") or finish_reason
|
||
t_part, r_part = _split_thinking(acc)
|
||
with _JOB_STREAM_LOCK:
|
||
_JOB_STREAMS[job_id] = {"thinking": t_part, "response": r_part}
|
||
chunk_count += 1
|
||
if chunk_count % 100 == 0:
|
||
j_check = _job_get(job_id)
|
||
if not j_check or j_check["status"] == "cancelled":
|
||
return
|
||
|
||
acc_thinking, answer = _split_thinking(acc)
|
||
|
||
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({"provider": provider["name"], "model": model, "finish_reason": finish_reason}, ensure_ascii=False),
|
||
now_iso(),
|
||
),
|
||
)
|
||
new_doc_id = cur.lastrowid
|
||
|
||
pipeline_ids = json.loads(j.get("pipeline_prompt_ids") or "[]")
|
||
email_to = json.loads(j.get("email_to") or "[]")
|
||
_job_set(job_id, status="done", result_document_id=new_doc_id, finished_at=now_iso(), llm_response=answer, llm_thinking=acc_thinking or None)
|
||
|
||
if pipeline_ids:
|
||
first_id, rest = pipeline_ids[0], pipeline_ids[1:]
|
||
enqueue_job(
|
||
"analysis",
|
||
project_id=doc["project_id"],
|
||
document_id=new_doc_id,
|
||
prompt_id=first_id,
|
||
pipeline_prompt_ids=json.dumps(rest),
|
||
email_to=json.dumps(email_to),
|
||
)
|
||
elif email_to:
|
||
subject = f"VoiceLog: {prm['name']} – {doc['title']}"
|
||
try:
|
||
# Collect intermediate docs (everything that fed into the final result)
|
||
chain = _collect_doc_chain(doc)
|
||
attachments = []
|
||
for step_doc in chain:
|
||
step_title = step_doc.get("title") or step_doc.get("kind", "dokument")
|
||
fname_base = _safe_fname(step_title)
|
||
content = step_doc.get("content_md") or ""
|
||
attachments.append((f"{fname_base}.md", content.encode("utf-8"), "text", "markdown"))
|
||
try:
|
||
pdf_bytes = _md_to_pdf(step_title, content)
|
||
attachments.append((f"{fname_base}.pdf", pdf_bytes, "application", "pdf"))
|
||
except Exception as pdf_err:
|
||
import logging
|
||
logging.warning(f"PDF-Generierung fehlgeschlagen für '{step_title}': {pdf_err}")
|
||
_send_email(email_to, subject, answer, attachments)
|
||
except Exception as mail_err:
|
||
_job_set(job_id, error=f"[E-Mail-Fehler] {mail_err}")
|
||
except Exception as e:
|
||
_job_set(job_id, status="error", error=str(e), finished_at=now_iso())
|
||
finally:
|
||
with _JOB_STREAM_LOCK:
|
||
_JOB_STREAMS.pop(job_id, None)
|
||
|
||
|
||
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,user_prompt,pipeline_prompt_ids,email_to,created_at)
|
||
VALUES (?,?,?,?,?,?,?,?,?,?,?)
|
||
""",
|
||
(
|
||
kind,
|
||
"queued",
|
||
kwargs.get("project_id"),
|
||
kwargs.get("document_id"),
|
||
kwargs.get("prompt_id"),
|
||
kwargs.get("title"),
|
||
kwargs.get("file_path"),
|
||
kwargs.get("user_prompt") or None,
|
||
kwargs.get("pipeline_prompt_ids") or None,
|
||
kwargs.get("email_to") or None,
|
||
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": "VoiceLog",
|
||
"short_name": "VoiceLog",
|
||
"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, "db_path": DB_PATH}
|
||
|
||
|
||
@app.get("/settings/models")
|
||
def settings_models(provider_id: Optional[int] = None):
|
||
provider = _get_provider(provider_id)
|
||
if not provider:
|
||
return JSONResponse({"ok": False, "error": "Kein Anbieter konfiguriert", "models": []})
|
||
headers = {}
|
||
if provider.get("api_key"):
|
||
headers["Authorization"] = f"Bearer {provider['api_key']}"
|
||
try:
|
||
r = requests.get(f"{provider['base_url'].rstrip('/')}/v1/models", headers=headers, timeout=5)
|
||
r.raise_for_status()
|
||
data = r.json()
|
||
names = sorted(m["id"] for m in data.get("data", []))
|
||
return JSONResponse({"ok": True, "models": names})
|
||
except Exception as e:
|
||
return JSONResponse({"ok": False, "error": str(e), "models": []})
|
||
|
||
|
||
@app.get("/settings", response_class=HTMLResponse)
|
||
def settings_page(msg: str = ""):
|
||
current_model = get_setting("llm_model", _SEED_MODEL)
|
||
current_num_ctx = get_setting("llm_num_ctx", "auto")
|
||
current_num_predict = get_setting("llm_num_predict", str(_SEED_NUM_PREDICT))
|
||
current_repeat_penalty = get_setting("llm_repeat_penalty", "1.15")
|
||
current_repeat_last_n = get_setting("llm_repeat_last_n", "128")
|
||
|
||
smtp_host = get_setting("smtp_host", "")
|
||
smtp_port = get_setting("smtp_port", "587")
|
||
smtp_user = get_setting("smtp_user", "")
|
||
smtp_password = get_setting("smtp_password", "")
|
||
smtp_from = get_setting("smtp_from", "")
|
||
smtp_tls = get_setting("smtp_tls", "true") == "true"
|
||
smtp_tls_checked = "checked" if smtp_tls else ""
|
||
|
||
with db() as c:
|
||
providers = c.execute("SELECT * FROM providers ORDER BY name").fetchall()
|
||
email_addrs = c.execute("SELECT id, label, email FROM email_addresses ORDER BY label").fetchall()
|
||
|
||
provider_rows = "".join(
|
||
f"""<tr id='prow_{p["id"]}'>
|
||
<td class='align-middle'><span class='fw-semibold'>{p["name"]}</span></td>
|
||
<td class='align-middle'><code class='small'>{p["base_url"]}</code></td>
|
||
<td class='text-center align-middle'><i class='bi bi-{"key-fill text-success" if p["api_key"] else "key text-secondary"}' title='{"API-Key gesetzt" if p["api_key"] else "Kein API-Key"}'></i></td>
|
||
<td class='text-center align-middle'>
|
||
{"<span class='badge bg-primary'>Standard</span>" if p["is_default"] else f"<form method='post' action='/providers/{p['id']}/set-default' class='d-inline'><button class='btn btn-sm btn-outline-secondary py-0 px-2' title='Als Standard setzen'><i class='bi bi-star'></i></button></form>"}
|
||
</td>
|
||
<td class='text-end align-middle'>
|
||
<button class='btn btn-sm btn-outline-primary py-0 px-2 me-1' type='button'
|
||
onclick='toggleEdit({p["id"]})' title='Bearbeiten'><i class='bi bi-pencil'></i></button>
|
||
<form method='post' action='/providers/{p["id"]}/delete' class='d-inline'
|
||
onsubmit='return confirm("Anbieter löschen?")'>
|
||
<button class='btn btn-sm btn-outline-danger py-0 px-2'><i class='bi bi-trash'></i></button>
|
||
</form>
|
||
</td>
|
||
</tr>
|
||
<tr id='pedit_{p["id"]}' class='d-none bg-body-tertiary'>
|
||
<td colspan='5' class='p-3'>
|
||
<form method='post' action='/providers/{p["id"]}/update'>
|
||
<div class='row g-2 align-items-end'>
|
||
<div class='col-12 col-md-3'>
|
||
<label class='form-label small mb-1'>Name</label>
|
||
<input class='form-control form-control-sm' name='name' value='{p["name"]}' required>
|
||
</div>
|
||
<div class='col-12 col-md-4'>
|
||
<label class='form-label small mb-1'>Base-URL</label>
|
||
<input class='form-control form-control-sm font-monospace' name='base_url' value='{p["base_url"]}' required>
|
||
</div>
|
||
<div class='col-12 col-md-3'>
|
||
<label class='form-label small mb-1'>API-Key <span class='text-secondary'>(leer lassen = unverändert)</span></label>
|
||
<input class='form-control form-control-sm' type='password' name='api_key' placeholder='{"••••••••" if p["api_key"] else "kein Key"}' autocomplete='new-password'>
|
||
</div>
|
||
<div class='col-auto d-flex gap-2 align-items-end'>
|
||
<button class='btn btn-primary btn-sm' type='submit'><i class='bi bi-check2'></i> Speichern</button>
|
||
<button class='btn btn-outline-secondary btn-sm' type='button' onclick='toggleEdit({p["id"]})'><i class='bi bi-x'></i></button>
|
||
{"<form method='post' action='/providers/" + str(p['id']) + "/clear-key' class='d-inline'><button class='btn btn-outline-warning btn-sm' type='submit' title='API-Key löschen'><i class='bi bi-key-x'></i></button></form>" if p["api_key"] else ""}
|
||
</div>
|
||
</div>
|
||
</form>
|
||
</td>
|
||
</tr>"""
|
||
for p in providers
|
||
)
|
||
|
||
email_addr_rows = "".join(
|
||
f"""<tr>
|
||
<td>{a["label"]}</td>
|
||
<td>{a["email"]}</td>
|
||
<td class='text-end'>
|
||
<form method='post' action='/settings/email-addresses/{a["id"]}/delete' class='d-inline'
|
||
onsubmit='return confirm("Adresse löschen?")'>
|
||
<button class='btn btn-sm btn-outline-danger py-0 px-2'><i class='bi bi-trash'></i></button>
|
||
</form>
|
||
</td>
|
||
</tr>"""
|
||
for a in email_addrs
|
||
)
|
||
|
||
provider_id_opts = "".join(
|
||
f"<option value='{p['id']}'>{p['name']}</option>" for p in providers
|
||
)
|
||
|
||
ctx_options = [("auto", "Automatisch (empfohlen)"), ("4096", "4 096"), ("8192", "8 192"),
|
||
("16384", "16 384"), ("32768", "32 768"), ("65536", "65 536"), ("131072", "131 072")]
|
||
ctx_opts_html = "".join(
|
||
f"<option value='{v}'{' selected' if v == current_num_ctx else ''}>{l}</option>"
|
||
for v, l in ctx_options
|
||
)
|
||
|
||
body = f"""
|
||
<div class='d-flex justify-content-between align-items-center mb-3'>
|
||
<h2 class='h4 mb-0'>Einstellungen</h2>
|
||
</div>
|
||
{f"<div class='alert alert-success py-2'>{msg}</div>" if msg else ""}
|
||
|
||
<div class='card' id='anbieter'>
|
||
<h5 class='h6 fw-semibold mb-3'><i class='bi bi-hdd-network text-primary me-1'></i> LLM-Anbieter & Standard-Modell</h5>
|
||
|
||
<h6 class='small text-secondary fw-semibold mb-2'>SCHRITT 1 — Anbieter verwalten</h6>
|
||
<p class='text-secondary small mb-2'>OpenAI-kompatible Endpunkte (Ollama, OpenAI, Groq, LM Studio, …). <i class='bi bi-star'></i> = als Standard setzen.</p>
|
||
{"<div class='table-responsive'><table class='table table-sm mb-3'><thead><tr><th>Name</th><th>Base-URL</th><th class='text-center'>API-Key</th><th class='text-center'><i class='bi bi-star' title='Standard'></i></th><th></th></tr></thead><tbody>" + provider_rows + "</tbody></table></div>" if providers else "<p class='text-secondary small'>Noch kein Anbieter konfiguriert.</p>"}
|
||
<details class='mb-4'>
|
||
<summary class='text-secondary small' style='cursor:pointer'><i class='bi bi-plus-circle me-1'></i> Neuen Anbieter hinzufügen</summary>
|
||
<div class='mt-2'>
|
||
<form method='post' action='/providers/add' class='row g-2 align-items-end'>
|
||
<div class='col-12 col-md-3'>
|
||
<label class='form-label'>Name</label>
|
||
<input class='form-control' name='name' placeholder='z.B. Lokales Ollama' required>
|
||
</div>
|
||
<div class='col-12 col-md-4'>
|
||
<label class='form-label'>Base-URL</label>
|
||
<input class='form-control font-monospace' name='base_url' placeholder='http://localhost:11434' required>
|
||
</div>
|
||
<div class='col-12 col-md-3'>
|
||
<label class='form-label'>API-Key <span class='text-secondary fw-normal'>(optional)</span></label>
|
||
<input class='form-control' type='password' name='api_key' autocomplete='new-password'>
|
||
</div>
|
||
<div class='col-auto d-flex align-items-end gap-2'>
|
||
<div class='form-check mb-2'>
|
||
<input class='form-check-input' type='checkbox' name='is_default' value='1' id='provDefault'>
|
||
<label class='form-check-label' for='provDefault'>Als Standard</label>
|
||
</div>
|
||
<button class='btn btn-outline-primary mb-0' type='submit'><i class='bi bi-plus-lg'></i> Hinzufügen</button>
|
||
</div>
|
||
</form>
|
||
</div>
|
||
</details>
|
||
|
||
<hr class='my-3'>
|
||
<h6 class='small text-secondary fw-semibold mb-2'>SCHRITT 2 — Standard-Modell & Parameter festlegen</h6>
|
||
<p class='text-secondary small mb-3'>
|
||
Gilt für alle Prompts ohne eigene Einstellungen. Standard-Anbieter:
|
||
<strong>{next((p["name"] for p in providers if p["is_default"]), "– keiner gesetzt –")}</strong>
|
||
</p>
|
||
<form method='post' action='/settings'>
|
||
<div class='row g-3'>
|
||
|
||
<div class='col-12'>
|
||
<label class='form-label'>Standard-Modell</label>
|
||
<div class='input-group'>
|
||
<select id='providerSelect' class='form-select flex-grow-0' style='width:auto;max-width:200px'
|
||
title='Anbieter für Modellliste wählen' onchange='onProviderChange()'>
|
||
{provider_id_opts}
|
||
</select>
|
||
<input id='modelInput' class='form-control font-monospace' name='llm_model'
|
||
value='{current_model}' placeholder='z.B. qwen3:8b' required>
|
||
<button type='button' class='btn btn-outline-secondary' id='loadModelsBtn' onclick='loadModels()'>
|
||
<i class='bi bi-arrow-clockwise' id='loadIcon'></i> Modelle laden
|
||
</button>
|
||
</div>
|
||
<div id='modelSelectWrap' class='mt-1 d-none'>
|
||
<select id='modelSelect' class='form-select font-monospace'></select>
|
||
</div>
|
||
<div class='form-text'>Anbieter im Dropdown links wählen → <em>Modelle laden</em> → Modell auswählen → Speichern. Aktuell: <code>{current_model}</code></div>
|
||
</div>
|
||
|
||
<div class='col-12 col-md-4'>
|
||
<label class='form-label'>Kontextgröße (num_ctx)</label>
|
||
<select class='form-select' name='llm_num_ctx'>{ctx_opts_html}</select>
|
||
<div class='form-text'>Automatisch wählt passend zur Promptgröße.</div>
|
||
</div>
|
||
|
||
<div class='col-12 col-md-4'>
|
||
<label class='form-label'>Max. Ausgabe-Tokens (num_predict)</label>
|
||
<input class='form-control' type='number' name='llm_num_predict' min='256' max='131072' step='256'
|
||
value='{current_num_predict}'>
|
||
</div>
|
||
|
||
<div class='col-12 col-md-2'>
|
||
<label class='form-label'>Repeat Penalty</label>
|
||
<input class='form-control' type='number' name='llm_repeat_penalty' min='1.0' max='2.0' step='0.01'
|
||
value='{current_repeat_penalty}'>
|
||
</div>
|
||
|
||
<div class='col-12 col-md-2'>
|
||
<label class='form-label'>Repeat Last N</label>
|
||
<input class='form-control' type='number' name='llm_repeat_last_n' min='0' max='2048' step='1'
|
||
value='{current_repeat_last_n}'>
|
||
</div>
|
||
|
||
</div>
|
||
<div class='mt-3'>
|
||
<button class='btn btn-primary' type='submit'><i class='bi bi-check2-circle'></i> Speichern</button>
|
||
</div>
|
||
</form>
|
||
</div>
|
||
|
||
<div class='card mt-3' id='smtp'>
|
||
<h5 class='h6 fw-semibold mb-3'><i class='bi bi-envelope text-primary me-1'></i> E-Mail / SMTP</h5>
|
||
<form method='post' action='/settings/smtp'>
|
||
<div class='row g-3'>
|
||
<div class='col-12 col-md-6'>
|
||
<label class='form-label'>SMTP-Host</label>
|
||
<input class='form-control' name='smtp_host' value='{smtp_host}' placeholder='mail.example.com'>
|
||
</div>
|
||
<div class='col-6 col-md-2'>
|
||
<label class='form-label'>Port</label>
|
||
<input class='form-control' type='number' name='smtp_port' value='{smtp_port}' min='1' max='65535'>
|
||
</div>
|
||
<div class='col-6 col-md-4'>
|
||
<label class='form-label'>Absender (From)</label>
|
||
<input class='form-control' type='email' name='smtp_from' value='{smtp_from}' placeholder='voicelog@example.com'>
|
||
</div>
|
||
<div class='col-12 col-md-6'>
|
||
<label class='form-label'>Benutzername</label>
|
||
<input class='form-control' name='smtp_user' value='{smtp_user}' autocomplete='off'>
|
||
</div>
|
||
<div class='col-12 col-md-6'>
|
||
<label class='form-label'>Passwort</label>
|
||
<input class='form-control' type='password' name='smtp_password' value='{smtp_password}' autocomplete='new-password'>
|
||
</div>
|
||
<div class='col-12'>
|
||
<div class='form-check form-switch'>
|
||
<input class='form-check-input' type='checkbox' role='switch' id='smtpTls'
|
||
name='smtp_tls' value='true' {smtp_tls_checked}>
|
||
<label class='form-check-label' for='smtpTls'>STARTTLS verwenden</label>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div class='mt-3'>
|
||
<button class='btn btn-primary' type='submit'><i class='bi bi-check2-circle'></i> Speichern</button>
|
||
</div>
|
||
</form>
|
||
</div>
|
||
|
||
<div class='card mt-3' id='email-adressen'>
|
||
<h5 class='h6 fw-semibold mb-3'><i class='bi bi-person-lines-fill text-primary me-1'></i> E-Mail-Adressen</h5>
|
||
{"<table class='table table-sm mb-3'><thead><tr><th>Name</th><th>E-Mail</th><th></th></tr></thead><tbody>" + email_addr_rows + "</tbody></table>" if email_addrs else "<p class='text-secondary small'>Noch keine Adressen gespeichert.</p>"}
|
||
<form method='post' action='/settings/email-addresses' class='row g-2 align-items-end'>
|
||
<div class='col-12 col-md-4'>
|
||
<label class='form-label'>Name / Label</label>
|
||
<input class='form-control' name='label' placeholder='z.B. Max Mustermann' required>
|
||
</div>
|
||
<div class='col-12 col-md-5'>
|
||
<label class='form-label'>E-Mail-Adresse</label>
|
||
<input class='form-control' type='email' name='email' placeholder='max@example.com' required>
|
||
</div>
|
||
<div class='col-auto'>
|
||
<button class='btn btn-outline-primary' type='submit'><i class='bi bi-plus-lg'></i> Hinzufügen</button>
|
||
</div>
|
||
</form>
|
||
</div>
|
||
|
||
<div class='card mt-3'>
|
||
<h5 class='h6 fw-semibold mb-2'><i class='bi bi-info-circle text-secondary me-1'></i> Systemkonfiguration (read-only)</h5>
|
||
<table class='table table-sm mb-0'>
|
||
<tbody>
|
||
<tr><td class='text-secondary' style='width:220px'>API_BASE</td><td><code>{API_BASE}</code></td></tr>
|
||
<tr><td class='text-secondary'>DB_PATH</td><td><code>{DB_PATH}</code></td></tr>
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
|
||
<script>
|
||
function onProviderChange() {{
|
||
document.getElementById('modelSelectWrap').classList.add('d-none');
|
||
}}
|
||
|
||
function toggleEdit(pid) {{
|
||
const row = document.getElementById('pedit_' + pid);
|
||
row.classList.toggle('d-none');
|
||
}}
|
||
|
||
async function loadModels() {{
|
||
const btn = document.getElementById('loadModelsBtn');
|
||
const icon = document.getElementById('loadIcon');
|
||
const wrap = document.getElementById('modelSelectWrap');
|
||
const sel = document.getElementById('modelSelect');
|
||
const inp = document.getElementById('modelInput');
|
||
const pid = document.getElementById('providerSelect')?.value || '';
|
||
btn.disabled = true;
|
||
icon.className = 'bi bi-hourglass-split';
|
||
try {{
|
||
const res = await fetch('/settings/models' + (pid ? '?provider_id=' + pid : ''));
|
||
const data = await res.json();
|
||
if (!data.ok || !data.models.length) {{
|
||
alert('Keine Modelle gefunden:\\n' + (data.error || ''));
|
||
return;
|
||
}}
|
||
const current = inp.value.trim();
|
||
sel.innerHTML = data.models.map(m =>
|
||
`<option value="${{m}}"${{m === current ? ' selected' : ''}}>${{m}}</option>`
|
||
).join('');
|
||
if (!data.models.includes(current) && current) {{
|
||
sel.innerHTML = `<option value="${{current}}" selected>${{current}} (aktuell)</option>` + sel.innerHTML;
|
||
}}
|
||
wrap.classList.remove('d-none');
|
||
sel.onchange = () => {{ inp.value = sel.value; }};
|
||
if (sel.value) inp.value = sel.value;
|
||
}} catch(e) {{
|
||
alert('Fehler: ' + e);
|
||
}} finally {{
|
||
btn.disabled = false;
|
||
icon.className = 'bi bi-check2';
|
||
}}
|
||
}}
|
||
</script>
|
||
"""
|
||
return layout("Einstellungen – VoiceLog", body)
|
||
|
||
|
||
@app.post("/settings", response_class=HTMLResponse)
|
||
def settings_save(
|
||
llm_model: str = Form(...),
|
||
llm_num_ctx: str = Form("auto"),
|
||
llm_num_predict: str = Form("16384"),
|
||
llm_repeat_penalty: str = Form("1.15"),
|
||
llm_repeat_last_n: str = Form("128"),
|
||
):
|
||
set_setting("llm_model", llm_model.strip())
|
||
set_setting("llm_num_ctx", llm_num_ctx.strip())
|
||
set_setting("llm_num_predict", str(int(float(llm_num_predict))))
|
||
set_setting("llm_repeat_penalty", str(float(llm_repeat_penalty)))
|
||
set_setting("llm_repeat_last_n", str(int(float(llm_repeat_last_n))))
|
||
return RedirectResponse("/settings?msg=Einstellungen+gespeichert.", status_code=303)
|
||
|
||
|
||
@app.post("/settings/smtp", response_class=HTMLResponse)
|
||
def settings_smtp_save(
|
||
smtp_host: str = Form(""),
|
||
smtp_port: str = Form("587"),
|
||
smtp_user: str = Form(""),
|
||
smtp_password: str = Form(""),
|
||
smtp_from: str = Form(""),
|
||
smtp_tls: Optional[str] = Form(None),
|
||
):
|
||
set_setting("smtp_host", smtp_host.strip())
|
||
set_setting("smtp_port", smtp_port.strip() or "587")
|
||
set_setting("smtp_user", smtp_user.strip())
|
||
set_setting("smtp_from", smtp_from.strip())
|
||
if smtp_password:
|
||
set_setting("smtp_password", smtp_password)
|
||
set_setting("smtp_tls", "true" if smtp_tls == "true" else "false")
|
||
return RedirectResponse("/settings?msg=SMTP-Einstellungen+gespeichert.#smtp", status_code=303)
|
||
|
||
|
||
@app.post("/settings/email-addresses", response_class=HTMLResponse)
|
||
def add_email_address(label: str = Form(...), email: str = Form(...)):
|
||
with db() as c:
|
||
c.execute(
|
||
"INSERT OR REPLACE INTO email_addresses(label, email, created_at) VALUES (?,?,?)",
|
||
(label.strip(), email.strip().lower(), now_iso()),
|
||
)
|
||
return RedirectResponse("/settings?msg=E-Mail-Adresse+gespeichert.#email-adressen", status_code=303)
|
||
|
||
|
||
@app.post("/settings/email-addresses/{addr_id}/delete", response_class=HTMLResponse)
|
||
def delete_email_address(addr_id: int):
|
||
with db() as c:
|
||
c.execute("DELETE FROM email_addresses WHERE id=?", (addr_id,))
|
||
return RedirectResponse("/settings?msg=E-Mail-Adresse+gelöscht.#email-adressen", status_code=303)
|
||
|
||
|
||
@app.post("/providers/add", response_class=HTMLResponse)
|
||
def add_provider(
|
||
name: str = Form(...),
|
||
base_url: str = Form(...),
|
||
api_key: str = Form(""),
|
||
is_default: Optional[str] = Form(None),
|
||
):
|
||
with db() as c:
|
||
if is_default == "1":
|
||
c.execute("UPDATE providers SET is_default=0")
|
||
c.execute(
|
||
"INSERT INTO providers(name, base_url, api_key, is_default, created_at) VALUES (?,?,?,?,?)",
|
||
(name.strip(), base_url.strip().rstrip("/"), api_key.strip() or None, 1 if is_default == "1" else 0, now_iso()),
|
||
)
|
||
return RedirectResponse("/settings?msg=Anbieter+hinzugefügt.#anbieter", status_code=303)
|
||
|
||
|
||
@app.post("/providers/{provider_id}/delete", response_class=HTMLResponse)
|
||
def delete_provider(provider_id: int):
|
||
with db() as c:
|
||
c.execute("DELETE FROM providers WHERE id=?", (provider_id,))
|
||
return RedirectResponse("/settings?msg=Anbieter+gelöscht.#anbieter", status_code=303)
|
||
|
||
|
||
@app.post("/providers/{provider_id}/set-default", response_class=HTMLResponse)
|
||
def set_default_provider(provider_id: int):
|
||
with db() as c:
|
||
c.execute("UPDATE providers SET is_default=0")
|
||
c.execute("UPDATE providers SET is_default=1 WHERE id=?", (provider_id,))
|
||
return RedirectResponse("/settings?msg=Standard-Anbieter+gesetzt.#anbieter", status_code=303)
|
||
|
||
|
||
@app.post("/providers/{provider_id}/update", response_class=HTMLResponse)
|
||
def update_provider(
|
||
provider_id: int,
|
||
name: str = Form(...),
|
||
base_url: str = Form(...),
|
||
api_key: str = Form(""),
|
||
):
|
||
with db() as c:
|
||
if api_key.strip():
|
||
c.execute(
|
||
"UPDATE providers SET name=?, base_url=?, api_key=? WHERE id=?",
|
||
(name.strip(), base_url.strip().rstrip("/"), api_key.strip(), provider_id),
|
||
)
|
||
else:
|
||
c.execute(
|
||
"UPDATE providers SET name=?, base_url=? WHERE id=?",
|
||
(name.strip(), base_url.strip().rstrip("/"), provider_id),
|
||
)
|
||
return RedirectResponse("/settings?msg=Anbieter+aktualisiert.#anbieter", status_code=303)
|
||
|
||
|
||
@app.post("/providers/{provider_id}/clear-key", response_class=HTMLResponse)
|
||
def clear_provider_key(provider_id: int):
|
||
with db() as c:
|
||
c.execute("UPDATE providers SET api_key=NULL WHERE id=?", (provider_id,))
|
||
return RedirectResponse("/settings?msg=API-Key+gelöscht.#anbieter", status_code=303)
|
||
|
||
|
||
@app.get("/", response_class=HTMLResponse)
|
||
def upload_page(msg: str = ""):
|
||
projects = get_projects()
|
||
existing_names = json.dumps([p["name"] for p in projects], ensure_ascii=False)
|
||
existing_map = json.dumps({p["name"]: p["id"] for p in projects}, ensure_ascii=False)
|
||
|
||
with db() as c:
|
||
prompts = c.execute("SELECT id, name FROM prompts ORDER BY name").fetchall()
|
||
email_addrs = c.execute("SELECT id, label, email FROM email_addresses ORDER BY label").fetchall()
|
||
|
||
smtp_configured = bool(get_setting("smtp_host", ""))
|
||
|
||
prompt_items = "".join(
|
||
f"""<li class='list-group-item d-flex align-items-center gap-2 py-2' draggable='true'
|
||
data-id='{p["id"]}' style='cursor:grab'>
|
||
<span class='drag-handle text-secondary' style='cursor:grab'><i class='bi bi-grip-vertical'></i></span>
|
||
<input class='form-check-input flex-shrink-0 mt-0' type='checkbox' id='pchk_{p["id"]}' value='{p["id"]}' onchange='updateOrder()'>
|
||
<label class='form-check-label mb-0 flex-grow-1' for='pchk_{p["id"]}'>{p["name"]}</label>
|
||
<span class='badge bg-secondary order-badge d-none'></span>
|
||
</li>"""
|
||
for p in prompts
|
||
)
|
||
|
||
email_items = "".join(
|
||
f"""<div class='form-check'>
|
||
<input class='form-check-input' type='checkbox' id='eml_{a["id"]}' value='{a["email"]}' onchange='updateEmails()'>
|
||
<label class='form-check-label' for='eml_{a["id"]}'>{a["label"]} <span class='text-secondary small'><{a["email"]}></span></label>
|
||
</div>"""
|
||
for a in email_addrs
|
||
)
|
||
|
||
email_section = ""
|
||
if smtp_configured and email_addrs:
|
||
email_section = f"""
|
||
<div class='card mt-3'>
|
||
<h6 class='fw-semibold mb-2'><i class='bi bi-envelope text-primary me-1'></i> E-Mail-Versand (optional)</h6>
|
||
<p class='text-secondary small mb-2'>Letztes Ergebnis der Pipeline nach Abschluss als Anhang versenden:</p>
|
||
{email_items}
|
||
<div id='emailPreview' class='text-secondary small mt-1 d-none'>Sende an: <span id='emailPreviewList'></span></div>
|
||
</div>"""
|
||
elif not smtp_configured:
|
||
email_section = "<div class='text-secondary small mt-3'><i class='bi bi-envelope-x me-1'></i> E-Mail-Versand nicht konfiguriert — <a href='/settings#smtp'>Einstellungen</a></div>"
|
||
elif not email_addrs:
|
||
email_section = "<div class='text-secondary small mt-3'><i class='bi bi-envelope-x me-1'></i> Keine E-Mail-Adressen gespeichert — <a href='/settings#email-adressen'>Einstellungen</a></div>"
|
||
|
||
no_prompts_hint = "" if prompts else "<div class='text-secondary small'>Keine Prompts vorhanden — <a href='/prompts'>Prompts anlegen</a></div>"
|
||
|
||
body = f"""
|
||
<div class='d-flex justify-content-between align-items-center mb-3'>
|
||
<h2 class='h4 mb-0'>Audio Upload</h2>
|
||
</div>
|
||
<p class='text-secondary'>Audiodateien hochladen, transkribieren lassen und optional eine Prompt-Pipeline durchlaufen.</p>
|
||
{f"<div class='alert alert-info py-2'>{msg}</div>" if msg else ""}
|
||
<div class='card'>
|
||
<div class='row g-3 mb-3'>
|
||
<div class='col-12 col-md-5'>
|
||
<label class='form-label'>Projekt <span class='text-secondary fw-normal'>(bestehendes wählen oder neuen Namen eingeben)</span></label>
|
||
<input class='form-control' id='projectInput' list='projectList' placeholder='Projekt wählen oder anlegen …' autocomplete='off'>
|
||
<datalist id='projectList'></datalist>
|
||
</div>
|
||
<div class='col-12 col-md-7'>
|
||
<label class='form-label'>Audio- / Videodateien</label>
|
||
<input class='form-control' type='file' id='fileInput' accept='audio/*,video/*' multiple required>
|
||
<div class='form-text'>Mehrere Dateien: <kbd>Strg</kbd> / <kbd>Cmd</kbd> gedrückt halten beim Auswählen</div>
|
||
</div>
|
||
</div>
|
||
<div id='filePreview' class='mb-3 d-none'>
|
||
<div class='text-secondary small mb-1'>Warteschlange:</div>
|
||
<ul id='fileList' class='list-group list-group-flush'></ul>
|
||
</div>
|
||
<button class='btn btn-primary' id='uploadBtn' onclick='startUpload()' disabled>
|
||
<i class='bi bi-cloud-upload'></i> <span id='uploadBtnLabel'>Hochladen & verarbeiten</span>
|
||
</button>
|
||
</div>
|
||
|
||
<div class='card mt-3'>
|
||
<h6 class='fw-semibold mb-2'><i class='bi bi-list-ol text-primary me-1'></i> Prompt-Pipeline <span class='text-secondary fw-normal small'>(optional, Reihenfolge per Drag & Drop)</span></h6>
|
||
{no_prompts_hint}
|
||
<ul class='list-group list-group-flush' id='promptList' style='user-select:none'>
|
||
{prompt_items}
|
||
</ul>
|
||
<div id='pipelinePreview' class='text-secondary small mt-2 d-none'>
|
||
Pipeline: <span id='pipelinePreviewText'></span>
|
||
</div>
|
||
</div>
|
||
{email_section}
|
||
|
||
<div id='uploadProgress' class='d-none mt-3'>
|
||
<div class='progress mb-2' style='height:8px'>
|
||
<div class='progress-bar progress-bar-striped progress-bar-animated' id='progressBar' style='width:0%'></div>
|
||
</div>
|
||
<div id='progressStatus' class='text-secondary small'></div>
|
||
</div>
|
||
<script>
|
||
const _existingNames = {existing_names};
|
||
const _existingMap = {existing_map};
|
||
const datalist = document.getElementById('projectList');
|
||
_existingNames.forEach(n => {{ const o = document.createElement('option'); o.value = n; datalist.appendChild(o); }});
|
||
|
||
const fileInput = document.getElementById('fileInput');
|
||
const projectInput = document.getElementById('projectInput');
|
||
const uploadBtn = document.getElementById('uploadBtn');
|
||
|
||
function updateBtn() {{
|
||
uploadBtn.disabled = !(fileInput.files.length > 0 && projectInput.value.trim());
|
||
}}
|
||
fileInput.addEventListener('change', () => {{
|
||
updateBtn();
|
||
const list = document.getElementById('fileList');
|
||
list.innerHTML = '';
|
||
Array.from(fileInput.files).forEach(f => {{
|
||
const stem = f.name.replace(/\\.[^/.]+$/, '');
|
||
const li = document.createElement('li');
|
||
li.className = 'list-group-item py-1 small d-flex justify-content-between';
|
||
const icon = f.type.startsWith('video/') ? 'bi-file-earmark-play' : 'bi-file-earmark-music';
|
||
li.innerHTML = `<span><i class='bi ${{icon}}'></i> ${{stem}}</span><span class='text-secondary'>${{(f.size/1024/1024).toFixed(1)}} MB</span>`;
|
||
list.appendChild(li);
|
||
}});
|
||
document.getElementById('filePreview').classList.toggle('d-none', fileInput.files.length === 0);
|
||
}});
|
||
projectInput.addEventListener('input', updateBtn);
|
||
|
||
// --- Prompt pipeline ordering ---
|
||
function updateOrder() {{
|
||
const items = document.querySelectorAll('#promptList li');
|
||
let n = 1;
|
||
items.forEach(li => {{
|
||
const chk = li.querySelector('input[type=checkbox]');
|
||
const badge = li.querySelector('.order-badge');
|
||
if (chk.checked) {{
|
||
badge.textContent = n++;
|
||
badge.classList.remove('d-none');
|
||
}} else {{
|
||
badge.classList.add('d-none');
|
||
}}
|
||
}});
|
||
const checked = [...items].filter(li => li.querySelector('input').checked);
|
||
const names = checked.map(li => li.querySelector('label').textContent.trim());
|
||
const preview = document.getElementById('pipelinePreview');
|
||
const previewText = document.getElementById('pipelinePreviewText');
|
||
if (names.length) {{
|
||
previewText.textContent = names.join(' → ');
|
||
preview.classList.remove('d-none');
|
||
}} else {{
|
||
preview.classList.add('d-none');
|
||
}}
|
||
}}
|
||
|
||
// Drag-and-drop reorder
|
||
let _dragSrc = null;
|
||
document.getElementById('promptList').addEventListener('dragstart', e => {{
|
||
_dragSrc = e.target.closest('li');
|
||
e.dataTransfer.effectAllowed = 'move';
|
||
}});
|
||
document.getElementById('promptList').addEventListener('dragover', e => {{
|
||
e.preventDefault();
|
||
const target = e.target.closest('li');
|
||
if (target && target !== _dragSrc) {{
|
||
const rect = target.getBoundingClientRect();
|
||
const after = e.clientY > rect.top + rect.height / 2;
|
||
target.parentNode.insertBefore(_dragSrc, after ? target.nextSibling : target);
|
||
}}
|
||
}});
|
||
document.getElementById('promptList').addEventListener('dragend', () => {{ updateOrder(); }});
|
||
|
||
// --- Email selection ---
|
||
let _selectedEmails = [];
|
||
function updateEmails() {{
|
||
_selectedEmails = [...document.querySelectorAll('#emailCheckboxes input:checked')].map(i => i.value);
|
||
const preview = document.getElementById('emailPreview');
|
||
const list = document.getElementById('emailPreviewList');
|
||
if (!preview) return;
|
||
if (_selectedEmails.length) {{
|
||
list.textContent = _selectedEmails.join(', ');
|
||
preview.classList.remove('d-none');
|
||
}} else {{
|
||
preview.classList.add('d-none');
|
||
}}
|
||
}}
|
||
|
||
async function startUpload() {{
|
||
const name = projectInput.value.trim();
|
||
if(!name || fileInput.files.length === 0) return;
|
||
uploadBtn.disabled = true;
|
||
document.getElementById('uploadProgress').classList.remove('d-none');
|
||
const files = Array.from(fileInput.files);
|
||
const bar = document.getElementById('progressBar');
|
||
const status = document.getElementById('progressStatus');
|
||
|
||
// resolve or create project
|
||
let projectId = _existingMap[name];
|
||
if(!projectId) {{
|
||
status.textContent = `Projekt "${{name}}" wird angelegt …`;
|
||
const r = await fetch('/projects/create-api', {{method:'POST', headers:{{'Content-Type':'application/x-www-form-urlencoded'}}, body: new URLSearchParams({{name}})}});
|
||
if(!r.ok) {{ alert('Fehler beim Anlegen des Projekts'); uploadBtn.disabled=false; return; }}
|
||
projectId = (await r.json()).id;
|
||
}}
|
||
|
||
// collect pipeline prompt IDs in current list order, only checked ones
|
||
const pipelineIds = [...document.querySelectorAll('#promptList li input:checked')].map(i => parseInt(i.value));
|
||
const emailTo = [...document.querySelectorAll('[id^="eml_"]:checked')].map(i => i.value);
|
||
|
||
for(let i=0; i<files.length; i++) {{
|
||
const f = files[i];
|
||
const stem = f.name.replace(/\\.[^/.]+$/, '');
|
||
status.textContent = `(${{i+1}}/${{files.length}}) ${{stem}} …`;
|
||
bar.style.width = (i / files.length * 100) + '%';
|
||
const fd = new FormData();
|
||
fd.append('project_id', projectId);
|
||
fd.append('file', f);
|
||
fd.append('pipeline_prompt_ids', JSON.stringify(pipelineIds));
|
||
fd.append('email_to', JSON.stringify(emailTo));
|
||
const r = await fetch('/upload', {{method:'POST', body: fd}});
|
||
if(!r.ok) {{ status.textContent = `Fehler bei ${{stem}}`; }}
|
||
}}
|
||
bar.style.width = '100%';
|
||
status.textContent = `${{files.length}} Datei(en) eingereicht — weiter zur Job-Übersicht …`;
|
||
setTimeout(() => location.href='/jobs', 800);
|
||
}}
|
||
</script>
|
||
"""
|
||
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("/projects/create-api")
|
||
def create_project_api(name: str = Form(...)):
|
||
name = name.strip()
|
||
if not name:
|
||
raise HTTPException(400, "Name darf nicht leer sein")
|
||
with db() as c:
|
||
existing = c.execute("SELECT id FROM projects WHERE name=?", (name,)).fetchone()
|
||
if existing:
|
||
return {"id": existing["id"]}
|
||
cur = c.execute("INSERT INTO projects(name, created_at) VALUES (?,?)", (name, now_iso()))
|
||
return {"id": cur.lastrowid}
|
||
|
||
|
||
@app.post("/upload")
|
||
async def upload(
|
||
project_id: int = Form(...),
|
||
file: UploadFile = File(...),
|
||
pipeline_prompt_ids: str = Form("[]"),
|
||
email_to: str = Form("[]"),
|
||
):
|
||
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("/", "_")
|
||
stem = filename.rsplit(".", 1)[0] or filename
|
||
temp_path = JOB_DIR / f"{now_iso().replace(':','-')}_{filename}"
|
||
temp_path.write_bytes(data)
|
||
|
||
try:
|
||
ids = json.loads(pipeline_prompt_ids)
|
||
if not isinstance(ids, list):
|
||
ids = []
|
||
except Exception:
|
||
ids = []
|
||
|
||
try:
|
||
emails = json.loads(email_to)
|
||
if not isinstance(emails, list):
|
||
emails = []
|
||
except Exception:
|
||
emails = []
|
||
|
||
job_id = enqueue_job(
|
||
"upload",
|
||
project_id=project_id,
|
||
title=stem,
|
||
file_path=str(temp_path),
|
||
pipeline_prompt_ids=json.dumps(ids) if ids else None,
|
||
email_to=json.dumps(emails) if emails else None,
|
||
)
|
||
return {"job_id": job_id}
|
||
|
||
|
||
@app.get("/library", response_class=HTMLResponse)
|
||
def library(project_id: Optional[str] = None, q_title: str = "", q_content: str = ""):
|
||
title_q = (q_title or "").strip()
|
||
content_q = (q_content or "").strip()
|
||
project_id_int = int(project_id) if (project_id and str(project_id).strip()) else None
|
||
|
||
where = []
|
||
params = []
|
||
if project_id_int:
|
||
where.append("d.project_id=?")
|
||
params.append(project_id_int)
|
||
if title_q:
|
||
where.append("LOWER(d.title) LIKE LOWER(?)")
|
||
params.append(f"%{title_q}%")
|
||
if content_q:
|
||
where.append("LOWER(d.content_md) LIKE LOWER(?)")
|
||
params.append(f"%{content_q}%")
|
||
|
||
where_sql = ("WHERE " + " AND ".join(where)) if where else ""
|
||
|
||
with db() as c:
|
||
projects = c.execute("SELECT id,name FROM projects ORDER BY name").fetchall()
|
||
docs = c.execute(
|
||
f"""
|
||
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_sql}
|
||
ORDER BY d.id DESC LIMIT 500
|
||
""",
|
||
tuple(params),
|
||
).fetchall()
|
||
|
||
p_opts = "<option value=''>Alle</option>" + "".join(
|
||
[f"<option value='{p['id']}' {'selected' if project_id_int==p['id'] else ''}>{p['name']}</option>" for p in projects]
|
||
)
|
||
rows = "".join(
|
||
[
|
||
f"<tr>"
|
||
f"<td style='width:36px'><input type='checkbox' class='form-check-input row-cb' value='{d['id']}'></td>"
|
||
f"<td>#{d['id']}</td>"
|
||
f"<td>{d['title']}</td>"
|
||
f"<td>{d['kind']}</td>"
|
||
f"<td>{d['project']}</td>"
|
||
f"<td><small>{d['created_at']}</small></td>"
|
||
f"<td><div class='btn-group btn-group-sm' role='group'>"
|
||
f"<a href='/document/{d['id']}' class='btn btn-outline-secondary' title='Ansehen'>👁️</a>"
|
||
f"<a href='/document/{d['id']}/download.md' class='btn btn-outline-secondary' title='Download'>⬇️</a>"
|
||
f"<a href='#' class='btn btn-outline-secondary' title='Umbenennen' onclick='libRename({d['id']}, {json.dumps(d['title'])});return false;'>✏️</a>"
|
||
f"<a href='#' class='btn btn-outline-secondary' title='Verschieben' onclick='libMove({d['id']});return false;'>📁</a>"
|
||
f"<a href='#' class='btn btn-outline-danger' title='Löschen' onclick='libDelete({d['id']});return false;'>🗑️</a>"
|
||
f"</div></td>"
|
||
f"</tr>"
|
||
for d in docs
|
||
]
|
||
)
|
||
project_js = json.dumps([{"value": p["id"], "label": p["name"]} for p in projects], ensure_ascii=False)
|
||
body = f"""
|
||
<div class='d-flex justify-content-between align-items-center mb-2'>
|
||
<h2 class='h4 mb-0'>Projekte · Dokumente</h2>
|
||
<span class='badge text-bg-secondary'>{len(docs)} Treffer</span>
|
||
</div>
|
||
<div class='text-secondary small mb-2'>Ansicht im Projektlisten-Stil mit Schnellaktionen.</div>
|
||
<form method='get' class='card'>
|
||
<div class='row g-2 align-items-end'>
|
||
<div class='col-12 col-md-3'>
|
||
<label class='form-label'>Projekt</label>
|
||
<select class='form-select' name='project_id'>{p_opts}</select>
|
||
</div>
|
||
<div class='col-12 col-md-3'>
|
||
<label class='form-label'>Titel enthält</label>
|
||
<input class='form-control' name='q_title' placeholder='Titel enthält …' value='{title_q.replace("'", "'")}'>
|
||
</div>
|
||
<div class='col-12 col-md-4'>
|
||
<label class='form-label'>Inhalt enthält</label>
|
||
<input class='form-control' name='q_content' placeholder='Inhalt enthält …' value='{content_q.replace("'", "'")}'>
|
||
</div>
|
||
<div class='col-6 col-md-1 d-grid'><button class='btn btn-primary' type='submit'>Filtern</button></div>
|
||
<div class='col-6 col-md-1 d-grid'><a class='btn btn-outline-secondary' href='/library'>Reset</a></div>
|
||
</div>
|
||
</form>
|
||
<div id='bulkBar' class='alert alert-primary d-none d-flex align-items-center gap-2 py-2 mb-2' role='alert'>
|
||
<strong id='bulkCount'>0 ausgewählt</strong>
|
||
<button type='button' class='btn btn-sm btn-primary' onclick='bulkMove()'><i class='bi bi-folder-symlink'></i> Verschieben</button>
|
||
<button type='button' class='btn btn-sm btn-danger' onclick='bulkDelete()'><i class='bi bi-trash'></i> Löschen</button>
|
||
</div>
|
||
<div class='card'>
|
||
<div class='table-responsive'>
|
||
<table class='table table-sm table-striped align-middle mb-0'>
|
||
<thead><tr>
|
||
<th style='width:36px'><input type='checkbox' class='form-check-input' id='cbAll' title='Alle wählen'></th>
|
||
<th>ID</th><th>Titel</th><th>Typ</th><th>Projekt</th><th>Erstellt</th><th>Aktionen</th>
|
||
</tr></thead>
|
||
<tbody>{rows or "<tr><td colspan='7' class='text-secondary'>Keine Einträge.</td></tr>"}</tbody>
|
||
</table>
|
||
</div>
|
||
</div>
|
||
<div class='d-flex gap-2 mt-2'>
|
||
<button type='button' class='btn btn-sm btn-outline-secondary' onclick='selectAll()'><i class='bi bi-check2-all'></i> Alle wählen</button>
|
||
<button type='button' class='btn btn-sm btn-outline-secondary' onclick='selectNone()'><i class='bi bi-x-square'></i> Alle abwählen</button>
|
||
</div>
|
||
<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();
|
||
}}
|
||
async function libPostMulti(url, params) {{
|
||
const body = new URLSearchParams();
|
||
for(const [k,v] of Object.entries(params)) {{
|
||
if(Array.isArray(v)) v.forEach(x => body.append(k, x));
|
||
else body.append(k, v);
|
||
}}
|
||
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.reload();
|
||
}}
|
||
function getSelected() {{
|
||
return [...document.querySelectorAll('.row-cb:checked')].map(cb => cb.value);
|
||
}}
|
||
function updateBulkBar() {{
|
||
const ids = getSelected();
|
||
const all = document.querySelectorAll('.row-cb');
|
||
document.getElementById('bulkCount').textContent = ids.length + ' ausgewählt';
|
||
document.getElementById('bulkBar').classList.toggle('d-none', ids.length === 0);
|
||
const cbAll = document.getElementById('cbAll');
|
||
cbAll.indeterminate = ids.length > 0 && ids.length < all.length;
|
||
cbAll.checked = all.length > 0 && ids.length === all.length;
|
||
}}
|
||
document.querySelectorAll('.row-cb').forEach(cb => {{
|
||
cb.addEventListener('change', function() {{
|
||
this.closest('tr').classList.toggle('table-active', this.checked);
|
||
updateBulkBar();
|
||
}});
|
||
}});
|
||
document.getElementById('cbAll').addEventListener('change', function() {{
|
||
document.querySelectorAll('.row-cb').forEach(cb => {{
|
||
cb.checked = this.checked;
|
||
cb.closest('tr').classList.toggle('table-active', this.checked);
|
||
}});
|
||
updateBulkBar();
|
||
}});
|
||
function selectAll() {{
|
||
document.querySelectorAll('.row-cb').forEach(cb => {{ cb.checked=true; cb.closest('tr').classList.add('table-active'); }});
|
||
updateBulkBar();
|
||
}}
|
||
function selectNone() {{
|
||
document.querySelectorAll('.row-cb').forEach(cb => {{ cb.checked=false; cb.closest('tr').classList.remove('table-active'); }});
|
||
updateBulkBar();
|
||
}}
|
||
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`, {{}});
|
||
}};
|
||
window.bulkMove = async function() {{
|
||
const ids = getSelected();
|
||
if(!ids.length) return;
|
||
const options = {project_js};
|
||
const v = await window.uiSelect(`${{ids.length}} Dokumente verschieben`, options, 'Projekt wählen');
|
||
if(v===null || v==='') return;
|
||
await libPostMulti('/documents/bulk-move', {{ids, project_id:v}});
|
||
}};
|
||
window.bulkDelete = async function() {{
|
||
const ids = getSelected();
|
||
if(!ids.length) return;
|
||
const ok = await window.uiConfirm(`${{ids.length}} Dokumente löschen?`);
|
||
if(!ok) return;
|
||
await libPostMulti('/documents/bulk-delete', {{ids}});
|
||
}};
|
||
</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()
|
||
|
||
content_escaped = (d['content_md'] or '').replace('`', '`').replace('</script>', '<\\/script>')
|
||
body = f"""
|
||
<div class='d-flex justify-content-between align-items-start flex-wrap gap-2 mb-2'>
|
||
<div>
|
||
<h2 class='h4 mb-1'>Dokument #{d['id']} – {d['title']}</h2>
|
||
<div class='text-secondary small'>Projekt: {d['project']} · Typ: {d['kind']} · {d['created_at']}</div>
|
||
</div>
|
||
<div class='d-flex gap-2 flex-wrap'>
|
||
<div class='btn-group btn-group-sm' id='view-actions'>
|
||
<a class='btn btn-outline-secondary' title='Download .md' href='/document/{doc_id}/download.md'><i class='bi bi-download'></i></a>
|
||
<button class='btn btn-outline-primary' title='Bearbeiten' onclick='startEdit()'><i class='bi bi-pencil'></i> Bearbeiten</button>
|
||
<a class='btn btn-outline-secondary' title='Umbenennen' href='#' onclick='renameDoc();return false;'><i class='bi bi-fonts'></i></a>
|
||
<a class='btn btn-outline-secondary' title='Verschieben' href='#' onclick='moveDoc();return false;'><i class='bi bi-folder-symlink'></i></a>
|
||
<a class='btn btn-outline-danger' title='Löschen' href='#' onclick='deleteDoc();return false;'><i class='bi bi-trash'></i></a>
|
||
</div>
|
||
<div class='btn-group btn-group-sm d-none' id='edit-actions'>
|
||
<button class='btn btn-success' onclick='saveEdit()'><i class='bi bi-check-lg'></i> Speichern</button>
|
||
<button class='btn btn-outline-secondary' onclick='cancelEdit()'><i class='bi bi-x-lg'></i> Abbrechen</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div class='card mdview' id='doc-view'>{rendered}</div>
|
||
<div class='d-none' id='doc-edit'>
|
||
<textarea id='doc-textarea' class='form-control' style='min-height:60vh;font-family:ui-monospace,SFMono-Regular,Menlo,monospace;font-size:.9rem;resize:vertical'></textarea>
|
||
</div>
|
||
|
||
<script>
|
||
const _originalContent = {json.dumps(d['content_md'] or '')};
|
||
function startEdit() {{
|
||
document.getElementById('doc-textarea').value = _originalContent;
|
||
document.getElementById('doc-view').classList.add('d-none');
|
||
document.getElementById('doc-edit').classList.remove('d-none');
|
||
document.getElementById('view-actions').classList.add('d-none');
|
||
document.getElementById('edit-actions').classList.remove('d-none');
|
||
document.getElementById('doc-textarea').focus();
|
||
}}
|
||
function cancelEdit() {{
|
||
document.getElementById('doc-view').classList.remove('d-none');
|
||
document.getElementById('doc-edit').classList.add('d-none');
|
||
document.getElementById('view-actions').classList.remove('d-none');
|
||
document.getElementById('edit-actions').classList.add('d-none');
|
||
}}
|
||
async function saveEdit() {{
|
||
const content = document.getElementById('doc-textarea').value;
|
||
const body = new URLSearchParams({{content_md: content}});
|
||
const r = await fetch('/document/{doc_id}/edit', {{method:'POST', headers:{{'Content-Type':'application/x-www-form-urlencoded'}}, body}});
|
||
if(!r.ok) {{ alert('Fehler: '+r.status); return; }}
|
||
location.reload();
|
||
}}
|
||
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}/edit", response_class=HTMLResponse)
|
||
def edit_document(doc_id: int, content_md: str = Form(...)):
|
||
with db() as c:
|
||
c.execute("UPDATE documents SET content_md=? WHERE id=?", (content_md, doc_id))
|
||
return HTMLResponse(f"<meta http-equiv='refresh' content='0; url=/document/{doc_id}'>")
|
||
|
||
|
||
@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.post("/documents/bulk-move", response_class=HTMLResponse)
|
||
def bulk_move_documents(ids: List[int] = Form(...), project_id: int = Form(...)):
|
||
with db() as c:
|
||
c.executemany("UPDATE documents SET project_id=? WHERE id=?", [(project_id, i) for i in ids])
|
||
return HTMLResponse("<meta http-equiv='refresh' content='0; url=/library'>")
|
||
|
||
|
||
@app.post("/documents/bulk-delete", response_class=HTMLResponse)
|
||
def bulk_delete_documents(ids: List[int] = Form(...)):
|
||
with db() as c:
|
||
c.executemany("DELETE FROM documents WHERE id=?", [(i,) for i in ids])
|
||
return HTMLResponse("<meta http-equiv='refresh' content='0; url=/library'>")
|
||
|
||
|
||
def _prompt_llm_block(p, providers, def_model, def_num_ctx, def_num_predict, def_repeat_penalty, def_repeat_last_n):
|
||
pid = p["id"]
|
||
use_default = int(p["llm_use_default"]) if p["llm_use_default"] is not None else 1
|
||
m = p["llm_model"] or def_model
|
||
nc = p["llm_num_ctx"] or def_num_ctx
|
||
np_ = p["llm_num_predict"] or def_num_predict
|
||
rp = p["llm_repeat_penalty"] or def_repeat_penalty
|
||
rn = p["llm_repeat_last_n"] or def_repeat_last_n
|
||
cur_provider_id = p["provider_id"] if "provider_id" in p.keys() else None
|
||
disabled = "disabled" if use_default else ""
|
||
opacity = "opacity:.45;pointer-events:none" if use_default else ""
|
||
ctx_opts = [("auto","Automatisch"), ("4096","4 096"), ("8192","8 192"), ("16384","16 384"),
|
||
("32768","32 768"), ("65536","65 536"), ("131072","131 072")]
|
||
ctx_html = "".join(f"<option value='{v}'{' selected' if v==str(nc) else ''}>{l}</option>" for v,l in ctx_opts)
|
||
provider_opts = "".join(
|
||
f"<option value='{pv['id']}'{' selected' if pv['id'] == cur_provider_id else ''}>{pv['name']}</option>"
|
||
for pv in providers
|
||
)
|
||
return (
|
||
f"<hr class='my-3'>"
|
||
f"<div class='d-flex align-items-center gap-3 mb-2'>"
|
||
f" <span class='text-secondary small fw-semibold'><i class='bi bi-robot me-1'></i>KI-Einstellungen</span>"
|
||
f" <div class='form-check form-switch mb-0'>"
|
||
f" <input class='form-check-input' type='checkbox' role='switch' id='llmdef_{pid}'"
|
||
f" onchange='toggleLlmDef({pid},this.checked)' {'checked' if use_default else ''}>"
|
||
f" <label class='form-check-label small' for='llmdef_{pid}'>Standard</label>"
|
||
f" </div>"
|
||
f"</div>"
|
||
f"<input type='hidden' id='llm_use_default_{pid}' name='llm_use_default' value='{use_default}'>"
|
||
f"<div id='llmfields_{pid}' class='row g-2' style='{opacity}'>"
|
||
f" <div class='col-12 col-md-4'>"
|
||
f" <label class='form-label small text-secondary mb-1'>Anbieter</label>"
|
||
f" <select class='form-select form-select-sm' name='provider_id' id='llmprov_{pid}'"
|
||
f" onchange='loadModelsInto(\"llmmodel_{pid}\",this.value)' {disabled}>"
|
||
f" <option value=''>– Standard –</option>{provider_opts}"
|
||
f" </select>"
|
||
f" </div>"
|
||
f" <div class='col-12 col-md-8'>"
|
||
f" <label class='form-label small text-secondary mb-1'>Modell</label>"
|
||
f" <div class='input-group input-group-sm'>"
|
||
f" <input id='llmmodel_{pid}' class='form-control form-control-sm font-monospace' name='llm_model' value='{m}' {disabled}>"
|
||
f" <button type='button' class='btn btn-outline-secondary btn-sm' onclick='loadModelsInto(\"llmmodel_{pid}\",document.getElementById(\"llmprov_{pid}\").value)' {'disabled' if use_default else ''}>"
|
||
f" <i class='bi bi-arrow-clockwise'></i>"
|
||
f" </button>"
|
||
f" </div>"
|
||
f" </div>"
|
||
f" <div class='col-6 col-md-3'>"
|
||
f" <label class='form-label small text-secondary mb-1'>Kontext</label>"
|
||
f" <select class='form-select form-select-sm' name='llm_num_ctx' {disabled}>{ctx_html}</select>"
|
||
f" </div>"
|
||
f" <div class='col-6 col-md-3'>"
|
||
f" <label class='form-label small text-secondary mb-1'>Max. Tokens</label>"
|
||
f" <input class='form-control form-control-sm' type='number' name='llm_num_predict' min='256' max='131072' step='256' value='{np_}' {disabled}>"
|
||
f" </div>"
|
||
f" <div class='col-6 col-md-3'>"
|
||
f" <label class='form-label small text-secondary mb-1'>Repeat Penalty</label>"
|
||
f" <input class='form-control form-control-sm' type='number' name='llm_repeat_penalty' min='1.0' max='2.0' step='0.01' value='{rp}' {disabled}>"
|
||
f" </div>"
|
||
f" <div class='col-6 col-md-3'>"
|
||
f" <label class='form-label small text-secondary mb-1'>Repeat Last N</label>"
|
||
f" <input class='form-control form-control-sm' type='number' name='llm_repeat_last_n' min='0' max='2048' step='1' value='{rn}' {disabled}>"
|
||
f" </div>"
|
||
f"</div>"
|
||
)
|
||
|
||
|
||
@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()
|
||
providers = c.execute("SELECT * FROM providers ORDER BY name").fetchall()
|
||
def_model = get_setting("llm_model", _SEED_MODEL)
|
||
def_num_ctx = get_setting("llm_num_ctx", "auto")
|
||
def_num_predict = get_setting("llm_num_predict", str(_SEED_NUM_PREDICT))
|
||
def_repeat_penalty = get_setting("llm_repeat_penalty", "1.15")
|
||
def_repeat_last_n = get_setting("llm_repeat_last_n", "128")
|
||
|
||
project_opts = "".join([f"<option value='{p['name']}'>{p['name']}</option>" for p in projects])
|
||
|
||
project_list = "".join([
|
||
f"<div class='card'>"
|
||
f"<div class='d-flex justify-content-between align-items-center mb-2'><div class='fw-semibold'>{p['name']}</div><span class='badge rounded-pill text-bg-light'>Projekt</span></div>"
|
||
f"<form method='post' action='/projects/update'>"
|
||
f"<input type='hidden' name='id' value='{p['id']}'>"
|
||
f"<div class='row g-2 align-items-end'>"
|
||
f"<div class='col-12 col-md-8'><label class='form-label small text-secondary mb-1'>Name</label><input class='form-control' name='name' value='{p['name']}'></div>"
|
||
f"<div class='col-12 col-md-4'><button class='btn btn-primary btn-sm' type='submit'><i class='bi bi-pencil-square'></i> Umbenennen</button></div>"
|
||
f"</div>"
|
||
f"</form>"
|
||
f"<form method='post' action='/projects/{p['id']}/delete' onsubmit='return confirm(\"Projekt löschen? Dokumente werden auf Default verschoben.\")' class='mt-2'>"
|
||
f"<button class='btn btn-outline-danger btn-sm' type='submit'><i class='bi bi-trash'></i> Löschen</button>"
|
||
f"</form>"
|
||
f"</div>" for p in projects
|
||
]) or "<p class='text-secondary'>Keine Projekte vorhanden.</p>"
|
||
|
||
prompt_list = "".join(
|
||
[
|
||
f"<div class='card'>"
|
||
f"<div class='d-flex justify-content-between align-items-center mb-2'><div class='fw-semibold'>{p['name']}</div><span class='badge rounded-pill text-bg-light'>#{p['id']}</span></div>"
|
||
f"<form method='post' action='/prompts/update'>"
|
||
f"<input type='hidden' name='id' value='{p['id']}'>"
|
||
f"<div class='row g-2'>"
|
||
f"<div class='col-12 col-lg-4'><label class='form-label small text-secondary mb-1'>Name</label><input class='form-control' name='name' value='{p['name']}'></div>"
|
||
f"<div class='col-12 col-lg-8'>"
|
||
f"<div class='d-flex justify-content-between align-items-center'>"
|
||
f"<label class='form-label small text-secondary mb-1'>Prompttext</label>"
|
||
f"<button type='button' class='btn btn-outline-secondary btn-sm py-0 px-2' onclick='openPromptEditor({p['id']})' title='Vollbild-Editor'><i class='bi bi-arrows-fullscreen'></i></button>"
|
||
f"</div>"
|
||
f"<textarea id='prompt_text_{p['id']}' class='form-control' name='prompt' style='min-height:110px; resize:vertical'>{p['prompt']}</textarea>"
|
||
f"</div>"
|
||
f"</div>"
|
||
+ _prompt_llm_block(p, providers, def_model, def_num_ctx, def_num_predict, def_repeat_penalty, def_repeat_last_n) +
|
||
f"<div class='mt-3 d-flex flex-wrap gap-2'>"
|
||
f"<button class='btn btn-primary btn-sm' type='submit'><i class='bi bi-check2-circle'></i> Speichern</button>"
|
||
f"<button class='btn btn-outline-secondary btn-sm' type='button' onclick='previewPrompt({p['id']})'><i class='bi bi-eye'></i> Anzeigen</button>"
|
||
f"</div>"
|
||
f"</form>"
|
||
f"<form method='post' action='/prompts/{p['id']}/delete' onsubmit='return confirm(\"Prompt löschen?\")' class='mt-2'>"
|
||
f"<button class='btn btn-outline-danger btn-sm' type='submit'><i class='bi bi-trash'></i> Löschen</button>"
|
||
f"</form>"
|
||
f"</div>"
|
||
for p in prompts
|
||
]
|
||
) or "<p class='text-secondary'>Keine Prompts vorhanden.</p>"
|
||
|
||
body = f"""
|
||
<div class='d-flex justify-content-between align-items-center mb-3'>
|
||
<span class='badge text-bg-secondary'>{len(prompts)} Prompts · {len(projects)} Projekte</span>
|
||
</div>
|
||
|
||
<ul class='nav nav-tabs mb-3' id='cfgTabs' role='tablist'>
|
||
<li class='nav-item' role='presentation'>
|
||
<button class='nav-link active' data-bs-toggle='tab' data-bs-target='#pane-projects' type='button' role='tab'>Projekte</button>
|
||
</li>
|
||
<li class='nav-item' role='presentation'>
|
||
<button class='nav-link' data-bs-toggle='tab' data-bs-target='#pane-prompts' type='button' role='tab'>Prompts</button>
|
||
</li>
|
||
</ul>
|
||
|
||
<div class='tab-content'>
|
||
<div class='tab-pane fade show active' id='pane-projects' role='tabpanel'>
|
||
<div class='card'>
|
||
<div class='d-flex align-items-center gap-2 mb-2'><i class='bi bi-folder-plus text-primary'></i><h4 class='h6 mb-0'>Neues Projekt anlegen</h4></div>
|
||
<form method='post' action='/projects' class='row g-2 align-items-end'>
|
||
<div class='col-12 col-md-8'><label class='form-label small text-secondary mb-1'>Projektname</label><input class='form-control' name='name' list='projectNames' placeholder='Projektname' required></div>
|
||
<datalist id='projectNames'>{project_opts}</datalist>
|
||
<div class='col-12 col-md-4'><button class='btn btn-primary btn-sm' type='submit'><i class='bi bi-plus-lg'></i> Anlegen</button></div>
|
||
</form>
|
||
</div>
|
||
{project_list}
|
||
</div>
|
||
|
||
<div class='tab-pane fade' id='pane-prompts' role='tabpanel'>
|
||
<div class='card'>
|
||
<div class='d-flex align-items-center gap-2 mb-2'><i class='bi bi-plus-circle text-primary'></i><h4 class='h6 mb-0'>Neuen Prompt anlegen</h4></div>
|
||
<form method='post' action='/prompts/add'>
|
||
<div class='mb-2'><label class='form-label small text-secondary mb-1'>Name</label><input class='form-control' name='name' placeholder='z. B. Executive Summary' required></div>
|
||
<div class='mb-2'><label class='form-label small text-secondary mb-1'>Prompttext</label><textarea class='form-control' name='prompt' placeholder='Prompttext' required></textarea></div>
|
||
<button class='btn btn-primary btn-sm' type='submit'><i class='bi bi-plus-lg'></i> Anlegen</button>
|
||
</form>
|
||
</div>
|
||
{prompt_list}
|
||
</div>
|
||
</div>
|
||
|
||
<div class='modal fade' id='promptPreviewModal' tabindex='-1' aria-hidden='true'>
|
||
<div class='modal-dialog modal-lg modal-dialog-scrollable'>
|
||
<div class='modal-content'>
|
||
<div class='modal-header'>
|
||
<h5 class='modal-title'>Prompt Vorschau</h5>
|
||
<button type='button' class='btn-close' data-bs-dismiss='modal' aria-label='Close'></button>
|
||
</div>
|
||
<div class='modal-body'>
|
||
<div id='promptPreviewBody' class='mdview'>Lade …</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class='modal fade' id='promptEditorModal' tabindex='-1' aria-hidden='true'>
|
||
<div class='modal-dialog modal-fullscreen'>
|
||
<div class='modal-content'>
|
||
<div class='modal-header'>
|
||
<h5 class='modal-title'>Prompt bearbeiten (Vollbild)</h5>
|
||
<button type='button' class='btn btn-outline-secondary btn-sm' onclick='closePromptEditor()'><i class='bi bi-fullscreen-exit'></i> Minimize</button>
|
||
</div>
|
||
<div class='modal-body'>
|
||
<textarea id='promptEditorTextarea' class='form-control' style='height:100%; min-height:70vh; font-family: ui-monospace, SFMono-Regular, Menlo, monospace;'></textarea>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<script>
|
||
let currentPromptEditorId = null;
|
||
|
||
async function previewPrompt(id) {{
|
||
const body = document.getElementById('promptPreviewBody');
|
||
body.innerHTML = 'Lade …';
|
||
const modalEl = document.getElementById('promptPreviewModal');
|
||
const modal = bootstrap.Modal.getOrCreateInstance(modalEl);
|
||
modal.show();
|
||
const r = await fetch('/prompts/' + id + '/preview');
|
||
const j = await r.json();
|
||
body.innerHTML = j.html || '<p class="text-secondary">Keine Vorschau.</p>';
|
||
}}
|
||
|
||
function openPromptEditor(id) {{
|
||
currentPromptEditorId = id;
|
||
const src = document.getElementById('prompt_text_' + id);
|
||
if(!src) return;
|
||
const ta = document.getElementById('promptEditorTextarea');
|
||
ta.value = src.value;
|
||
const modal = bootstrap.Modal.getOrCreateInstance(document.getElementById('promptEditorModal'));
|
||
modal.show();
|
||
}}
|
||
|
||
function syncPromptEditorBack() {{
|
||
const ta = document.getElementById('promptEditorTextarea');
|
||
if(currentPromptEditorId !== null) {{
|
||
const dst = document.getElementById('prompt_text_' + currentPromptEditorId);
|
||
if(dst) dst.value = ta.value;
|
||
}}
|
||
}}
|
||
|
||
function closePromptEditor() {{
|
||
syncPromptEditorBack();
|
||
const modal = bootstrap.Modal.getOrCreateInstance(document.getElementById('promptEditorModal'));
|
||
modal.hide();
|
||
}}
|
||
|
||
document.addEventListener('DOMContentLoaded', () => {{
|
||
const el = document.getElementById('promptEditorModal');
|
||
if(el) el.addEventListener('hide.bs.modal', syncPromptEditorBack);
|
||
}});
|
||
|
||
const _llmDefaults = {{
|
||
model: {json.dumps(def_model)},
|
||
num_ctx: {json.dumps(def_num_ctx)},
|
||
num_predict: {json.dumps(def_num_predict)},
|
||
repeat_penalty: {json.dumps(def_repeat_penalty)},
|
||
repeat_last_n: {json.dumps(def_repeat_last_n)},
|
||
}};
|
||
|
||
function toggleLlmDef(pid, isDefault) {{
|
||
document.getElementById('llm_use_default_' + pid).value = isDefault ? '1' : '0';
|
||
const wrap = document.getElementById('llmfields_' + pid);
|
||
const inputs = wrap.querySelectorAll('input,select,button');
|
||
if (isDefault) {{
|
||
wrap.style.opacity = '.45';
|
||
wrap.style.pointerEvents = 'none';
|
||
inputs.forEach(el => el.disabled = true);
|
||
}} else {{
|
||
wrap.style.opacity = '';
|
||
wrap.style.pointerEvents = '';
|
||
inputs.forEach(el => el.disabled = false);
|
||
// pre-fill from defaults if field is empty
|
||
const modelInp = document.getElementById('llmmodel_' + pid);
|
||
if (modelInp && !modelInp.value) modelInp.value = _llmDefaults.model;
|
||
}}
|
||
}}
|
||
|
||
async function loadModelsInto(inputId, providerId) {{
|
||
const inp = document.getElementById(inputId);
|
||
if (!inp) return;
|
||
const btn = inp.nextElementSibling;
|
||
if (btn) btn.disabled = true;
|
||
try {{
|
||
const pid = providerId || '';
|
||
const res = await fetch('/settings/models' + (pid ? '?provider_id=' + pid : ''));
|
||
const data = await res.json();
|
||
if (!data.ok || !data.models.length) {{
|
||
alert('Keine Modelle gefunden: ' + (data.error || ''));
|
||
return;
|
||
}}
|
||
const v = await window.uiSelect('Modell wählen',
|
||
data.models.map(m => ({{value: m, label: m}})),
|
||
'– Modell auswählen –'
|
||
);
|
||
if (v) inp.value = v;
|
||
}} catch(e) {{
|
||
alert('Fehler: ' + e);
|
||
}} finally {{
|
||
if (btn) btn.disabled = false;
|
||
}}
|
||
}}
|
||
</script>
|
||
"""
|
||
return layout("Prompts & Projekte", body)
|
||
|
||
|
||
@app.get("/prompts/{prompt_id}/preview")
|
||
def prompt_preview(prompt_id: int):
|
||
with db() as c:
|
||
p = c.execute("SELECT id,name,prompt FROM prompts WHERE id=?", (prompt_id,)).fetchone()
|
||
if not p:
|
||
raise HTTPException(404, "Prompt nicht gefunden")
|
||
html = md.markdown(p["prompt"] or "", extensions=["fenced_code", "tables", "nl2br"])
|
||
return {"id": p["id"], "name": p["name"], "html": html}
|
||
|
||
@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(...),
|
||
llm_use_default: int = Form(1),
|
||
llm_model: Optional[str] = Form(None),
|
||
llm_num_ctx: Optional[str] = Form(None),
|
||
llm_num_predict: Optional[str] = Form(None),
|
||
llm_repeat_penalty: Optional[str] = Form(None),
|
||
llm_repeat_last_n: Optional[str] = Form(None),
|
||
provider_id: Optional[int] = Form(None),
|
||
):
|
||
with db() as c:
|
||
c.execute(
|
||
"""UPDATE prompts SET name=?, prompt=?, updated_at=?,
|
||
llm_use_default=?, llm_model=?,
|
||
llm_num_ctx=?, llm_num_predict=?, llm_repeat_penalty=?, llm_repeat_last_n=?,
|
||
provider_id=?
|
||
WHERE id=?""",
|
||
(
|
||
name.strip(), prompt.strip(), now_iso(),
|
||
llm_use_default,
|
||
(llm_model or "").strip() or None,
|
||
(llm_num_ctx or "auto").strip(),
|
||
int(float(llm_num_predict)) if llm_num_predict else None,
|
||
float(llm_repeat_penalty) if llm_repeat_penalty else None,
|
||
int(float(llm_repeat_last_n)) if llm_repeat_last_n else None,
|
||
provider_id or None,
|
||
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 _parse_utcish(ts: Optional[str]) -> Optional[datetime]:
|
||
if not ts:
|
||
return None
|
||
try:
|
||
return datetime.fromisoformat(str(ts).replace("Z", "+00:00")).replace(tzinfo=None)
|
||
except Exception:
|
||
try:
|
||
return datetime.fromisoformat(str(ts))
|
||
except Exception:
|
||
return None
|
||
|
||
|
||
def _fmt_elapsed(start_iso: Optional[str], end_iso: Optional[str] = None) -> str:
|
||
s = _parse_utcish(start_iso)
|
||
if not s:
|
||
return "-"
|
||
try:
|
||
e = _parse_utcish(end_iso) if end_iso else datetime.utcnow()
|
||
if not e:
|
||
e = datetime.utcnow()
|
||
sec = max(0, int((e - s).total_seconds()))
|
||
if sec < 60:
|
||
return f"{sec}s"
|
||
m, s2 = divmod(sec, 60)
|
||
if m < 60:
|
||
return f"{m}m {s2}s"
|
||
h, m2 = divmod(m, 60)
|
||
return f"{h}h {m2}m"
|
||
except Exception:
|
||
return "-"
|
||
|
||
|
||
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"] not in ("done", "error", "cancelled"):
|
||
_job_set(job_id, status="cancelled", finished_at=now_iso(), error="Cancelled by user")
|
||
return {"ok": True, "status": "cancelled"}
|
||
|
||
|
||
@app.post("/jobs/{job_id}/cancel-form")
|
||
def jobs_cancel_form(job_id: int):
|
||
jobs_cancel(job_id)
|
||
return RedirectResponse(url="/jobs", status_code=303)
|
||
|
||
|
||
@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.post("/jobs/{job_id}/delete-form")
|
||
def jobs_delete_form(job_id: int):
|
||
jobs_delete(job_id)
|
||
return RedirectResponse(url="/jobs", status_code=303)
|
||
|
||
|
||
@app.get("/jobs/{job_id}/debug-data")
|
||
def job_debug_data(job_id: int):
|
||
j = _job_get(job_id)
|
||
if not j:
|
||
raise HTTPException(404, "job not found")
|
||
with _JOB_STREAM_LOCK:
|
||
live = dict(_JOB_STREAMS[job_id]) if job_id in _JOB_STREAMS else None
|
||
return {
|
||
"status": j["status"],
|
||
"kind": j["kind"],
|
||
"llm_prompt": j.get("llm_prompt"),
|
||
"llm_thinking": live["thinking"] if live is not None else j.get("llm_thinking"),
|
||
"llm_response": live["response"] if live is not None else j.get("llm_response"),
|
||
"streaming": live is not None,
|
||
}
|
||
|
||
|
||
@app.get("/jobs", response_class=HTMLResponse)
|
||
def jobs_page(queued: Optional[int] = None):
|
||
items = _jobs_payload(200)
|
||
|
||
def _badge(status: str) -> str:
|
||
s = (status or "").lower()
|
||
if s == "done":
|
||
return "success"
|
||
if s in ("running", "queued"):
|
||
return "primary"
|
||
if s == "cancelled":
|
||
return "warning"
|
||
return "danger"
|
||
|
||
cards = []
|
||
for it in items:
|
||
start_ts = it.get("started_at") or it.get("created_at") or ""
|
||
end_ts = it.get("finished_at") or ""
|
||
actions = ""
|
||
if it["status"] not in ("done", "error", "cancelled"):
|
||
actions += f"<button class='btn btn-outline-warning btn-sm' onclick='cancelJob({it['id']})'><i class=\'bi bi-x-circle\'></i> Abbrechen</button> "
|
||
actions += f"<button class='btn btn-outline-danger btn-sm' onclick='deleteJob({it['id']})'><i class=\'bi bi-trash\'></i> Löschen</button> "
|
||
if it.get("result_document_id"):
|
||
actions += f"<a class='btn btn-primary btn-sm' href='/document/{it['result_document_id']}'><i class='bi bi-box-arrow-up-right'></i> Ergebnis</a>"
|
||
if it["kind"] == "analysis":
|
||
actions += f" <button class='btn btn-outline-info btn-sm' onclick='openDebug({it['id']})'><i class='bi bi-bug'></i> Debug</button>"
|
||
|
||
err = f"<div class='alert alert-danger mt-2 mb-0 py-2 small'>{str(it['error']).replace('<','<')}</div>" if it.get("error") else ""
|
||
cards.append(
|
||
f"<div class='card job-card' data-job-id='{it['id']}'>"
|
||
f"<div class='d-flex justify-content-between align-items-center flex-wrap gap-2'>"
|
||
f"<div class='d-flex align-items-center gap-2'><input class='form-check-input job-select' type='checkbox' value='{it['id']}'><div><div class='fw-semibold'>Job #{it['id']} · {it['kind']}</div>"
|
||
f"<div class='text-secondary small'>erstellt: {it['created_at']} · läuft: <span class='elapsed' data-start='{start_ts}' data-end='{end_ts}'>{_fmt_elapsed(start_ts, end_ts)}</span></div></div></div>"
|
||
f"<span class='badge text-bg-{_badge(it['status'])}'>{it['status']}</span></div>"
|
||
f"<div class='small mt-2'>"
|
||
f"{('Projekt: '+it['project_name']) if it.get('project_name') else ''}"
|
||
f"{('<br>Dokument: '+it['document_title']) if it.get('document_title') else ''}"
|
||
f"{('<br>Prompt: '+it['prompt_name']) if it.get('prompt_name') else ''}"
|
||
f"</div>"
|
||
f"<div class='d-flex flex-wrap gap-2 mt-3'>{actions}</div>"
|
||
f"{err}"
|
||
f"</div>"
|
||
)
|
||
|
||
pre = "".join(cards) or "<p class='text-secondary'>Keine Jobs.</p>"
|
||
|
||
notice = f"<div class='alert alert-info py-2'><b>Job #{queued} wurde eingereiht.</b></div>" if queued else ""
|
||
body = f"""
|
||
<h2 class='h4 mb-2'>Hintergrundverarbeitung</h2>
|
||
<p class='text-secondary small'>Maximal 2 Jobs gleichzeitig. Seite aktualisiert automatisch.</p>
|
||
{notice}
|
||
<div class='card py-2'>
|
||
<div class='d-flex flex-wrap gap-2 align-items-center'>
|
||
<button class='btn btn-outline-secondary btn-sm' type='button' onclick='selectAllJobs()'><i class='bi bi-check2-square'></i> Alle wählen</button>
|
||
<button class='btn btn-outline-secondary btn-sm' type='button' onclick='clearJobSelection()'><i class='bi bi-square'></i> Auswahl löschen</button>
|
||
<button class='btn btn-outline-danger btn-sm' type='button' onclick='deleteSelectedJobs()'><i class='bi bi-trash'></i> Ausgewählte löschen</button>
|
||
<button class='btn btn-danger btn-sm' type='button' onclick='deleteAllJobs()'><i class='bi bi-trash3'></i> Alle Jobs löschen</button>
|
||
<span id='jobs-selected-count' class='text-secondary small ms-auto'>0 ausgewählt</span>
|
||
</div>
|
||
</div>
|
||
<div id='jobs-status' class='text-secondary small mb-2'></div>
|
||
<div id='jobs-root'>{pre}</div>
|
||
<script>
|
||
function parseUtcish(ts) {{
|
||
if(!ts) return NaN;
|
||
const hasZone = /Z$|[+-]\d\d:\d\d$/.test(ts);
|
||
return Date.parse(hasZone ? ts : (ts + 'Z'));
|
||
}}
|
||
function since(ts, endTs=null) {{
|
||
if(!ts) return '-';
|
||
const s = Math.max(0, Math.floor(((endTs ? parseUtcish(endTs) : Date.now()) - parseUtcish(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';
|
||
}}
|
||
function badgeColor(s) {{
|
||
return {{done:'success', running:'primary', queued:'primary', cancelled:'warning'}}[s] || 'danger';
|
||
}}
|
||
function renderCard(it) {{
|
||
const startTs = it.started_at || it.created_at || '';
|
||
const endTs = it.finished_at || '';
|
||
let actions = '';
|
||
if(!['done','error','cancelled'].includes(it.status))
|
||
actions += `<button class='btn btn-outline-warning btn-sm' onclick='cancelJob(${{it.id}})'><i class='bi bi-x-circle'></i> Abbrechen</button> `;
|
||
actions += `<button class='btn btn-outline-danger btn-sm' onclick='deleteJob(${{it.id}})'><i class='bi bi-trash'></i> Löschen</button> `;
|
||
if(it.result_document_id)
|
||
actions += `<a class='btn btn-primary btn-sm' href='/document/${{it.result_document_id}}'><i class='bi bi-box-arrow-up-right'></i> Ergebnis</a>`;
|
||
if(it.kind === 'analysis')
|
||
actions += ` <button class='btn btn-outline-info btn-sm' onclick='openDebug(${{it.id}})'><i class='bi bi-bug'></i> Debug</button>`;
|
||
const err = it.error ? `<div class='alert alert-danger mt-2 mb-0 py-2 small'>${{String(it.error).replace(/</g,'<')}}</div>` : '';
|
||
const meta = [
|
||
it.project_name ? 'Projekt: '+it.project_name : '',
|
||
it.document_title? 'Dokument: '+it.document_title : '',
|
||
it.prompt_name ? 'Prompt: '+it.prompt_name : '',
|
||
].filter(Boolean).join('<br>');
|
||
return `<div class='card job-card' data-job-id='${{it.id}}'>
|
||
<div class='d-flex justify-content-between align-items-center flex-wrap gap-2'>
|
||
<div class='d-flex align-items-center gap-2'>
|
||
<input class='form-check-input job-select' type='checkbox' value='${{it.id}}'>
|
||
<div>
|
||
<div class='fw-semibold'>Job #${{it.id}} · ${{it.kind}}</div>
|
||
<div class='text-secondary small'>erstellt: ${{it.created_at}} · läuft: <span class='elapsed' data-start='${{startTs}}' data-end='${{endTs}}'>${{since(startTs, endTs||null)}}</span></div>
|
||
</div>
|
||
</div>
|
||
<span class='badge text-bg-${{badgeColor(it.status)}}'>${{it.status}}</span>
|
||
</div>
|
||
<div class='small mt-2'>${{meta}}</div>
|
||
<div class='d-flex flex-wrap gap-2 mt-3'>${{actions}}</div>
|
||
${{err}}
|
||
</div>`;
|
||
}}
|
||
async function post(url) {{
|
||
const r = await fetch(url, {{method:'POST'}});
|
||
if(!r.ok) throw new Error('Fehler '+r.status);
|
||
}}
|
||
function selectedJobIds() {{
|
||
return Array.from(document.querySelectorAll('.job-select:checked')).map(el => Number(el.value));
|
||
}}
|
||
function updateSelectionCount() {{
|
||
const el = document.getElementById('jobs-selected-count');
|
||
if(el) el.textContent = `${{selectedJobIds().length}} ausgewählt`;
|
||
}}
|
||
function selectAllJobs() {{
|
||
document.querySelectorAll('.job-select').forEach(el => el.checked = true);
|
||
updateSelectionCount();
|
||
}}
|
||
function clearJobSelection() {{
|
||
document.querySelectorAll('.job-select').forEach(el => el.checked = false);
|
||
updateSelectionCount();
|
||
}}
|
||
async function deleteSelectedJobs() {{
|
||
const ids = selectedJobIds();
|
||
if(!ids.length) return alert('Keine Jobs ausgewählt');
|
||
if(!confirm(`${{ids.length}} Jobs wirklich löschen?`)) return;
|
||
for(const id of ids) await post('/jobs/'+id+'/delete');
|
||
await refreshJobs();
|
||
}}
|
||
async function deleteAllJobs() {{
|
||
const ids = Array.from(document.querySelectorAll('.job-select')).map(el => Number(el.value));
|
||
if(!ids.length) return;
|
||
if(!confirm(`Wirklich ALLE ${{ids.length}} Jobs löschen?`)) return;
|
||
for(const id of ids) await post('/jobs/'+id+'/delete');
|
||
await refreshJobs();
|
||
}}
|
||
async function cancelJob(id) {{
|
||
if(!confirm('Job abbrechen?')) return;
|
||
await post('/jobs/'+id+'/cancel');
|
||
await refreshJobs();
|
||
}}
|
||
async function deleteJob(id) {{
|
||
if(!confirm('Job löschen?')) return;
|
||
await post('/jobs/'+id+'/delete');
|
||
await refreshJobs();
|
||
}}
|
||
let _pollTimer = null;
|
||
async function refreshJobs() {{
|
||
try {{
|
||
const checked = new Set(selectedJobIds());
|
||
const r = await fetch('/jobs/data');
|
||
const {{items}} = await r.json();
|
||
const root = document.getElementById('jobs-root');
|
||
if(!items.length) {{
|
||
root.innerHTML = "<p class='text-secondary'>Keine Jobs.</p>";
|
||
}} else {{
|
||
root.innerHTML = items.map(renderCard).join('');
|
||
root.querySelectorAll('.job-select').forEach(el => {{
|
||
if(checked.has(Number(el.value))) el.checked = true;
|
||
}});
|
||
}}
|
||
updateSelectionCount();
|
||
const hasActive = items.some(it => ['queued','running'].includes(it.status));
|
||
document.getElementById('jobs-status').textContent = hasActive ? 'Live-Update aktiv …' : '';
|
||
schedulePoll(hasActive ? 3000 : 10000);
|
||
}} catch(e) {{
|
||
document.getElementById('jobs-status').textContent = 'Verbindungsfehler – versuche erneut …';
|
||
schedulePoll(5000);
|
||
}}
|
||
}}
|
||
function schedulePoll(ms) {{
|
||
clearTimeout(_pollTimer);
|
||
_pollTimer = setTimeout(refreshJobs, ms);
|
||
}}
|
||
document.addEventListener('change', e => {{
|
||
if(e.target?.classList?.contains('job-select')) updateSelectionCount();
|
||
}});
|
||
document.addEventListener('visibilitychange', () => {{
|
||
if(document.visibilityState === 'visible') refreshJobs();
|
||
}});
|
||
setInterval(() => document.querySelectorAll('.elapsed').forEach(el => {{
|
||
el.textContent = since(el.dataset.start, el.dataset.end || null);
|
||
}}), 1000);
|
||
updateSelectionCount();
|
||
schedulePoll(3000);
|
||
|
||
let _debugJobId = null;
|
||
let _debugPollTimer = null;
|
||
async function openDebug(jobId) {{
|
||
_debugJobId = jobId;
|
||
document.getElementById('debug-job-id').textContent = jobId;
|
||
document.getElementById('debug-prompt-content').textContent = 'Lade …';
|
||
document.getElementById('debug-response-content').textContent = 'Lade …';
|
||
bootstrap.Modal.getOrCreateInstance(document.getElementById('debugModal')).show();
|
||
await refreshDebug();
|
||
}}
|
||
async function refreshDebug() {{
|
||
if (!_debugJobId) return;
|
||
try {{
|
||
const r = await fetch('/jobs/' + _debugJobId + '/debug-data');
|
||
if (!r.ok) return;
|
||
const d = await r.json();
|
||
if (d.kind !== 'analysis') {{
|
||
document.getElementById('debug-prompt-content').textContent = 'Upload-Job – kein LLM-Prompt';
|
||
document.getElementById('debug-response-content').textContent = '-';
|
||
return;
|
||
}}
|
||
document.getElementById('debug-prompt-content').textContent = d.llm_prompt || '(kein Prompt gespeichert)';
|
||
const respEl = document.getElementById('debug-response-content');
|
||
const atBottom = respEl.scrollHeight - respEl.scrollTop <= respEl.clientHeight + 20;
|
||
const thinking = d.llm_thinking || '';
|
||
const response = d.llm_response || '';
|
||
if (thinking) {{
|
||
respEl.innerHTML =
|
||
`<span style='color:#94a3b8;font-size:.75rem;letter-spacing:.06em'>▶ THINKING</span>\n` +
|
||
`<span style='color:#7dd3fc'>${{thinking.replace(/</g,'<')}}</span>` +
|
||
(response ? `\n\n<span style='color:#94a3b8;font-size:.75rem;letter-spacing:.06em'>▶ ANTWORT</span>\n<span style='color:#c7f9cc'>${{response.replace(/</g,'<')}}</span>` : '');
|
||
}} else {{
|
||
respEl.textContent = response || '(noch keine Antwort)';
|
||
}}
|
||
if (atBottom) respEl.scrollTop = respEl.scrollHeight;
|
||
const badge = document.getElementById('debug-streaming-badge');
|
||
badge.classList.toggle('d-none', !d.streaming);
|
||
clearTimeout(_debugPollTimer);
|
||
if (d.streaming || d.status === 'running') {{
|
||
_debugPollTimer = setTimeout(refreshDebug, 500);
|
||
}}
|
||
}} catch(e) {{
|
||
clearTimeout(_debugPollTimer);
|
||
_debugPollTimer = setTimeout(refreshDebug, 2000);
|
||
}}
|
||
}}
|
||
document.addEventListener('DOMContentLoaded', () => {{
|
||
const el = document.getElementById('debugModal');
|
||
if (el) el.addEventListener('hide.bs.modal', () => {{
|
||
_debugJobId = null;
|
||
clearTimeout(_debugPollTimer);
|
||
}});
|
||
}});
|
||
</script>
|
||
|
||
<div class='modal fade' id='debugModal' tabindex='-1' aria-hidden='true'>
|
||
<div class='modal-dialog modal-xl modal-dialog-scrollable'>
|
||
<div class='modal-content'>
|
||
<div class='modal-header py-2'>
|
||
<h5 class='modal-title'><i class='bi bi-bug'></i> Debug · Job #<span id='debug-job-id'></span></h5>
|
||
<button type='button' class='btn-close' data-bs-dismiss='modal'></button>
|
||
</div>
|
||
<div class='modal-body p-0'>
|
||
<ul class='nav nav-tabs px-3 pt-2'>
|
||
<li class='nav-item'>
|
||
<button class='nav-link active' data-bs-toggle='tab' data-bs-target='#debug-prompt-pane' type='button'>
|
||
<i class='bi bi-send'></i> Gesendeter Prompt
|
||
</button>
|
||
</li>
|
||
<li class='nav-item'>
|
||
<button class='nav-link' data-bs-toggle='tab' data-bs-target='#debug-response-pane' type='button'>
|
||
<i class='bi bi-cpu'></i> Antwort
|
||
<span id='debug-streaming-badge' class='badge text-bg-primary ms-1 d-none'>● live</span>
|
||
</button>
|
||
</li>
|
||
</ul>
|
||
<div class='tab-content p-3'>
|
||
<div class='tab-pane fade show active' id='debug-prompt-pane'>
|
||
<pre id='debug-prompt-content' style='max-height:65vh;overflow:auto;white-space:pre-wrap;background:#0f172a;color:#c7f9cc;padding:12px;border-radius:10px;font-size:.82rem'></pre>
|
||
</div>
|
||
<div class='tab-pane fade' id='debug-response-pane'>
|
||
<pre id='debug-response-content' style='max-height:65vh;overflow:auto;white-space:pre-wrap;background:#0f172a;color:#c7f9cc;padding:12px;border-radius:10px;font-size:.82rem'></pre>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
"""
|
||
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()
|
||
projects = c.execute("SELECT id,name FROM projects 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])
|
||
proj_opts = "".join([f"<option value='{p['id']}'>{p['name']}</option>" for p in projects])
|
||
|
||
body = f"""
|
||
<h2 class='h4 mb-3'>Prompt ausführen</h2>
|
||
<ul class='nav nav-tabs mb-3' id='runTabs' role='tablist'>
|
||
<li class='nav-item' role='presentation'>
|
||
<button class='nav-link active' data-bs-toggle='tab' data-bs-target='#pane-single' type='button' role='tab'><i class='bi bi-file-earmark-text'></i> Einzeldokument</button>
|
||
</li>
|
||
<li class='nav-item' role='presentation'>
|
||
<button class='nav-link' data-bs-toggle='tab' data-bs-target='#pane-project' type='button' role='tab'><i class='bi bi-folder2-open'></i> Projekt-Batch</button>
|
||
</li>
|
||
</ul>
|
||
<div class='tab-content'>
|
||
<div class='tab-pane fade show active' id='pane-single' role='tabpanel'>
|
||
<form method='post' action='/run' class='card'>
|
||
<div class='mb-3'>
|
||
<label class='form-label'>Dokument</label>
|
||
<select class='form-select' name='document_id'>{d_opts}</select>
|
||
</div>
|
||
<div class='mb-3'>
|
||
<label class='form-label'>Prompt</label>
|
||
<select class='form-select' name='prompt_id'>{p_opts}</select>
|
||
</div>
|
||
<div class='mb-3'>
|
||
<label class='form-label'>Zusatzinfos <span class='text-secondary fw-normal'>(optional — wird dem LLM zusätzlich mitgegeben)</span></label>
|
||
<textarea class='form-control' name='user_prompt' rows='3' placeholder='z. B. Fokus auf Entscheidungen, Kontext zum Meeting …'></textarea>
|
||
</div>
|
||
<button class='btn btn-primary' type='submit'><i class='bi bi-play-circle'></i> Ausführen</button>
|
||
</form>
|
||
</div>
|
||
<div class='tab-pane fade' id='pane-project' role='tabpanel'>
|
||
<form method='post' action='/run/project' class='card'>
|
||
<div class='text-secondary small mb-3'>Führt den gewählten Prompt für <strong>alle Transkripte</strong> des Projekts aus — je Transkript ein Job.</div>
|
||
<div class='mb-3'>
|
||
<label class='form-label'>Projekt</label>
|
||
<select class='form-select' name='project_id'>{proj_opts}</select>
|
||
</div>
|
||
<div class='mb-3'>
|
||
<label class='form-label'>Prompt</label>
|
||
<select class='form-select' name='prompt_id'>{p_opts}</select>
|
||
</div>
|
||
<div class='mb-3'>
|
||
<label class='form-label'>Zusatzinfos <span class='text-secondary fw-normal'>(optional — wird für alle Jobs mitgegeben)</span></label>
|
||
<textarea class='form-control' name='user_prompt' rows='3' placeholder='z. B. Fokus auf Entscheidungen, Kontext zum Meeting …'></textarea>
|
||
</div>
|
||
<button class='btn btn-primary' type='submit'><i class='bi bi-play-fill'></i> Alle Transkripte verarbeiten</button>
|
||
</form>
|
||
</div>
|
||
</div>
|
||
"""
|
||
return layout("Run", body)
|
||
|
||
|
||
@app.post("/run", response_class=HTMLResponse)
|
||
def run_prompt(document_id: int = Form(...), prompt_id: int = Form(...), user_prompt: str = 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, user_prompt=user_prompt.strip() or None)
|
||
return HTMLResponse(f"<meta http-equiv='refresh' content='0; url=/jobs?queued={job_id}'>")
|
||
|
||
|
||
@app.post("/run/project", response_class=HTMLResponse)
|
||
def run_project(project_id: int = Form(...), prompt_id: int = Form(...), user_prompt: str = Form("")):
|
||
with db() as c:
|
||
prm = c.execute("SELECT id FROM prompts WHERE id=?", (prompt_id,)).fetchone()
|
||
transcripts = c.execute(
|
||
"SELECT id FROM documents WHERE project_id=? AND kind='transcript'",
|
||
(project_id,),
|
||
).fetchall()
|
||
if not prm:
|
||
raise HTTPException(404, "Prompt nicht gefunden")
|
||
if not transcripts:
|
||
raise HTTPException(400, "Keine Transkripte im Projekt gefunden")
|
||
|
||
up = user_prompt.strip() or None
|
||
for doc in transcripts:
|
||
enqueue_job("analysis", document_id=doc["id"], prompt_id=prompt_id, user_prompt=up)
|
||
|
||
return HTMLResponse("<meta http-equiv='refresh' content='0; url=/jobs'>")
|