feat(diarization-ui): prompt pipeline on upload + email delivery

- Upload page: select and reorder prompts via drag & drop; after
  transcription each prompt runs sequentially, feeding its output
  into the next prompt in the chain
- After the last pipeline step the final document is optionally sent
  as a Markdown attachment via SMTP to selected recipients
- Settings: new SMTP configuration section (host, port, user,
  password, sender, STARTTLS toggle)
- Settings: managed list of email recipient addresses (add/delete)
- DB migrations: jobs.pipeline_prompt_ids, jobs.email_to,
  email_addresses table

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-06-23 14:19:31 +02:00
parent 0e43185821
commit ce40a3376f

363
app.py
View File

@@ -1,5 +1,11 @@
import email as email_lib
import email.encoders
import email.mime.base
import email.mime.multipart
import email.mime.text
import json import json
import os import os
import smtplib
import sqlite3 import sqlite3
import threading import threading
from concurrent.futures import ThreadPoolExecutor from concurrent.futures import ThreadPoolExecutor
@@ -144,6 +150,26 @@ def init_db():
except Exception: except Exception:
pass 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 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 # defaults
c.execute("INSERT OR IGNORE INTO projects(name, created_at) VALUES (?,?)", ("Default", now_iso())) c.execute("INSERT OR IGNORE INTO projects(name, created_at) VALUES (?,?)", ("Default", now_iso()))
c.execute( c.execute(
@@ -177,6 +203,41 @@ def set_setting(key: str, value: str):
c.execute("INSERT OR REPLACE INTO settings(key, value) VALUES (?,?)", (key, value)) c.execute("INSERT OR REPLACE INTO settings(key, value) VALUES (?,?)", (key, value))
def _send_email(to_addrs: list[str], subject: str, body_md: str, attachment_name: str):
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"
msg = email.mime.multipart.MIMEMultipart()
msg["From"] = from_addr
msg["To"] = ", ".join(to_addrs)
msg["Subject"] = subject
msg.attach(email.mime.text.MIMEText("Anbei das Ergebnis als Markdown-Datei.", "plain", "utf-8"))
part = email.mime.base.MIMEBase("text", "markdown")
part.set_payload(body_md.encode("utf-8"))
email.encoders.encode_base64(part)
part.add_header("Content-Disposition", "attachment", filename=attachment_name)
msg.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, msg.as_string())
else:
with smtplib.SMTP(host, port, timeout=30) as s:
if user:
s.login(user, password)
s.sendmail(from_addr, to_addrs, msg.as_string())
def layout(title: str, body: str) -> str: def layout(title: str, body: str) -> str:
return f""" return f"""
<!doctype html> <!doctype html>
@@ -381,7 +442,20 @@ def _process_upload_job(job_id: int):
) )
new_doc_id = cur.lastrowid 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()) _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: except Exception as e:
_job_set(job_id, status="error", error=str(e), finished_at=now_iso()) _job_set(job_id, status="error", error=str(e), finished_at=now_iso())
@@ -492,7 +566,27 @@ def _process_analysis_job(job_id: int):
) )
new_doc_id = cur.lastrowid 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) _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:
safe_title = (doc["title"] or "ergebnis").replace("/", "_").replace(" ", "_")
subject = f"VoiceLog: {prm['name']} {doc['title']}"
try:
_send_email(email_to, subject, answer, f"{safe_title}_{prm['name']}.md")
except Exception as mail_err:
_job_set(job_id, error=f"[E-Mail-Fehler] {mail_err}")
except Exception as e: except Exception as e:
_job_set(job_id, status="error", error=str(e), finished_at=now_iso()) _job_set(job_id, status="error", error=str(e), finished_at=now_iso())
finally: finally:
@@ -505,8 +599,8 @@ def enqueue_job(kind: str, **kwargs) -> int:
with db() as c: with db() as c:
cur = c.execute( cur = c.execute(
""" """
INSERT INTO jobs(kind,status,project_id,document_id,prompt_id,title,file_path,user_prompt,created_at) INSERT INTO jobs(kind,status,project_id,document_id,prompt_id,title,file_path,user_prompt,pipeline_prompt_ids,email_to,created_at)
VALUES (?,?,?,?,?,?,?,?,?) VALUES (?,?,?,?,?,?,?,?,?,?,?)
""", """,
( (
kind, kind,
@@ -517,6 +611,8 @@ def enqueue_job(kind: str, **kwargs) -> int:
kwargs.get("title"), kwargs.get("title"),
kwargs.get("file_path"), kwargs.get("file_path"),
kwargs.get("user_prompt") or None, kwargs.get("user_prompt") or None,
kwargs.get("pipeline_prompt_ids") or None,
kwargs.get("email_to") or None,
now_iso(), now_iso(),
), ),
) )
@@ -594,6 +690,31 @@ def settings_page(msg: str = ""):
current_repeat_penalty = get_setting("ollama_repeat_penalty", "1.15") current_repeat_penalty = get_setting("ollama_repeat_penalty", "1.15")
current_repeat_last_n = get_setting("ollama_repeat_last_n", "128") current_repeat_last_n = get_setting("ollama_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:
email_addrs = c.execute("SELECT id, label, email FROM email_addresses ORDER BY label").fetchall()
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
)
think_checked = "checked" if current_think else "" think_checked = "checked" if current_think else ""
ctx_options = [("auto", "Automatisch (empfohlen)"), ("4096", "4 096"), ("8192", "8 192"), ctx_options = [("auto", "Automatisch (empfohlen)"), ("4096", "4 096"), ("8192", "8 192"),
@@ -669,6 +790,62 @@ def settings_page(msg: str = ""):
</form> </form>
</div> </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'> <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> <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'> <table class='table table-sm mb-0'>
@@ -736,16 +913,94 @@ def settings_save(
return RedirectResponse("/settings?msg=Einstellungen+gespeichert.", status_code=303) 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.get("/", response_class=HTMLResponse) @app.get("/", response_class=HTMLResponse)
def upload_page(msg: str = ""): def upload_page(msg: str = ""):
projects = get_projects() projects = get_projects()
existing_names = json.dumps([p["name"] for p in projects], ensure_ascii=False) 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) 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'>&lt;{a["email"]}&gt;</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""" body = f"""
<div class='d-flex justify-content-between align-items-center mb-3'> <div class='d-flex justify-content-between align-items-center mb-3'>
<h2 class='h4 mb-0'>Audio Upload</h2> <h2 class='h4 mb-0'>Audio Upload</h2>
</div> </div>
<p class='text-secondary'>Mehrere Audiodateien gleichzeitig hochladen — je Datei wird ein Transkriptions-Job erstellt.</p> <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 ""} {f"<div class='alert alert-info py-2'>{msg}</div>" if msg else ""}
<div class='card'> <div class='card'>
<div class='row g-3 mb-3'> <div class='row g-3 mb-3'>
@@ -768,7 +1023,20 @@ def upload_page(msg: str = ""):
<i class='bi bi-cloud-upload'></i> <span id='uploadBtnLabel'>Hochladen & verarbeiten</span> <i class='bi bi-cloud-upload'></i> <span id='uploadBtnLabel'>Hochladen & verarbeiten</span>
</button> </button>
</div> </div>
<div id='uploadProgress' class='d-none'>
<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 &amp; 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 mb-2' style='height:8px'>
<div class='progress-bar progress-bar-striped progress-bar-animated' id='progressBar' style='width:0%'></div> <div class='progress-bar progress-bar-striped progress-bar-animated' id='progressBar' style='width:0%'></div>
</div> </div>
@@ -803,6 +1071,64 @@ fileInput.addEventListener('change', () => {{
}}); }});
projectInput.addEventListener('input', updateBtn); 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() {{ async function startUpload() {{
const name = projectInput.value.trim(); const name = projectInput.value.trim();
if(!name || fileInput.files.length === 0) return; if(!name || fileInput.files.length === 0) return;
@@ -821,6 +1147,10 @@ async function startUpload() {{
projectId = (await r.json()).id; 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++) {{ for(let i=0; i<files.length; i++) {{
const f = files[i]; const f = files[i];
const stem = f.name.replace(/\\.[^/.]+$/, ''); const stem = f.name.replace(/\\.[^/.]+$/, '');
@@ -829,6 +1159,8 @@ async function startUpload() {{
const fd = new FormData(); const fd = new FormData();
fd.append('project_id', projectId); fd.append('project_id', projectId);
fd.append('file', f); 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}}); const r = await fetch('/upload', {{method:'POST', body: fd}});
if(!r.ok) {{ status.textContent = `Fehler bei ${{stem}}`; }} if(!r.ok) {{ status.textContent = `Fehler bei ${{stem}}`; }}
}} }}
@@ -885,7 +1217,12 @@ def create_project_api(name: str = Form(...)):
@app.post("/upload") @app.post("/upload")
async def upload(project_id: int = Form(...), file: UploadFile = File(...)): async def upload(
project_id: int = Form(...),
file: UploadFile = File(...),
pipeline_prompt_ids: str = Form("[]"),
email_to: str = Form("[]"),
):
data = await file.read() data = await file.read()
if not data: if not data:
raise HTTPException(400, "Leere Datei") raise HTTPException(400, "Leere Datei")
@@ -896,11 +1233,27 @@ async def upload(project_id: int = Form(...), file: UploadFile = File(...)):
temp_path = JOB_DIR / f"{now_iso().replace(':','-')}_{filename}" temp_path = JOB_DIR / f"{now_iso().replace(':','-')}_{filename}"
temp_path.write_bytes(data) 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( job_id = enqueue_job(
"upload", "upload",
project_id=project_id, project_id=project_id,
title=stem, title=stem,
file_path=str(temp_path), 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} return {"job_id": job_id}