Export: Zusammenfassung-Sektion, Checkbox-Auswahl, neue Reihenfolge

Research-Briefings:
- Neue Sektion ZUSAMMENFASSUNG mit Bullet-Points als erstes Element
- UEBERBLICK entfernt, durch ZUSAMMENFASSUNG ersetzt
- Inkrementelles Briefing ebenfalls angepasst

Export-System:
- Zusammenfassung wird direkt aus dem Bericht extrahiert (kein
  separater KI-Aufruf mehr fuer Research-Lagen)
- Reihenfolge: Zusammenfassung > Recherchebericht > Faktencheck > Quellen > Timeline
- Sections-basiert statt scope-basiert (rueckwaertskompatibel)
- Checkbox-Dialog statt Radio-Buttons im Frontend
- Bereiche: Zusammenfassung, Recherchebericht, Faktencheck, Quellen, Timeline, Karte
- PDF und DOCX Templates angepasst
- Backend akzeptiert sections-Parameter (kommagetrennt)
Dieser Commit ist enthalten in:
claude-dev
2026-04-11 20:56:04 +00:00
Ursprung 89cc920bdc
Commit fa12d4cfd6
7 geänderte Dateien mit 188 neuen und 66 gelöschten Zeilen

Datei anzeigen

@@ -66,8 +66,8 @@ AUFTRAG:
Erstelle ein strukturiertes Briefing auf {output_language} mit folgenden Abschnitten. Sei so ausführlich wie nötig, um alle Aspekte gründlich abzudecken.
Verwende durchgehend Inline-Quellenverweise [1], [2], [3] etc. im Text.
## ÜBERBLICK
Kurze Einordnung des Themas (2-3 Sätze)
## ZUSAMMENFASSUNG
Kompakte Übersicht als Aufzählung (4-8 Bullet Points mit "- "). Jeder Punkt fasst einen Kernaspekt des Themas in 1-2 Sätzen zusammen. Der Leser soll nach dieser Sektion das Wesentliche erfasst haben, ohne den Rest lesen zu müssen.
## HINTERGRUND
Historischer Kontext, relevante Vorgeschichte
@@ -171,7 +171,7 @@ NEUE QUELLEN SEIT DEM LETZTEN UPDATE:
AUFTRAG:
Aktualisiere das Briefing mit den neuen Erkenntnissen. Sei so ausführlich wie nötig. Behalte die Struktur bei:
## ÜBERBLICK
## ZUSAMMENFASSUNG
## HINTERGRUND
## AKTEURE
## AKTUELLE LAGE

Datei anzeigen

@@ -171,6 +171,55 @@ def _strip_citation_numbers(text: str) -> str:
def _extract_zusammenfassung(summary_text: str) -> tuple[str, str]:
"""Extrahiert die ZUSAMMENFASSUNG-Sektion aus einem Research-Briefing.
Returns:
(zusammenfassung_html, remaining_summary)
zusammenfassung_html: HTML-formatierte Bullet Points
remaining_summary: Der Rest des Berichts ohne die Zusammenfassung
"""
if not summary_text:
return "", summary_text
# Suche nach ## ZUSAMMENFASSUNG ... bis zur naechsten ## Ueberschrift
pattern = r"(## ZUSAMMENFASSUNG\s*\n)(.*?)(?=\n## |\Z)"
match = re.search(pattern, summary_text, re.DOTALL)
if not match:
return "", summary_text
zusammenfassung_raw = match.group(2).strip()
# Rest des Berichts ohne die Zusammenfassung-Sektion
remaining = summary_text[:match.start()] + summary_text[match.end():]
remaining = remaining.strip()
# Bullet Points als HTML formatieren
lines = []
for line in zusammenfassung_raw.split("\n"):
stripped = line.strip()
if stripped.startswith("- "):
clean = _strip_citation_numbers(stripped[2:].strip())
if clean:
lines.append(clean)
elif stripped.startswith("* "):
clean = _strip_citation_numbers(stripped[2:].strip())
if clean:
lines.append(clean)
elif stripped and not stripped.startswith("#"):
clean = _strip_citation_numbers(stripped)
if clean:
lines.append(clean)
if lines:
html = "<ul>\n" + "\n".join(f"<li>{line}</li>" for line in lines) + "\n</ul>"
else:
html = f"<p>{_strip_citation_numbers(zusammenfassung_raw)}</p>"
return html, remaining
async def generate_executive_summary(summary_text: str) -> str:
"""KI-verdichtetes Executive Summary aus dem Lagebild."""
if not summary_text or len(summary_text.strip()) < 50:
@@ -246,8 +295,31 @@ LAGEBILD:
async def generate_pdf(
incident: dict, articles: list, fact_checks: list, snapshots: list,
scope: str, creator: str, executive_summary_html: str,
sections: set[str] | None = None,
) -> bytes:
"""PDF-Report via WeasyPrint generieren."""
# Sections aus scope ableiten wenn nicht explizit angegeben
if sections is None:
if scope == "summary":
sections = {"zusammenfassung"}
elif scope == "report":
sections = {"zusammenfassung", "bericht", "faktencheck", "quellen"}
else: # full
sections = {"zusammenfassung", "bericht", "faktencheck", "quellen", "timeline"}
# Fuer Research-Lagen: Zusammenfassung aus dem Bericht extrahieren
is_research = incident.get("type") == "research"
zusammenfassung_html = executive_summary_html
bericht_summary = incident.get("summary", "")
zusammenfassung_title = "Executive Summary"
if is_research and bericht_summary:
extracted_html, remaining = _extract_zusammenfassung(bericht_summary)
if extracted_html:
zusammenfassung_html = extracted_html
zusammenfassung_title = "Zusammenfassung"
bericht_summary = remaining
env = Environment(loader=FileSystemLoader(str(TEMPLATE_DIR)))
template = env.get_template("report.html")
@@ -260,13 +332,12 @@ async def generate_pdf(
report_date=now.strftime("%d.%m.%Y, %H:%M Uhr"),
creator=creator,
logo_base64=_get_logo_base64(),
executive_summary=executive_summary_html,
executive_summary=zusammenfassung_html,
zusammenfassung_title=zusammenfassung_title,
sections=sections,
scope=scope,
lagebild_html=_markdown_to_html(
_strip_citation_numbers(
_truncate_lagebild(incident.get("summary", ""), 4000) if scope == "report"
else incident.get("summary", "")
)
_strip_citation_numbers(bericht_summary)
),
lagebild_timestamp=(incident.get("updated_at") or "")[:16].replace("T", " "),
sources=_prepare_sources(incident)[:30] if scope == "report" else _prepare_sources(incident),
@@ -292,10 +363,33 @@ async def generate_pdf(
async def generate_docx(
incident: dict, articles: list, fact_checks: list, snapshots: list,
scope: str, creator: str, executive_summary_text: str,
sections: set[str] | None = None,
) -> bytes:
"""Word-Report via python-docx generieren."""
doc = Document()
# Sections aus scope ableiten wenn nicht explizit angegeben
if sections is None:
if scope == "summary":
sections = {"zusammenfassung"}
elif scope == "report":
sections = {"zusammenfassung", "bericht", "faktencheck", "quellen"}
else: # full
sections = {"zusammenfassung", "bericht", "faktencheck", "quellen", "timeline"}
# Fuer Research-Lagen: Zusammenfassung aus dem Bericht extrahieren
is_research = incident.get("type") == "research"
zusammenfassung_text = executive_summary_text
bericht_summary = incident.get("summary") or "Keine Zusammenfassung verfuegbar."
zusammenfassung_title = "Executive Summary"
if is_research and bericht_summary:
extracted_html, remaining = _extract_zusammenfassung(bericht_summary)
if extracted_html:
zusammenfassung_text = extracted_html
zusammenfassung_title = "Zusammenfassung"
bericht_summary = remaining
# Styles
style = doc.styles['Normal']
style.font.size = Pt(10)
@@ -347,23 +441,21 @@ async def generate_docx(
doc.add_page_break()
# --- Executive Summary ---
doc.add_heading("Executive Summary", level=1)
# --- Zusammenfassung / Executive Summary ---
if "zusammenfassung" in sections:
doc.add_heading(zusammenfassung_title, level=1)
# HTML-Tags entfernen und als Bullet Points
clean_text = re.sub(r'<[^>]+>', '', executive_summary_text)
clean_text = re.sub(r'<[^>]+>', '', zusammenfassung_text)
lines = [line.strip().lstrip("- ").lstrip("* ") for line in clean_text.strip().split("\n") if line.strip()]
for line in lines:
if line:
doc.add_paragraph(line, style='List Bullet')
if scope in ("report", "full"):
# --- Lagebild ---
doc.add_heading("Recherchebericht" if incident.get("type") == "research" else "Lagebild", level=1)
raw_summary = incident.get("summary") or "Keine Zusammenfassung verfügbar."
summary = _strip_citation_numbers(
_truncate_lagebild(raw_summary, 4000) if scope == "report" else raw_summary
)
if "bericht" in sections:
# --- Lagebild / Recherchebericht ---
doc.add_heading("Recherchebericht" if is_research else "Lagebild", level=1)
summary = _strip_citation_numbers(bericht_summary)
# Markdown-Formatierung entfernen
clean_summary = re.sub(r'\*\*(.+?)\*\*', r'\1', summary)
clean_summary = re.sub(r'\[([^\]]+)\]\([^)]+\)', r'\1', clean_summary)
@@ -379,8 +471,9 @@ async def generate_docx(
else:
doc.add_paragraph(para_text)
if "faktencheck" in sections:
# --- Faktencheck ---
report_fcs = fact_checks[:20] if scope == 'report' else fact_checks
report_fcs = fact_checks
if report_fcs:
doc.add_heading("Faktencheck", level=1)
table = doc.add_table(rows=1, cols=3)
@@ -400,10 +493,9 @@ async def generate_docx(
row[1].text = FC_STATUS_LABELS.get(fc.get("status", ""), fc.get("status", ""))
row[2].text = str(fc.get("sources_count", 0))
if "quellen" in sections:
# --- Quellenstatistik ---
source_stats = _prepare_source_stats(articles)
if scope == 'report':
source_stats = source_stats[:20]
if source_stats:
doc.add_heading("Quellenstatistik", level=1)
table = doc.add_table(rows=1, cols=3)
@@ -423,7 +515,7 @@ async def generate_docx(
row[1].text = str(stat["count"])
row[2].text = stat["languages"]
if scope == "full":
if "timeline" in sections:
# --- Artikelverzeichnis ---
if articles:
doc.add_page_break()

Datei anzeigen

@@ -78,17 +78,27 @@ tr:nth-child(even) { background: #f8f9fa; }
</div>
<!-- Executive Summary -->
<!-- Zusammenfassung / Executive Summary -->
{% if 'zusammenfassung' in sections %}
<div class="section">
<h2>Executive Summary</h2>
<h2>{{ zusammenfassung_title }}</h2>
<div class="exec-summary">
{{ executive_summary | safe }}
</div>
</div>
{% endif %}
<!-- Recherchebericht / Lagebild -->
{% if 'bericht' in sections %}
<div class="section">
<h2>{% if incident.type == "research" %}Recherchebericht{% else %}Lagebild{% endif %}</h2>
{% if lagebild_timestamp %}<p style="font-size:9pt;color:#888;margin-bottom:10px;">Aktualisiert: {{ lagebild_timestamp }}</p>{% endif %}
<div class="lagebild-content">{{ lagebild_html | safe }}</div>
</div>
{% endif %}
{% if scope in ('report', 'full') %}
<!-- Faktencheck -->
{% if fact_checks %}
{% if 'faktencheck' in sections and fact_checks %}
<div class="section">
<h2>Faktencheck</h2>
<table>
@@ -106,10 +116,12 @@ tr:nth-child(even) { background: #f8f9fa; }
</div>
{% endif %}
<!-- Quellenstatistik -->
{% if source_stats %}
<!-- Quellenverzeichnis -->
{% if 'quellen' in sections and sources %}
<div class="section">
<h2>Quellenstatistik</h2>
<h2>Quellenverzeichnis</h2>
{% if source_stats %}
<h3>Quellenstatistik</h3>
<table>
<thead><tr><th>Quelle</th><th>Artikel</th><th>Sprache</th></tr></thead>
<tbody>
@@ -118,18 +130,8 @@ tr:nth-child(even) { background: #f8f9fa; }
{% endfor %}
</tbody>
</table>
</div>
<!-- Lagebild -->
<div class="section">
<h2>{% if incident.type == "research" %}Recherchebericht{% else %}Lagebild{% endif %}</h2>
{% if lagebild_timestamp %}<p style="font-size:9pt;color:#888;margin-bottom:10px;">Aktualisiert: {{ lagebild_timestamp }}</p>{% endif %}
<div class="lagebild-content">{{ lagebild_html | safe }}</div>
</div>
<!-- Quellenverzeichnis -->
{% if sources %}
<div class="section">
<h2>Quellenverzeichnis</h2>
{% endif %}
<h3>Quellen</h3>
<table class="quellen-table">
<thead><tr><th style="width:30px">#</th><th style="width:120px">Quelle</th><th>URL</th></tr></thead>
<tbody>
@@ -141,12 +143,8 @@ tr:nth-child(even) { background: #f8f9fa; }
</div>
{% endif %}
{% endif %}
{% endif %}
{% if scope == 'full' %}
<!-- Timeline -->
{% if timeline %}
{% if 'timeline' in sections and timeline %}
<div class="section" style="page-break-before:always;">
<h2>Ereignis-Timeline</h2>
{% for event in timeline %}
@@ -160,7 +158,7 @@ tr:nth-child(even) { background: #f8f9fa; }
{% endif %}
<!-- Artikelverzeichnis -->
{% if articles %}
{% if 'timeline' in sections and articles %}
<div class="section" style="page-break-before:always;">
<h2>Artikelverzeichnis ({{ articles | length }} Artikel)</h2>
<table>
@@ -178,7 +176,6 @@ tr:nth-child(even) { background: #f8f9fa; }
</table>
</div>
{% endif %}
{% endif %}
<div class="report-footer">
Erstellt mit AegisSight Monitor &mdash; aegis-sight.de &mdash; {{ report_date }}

Datei anzeigen

@@ -713,12 +713,21 @@ async def export_incident(
incident_id: int,
format: str = Query("pdf", pattern="^(pdf|docx)$"),
scope: str = Query("report", pattern="^(summary|report|full)$"),
sections: str = Query(None),
current_user: dict = Depends(get_current_user),
db: aiosqlite.Connection = Depends(db_dependency),
):
"""Lage als PDF oder Word exportieren."""
from report_generator import generate_pdf, generate_docx, generate_executive_summary
# Sections aus Komma-getrenntem String parsen
VALID_SECTIONS = {"zusammenfassung", "bericht", "faktencheck", "quellen", "timeline", "karte"}
sections_set = None
if sections:
sections_set = {s.strip() for s in sections.split(",") if s.strip() in VALID_SECTIONS}
if not sections_set:
sections_set = None
tenant_id = current_user.get("tenant_id")
row = await _check_incident_access(db, incident_id, current_user["id"], tenant_id)
incident = dict(row)
@@ -765,18 +774,28 @@ async def export_incident(
date_str = datetime.now(TIMEZONE).strftime("%Y%m%d")
slug = _slugify(incident["title"])
scope_labels = {"summary": "executive_summary", "report": "lagebericht", "full": "vollstaendig"}
# Wenn sections explizit angegeben, passenden Label waehlen
if sections_set:
if sections_set == {"zusammenfassung"}:
scope_labels_key = "executive_summary"
elif "timeline" in sections_set:
scope_labels_key = "vollstaendig"
else:
scope_labels_key = "lagebericht"
else:
scope_labels_key = scope_labels.get(scope, "lagebericht")
if format == "pdf":
pdf_bytes = await generate_pdf(incident, articles, fact_checks, snapshots, scope, creator, exec_summary)
filename = f"{slug}_{scope_labels[scope]}_{date_str}.pdf"
pdf_bytes = await generate_pdf(incident, articles, fact_checks, snapshots, scope, creator, exec_summary, sections=sections_set)
filename = f"{slug}_{scope_labels_key}_{date_str}.pdf"
return StreamingResponse(
io.BytesIO(pdf_bytes),
media_type="application/pdf",
headers={"Content-Disposition": f'attachment; filename="{filename}"'},
)
else:
docx_bytes = await generate_docx(incident, articles, fact_checks, snapshots, scope, creator, exec_summary)
filename = f"{slug}_{scope_labels[scope]}_{date_str}.docx"
docx_bytes = await generate_docx(incident, articles, fact_checks, snapshots, scope, creator, exec_summary, sections=sections_set)
filename = f"{slug}_{scope_labels_key}_{date_str}.docx"
return StreamingResponse(
io.BytesIO(docx_bytes),
media_type="application/vnd.openxmlformats-officedocument.wordprocessingml.document",

Datei anzeigen

@@ -668,10 +668,13 @@
</div>
<div class="modal-body" style="padding:20px;">
<div style="margin-bottom:16px;">
<label style="font-size:11px;text-transform:uppercase;letter-spacing:1px;color:var(--text-secondary);display:block;margin-bottom:8px;">Umfang</label>
<label class="export-radio"><input type="radio" name="export-scope" value="summary" checked><span>Executive Summary</span><span class="export-radio-desc">1-2 Seiten, Kernpunkte</span></label>
<label class="export-radio"><input type="radio" name="export-scope" value="report"><span>Lagebericht</span><span class="export-radio-desc">Lagebild, Faktencheck, Quellen</span></label>
<label class="export-radio"><input type="radio" name="export-scope" value="full"><span>Vollst&auml;ndiger Bericht</span><span class="export-radio-desc">+ Timeline, Artikelverzeichnis</span></label>
<label style="font-size:11px;text-transform:uppercase;letter-spacing:1px;color:var(--text-secondary);display:block;margin-bottom:8px;">Bereiche</label>
<label class="export-radio"><input type="checkbox" name="export-section" value="zusammenfassung" checked><span>Zusammenfassung</span></label>
<label class="export-radio"><input type="checkbox" name="export-section" value="bericht" checked><span>Recherchebericht / Lagebild</span></label>
<label class="export-radio"><input type="checkbox" name="export-section" value="faktencheck" checked><span>Faktencheck</span></label>
<label class="export-radio"><input type="checkbox" name="export-section" value="quellen" checked><span>Quellen</span></label>
<label class="export-radio"><input type="checkbox" name="export-section" value="timeline"><span>Timeline</span></label>
<label class="export-radio"><input type="checkbox" name="export-section" value="karte"><span>Karte</span></label>
</div>
<div style="margin-bottom:16px;">
<label style="font-size:11px;text-transform:uppercase;letter-spacing:1px;color:var(--text-secondary);display:block;margin-bottom:8px;">Format</label>

Datei anzeigen

@@ -237,9 +237,15 @@ const API = {
resetTutorialState() {
return this._request('DELETE', '/tutorial/state');
},
exportReport(id, format, scope) {
exportReport(id, format, scope, sections) {
const token = localStorage.getItem('osint_token');
return fetch(`${this.baseUrl}/incidents/${id}/export?format=${format}&scope=${scope}`, {
let url = `${this.baseUrl}/incidents/${id}/export?format=${format}`;
if (sections && sections.length > 0) {
url += `&sections=${sections.join(',')}`;
} else if (scope) {
url += `&scope=${scope}`;
}
return fetch(url, {
headers: { 'Authorization': `Bearer ${token}` },
});
},

Datei anzeigen

@@ -2284,16 +2284,21 @@ async handleRefresh() {
async submitExport() {
if (!this.currentIncidentId) return;
const scope = document.querySelector('input[name="export-scope"]:checked').value;
const checked = document.querySelectorAll('input[name="export-section"]:checked');
const sections = Array.from(checked).map(cb => cb.value);
if (sections.length === 0) {
UI.showToast('Bitte mindestens einen Bereich ausw\u00e4hlen.', 'warning');
return;
}
const format = document.querySelector('input[name="export-format"]:checked').value;
const btn = document.getElementById('export-submit-btn');
const origText = btn.textContent;
btn.disabled = true;
btn.textContent = scope === 'summary' ? 'KI generiert Executive Summary...' : 'Wird erstellt...';
btn.textContent = 'Wird erstellt...';
try {
const response = await API.exportReport(this.currentIncidentId, format, scope);
const response = await API.exportReport(this.currentIncidentId, format, null, sections);
if (!response.ok) {
const err = await response.json().catch(() => ({}));
throw new Error(err.detail || 'Fehler ' + response.status);