diff --git a/app.py b/app.py
index 5c6beeb..4362325 100644
--- a/app.py
+++ b/app.py
@@ -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"""
@@ -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"""
+ | {a["label"]} |
+ {a["email"]} |
+
+
+ |
+
"""
+ 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 = ""):
+
+
+
+
E-Mail-Adressen
+ {"
| Name | E-Mail | |
" + email_addr_rows + "
" if email_addrs else "
Noch keine Adressen gespeichert.
"}
+
+
+
Systemkonfiguration (read-only)
@@ -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"""
+
+
+
+
+ """
+ for p in prompts
+ )
+
+ email_items = "".join(
+ f"""
+
+
+
"""
+ for a in email_addrs
+ )
+
+ email_section = ""
+ if smtp_configured and email_addrs:
+ email_section = f"""
+
+
E-Mail-Versand (optional)
+
Letztes Ergebnis der Pipeline nach Abschluss als Anhang versenden:
+ {email_items}
+
Sende an:
+
"""
+ elif not smtp_configured:
+ email_section = " E-Mail-Versand nicht konfiguriert —
Einstellungen "
+ elif not email_addrs:
+ email_section = " Keine E-Mail-Adressen gespeichert —
Einstellungen "
+
+ no_prompts_hint = "" if prompts else "Keine Prompts vorhanden —
Prompts anlegen "
+
body = f"""
Audio Upload
-Mehrere Audiodateien gleichzeitig hochladen — je Datei wird ein Transkriptions-Job erstellt.
+Audiodateien hochladen, transkribieren lassen und optional eine Prompt-Pipeline durchlaufen.
{f"{msg}
" if msg else ""}
@@ -768,7 +1023,20 @@ def upload_page(msg: str = ""):
Hochladen & verarbeiten
-
+
+
+
Prompt-Pipeline (optional, Reihenfolge per Drag & Drop)
+ {no_prompts_hint}
+
+
+ Pipeline:
+
+
+{email_section}
+
+
@@ -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