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 / SMTP
+
+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+
+ + +
+
+
+
+ +
+
+
+ +
+
E-Mail-Adressen
+ {"" + email_addr_rows + "
NameE-Mail
" 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} +
      + {prompt_items} +
    +
    + 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