feat(email): result as HTML body, intermediate steps as .md + .pdf attachments
- Final pipeline result becomes the email body (plain text + HTML rendered from Markdown via multipart/alternative) - All intermediate steps (transcript + prior analyses) are attached each as a .md file and a .pdf file - _md_to_pdf(): pure-Python PDF generation via fpdf2 with minimal Markdown rendering (headers, bullets, numbered lists, inline cleanup) - _collect_doc_chain(): walks source_document_id links to gather all ancestor documents oldest-first - Add fpdf2==2.8.3 to requirements.txt Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
135
app.py
135
app.py
@@ -14,6 +14,7 @@ from datetime import datetime
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import List, Optional
|
from typing import List, Optional
|
||||||
|
|
||||||
|
import fpdf as fpdflib
|
||||||
import markdown as md
|
import markdown as md
|
||||||
import requests
|
import requests
|
||||||
from fastapi import FastAPI, File, Form, HTTPException, UploadFile
|
from fastapi import FastAPI, File, Form, HTTPException, UploadFile
|
||||||
@@ -259,7 +260,91 @@ def _split_thinking(text: str) -> tuple[str, str]:
|
|||||||
return "", text
|
return "", text
|
||||||
|
|
||||||
|
|
||||||
def _send_email(to_addrs: list[str], subject: str, body_md: str, attachment_name: str):
|
def _md_to_pdf(title: str, content_md: str) -> bytes:
|
||||||
|
"""Render Markdown content to a PDF byte string."""
|
||||||
|
class PDF(fpdflib.FPDF):
|
||||||
|
def header(self):
|
||||||
|
self.set_font("Helvetica", "B", 9)
|
||||||
|
self.set_text_color(100, 100, 100)
|
||||||
|
self.cell(0, 6, title, align="L")
|
||||||
|
self.ln(2)
|
||||||
|
self.set_draw_color(200, 200, 200)
|
||||||
|
self.line(self.l_margin, self.get_y(), self.w - self.r_margin, self.get_y())
|
||||||
|
self.ln(4)
|
||||||
|
|
||||||
|
def footer(self):
|
||||||
|
self.set_y(-12)
|
||||||
|
self.set_font("Helvetica", "I", 8)
|
||||||
|
self.set_text_color(160, 160, 160)
|
||||||
|
self.cell(0, 6, f"Seite {self.page_no()}", align="C")
|
||||||
|
|
||||||
|
pdf = PDF(orientation="P", unit="mm", format="A4")
|
||||||
|
pdf.set_auto_page_break(auto=True, margin=15)
|
||||||
|
pdf.add_page()
|
||||||
|
|
||||||
|
# Render markdown line by line with minimal formatting
|
||||||
|
for raw_line in content_md.split("\n"):
|
||||||
|
line = raw_line.rstrip()
|
||||||
|
if line.startswith("# "):
|
||||||
|
pdf.set_font("Helvetica", "B", 14)
|
||||||
|
pdf.set_text_color(20, 20, 20)
|
||||||
|
pdf.multi_cell(0, 7, line[2:])
|
||||||
|
pdf.ln(1)
|
||||||
|
elif line.startswith("## "):
|
||||||
|
pdf.set_font("Helvetica", "B", 12)
|
||||||
|
pdf.set_text_color(40, 40, 40)
|
||||||
|
pdf.multi_cell(0, 6, line[3:])
|
||||||
|
pdf.ln(1)
|
||||||
|
elif line.startswith("### "):
|
||||||
|
pdf.set_font("Helvetica", "B", 11)
|
||||||
|
pdf.set_text_color(60, 60, 60)
|
||||||
|
pdf.multi_cell(0, 6, line[4:])
|
||||||
|
elif line.startswith(("- ", "* ", "+ ")):
|
||||||
|
pdf.set_font("Helvetica", "", 10)
|
||||||
|
pdf.set_text_color(30, 30, 30)
|
||||||
|
pdf.multi_cell(0, 5, " • " + line[2:])
|
||||||
|
elif re.match(r"^\d+\. ", line):
|
||||||
|
pdf.set_font("Helvetica", "", 10)
|
||||||
|
pdf.set_text_color(30, 30, 30)
|
||||||
|
pdf.multi_cell(0, 5, " " + line)
|
||||||
|
elif line == "" or line == "---":
|
||||||
|
pdf.ln(3)
|
||||||
|
else:
|
||||||
|
# Strip inline markdown markers for bold/italic
|
||||||
|
clean = re.sub(r"\*\*(.+?)\*\*", r"\1", line)
|
||||||
|
clean = re.sub(r"\*(.+?)\*", r"\1", clean)
|
||||||
|
clean = re.sub(r"`(.+?)`", r"\1", clean)
|
||||||
|
pdf.set_font("Helvetica", "", 10)
|
||||||
|
pdf.set_text_color(30, 30, 30)
|
||||||
|
pdf.multi_cell(0, 5, clean)
|
||||||
|
|
||||||
|
return bytes(pdf.output())
|
||||||
|
|
||||||
|
|
||||||
|
def _collect_doc_chain(input_doc) -> list[dict]:
|
||||||
|
"""Walk source_document_id links back to the original, return list oldest-first."""
|
||||||
|
chain = []
|
||||||
|
current = dict(input_doc)
|
||||||
|
chain.append(current)
|
||||||
|
while current.get("source_document_id"):
|
||||||
|
with db() as c:
|
||||||
|
row = c.execute("SELECT * FROM documents WHERE id=?", (current["source_document_id"],)).fetchone()
|
||||||
|
if not row:
|
||||||
|
break
|
||||||
|
current = dict(row)
|
||||||
|
chain.insert(0, current)
|
||||||
|
return chain
|
||||||
|
|
||||||
|
|
||||||
|
def _safe_fname(title: str) -> str:
|
||||||
|
return re.sub(r"[^a-zA-Z0-9äöüÄÖÜß\-_]", "_", title)[:60]
|
||||||
|
|
||||||
|
|
||||||
|
def _send_email(to_addrs: list[str], subject: str, body_md: str, attachments: list[tuple]):
|
||||||
|
"""
|
||||||
|
Send email with body_md as HTML/plain body and attachments as list of
|
||||||
|
(filename, bytes_content, mime_maintype, mime_subtype).
|
||||||
|
"""
|
||||||
host = get_setting("smtp_host", "")
|
host = get_setting("smtp_host", "")
|
||||||
if not host:
|
if not host:
|
||||||
raise RuntimeError("SMTP-Host nicht konfiguriert")
|
raise RuntimeError("SMTP-Host nicht konfiguriert")
|
||||||
@@ -269,29 +354,39 @@ def _send_email(to_addrs: list[str], subject: str, body_md: str, attachment_name
|
|||||||
from_addr = get_setting("smtp_from", user)
|
from_addr = get_setting("smtp_from", user)
|
||||||
use_tls = get_setting("smtp_tls", "true") == "true"
|
use_tls = get_setting("smtp_tls", "true") == "true"
|
||||||
|
|
||||||
msg = email.mime.multipart.MIMEMultipart()
|
outer = email.mime.multipart.MIMEMultipart("mixed")
|
||||||
msg["From"] = from_addr
|
outer["From"] = from_addr
|
||||||
msg["To"] = ", ".join(to_addrs)
|
outer["To"] = ", ".join(to_addrs)
|
||||||
msg["Subject"] = subject
|
outer["Subject"] = subject
|
||||||
msg.attach(email.mime.text.MIMEText("Anbei das Ergebnis als Markdown-Datei.", "plain", "utf-8"))
|
|
||||||
|
|
||||||
part = email.mime.base.MIMEBase("text", "markdown")
|
# Multipart/alternative body (plain + HTML)
|
||||||
part.set_payload(body_md.encode("utf-8"))
|
body_html = md.markdown(body_md, extensions=["fenced_code", "tables", "nl2br"])
|
||||||
|
full_html = f"""<html><body style="font-family:sans-serif;max-width:800px;margin:auto;padding:20px">
|
||||||
|
{body_html}
|
||||||
|
</body></html>"""
|
||||||
|
alt = email.mime.multipart.MIMEMultipart("alternative")
|
||||||
|
alt.attach(email.mime.text.MIMEText(body_md, "plain", "utf-8"))
|
||||||
|
alt.attach(email.mime.text.MIMEText(full_html, "html", "utf-8"))
|
||||||
|
outer.attach(alt)
|
||||||
|
|
||||||
|
for fname, data, maintype, subtype in attachments:
|
||||||
|
part = email.mime.base.MIMEBase(maintype, subtype)
|
||||||
|
part.set_payload(data)
|
||||||
email.encoders.encode_base64(part)
|
email.encoders.encode_base64(part)
|
||||||
part.add_header("Content-Disposition", "attachment", filename=attachment_name)
|
part.add_header("Content-Disposition", "attachment", filename=fname)
|
||||||
msg.attach(part)
|
outer.attach(part)
|
||||||
|
|
||||||
if use_tls:
|
if use_tls:
|
||||||
with smtplib.SMTP(host, port, timeout=30) as s:
|
with smtplib.SMTP(host, port, timeout=30) as s:
|
||||||
s.starttls()
|
s.starttls()
|
||||||
if user:
|
if user:
|
||||||
s.login(user, password)
|
s.login(user, password)
|
||||||
s.sendmail(from_addr, to_addrs, msg.as_string())
|
s.sendmail(from_addr, to_addrs, outer.as_string())
|
||||||
else:
|
else:
|
||||||
with smtplib.SMTP(host, port, timeout=30) as s:
|
with smtplib.SMTP(host, port, timeout=30) as s:
|
||||||
if user:
|
if user:
|
||||||
s.login(user, password)
|
s.login(user, password)
|
||||||
s.sendmail(from_addr, to_addrs, msg.as_string())
|
s.sendmail(from_addr, to_addrs, outer.as_string())
|
||||||
|
|
||||||
|
|
||||||
def layout(title: str, body: str) -> str:
|
def layout(title: str, body: str) -> str:
|
||||||
@@ -667,10 +762,22 @@ def _process_analysis_job(job_id: int):
|
|||||||
email_to=json.dumps(email_to),
|
email_to=json.dumps(email_to),
|
||||||
)
|
)
|
||||||
elif email_to:
|
elif email_to:
|
||||||
safe_title = (doc["title"] or "ergebnis").replace("/", "_").replace(" ", "_")
|
|
||||||
subject = f"VoiceLog: {prm['name']} – {doc['title']}"
|
subject = f"VoiceLog: {prm['name']} – {doc['title']}"
|
||||||
try:
|
try:
|
||||||
_send_email(email_to, subject, answer, f"{safe_title}_{prm['name']}.md")
|
# Collect intermediate docs (everything that fed into the final result)
|
||||||
|
chain = _collect_doc_chain(doc)
|
||||||
|
attachments = []
|
||||||
|
for step_doc in chain:
|
||||||
|
step_title = step_doc.get("title") or step_doc.get("kind", "dokument")
|
||||||
|
fname_base = _safe_fname(step_title)
|
||||||
|
content = step_doc.get("content_md") or ""
|
||||||
|
attachments.append((f"{fname_base}.md", content.encode("utf-8"), "text", "markdown"))
|
||||||
|
try:
|
||||||
|
pdf_bytes = _md_to_pdf(step_title, content)
|
||||||
|
attachments.append((f"{fname_base}.pdf", pdf_bytes, "application", "pdf"))
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
_send_email(email_to, subject, answer, attachments)
|
||||||
except Exception as mail_err:
|
except Exception as mail_err:
|
||||||
_job_set(job_id, error=f"[E-Mail-Fehler] {mail_err}")
|
_job_set(job_id, error=f"[E-Mail-Fehler] {mail_err}")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
|||||||
@@ -3,3 +3,4 @@ uvicorn[standard]==0.32.1
|
|||||||
requests==2.32.3
|
requests==2.32.3
|
||||||
python-multipart==0.0.12
|
python-multipart==0.0.12
|
||||||
markdown==3.7
|
markdown==3.7
|
||||||
|
fpdf2==2.8.3
|
||||||
|
|||||||
Reference in New Issue
Block a user