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 os
import smtplib
import sqlite3
import threading
from concurrent.futures import ThreadPoolExecutor
@@ -144,6 +150,26 @@ def init_db():
except Exception:
pass
for col in ["pipeline_prompt_ids TEXT", "email_to TEXT"]:
try:
c.execute(f"ALTER TABLE jobs ADD COLUMN {col}")
except Exception:
pass
try:
c.execute(
"""
CREATE TABLE IF NOT EXISTS email_addresses (
id INTEGER PRIMARY KEY AUTOINCREMENT,
label TEXT NOT NULL,
email TEXT UNIQUE NOT NULL,
created_at TEXT NOT NULL
)
"""
)
except Exception:
pass
# defaults
c.execute("INSERT OR IGNORE INTO projects(name, created_at) VALUES (?,?)", ("Default", now_iso()))
c.execute(
@@ -177,6 +203,41 @@ def set_setting(key: str, value: str):
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:
return f"""
<!doctype html>
@@ -381,7 +442,20 @@ def _process_upload_job(job_id: int):
)
new_doc_id = cur.lastrowid
pipeline_ids = json.loads(j.get("pipeline_prompt_ids") or "[]")
email_to = json.loads(j.get("email_to") or "[]")
_job_set(job_id, status="done", result_document_id=new_doc_id, finished_at=now_iso())
if pipeline_ids:
first_id, rest = pipeline_ids[0], pipeline_ids[1:]
enqueue_job(
"analysis",
project_id=j["project_id"],
document_id=new_doc_id,
prompt_id=first_id,
pipeline_prompt_ids=json.dumps(rest),
email_to=json.dumps(email_to),
)
except Exception as e:
_job_set(job_id, status="error", error=str(e), finished_at=now_iso())
@@ -492,7 +566,27 @@ def _process_analysis_job(job_id: int):
)
new_doc_id = cur.lastrowid
pipeline_ids = json.loads(j.get("pipeline_prompt_ids") or "[]")
email_to = json.loads(j.get("email_to") or "[]")
_job_set(job_id, status="done", result_document_id=new_doc_id, finished_at=now_iso(), llm_response=answer, llm_thinking=acc_thinking or None)
if pipeline_ids:
first_id, rest = pipeline_ids[0], pipeline_ids[1:]
enqueue_job(
"analysis",
project_id=doc["project_id"],
document_id=new_doc_id,
prompt_id=first_id,
pipeline_prompt_ids=json.dumps(rest),
email_to=json.dumps(email_to),
)
elif email_to:
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:
_job_set(job_id, status="error", error=str(e), finished_at=now_iso())
finally:
@@ -505,8 +599,8 @@ def enqueue_job(kind: str, **kwargs) -> int:
with db() as c:
cur = c.execute(
"""
INSERT INTO jobs(kind,status,project_id,document_id,prompt_id,title,file_path,user_prompt,created_at)
VALUES (?,?,?,?,?,?,?,?,?)
INSERT INTO jobs(kind,status,project_id,document_id,prompt_id,title,file_path,user_prompt,pipeline_prompt_ids,email_to,created_at)
VALUES (?,?,?,?,?,?,?,?,?,?,?)
""",
(
kind,
@@ -517,6 +611,8 @@ def enqueue_job(kind: str, **kwargs) -> int:
kwargs.get("title"),
kwargs.get("file_path"),
kwargs.get("user_prompt") or None,
kwargs.get("pipeline_prompt_ids") or None,
kwargs.get("email_to") or None,
now_iso(),
),
)
@@ -594,6 +690,31 @@ def settings_page(msg: str = ""):
current_repeat_penalty = get_setting("ollama_repeat_penalty", "1.15")
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 ""
ctx_options = [("auto", "Automatisch (empfohlen)"), ("4096", "4 096"), ("8192", "8 192"),
@@ -669,6 +790,62 @@ def settings_page(msg: str = ""):
</form>
</div>
<div class='card mt-3' id='smtp'>
<h5 class='h6 fw-semibold mb-3'><i class='bi bi-envelope text-primary me-1'></i> E-Mail / SMTP</h5>
<form method='post' action='/settings/smtp'>
<div class='row g-3'>
<div class='col-12 col-md-6'>
<label class='form-label'>SMTP-Host</label>
<input class='form-control' name='smtp_host' value='{smtp_host}' placeholder='mail.example.com'>
</div>
<div class='col-6 col-md-2'>
<label class='form-label'>Port</label>
<input class='form-control' type='number' name='smtp_port' value='{smtp_port}' min='1' max='65535'>
</div>
<div class='col-6 col-md-4'>
<label class='form-label'>Absender (From)</label>
<input class='form-control' type='email' name='smtp_from' value='{smtp_from}' placeholder='voicelog@example.com'>
</div>
<div class='col-12 col-md-6'>
<label class='form-label'>Benutzername</label>
<input class='form-control' name='smtp_user' value='{smtp_user}' autocomplete='off'>
</div>
<div class='col-12 col-md-6'>
<label class='form-label'>Passwort</label>
<input class='form-control' type='password' name='smtp_password' value='{smtp_password}' autocomplete='new-password'>
</div>
<div class='col-12'>
<div class='form-check form-switch'>
<input class='form-check-input' type='checkbox' role='switch' id='smtpTls'
name='smtp_tls' value='true' {smtp_tls_checked}>
<label class='form-check-label' for='smtpTls'>STARTTLS verwenden</label>
</div>
</div>
</div>
<div class='mt-3'>
<button class='btn btn-primary' type='submit'><i class='bi bi-check2-circle'></i> Speichern</button>
</div>
</form>
</div>
<div class='card mt-3' id='email-adressen'>
<h5 class='h6 fw-semibold mb-3'><i class='bi bi-person-lines-fill text-primary me-1'></i> E-Mail-Adressen</h5>
{"<table class='table table-sm mb-3'><thead><tr><th>Name</th><th>E-Mail</th><th></th></tr></thead><tbody>" + email_addr_rows + "</tbody></table>" if email_addrs else "<p class='text-secondary small'>Noch keine Adressen gespeichert.</p>"}
<form method='post' action='/settings/email-addresses' class='row g-2 align-items-end'>
<div class='col-12 col-md-4'>
<label class='form-label'>Name / Label</label>
<input class='form-control' name='label' placeholder='z.B. Max Mustermann' required>
</div>
<div class='col-12 col-md-5'>
<label class='form-label'>E-Mail-Adresse</label>
<input class='form-control' type='email' name='email' placeholder='max@example.com' required>
</div>
<div class='col-auto'>
<button class='btn btn-outline-primary' type='submit'><i class='bi bi-plus-lg'></i> Hinzufügen</button>
</div>
</form>
</div>
<div class='card mt-3'>
<h5 class='h6 fw-semibold mb-2'><i class='bi bi-info-circle text-secondary me-1'></i> Systemkonfiguration (read-only)</h5>
<table class='table table-sm mb-0'>
@@ -736,16 +913,94 @@ def settings_save(
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)
def upload_page(msg: str = ""):
projects = get_projects()
existing_names = json.dumps([p["name"] for p in projects], ensure_ascii=False)
existing_map = json.dumps({p["name"]: p["id"] for p in projects}, ensure_ascii=False)
with db() as c:
prompts = c.execute("SELECT id, name FROM prompts ORDER BY name").fetchall()
email_addrs = c.execute("SELECT id, label, email FROM email_addresses ORDER BY label").fetchall()
smtp_configured = bool(get_setting("smtp_host", ""))
prompt_items = "".join(
f"""<li class='list-group-item d-flex align-items-center gap-2 py-2' draggable='true'
data-id='{p["id"]}' style='cursor:grab'>
<span class='drag-handle text-secondary' style='cursor:grab'><i class='bi bi-grip-vertical'></i></span>
<input class='form-check-input flex-shrink-0 mt-0' type='checkbox' id='pchk_{p["id"]}' value='{p["id"]}' onchange='updateOrder()'>
<label class='form-check-label mb-0 flex-grow-1' for='pchk_{p["id"]}'>{p["name"]}</label>
<span class='badge bg-secondary order-badge d-none'></span>
</li>"""
for p in prompts
)
email_items = "".join(
f"""<div class='form-check'>
<input class='form-check-input' type='checkbox' id='eml_{a["id"]}' value='{a["email"]}' onchange='updateEmails()'>
<label class='form-check-label' for='eml_{a["id"]}'>{a["label"]} <span class='text-secondary small'>&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"""
<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'>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 ""}
<div class='card'>
<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>
</button>
</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-bar progress-bar-striped progress-bar-animated' id='progressBar' style='width:0%'></div>
</div>
@@ -803,6 +1071,64 @@ fileInput.addEventListener('change', () => {{
}});
projectInput.addEventListener('input', updateBtn);
// --- Prompt pipeline ordering ---
function updateOrder() {{
const items = document.querySelectorAll('#promptList li');
let n = 1;
items.forEach(li => {{
const chk = li.querySelector('input[type=checkbox]');
const badge = li.querySelector('.order-badge');
if (chk.checked) {{
badge.textContent = n++;
badge.classList.remove('d-none');
}} else {{
badge.classList.add('d-none');
}}
}});
const checked = [...items].filter(li => li.querySelector('input').checked);
const names = checked.map(li => li.querySelector('label').textContent.trim());
const preview = document.getElementById('pipelinePreview');
const previewText = document.getElementById('pipelinePreviewText');
if (names.length) {{
previewText.textContent = names.join('');
preview.classList.remove('d-none');
}} else {{
preview.classList.add('d-none');
}}
}}
// Drag-and-drop reorder
let _dragSrc = null;
document.getElementById('promptList').addEventListener('dragstart', e => {{
_dragSrc = e.target.closest('li');
e.dataTransfer.effectAllowed = 'move';
}});
document.getElementById('promptList').addEventListener('dragover', e => {{
e.preventDefault();
const target = e.target.closest('li');
if (target && target !== _dragSrc) {{
const rect = target.getBoundingClientRect();
const after = e.clientY > rect.top + rect.height / 2;
target.parentNode.insertBefore(_dragSrc, after ? target.nextSibling : target);
}}
}});
document.getElementById('promptList').addEventListener('dragend', () => {{ updateOrder(); }});
// --- Email selection ---
let _selectedEmails = [];
function updateEmails() {{
_selectedEmails = [...document.querySelectorAll('#emailCheckboxes input:checked')].map(i => i.value);
const preview = document.getElementById('emailPreview');
const list = document.getElementById('emailPreviewList');
if (!preview) return;
if (_selectedEmails.length) {{
list.textContent = _selectedEmails.join(', ');
preview.classList.remove('d-none');
}} else {{
preview.classList.add('d-none');
}}
}}
async function startUpload() {{
const name = projectInput.value.trim();
if(!name || fileInput.files.length === 0) return;
@@ -821,6 +1147,10 @@ async function startUpload() {{
projectId = (await r.json()).id;
}}
// collect pipeline prompt IDs in current list order, only checked ones
const pipelineIds = [...document.querySelectorAll('#promptList li input:checked')].map(i => parseInt(i.value));
const emailTo = [...document.querySelectorAll('[id^="eml_"]:checked')].map(i => i.value);
for(let i=0; i<files.length; i++) {{
const f = files[i];
const stem = f.name.replace(/\\.[^/.]+$/, '');
@@ -829,6 +1159,8 @@ async function startUpload() {{
const fd = new FormData();
fd.append('project_id', projectId);
fd.append('file', f);
fd.append('pipeline_prompt_ids', JSON.stringify(pipelineIds));
fd.append('email_to', JSON.stringify(emailTo));
const r = await fetch('/upload', {{method:'POST', body: fd}});
if(!r.ok) {{ status.textContent = `Fehler bei ${{stem}}`; }}
}}
@@ -885,7 +1217,12 @@ def create_project_api(name: str = Form(...)):
@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()
if not data:
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.write_bytes(data)
try:
ids = json.loads(pipeline_prompt_ids)
if not isinstance(ids, list):
ids = []
except Exception:
ids = []
try:
emails = json.loads(email_to)
if not isinstance(emails, list):
emails = []
except Exception:
emails = []
job_id = enqueue_job(
"upload",
project_id=project_id,
title=stem,
file_path=str(temp_path),
pipeline_prompt_ids=json.dumps(ids) if ids else None,
email_to=json.dumps(emails) if emails else None,
)
return {"job_id": job_id}