Export-System: PDF/Word mit Executive Summary, Deckblatt, Klassifizierung
- Neuer report_generator.py: WeasyPrint (PDF) + python-docx (Word) - 3 Stufen: Executive Summary (KI-generiert), Lagebericht, Vollständiger Bericht - 3 Klassifizierungsstufen: Offen, Nur für den Dienstgebrauch, Vertraulich - Deckblatt mit AegisSight Logo, Titel, Typ, Klassifizierung - Executive Summary: Claude Haiku verdichtet Lagebild auf 3-5 Kernpunkte - Jinja2 HTML-Template für PDF (A4-optimiert) - Alte Exporte entfernt (Markdown, JSON, Browser-Print) - Neues Export-Modal im Dashboard (Umfang/Format/Stufe)
Dieser Commit ist enthalten in:
@@ -9,6 +9,7 @@ from datetime import datetime
|
||||
from config import TIMEZONE
|
||||
import asyncio
|
||||
import aiosqlite
|
||||
import io
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
@@ -629,182 +630,18 @@ def _slugify(text: str) -> str:
|
||||
return text[:80].lower()
|
||||
|
||||
|
||||
def _build_markdown_export(
|
||||
incident: dict, articles: list, fact_checks: list,
|
||||
snapshots: list, scope: str, creator: str
|
||||
) -> str:
|
||||
"""Markdown-Dokument zusammenbauen."""
|
||||
typ = "Hintergrundrecherche" if incident.get("type") == "research" else "Breaking News"
|
||||
updated = (incident.get("updated_at") or "")[:16].replace("T", " ")
|
||||
|
||||
lines = []
|
||||
lines.append(f"# {incident['title']}")
|
||||
lines.append(f"> {typ} | Erstellt von {creator} | Stand: {updated}")
|
||||
lines.append("")
|
||||
|
||||
# Lagebild
|
||||
summary = incident.get("summary") or "*Noch kein Lagebild verf\u00fcgbar.*"
|
||||
lines.append("## Lagebild")
|
||||
lines.append("")
|
||||
lines.append(summary)
|
||||
lines.append("")
|
||||
|
||||
# Quellenverzeichnis aus sources_json
|
||||
sources_json = incident.get("sources_json")
|
||||
if sources_json:
|
||||
try:
|
||||
sources = json.loads(sources_json) if isinstance(sources_json, str) else sources_json
|
||||
if sources:
|
||||
lines.append("## Quellenverzeichnis")
|
||||
lines.append("")
|
||||
for i, src in enumerate(sources, 1):
|
||||
name = src.get("name") or src.get("title") or src.get("url", "")
|
||||
url = src.get("url", "")
|
||||
if url:
|
||||
lines.append(f"{i}. [{name}]({url})")
|
||||
else:
|
||||
lines.append(f"{i}. {name}")
|
||||
lines.append("")
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
pass
|
||||
|
||||
# Faktencheck
|
||||
if fact_checks:
|
||||
lines.append("## Faktencheck")
|
||||
lines.append("")
|
||||
for fc in fact_checks:
|
||||
claim = fc.get("claim", "")
|
||||
fc_status = fc.get("status", "")
|
||||
sources_count = fc.get("sources_count", 0)
|
||||
evidence = fc.get("evidence", "")
|
||||
status_label = {
|
||||
"confirmed": "Best\u00e4tigt", "unconfirmed": "Unbest\u00e4tigt",
|
||||
"disputed": "Umstritten", "false": "Falsch",
|
||||
}.get(fc_status, fc_status)
|
||||
line = f"- **{claim}** \u2014 {status_label} ({sources_count} Quellen)"
|
||||
if evidence:
|
||||
line += f"\n {evidence}"
|
||||
lines.append(line)
|
||||
lines.append("")
|
||||
|
||||
# Scope=full: Artikel\u00fcbersicht
|
||||
if scope == "full" and articles:
|
||||
lines.append("## Artikel\u00fcbersicht")
|
||||
lines.append("")
|
||||
lines.append("| Headline | Quelle | Sprache | Datum |")
|
||||
lines.append("|----------|--------|---------|-------|")
|
||||
for art in articles:
|
||||
headline = (art.get("headline_de") or art.get("headline") or "").replace("|", "/")
|
||||
source = (art.get("source") or "").replace("|", "/")
|
||||
lang = art.get("language", "")
|
||||
pub = (art.get("published_at") or art.get("collected_at") or "")[:16]
|
||||
lines.append(f"| {headline} | {source} | {lang} | {pub} |")
|
||||
lines.append("")
|
||||
|
||||
# Scope=full: Snapshot-Verlauf
|
||||
if scope == "full" and snapshots:
|
||||
lines.append("## Snapshot-Verlauf")
|
||||
lines.append("")
|
||||
for snap in snapshots:
|
||||
snap_date = (snap.get("created_at") or "")[:16].replace("T", " ")
|
||||
art_count = snap.get("article_count", 0)
|
||||
fc_count = snap.get("fact_check_count", 0)
|
||||
lines.append(f"### Snapshot vom {snap_date}")
|
||||
lines.append(f"Artikel: {art_count} | Faktenchecks: {fc_count}")
|
||||
lines.append("")
|
||||
snap_summary = snap.get("summary", "")
|
||||
if snap_summary:
|
||||
lines.append(snap_summary)
|
||||
lines.append("")
|
||||
|
||||
now = datetime.now(TIMEZONE).strftime("%Y-%m-%d %H:%M Uhr")
|
||||
lines.append("---")
|
||||
lines.append(f"*Exportiert am {now} aus AegisSight Monitor*")
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def _build_json_export(
|
||||
incident: dict, articles: list, fact_checks: list,
|
||||
snapshots: list, scope: str, creator: str
|
||||
) -> dict:
|
||||
"""Strukturiertes JSON fuer Export."""
|
||||
now = datetime.now(TIMEZONE).strftime("%Y-%m-%dT%H:%M:%SZ")
|
||||
|
||||
sources = []
|
||||
sources_json = incident.get("sources_json")
|
||||
if sources_json:
|
||||
try:
|
||||
sources = json.loads(sources_json) if isinstance(sources_json, str) else sources_json
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
pass
|
||||
|
||||
export = {
|
||||
"export_version": "1.0",
|
||||
"exported_at": now,
|
||||
"scope": scope,
|
||||
"incident": {
|
||||
"id": incident["id"],
|
||||
"title": incident["title"],
|
||||
"description": incident.get("description"),
|
||||
"type": incident.get("type"),
|
||||
"status": incident.get("status"),
|
||||
"visibility": incident.get("visibility"),
|
||||
"created_by": creator,
|
||||
"created_at": incident.get("created_at"),
|
||||
"updated_at": incident.get("updated_at"),
|
||||
"summary": incident.get("summary"),
|
||||
"international_sources": bool(incident.get("international_sources")),
|
||||
"include_telegram": bool(incident.get("include_telegram")),
|
||||
},
|
||||
"sources": sources,
|
||||
"fact_checks": [
|
||||
{
|
||||
"claim": fc.get("claim"),
|
||||
"status": fc.get("status"),
|
||||
"sources_count": fc.get("sources_count"),
|
||||
"evidence": fc.get("evidence"),
|
||||
"checked_at": fc.get("checked_at"),
|
||||
}
|
||||
for fc in fact_checks
|
||||
],
|
||||
}
|
||||
|
||||
if scope == "full":
|
||||
export["articles"] = [
|
||||
{
|
||||
"headline": art.get("headline"),
|
||||
"headline_de": art.get("headline_de"),
|
||||
"source": art.get("source"),
|
||||
"source_url": art.get("source_url"),
|
||||
"language": art.get("language"),
|
||||
"published_at": art.get("published_at"),
|
||||
"collected_at": art.get("collected_at"),
|
||||
"verification_status": art.get("verification_status"),
|
||||
}
|
||||
for art in articles
|
||||
]
|
||||
export["snapshots"] = [
|
||||
{
|
||||
"created_at": snap.get("created_at"),
|
||||
"article_count": snap.get("article_count"),
|
||||
"fact_check_count": snap.get("fact_check_count"),
|
||||
"summary": snap.get("summary"),
|
||||
}
|
||||
for snap in snapshots
|
||||
]
|
||||
|
||||
return export
|
||||
|
||||
|
||||
@router.get("/{incident_id}/export")
|
||||
async def export_incident(
|
||||
incident_id: int,
|
||||
format: str = Query(..., pattern="^(md|json)$"),
|
||||
scope: str = Query("report", pattern="^(report|full)$"),
|
||||
format: str = Query("pdf", pattern="^(pdf|docx)$"),
|
||||
scope: str = Query("report", pattern="^(summary|report|full)$"),
|
||||
classification: str = Query("offen", pattern="^(offen|dienstgebrauch|vertraulich)$"),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
db: aiosqlite.Connection = Depends(db_dependency),
|
||||
):
|
||||
"""Lage als Markdown oder JSON exportieren."""
|
||||
"""Lage als PDF oder Word exportieren."""
|
||||
from report_generator import generate_pdf, generate_docx, generate_executive_summary
|
||||
|
||||
tenant_id = current_user.get("tenant_id")
|
||||
row = await _check_incident_access(db, incident_id, current_user["id"], tenant_id)
|
||||
incident = dict(row)
|
||||
@@ -837,23 +674,35 @@ async def export_incident(
|
||||
)
|
||||
snapshots = [dict(r) for r in await cursor.fetchall()]
|
||||
|
||||
# Dateiname
|
||||
# Executive Summary (KI-generiert, gecacht)
|
||||
exec_summary = incident.get("executive_summary")
|
||||
if not exec_summary:
|
||||
summary_text = incident.get("summary") or ""
|
||||
exec_summary = await generate_executive_summary(summary_text)
|
||||
await db.execute(
|
||||
"UPDATE incidents SET executive_summary = ? WHERE id = ?",
|
||||
(exec_summary, incident_id),
|
||||
)
|
||||
await db.commit()
|
||||
|
||||
date_str = datetime.now(TIMEZONE).strftime("%Y%m%d")
|
||||
slug = _slugify(incident["title"])
|
||||
scope_suffix = "_vollexport" if scope == "full" else ""
|
||||
scope_labels = {"summary": "executive_summary", "report": "lagebericht", "full": "vollstaendig"}
|
||||
|
||||
if format == "md":
|
||||
body = _build_markdown_export(incident, articles, fact_checks, snapshots, scope, creator)
|
||||
filename = f"{slug}{scope_suffix}_{date_str}.md"
|
||||
media_type = "text/markdown; charset=utf-8"
|
||||
if format == "pdf":
|
||||
pdf_bytes = await generate_pdf(incident, articles, fact_checks, snapshots, scope, classification, creator, exec_summary)
|
||||
filename = f"{slug}_{scope_labels[scope]}_{date_str}.pdf"
|
||||
return StreamingResponse(
|
||||
io.BytesIO(pdf_bytes),
|
||||
media_type="application/pdf",
|
||||
headers={"Content-Disposition": f'attachment; filename="{filename}"'},
|
||||
)
|
||||
else:
|
||||
export_data = _build_json_export(incident, articles, fact_checks, snapshots, scope, creator)
|
||||
body = json.dumps(export_data, ensure_ascii=False, indent=2)
|
||||
filename = f"{slug}{scope_suffix}_{date_str}.json"
|
||||
media_type = "application/json; charset=utf-8"
|
||||
docx_bytes = await generate_docx(incident, articles, fact_checks, snapshots, scope, classification, creator, exec_summary)
|
||||
filename = f"{slug}_{scope_labels[scope]}_{date_str}.docx"
|
||||
return StreamingResponse(
|
||||
io.BytesIO(docx_bytes),
|
||||
media_type="application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
||||
headers={"Content-Disposition": f'attachment; filename="{filename}"'},
|
||||
)
|
||||
|
||||
return StreamingResponse(
|
||||
iter([body]),
|
||||
media_type=media_type,
|
||||
headers={"Content-Disposition": f'attachment; filename="{filename}"'},
|
||||
)
|
||||
|
||||
In neuem Issue referenzieren
Einen Benutzer sperren