2026-03-21 13:47:36 +01:00
import json
import os
import sqlite3
2026-03-21 15:00:21 +01:00
import threading
from concurrent . futures import ThreadPoolExecutor
2026-03-21 13:47:36 +01:00
from datetime import datetime
2026-03-21 15:00:21 +01:00
from pathlib import Path
2026-03-27 09:49:31 +01:00
from typing import List , Optional
2026-03-21 13:47:36 +01:00
2026-03-21 14:32:24 +01:00
import markdown as md
2026-03-21 13:47:36 +01:00
import requests
from fastapi import FastAPI , File , Form , HTTPException , UploadFile
2026-03-21 15:19:03 +01:00
from fastapi . responses import HTMLResponse , PlainTextResponse , Response , JSONResponse , RedirectResponse
2026-03-21 13:47:36 +01:00
API_BASE = os . getenv ( " API_BASE " , " http://gx10.aquantico.lan:8093 " ) . rstrip ( " / " )
OLLAMA_BASE_URL = os . getenv ( " OLLAMA_BASE_URL " , " http://gx10.aquantico.lan:11434 " ) . rstrip ( " / " )
OLLAMA_MODEL = os . getenv ( " OLLAMA_MODEL " , " qwen3.5:9b " )
DB_PATH = os . getenv ( " DB_PATH " , " /data/ui.db " )
2026-03-21 14:02:21 +01:00
app = FastAPI ( title = " Diarization UI " )
2026-03-21 13:47:36 +01:00
2026-03-21 15:00:21 +01:00
JOB_DIR = Path ( os . getenv ( " JOB_DIR " , " /data/jobs " ) )
EXECUTOR = ThreadPoolExecutor ( max_workers = 2 )
JOB_LOCK = threading . Lock ( )
2026-03-21 13:47:36 +01:00
def db ( ) :
conn = sqlite3 . connect ( DB_PATH )
conn . row_factory = sqlite3 . Row
return conn
2026-03-21 14:02:21 +01:00
def now_iso ( ) - > str :
return datetime . utcnow ( ) . isoformat ( )
2026-03-21 13:47:36 +01:00
def init_db ( ) :
os . makedirs ( os . path . dirname ( DB_PATH ) , exist_ok = True )
with db ( ) as c :
c . execute (
"""
2026-03-21 14:02:21 +01:00
CREATE TABLE IF NOT EXISTS projects (
2026-03-21 13:47:36 +01:00
id INTEGER PRIMARY KEY AUTOINCREMENT ,
2026-03-21 14:02:21 +01:00
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 ,
2026-03-21 13:47:36 +01:00
created_at TEXT NOT NULL ,
2026-03-21 14:02:21 +01:00
updated_at TEXT NOT NULL
2026-03-21 13:47:36 +01:00
)
"""
)
c . execute (
"""
2026-03-21 14:02:21 +01:00
CREATE TABLE IF NOT EXISTS documents (
2026-03-21 13:47:36 +01:00
id INTEGER PRIMARY KEY AUTOINCREMENT ,
2026-03-21 14:02:21 +01:00
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 ,
2026-03-21 13:47:36 +01:00
created_at TEXT NOT NULL ,
2026-03-21 14:02:21 +01:00
FOREIGN KEY ( project_id ) REFERENCES projects ( id ) ,
FOREIGN KEY ( source_document_id ) REFERENCES documents ( id ) ,
FOREIGN KEY ( prompt_id ) REFERENCES prompts ( id )
2026-03-21 13:47:36 +01:00
)
"""
)
2026-03-21 15:00:21 +01:00
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
)
"""
)
2026-03-21 13:47:36 +01:00
2026-03-27 09:56:37 +01:00
# migrations
try :
c . execute ( " ALTER TABLE jobs ADD COLUMN user_prompt TEXT " )
except Exception :
pass
2026-03-21 14:02:21 +01:00
# defaults
c . execute ( " INSERT OR IGNORE INTO projects(name, created_at) VALUES (?,?) " , ( " Default " , now_iso ( ) ) )
c . execute (
" INSERT OR IGNORE INTO prompts(name, prompt, created_at, updated_at) VALUES (?,?,?,?) " ,
(
" Zusammenfassung " ,
" Erstelle eine prägnante Zusammenfassung des Gesprächs in Stichpunkten. " ,
now_iso ( ) ,
now_iso ( ) ,
) ,
)
c . execute (
" INSERT OR IGNORE INTO prompts(name, prompt, created_at, updated_at) VALUES (?,?,?,?) " ,
(
" Aufgaben " ,
" Extrahiere alle Aufgaben. Gib pro Aufgabe: Verantwortlich, Aufgabe, Deadline (falls vorhanden), Priorität. " ,
now_iso ( ) ,
now_iso ( ) ,
) ,
)
def layout ( title : str , body : str ) - > str :
return f """
< ! doctype html >
2026-03-21 14:30:15 +01:00
< 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 ' >
2026-03-27 09:46:24 +01:00
< 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 ' >
2026-03-21 14:02:21 +01:00
< title > { title } < / title >
< style >
2026-03-27 09:46:24 +01:00
: root { { - - sidebar : #0b1220;--sidebar-border:#1e293b;--page:#f3f6fb;--card:#ffffff;--text:#0f172a;--muted:#64748b;--accent:#2563eb}}
2026-03-21 14:30:15 +01:00
* { { box - sizing : border - box } }
2026-03-27 09:46:24 +01:00
body { { font - family : Inter , system - ui , Arial , sans - serif ; background : var ( - - page ) ; color : var ( - - text ) ; margin : 0 ; display : flex ; min - height : 100 vh } }
nav { { width : 260 px ; background : linear - gradient ( 180 deg , #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 : .2 px ; margin : 6 px 10 px 14 px ; color : #dbeafe;font-size:1.05rem}}
. navsection { { margin : 14 px 10 px 8 px ; color : #94a3b8;font-size:.72rem;text-transform:uppercase;letter-spacing:.08em}}
nav a { { display : flex ; gap : 10 px ; 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 : 1 rem ; opacity : .95 ; width : 18 px ; text - align : center } }
nav a : hover { { background : #16243d;color:#fff;border-color:#2c3d63}}
2026-03-21 15:42:36 +01:00
. app { { flex : 1 ; display : flex ; flex - direction : column ; min - width : 0 } }
2026-03-27 09:46:24 +01:00
. topbar { { height : 62 px ; background : rgba ( 255 , 255 , 255 , .92 ) ; backdrop - filter : blur ( 8 px ) ; border - bottom : 1 px solid #e2e8f0;display:flex;align-items:center;padding:0 18px;gap:12px;position:sticky;top:0;z-index:20}}
. topbar h1 { { font - size : 1.02 rem ; margin : 0 ; font - weight : 700 } }
. menu - btn { { display : none ; border : 1 px solid #dbe3ef;background:#fff;color:#0f172a;border-radius:10px;padding:.35rem .55rem;font-size:1rem;line-height:1}}
main { { padding : 18 px ; max - width : 1400 px ; width : 100 % ; margin : 0 auto } }
. card { { background : var ( - - card ) ; border : 1 px 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 } }
2026-03-21 15:42:36 +01:00
. mdview blockquote { { border - left : 3 px solid #94a3b8;padding-left:10px;color:#334155}}
2026-03-27 09:46:24 +01:00
. hint , small { { color : var ( - - muted ) } }
. btn { { - - bs - btn - padding - y : .34 rem ; - - bs - btn - padding - x : .72 rem ; - - bs - btn - font - size : .9 rem ; 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 ( 92 vw , 540 px ) ; 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 : 8 px ; justify - content : flex - end ; margin - top : 12 px } }
. iconbtn { { text - decoration : none } }
@media ( max - width : 980 px ) { {
nav { { width : 84 px ; padding : 10 px 6 px } }
nav a span : last - child , . navsection { { display : none } }
. brand { { font - size : .78 rem ; margin : 8 px 8 px 12 px } }
2026-03-22 07:43:09 +01:00
} }
@media ( max - width : 760 px ) { {
body { { display : block } }
2026-03-27 09:46:24 +01:00
nav { { position : fixed ; left : 0 ; top : 0 ; bottom : 0 ; width : 270 px ; height : 100 vh ; transform : translateX ( - 105 % ) ; transition : transform .22 s ease ; box - shadow : 0 22 px 42 px rgba ( 2 , 6 , 23 , .45 ) } }
2026-03-22 07:47:08 +01:00
body . nav - open nav { { transform : translateX ( 0 ) } }
2026-03-27 09:46:24 +01:00
. nav - backdrop { { display : none ; position : fixed ; inset : 0 ; background : rgba ( 2 , 6 , 23 , .45 ) ; z - index : 55 } }
2026-03-22 07:47:08 +01:00
body . nav - open . nav - backdrop { { display : block } }
2026-03-27 09:46:24 +01:00
. menu - btn { { display : inline - flex ; align - items : center ; justify - content : center } }
. topbar { { padding : 0 12 px } }
main { { padding : 12 px } }
nav a span : last - child , . navsection { { display : block } }
2026-03-22 07:43:09 +01:00
} }
2026-03-21 14:30:15 +01:00
< / style >
< script >
if ( ' serviceWorker ' in navigator ) { { window . addEventListener ( ' load ' , ( ) = > navigator . serviceWorker . register ( ' /sw.js ' ) . catch ( ( ) = > { { } } ) ) ; } }
2026-03-22 07:47:08 +01:00
window . toggleNav = function ( ) { { document . body . classList . toggle ( ' nav-open ' ) ; } } ;
window . closeNav = function ( ) { { document . body . classList . remove ( ' nav-open ' ) ; } } ;
2026-03-21 14:43:55 +01:00
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 > ` } } ) ;
} } ;
2026-03-27 09:46:24 +01:00
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 ' ;
} }
} } ) ;
} } ) ;
2026-03-21 14:30:15 +01:00
< / script >
< / head >
2026-03-21 14:02:21 +01:00
< body >
2026-03-27 09:46:24 +01:00
< div id = ' modal-backdrop ' class = ' oc-modal-backdrop ' > < div id = ' modal-box ' class = ' oc-modal ' > < / div > < / div >
2026-03-22 07:47:08 +01:00
< div class = ' nav-backdrop ' onclick = ' closeNav() ' > < / div >
2026-03-21 14:02:21 +01:00
< nav >
2026-03-27 09:46:24 +01:00
< 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 >
2026-03-21 15:42:36 +01:00
< div class = ' navsection ' > System < / div >
2026-03-27 09:46:24 +01:00
< a href = ' /healthz ' onclick = ' closeNav() ' > < i class = ' bi bi-heart-pulse ' > < / i > < span > Health < / span > < / a >
2026-03-21 14:02:21 +01:00
< / nav >
2026-03-21 15:42:36 +01:00
< div class = ' app ' >
< div class = ' topbar ' >
2026-03-27 09:46:24 +01:00
< button class = ' menu-btn ' type = ' button ' onclick = ' toggleNav() ' > < i class = ' bi bi-list ' > < / i > < / button >
2026-03-21 15:42:36 +01:00
< h1 > { title } < / h1 >
< / div >
2026-03-27 09:46:24 +01:00
< main class = ' container-fluid py-2 ' > { body } < / main >
2026-03-21 15:42:36 +01:00
< / div >
2026-03-27 09:46:24 +01:00
< script src = ' https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js ' > < / script >
2026-03-21 14:02:21 +01:00
< / body > < / html >
"""
def get_projects ( ) :
with db ( ) as c :
return c . execute ( " SELECT id,name FROM projects ORDER BY name " ) . fetchall ( )
2026-03-21 14:25:18 +01:00
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 " "
2026-03-21 14:02:21 +01:00
def get_prompts ( ) :
with db ( ) as c :
return c . execute ( " SELECT id,name,prompt FROM prompts ORDER BY name " ) . fetchall ( )
2026-03-21 13:47:36 +01:00
2026-03-21 15:07:31 +01:00
def _job_get ( job_id : int ) :
with db ( ) as c :
2026-03-27 10:06:41 +01:00
row = c . execute ( " SELECT * FROM jobs WHERE id=? " , ( job_id , ) ) . fetchone ( )
return dict ( row ) if row else None
2026-03-21 15:07:31 +01:00
2026-03-21 15:00:21 +01:00
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 ) :
2026-03-21 15:07:31 +01:00
j = _job_get ( job_id )
2026-03-21 15:00:21 +01:00
if not j :
return
2026-03-21 15:07:31 +01:00
if j [ " status " ] == " cancelled " :
return
2026-03-21 15:00:21 +01:00
_job_set ( job_id , status = " running " , started_at = now_iso ( ) )
try :
2026-03-21 15:07:31 +01:00
j = _job_get ( job_id )
if not j or j [ " status " ] == " cancelled " :
return
2026-03-21 15:00:21 +01:00
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 ( )
2026-03-21 15:07:31 +01:00
j = _job_get ( job_id )
if not j or j [ " status " ] == " cancelled " :
return
2026-03-21 15:00:21 +01:00
content_md = payload . get ( " formatted_text " , " " )
doc_title = ( j [ " title " ] or " " ) . strip ( ) or filename
with db ( ) as c :
cur = c . execute (
" INSERT INTO documents(project_id, kind, title, content_md, raw_json, created_at) VALUES (?,?,?,?,?,?) " ,
( j [ " project_id " ] , " transcript " , doc_title , content_md , json . dumps ( payload , ensure_ascii = False ) , now_iso ( ) ) ,
)
new_doc_id = cur . lastrowid
_job_set ( job_id , status = " done " , result_document_id = new_doc_id , finished_at = now_iso ( ) )
except Exception as e :
_job_set ( job_id , status = " error " , error = str ( e ) , finished_at = now_iso ( ) )
def _process_analysis_job ( job_id : int ) :
2026-03-21 15:07:31 +01:00
j = _job_get ( job_id )
2026-03-21 15:00:21 +01:00
if not j :
return
2026-03-21 15:07:31 +01:00
if j [ " status " ] == " cancelled " :
return
2026-03-21 15:00:21 +01:00
_job_set ( job_id , status = " running " , started_at = now_iso ( ) )
try :
2026-03-21 15:07:31 +01:00
j = _job_get ( job_id )
if not j or j [ " status " ] == " cancelled " :
return
2026-03-21 15:00:21 +01:00
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 " )
2026-03-27 10:06:41 +01:00
user_extra = ( j . get ( " user_prompt " ) or " " ) . strip ( )
2026-03-21 15:00:21 +01:00
llm_prompt = (
" Du bist ein präziser Assistent. Antworte auf Deutsch. \\ n "
2026-03-27 09:56:37 +01:00
f " AUFTRAG: \\ n { prm [ ' prompt ' ] } \\ n "
+ ( f " \\ nZUSATZINFOS: \\ n { user_extra } \\ n " if user_extra else " " )
+ f " \\ nTEXT: \\ n { doc [ ' content_md ' ] } \\ n "
2026-03-21 15:00:21 +01:00
)
r = requests . post (
f " { OLLAMA_BASE_URL } /api/generate " ,
json = { " model " : OLLAMA_MODEL , " prompt " : llm_prompt , " stream " : False } ,
timeout = 1200 ,
)
r . raise_for_status ( )
answer = r . json ( ) . get ( " response " , " " )
2026-03-21 15:07:31 +01:00
j = _job_get ( job_id )
if not j or j [ " status " ] == " cancelled " :
return
2026-03-21 15:00:21 +01:00
with db ( ) as c :
cur = c . execute (
"""
INSERT INTO documents ( project_id , kind , title , content_md , source_document_id , prompt_id , raw_json , created_at )
VALUES ( ? , ? , ? , ? , ? , ? , ? , ? )
""" ,
(
doc [ " project_id " ] ,
" analysis " ,
f " Analyse: { prm [ ' name ' ] } · { doc [ ' title ' ] } " ,
answer ,
doc [ " id " ] ,
prm [ " id " ] ,
json . dumps ( { " ollama_response " : r . json ( ) } , ensure_ascii = False ) ,
now_iso ( ) ,
) ,
)
new_doc_id = cur . lastrowid
_job_set ( job_id , status = " done " , result_document_id = new_doc_id , finished_at = now_iso ( ) )
except Exception as e :
_job_set ( job_id , status = " error " , error = str ( e ) , finished_at = now_iso ( ) )
def enqueue_job ( kind : str , * * kwargs ) - > int :
with JOB_LOCK :
with db ( ) as c :
cur = c . execute (
"""
2026-03-27 09:56:37 +01:00
INSERT INTO jobs ( kind , status , project_id , document_id , prompt_id , title , file_path , user_prompt , created_at )
VALUES ( ? , ? , ? , ? , ? , ? , ? , ? , ? )
2026-03-21 15:00:21 +01:00
""" ,
(
kind ,
" queued " ,
kwargs . get ( " project_id " ) ,
kwargs . get ( " document_id " ) ,
kwargs . get ( " prompt_id " ) ,
kwargs . get ( " title " ) ,
kwargs . get ( " file_path " ) ,
2026-03-27 09:56:37 +01:00
kwargs . get ( " user_prompt " ) or None ,
2026-03-21 15:00:21 +01:00
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
2026-03-21 13:47:36 +01:00
@app.on_event ( " startup " )
def startup ( ) :
init_db ( )
2026-03-21 15:00:21 +01:00
JOB_DIR . mkdir ( parents = True , exist_ok = True )
2026-03-21 13:47:36 +01:00
2026-03-21 14:30:15 +01:00
@app.get ( " /manifest.webmanifest " )
def manifest ( ) :
return {
2026-03-22 07:47:08 +01:00
" name " : " VoiceLog " ,
" short_name " : " VoiceLog " ,
2026-03-21 14:30:15 +01:00
" 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 " )
2026-03-21 13:47:36 +01:00
@app.get ( " /healthz " )
def healthz ( ) :
2026-03-21 14:02:21 +01:00
return { " ok " : True , " api_base " : API_BASE , " ollama_base_url " : OLLAMA_BASE_URL , " ollama_model " : OLLAMA_MODEL , " db_path " : DB_PATH }
2026-03-21 13:47:36 +01:00
@app.get ( " / " , response_class = HTMLResponse )
2026-03-21 14:02:21 +01:00
def upload_page ( msg : str = " " ) :
projects = get_projects ( )
opts = " " . join ( [ f " <option value= ' { p [ ' id ' ] } ' > { p [ ' name ' ] } </option> " for p in projects ] )
body = f """
2026-03-27 09:46:24 +01:00
< 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 ' > Audio wird transkribiert + mit Sprechern angereichert und als Dokument gespeichert . < / p >
{ f " <div class= ' alert alert-info py-2 ' > { msg } </div> " if msg else " " }
2026-03-21 14:02:21 +01:00
< form action = ' /upload ' method = ' post ' enctype = ' multipart/form-data ' class = ' card ' >
2026-03-27 09:46:24 +01:00
< div class = ' row g-2 ' >
< div class = ' col-12 col-lg-4 ' >
< label class = ' form-label ' > Projekt < / label >
< select class = ' form-select ' name = ' project_id ' > { opts } < / select >
< / div >
< div class = ' col-12 col-lg-4 ' >
< label class = ' form-label ' > Titel ( optional ) < / label >
< input class = ' form-control ' name = ' title ' placeholder = ' z. B. Team-Call 23.03 ' >
< / div >
< div class = ' col-12 col-lg-4 ' >
< label class = ' form-label ' > Audio - Datei < / label >
< input class = ' form-control ' type = ' file ' name = ' file ' accept = ' audio/* ' required >
< / div >
2026-03-21 14:02:21 +01:00
< / div >
2026-03-27 09:46:24 +01:00
< div class = ' mt-3 ' >
< button class = ' btn btn-primary ' type = ' submit ' > Verarbeiten & speichern < / button >
2026-03-21 14:02:21 +01:00
< / div >
< / form >
2026-03-21 13:47:36 +01:00
"""
2026-03-21 14:02:21 +01:00
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 ' > " )
2026-03-21 13:47:36 +01:00
2026-03-21 14:25:18 +01:00
@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 ' > " )
2026-03-21 14:02:21 +01:00
@app.post ( " /upload " , response_class = HTMLResponse )
async def upload ( project_id : int = Form ( . . . ) , title : str = Form ( " " ) , file : UploadFile = File ( . . . ) ) :
2026-03-21 13:47:36 +01:00
data = await file . read ( )
if not data :
2026-03-21 14:02:21 +01:00
raise HTTPException ( 400 , " Leere Datei " )
2026-03-21 13:47:36 +01:00
2026-03-21 15:00:21 +01:00
JOB_DIR . mkdir ( parents = True , exist_ok = True )
filename = ( file . filename or " audio.bin " ) . replace ( " / " , " _ " )
temp_path = JOB_DIR / f " { now_iso ( ) . replace ( ' : ' , ' - ' ) } _ { filename } "
temp_path . write_bytes ( data )
2026-03-21 13:47:36 +01:00
2026-03-21 15:00:21 +01:00
job_id = enqueue_job (
" upload " ,
project_id = project_id ,
title = ( title or " " ) . strip ( ) or filename ,
file_path = str ( temp_path ) ,
)
return HTMLResponse ( f " <meta http-equiv= ' refresh ' content= ' 0; url=/jobs?queued= { job_id } ' > " )
2026-03-21 13:47:36 +01:00
2026-03-21 14:02:21 +01:00
@app.get ( " /library " , response_class = HTMLResponse )
2026-03-21 15:38:47 +01:00
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 " "
2026-03-21 13:47:36 +01:00
with db ( ) as c :
2026-03-21 14:02:21 +01:00
projects = c . execute ( " SELECT id,name FROM projects ORDER BY name " ) . fetchall ( )
2026-03-21 15:38:47 +01:00
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 ( )
2026-03-21 14:02:21 +01:00
p_opts = " <option value= ' ' >Alle</option> " + " " . join (
2026-03-21 15:38:47 +01:00
[ f " <option value= ' { p [ ' id ' ] } ' { ' selected ' if project_id_int == p [ ' id ' ] else ' ' } > { p [ ' name ' ] } </option> " for p in projects ]
2026-03-21 14:02:21 +01:00
)
2026-03-21 15:41:12 +01:00
rows = " " . join (
2026-03-21 14:02:21 +01:00
[
2026-03-21 15:41:12 +01:00
f " <tr> "
2026-03-27 09:49:31 +01:00
f " <td style= ' width:36px ' ><input type= ' checkbox ' class= ' form-check-input row-cb ' value= ' { d [ ' id ' ] } ' ></td> "
2026-03-21 15:41:12 +01:00
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> "
2026-03-27 09:46:24 +01:00
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> "
2026-03-21 15:41:12 +01:00
f " </tr> "
2026-03-21 14:02:21 +01:00
for d in docs
]
)
2026-03-21 14:43:55 +01:00
project_js = json . dumps ( [ { " value " : p [ " id " ] , " label " : p [ " name " ] } for p in projects ] , ensure_ascii = False )
2026-03-21 14:02:21 +01:00
body = f """
2026-03-27 09:46:24 +01:00
< 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 >
2026-03-27 09:40:27 +01:00
< / div >
2026-03-27 09:46:24 +01:00
< 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( " ' " , " & #39;")}'>
< / 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( " ' " , " & #39;")}'>
< / 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 >
2026-03-27 09:49:31 +01:00
< 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 >
2026-03-27 09:46:24 +01:00
< div class = ' card ' >
< div class = ' table-responsive ' >
< table class = ' table table-sm table-striped align-middle mb-0 ' >
2026-03-27 09:49:31 +01:00
< 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 >
2026-03-27 09:46:24 +01:00
< / table >
< / div >
2026-03-27 09:40:27 +01:00
< / div >
2026-03-27 09:49:31 +01:00
< 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 >
2026-03-21 14:43:55 +01:00
< 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 ( ) ;
} }
2026-03-27 09:49:31 +01:00
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 ( ) ;
} }
2026-03-21 14:43:55 +01:00
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 ` , { { } } ) ;
} } ;
2026-03-27 09:49:31 +01:00
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 } } ) ;
} } ;
2026-03-21 14:43:55 +01:00
< / script >
2026-03-21 14:02:21 +01:00
"""
return layout ( " Library " , body )
2026-03-21 13:47:36 +01:00
2026-03-21 14:02:21 +01:00
@app.get ( " /document/ {doc_id} " , response_class = HTMLResponse )
def view_document ( doc_id : int ) :
2026-03-21 13:47:36 +01:00
with db ( ) as c :
2026-03-21 14:02:21 +01:00
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 " )
2026-03-21 14:32:24 +01:00
rendered = md . markdown ( d [ " content_md " ] or " " , extensions = [ " fenced_code " , " tables " , " nl2br " ] )
2026-03-21 14:35:26 +01:00
projects = get_projects ( )
2026-03-21 14:02:21 +01:00
body = f """
2026-03-27 09:46:24 +01:00
< 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 = ' btn-group btn-group-sm ' >
< a class = ' btn btn-outline-secondary ' title = ' Download .md ' href = ' /document/ {doc_id} /download.md ' > ⬇ ️ < / a >
< a class = ' btn btn-outline-secondary ' title = ' Umbenennen ' href = ' # ' onclick = ' renameDoc();return false; ' > ✏ ️ < / a >
< a class = ' btn btn-outline-secondary ' title = ' Verschieben ' href = ' # ' onclick = ' moveDoc();return false; ' > 📁 < / a >
< a class = ' btn btn-outline-danger ' title = ' Löschen ' href = ' # ' onclick = ' deleteDoc();return false; ' > 🗑 ️ < / a >
< / div >
< / div >
2026-03-21 14:32:24 +01:00
< div class = ' card mdview ' > { rendered } < / div >
2026-03-21 14:35:26 +01:00
< script >
2026-03-21 14:38:40 +01:00
window . postForm = async function ( url , data ) { {
2026-03-21 14:35:26 +01:00
const body = new URLSearchParams ( data ) ;
2026-03-21 14:38:40 +01:00
const r = await fetch ( url , { { method : ' POST ' , headers : { { ' Content-Type ' : ' application/x-www-form-urlencoded ' } } , body } } ) ;
if ( ! r . ok ) { { alert ( ' Fehler: ' + r . status ) ; return ; } }
2026-03-21 14:35:26 +01:00
location . href = ' /library ' ;
2026-03-21 14:38:40 +01:00
} } ;
2026-03-21 14:43:55 +01:00
window . renameDoc = async function ( ) { {
const v = await window . uiPrompt ( ' Neuer Dokumentname ' , { json . dumps ( d [ ' title ' ] ) } ) ;
2026-03-21 14:35:26 +01:00
if ( v == = null ) return ;
2026-03-21 14:38:40 +01:00
window . postForm ( ' /document/ {doc_id} /rename ' , { { title : v } } ) ;
} } ;
2026-03-21 14:43:55 +01:00
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 ;
2026-03-21 14:38:40 +01:00
window . postForm ( ' /document/ {doc_id} /move ' , { { project_id : v } } ) ;
} } ;
2026-03-21 14:43:55 +01:00
window . deleteDoc = async function ( ) { {
const ok = await window . uiConfirm ( ' Dokument wirklich löschen? ' ) ;
if ( ! ok ) return ;
2026-03-21 14:38:40 +01:00
window . postForm ( ' /document/ {doc_id} /delete ' , { { } } ) ;
} } ;
2026-03-21 14:35:26 +01:00
< / script >
2026-03-21 14:02:21 +01:00
"""
return layout ( " Dokument " , body )
2026-03-21 13:47:36 +01:00
2026-03-21 14:02:21 +01:00
2026-03-21 14:21:10 +01:00
@app.get ( " /document/ {doc_id} /download.md " , response_class = PlainTextResponse )
2026-03-21 14:02:21 +01:00
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 " )
2026-03-21 14:27:52 +01:00
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 } " } ,
)
2026-03-21 14:02:21 +01:00
2026-03-21 14:25:18 +01:00
@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 ' > " )
2026-03-27 09:49:31 +01:00
@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 ' > " )
2026-03-21 14:02:21 +01:00
@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 ( )
2026-03-27 09:46:24 +01:00
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 (
2026-03-21 14:02:21 +01:00
[
2026-03-27 09:46:24 +01:00
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> "
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> "
2026-03-21 14:25:18 +01:00
f " </div> "
2026-03-21 14:02:21 +01:00
for p in prompts
]
2026-03-27 09:46:24 +01:00
) or " <p class= ' text-secondary ' >Keine Prompts vorhanden.</p> "
2026-03-21 14:02:21 +01:00
body = f """
2026-03-27 09:46:24 +01:00
< 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 >
2026-03-21 14:02:21 +01:00
< / div >
2026-03-27 09:46:24 +01:00
< 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 >
2026-03-21 14:02:21 +01:00
< / div >
2026-03-27 09:46:24 +01:00
< 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 ) ;
} } ) ;
< / script >
2026-03-21 14:02:21 +01:00
"""
2026-03-27 09:46:24 +01:00
return layout ( " Prompts & Projekte " , body )
2026-03-21 14:02:21 +01:00
2026-03-27 09:46:24 +01:00
@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 }
2026-03-21 14:02:21 +01:00
@app.post ( " /prompts/add " , response_class = HTMLResponse )
def prompt_add ( name : str = Form ( . . . ) , prompt : str = Form ( . . . ) ) :
with db ( ) as c :
c . execute (
" INSERT INTO prompts(name,prompt,created_at,updated_at) VALUES (?,?,?,?) " ,
( name . strip ( ) , prompt . strip ( ) , now_iso ( ) , now_iso ( ) ) ,
)
return HTMLResponse ( " <meta http-equiv= ' refresh ' content= ' 0; url=/prompts ' > " )
@app.post ( " /prompts/update " , response_class = HTMLResponse )
def prompt_update ( id : int = Form ( . . . ) , name : str = Form ( . . . ) , prompt : str = Form ( . . . ) ) :
with db ( ) as c :
c . execute ( " UPDATE prompts SET name=?, prompt=?, updated_at=? WHERE id=? " , ( name . strip ( ) , prompt . strip ( ) , now_iso ( ) , id ) )
return HTMLResponse ( " <meta http-equiv= ' refresh ' content= ' 0; url=/prompts ' > " )
2026-03-21 14:25:18 +01:00
@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 ' > " )
2026-03-21 15:27:43 +01:00
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
2026-03-21 15:21:00 +01:00
def _fmt_elapsed ( start_iso : Optional [ str ] , end_iso : Optional [ str ] = None ) - > str :
2026-03-21 15:27:43 +01:00
s = _parse_utcish ( start_iso )
if not s :
2026-03-21 15:21:00 +01:00
return " - "
try :
2026-03-21 15:27:43 +01:00
e = _parse_utcish ( end_iso ) if end_iso else datetime . utcnow ( )
if not e :
e = datetime . utcnow ( )
2026-03-21 15:21:00 +01:00
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 " - "
2026-03-21 15:07:31 +01:00
def _jobs_payload ( limit : int = 200 ) :
2026-03-21 15:00:21 +01:00
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
2026-03-21 15:07:31 +01:00
ORDER BY j . id DESC LIMIT ?
""" ,
( limit , ) ,
2026-03-21 15:00:21 +01:00
) . fetchall ( )
2026-03-21 15:07:31 +01:00
return [ dict ( j ) for j in jobs ]
2026-03-21 15:00:21 +01:00
2026-03-21 15:07:31 +01:00
@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 " )
2026-03-21 15:19:03 +01:00
if j [ " status " ] not in ( " done " , " error " , " cancelled " ) :
_job_set ( job_id , status = " cancelled " , finished_at = now_iso ( ) , error = " Cancelled by user " )
2026-03-21 15:07:31 +01:00
return { " ok " : True , " status " : " cancelled " }
2026-03-21 15:19:03 +01:00
@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 )
2026-03-21 15:07:31 +01:00
@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 }
2026-03-21 15:19:03 +01:00
@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 )
2026-03-21 15:07:31 +01:00
@app.get ( " /jobs " , response_class = HTMLResponse )
def jobs_page ( queued : Optional [ int ] = None ) :
2026-03-21 15:10:39 +01:00
items = _jobs_payload ( 200 )
2026-03-27 09:46:24 +01:00
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> "
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> "
2026-03-21 15:13:17 +01:00
)
2026-03-21 15:10:39 +01:00
2026-03-27 09:46:24 +01:00
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 " "
2026-03-21 15:00:21 +01:00
body = f """
2026-03-27 09:46:24 +01:00
< h2 class = ' h4 mb-2 ' > Hintergrundverarbeitung < / h2 >
< p class = ' text-secondary small ' > Maximal 2 Jobs gleichzeitig . Seite aktualisiert automatisch . < / p >
2026-03-21 15:00:21 +01:00
{ notice }
2026-03-27 09:46:24 +01:00
< 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 >
2026-03-27 10:03:01 +01:00
< div id = ' jobs-status ' class = ' text-secondary small mb-2 ' > < / div >
2026-03-21 15:10:39 +01:00
< div id = ' jobs-root ' > { pre } < / div >
2026-03-21 15:07:31 +01:00
< script >
2026-03-21 15:27:43 +01:00
function parseUtcish ( ts ) { {
if ( ! ts ) return NaN ;
const hasZone = / Z $ | [ + - ] \d \d : \d \d $ / . test ( ts ) ;
2026-03-27 09:46:24 +01:00
return Date . parse ( hasZone ? ts : ( ts + ' Z ' ) ) ;
2026-03-21 15:27:43 +01:00
} }
2026-03-21 15:23:09 +01:00
function since ( ts , endTs = null ) { {
2026-03-21 15:07:31 +01:00
if ( ! ts ) return ' - ' ;
2026-03-27 10:03:01 +01:00
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 ' ;
2026-03-21 15:07:31 +01:00
const h = Math . floor ( m / 60 ) ; return h + ' h ' + ( m % 60 ) + ' m ' ;
} }
2026-03-27 10:03:01 +01:00
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 > ` ;
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 > ` ;
2026-03-21 15:23:09 +01:00
} }
2026-03-21 15:07:31 +01:00
async function post ( url ) { {
const r = await fetch ( url , { { method : ' POST ' } } ) ;
2026-03-27 09:46:24 +01:00
if ( ! r . ok ) throw new Error ( ' Fehler ' + r . status ) ;
2026-03-21 15:07:31 +01:00
} }
2026-03-27 09:46:24 +01:00
function selectedJobIds ( ) { {
return Array . from ( document . querySelectorAll ( ' .job-select:checked ' ) ) . map ( el = > Number ( el . value ) ) ;
} }
function updateSelectionCount ( ) { {
const el = document . getElementById ( ' jobs-selected-count ' ) ;
2026-03-27 10:03:01 +01:00
if ( el ) el . textContent = ` $ { { selectedJobIds ( ) . length } } ausgewählt ` ;
2026-03-21 15:07:31 +01:00
} }
2026-03-27 09:46:24 +01:00
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 ;
2026-03-27 10:03:01 +01:00
for ( const id of ids ) await post ( ' /jobs/ ' + id + ' /delete ' ) ;
await refreshJobs ( ) ;
2026-03-27 09:46:24 +01:00
} }
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 ;
2026-03-27 10:03:01 +01:00
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 ( ) ;
2026-03-27 09:46:24 +01:00
} }
2026-03-27 10:03:01 +01:00
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 ) ;
2026-03-21 15:07:31 +01:00
< / script >
2026-03-21 15:00:21 +01:00
"""
return layout ( " Jobs " , body )
2026-03-21 14:02:21 +01:00
@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 ( )
2026-03-27 09:56:37 +01:00
projects = c . execute ( " SELECT id,name FROM projects ORDER BY name " ) . fetchall ( )
2026-03-21 14:02:21 +01:00
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 ] )
2026-03-27 09:56:37 +01:00
proj_opts = " " . join ( [ f " <option value= ' { p [ ' id ' ] } ' > { p [ ' name ' ] } </option> " for p in projects ] )
2026-03-21 14:02:21 +01:00
body = f """
2026-03-27 09:46:24 +01:00
< h2 class = ' h4 mb-3 ' > Prompt ausführen < / h2 >
2026-03-27 09:56:37 +01:00
< 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 >
2026-03-27 09:46:24 +01:00
< / div >
2026-03-27 09:56:37 +01:00
< 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 >
2026-03-27 09:46:24 +01:00
< / div >
2026-03-27 09:56:37 +01:00
< / div >
2026-03-21 14:02:21 +01:00
"""
return layout ( " Run " , body )
2026-03-21 13:47:36 +01:00
2026-03-21 14:02:21 +01:00
@app.post ( " /run " , response_class = HTMLResponse )
2026-03-27 09:56:37 +01:00
def run_prompt ( document_id : int = Form ( . . . ) , prompt_id : int = Form ( . . . ) , user_prompt : str = Form ( " " ) ) :
2026-03-21 14:02:21 +01:00
with db ( ) as c :
2026-03-21 15:00:21 +01:00
doc = c . execute ( " SELECT id FROM documents WHERE id=? " , ( document_id , ) ) . fetchone ( )
prm = c . execute ( " SELECT id FROM prompts WHERE id=? " , ( prompt_id , ) ) . fetchone ( )
2026-03-21 14:02:21 +01:00
if not doc or not prm :
raise HTTPException ( 404 , " Dokument oder Prompt nicht gefunden " )
2026-03-21 13:47:36 +01:00
2026-03-27 09:56:37 +01:00
job_id = enqueue_job ( " analysis " , document_id = document_id , prompt_id = prompt_id , user_prompt = user_prompt . strip ( ) or None )
2026-03-21 15:00:21 +01:00
return HTMLResponse ( f " <meta http-equiv= ' refresh ' content= ' 0; url=/jobs?queued= { job_id } ' > " )
2026-03-27 09:56:37 +01:00
@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 ' > " )