feat: Netzwerkanalyse-Feature (Wissensgraph)

Neues Feature zur Visualisierung von Entitäten und Beziehungen
aus ausgewählten Lagen als interaktiver d3.js-Netzwerkgraph.

- Haiku extrahiert Entitäten (Person, Organisation, Ort, Ereignis, Militär)
- Opus analysiert Beziehungen und korrigiert Haiku-Fehler
- 6 neue DB-Tabellen (network_analyses, _entities, _relations, etc.)
- REST-API: CRUD + Generierung + Export (JSON/CSV)
- d3.js Force-Directed Graph mit Zoom, Filter, Suche, Export
- WebSocket-Events für Live-Progress während Generierung
- Sidebar-Integration mit Netzwerkanalysen-Sektion

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Dieser Commit ist enthalten in:
Claude Dev
2026-03-16 00:34:26 +01:00
Ursprung d86dae1e86
Commit 9a35973d00
11 geänderte Dateien mit 4047 neuen und 603 gelöschten Zeilen

Datei anzeigen

@@ -0,0 +1,406 @@
"""Router für Netzwerkanalyse CRUD-Operationen."""
import hashlib
import json
import csv
import io
from datetime import datetime
from typing import Optional
import aiosqlite
from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException, Query
from fastapi.responses import StreamingResponse
from auth import get_current_user
from database import db_dependency, get_db
from middleware.license_check import require_writable_license
from models_network import (
NetworkAnalysisCreate,
NetworkAnalysisUpdate,
)
from config import TIMEZONE
router = APIRouter(prefix="/api/network-analyses", tags=["network-analyses"])
async def _check_analysis_access(
db: aiosqlite.Connection, analysis_id: int, tenant_id: int
) -> dict:
"""Analyse laden und Tenant-Zugriff prüfen."""
cursor = await db.execute(
"SELECT * FROM network_analyses WHERE id = ? AND tenant_id = ?",
(analysis_id, tenant_id),
)
row = await cursor.fetchone()
if not row:
raise HTTPException(status_code=404, detail="Netzwerkanalyse nicht gefunden")
return dict(row)
async def _enrich_analysis(db: aiosqlite.Connection, analysis: dict) -> dict:
"""Analyse mit Incident-IDs, Titeln und Ersteller-Name anreichern."""
analysis_id = analysis["id"]
cursor = await db.execute(
"""SELECT nai.incident_id, i.title
FROM network_analysis_incidents nai
LEFT JOIN incidents i ON i.id = nai.incident_id
WHERE nai.network_analysis_id = ?""",
(analysis_id,),
)
rows = await cursor.fetchall()
analysis["incident_ids"] = [r["incident_id"] for r in rows]
analysis["incident_titles"] = [r["title"] or "" for r in rows]
cursor = await db.execute(
"SELECT email FROM users WHERE id = ?", (analysis.get("created_by"),)
)
user_row = await cursor.fetchone()
analysis["created_by_username"] = user_row["email"] if user_row else "Unbekannt"
analysis["created_at"] = analysis.get("created_at", "")
analysis["has_update"] = False
return analysis
async def _calculate_data_hash(db: aiosqlite.Connection, analysis_id: int) -> str:
"""SHA-256 über verknüpfte Artikel- und Factcheck-Daten."""
cursor = await db.execute(
"""SELECT DISTINCT a.id, a.collected_at
FROM network_analysis_incidents nai
JOIN articles a ON a.incident_id = nai.incident_id
WHERE nai.network_analysis_id = ?
ORDER BY a.id""",
(analysis_id,),
)
article_rows = await cursor.fetchall()
cursor = await db.execute(
"""SELECT DISTINCT fc.id, fc.checked_at
FROM network_analysis_incidents nai
JOIN fact_checks fc ON fc.incident_id = nai.incident_id
WHERE nai.network_analysis_id = ?
ORDER BY fc.id""",
(analysis_id,),
)
fc_rows = await cursor.fetchall()
parts = []
for r in article_rows:
parts.append(f"a:{r['id']}:{r['collected_at']}")
for r in fc_rows:
parts.append(f"fc:{r['id']}:{r['checked_at']}")
return hashlib.sha256("|".join(parts).encode("utf-8")).hexdigest()
# --- Endpoints ---
@router.get("")
async def list_network_analyses(
db: aiosqlite.Connection = Depends(db_dependency),
current_user: dict = Depends(get_current_user),
):
"""Alle Netzwerkanalysen des Tenants auflisten."""
tenant_id = current_user["tenant_id"]
cursor = await db.execute(
"SELECT * FROM network_analyses WHERE tenant_id = ? ORDER BY created_at DESC",
(tenant_id,),
)
rows = await cursor.fetchall()
results = []
for row in rows:
results.append(await _enrich_analysis(db, dict(row)))
return results
@router.post("", status_code=201, dependencies=[Depends(require_writable_license)])
async def create_network_analysis(
body: NetworkAnalysisCreate,
background_tasks: BackgroundTasks,
db: aiosqlite.Connection = Depends(db_dependency),
current_user: dict = Depends(get_current_user),
):
"""Neue Netzwerkanalyse erstellen und Generierung starten."""
from agents.entity_extractor import extract_and_relate_entities
from main import ws_manager
tenant_id = current_user["tenant_id"]
user_id = current_user["id"]
if not body.incident_ids:
raise HTTPException(status_code=400, detail="Mindestens eine Lage auswählen")
# Prüfen ob alle Incidents dem Tenant gehören
placeholders = ",".join("?" for _ in body.incident_ids)
cursor = await db.execute(
f"SELECT id FROM incidents WHERE id IN ({placeholders}) AND tenant_id = ?",
(*body.incident_ids, tenant_id),
)
found_ids = {r["id"] for r in await cursor.fetchall()}
missing = set(body.incident_ids) - found_ids
if missing:
raise HTTPException(
status_code=400,
detail=f"Lagen nicht gefunden: {', '.join(str(i) for i in missing)}"
)
# Analyse anlegen
cursor = await db.execute(
"""INSERT INTO network_analyses (name, status, tenant_id, created_by)
VALUES (?, 'generating', ?, ?)""",
(body.name, tenant_id, user_id),
)
analysis_id = cursor.lastrowid
for incident_id in body.incident_ids:
await db.execute(
"INSERT INTO network_analysis_incidents (network_analysis_id, incident_id) VALUES (?, ?)",
(analysis_id, incident_id),
)
await db.commit()
# Hintergrund-Generierung starten
background_tasks.add_task(extract_and_relate_entities, analysis_id, tenant_id, ws_manager)
cursor = await db.execute("SELECT * FROM network_analyses WHERE id = ?", (analysis_id,))
row = await cursor.fetchone()
return await _enrich_analysis(db, dict(row))
@router.get("/{analysis_id}")
async def get_network_analysis(
analysis_id: int,
db: aiosqlite.Connection = Depends(db_dependency),
current_user: dict = Depends(get_current_user),
):
"""Einzelne Netzwerkanalyse abrufen."""
tenant_id = current_user["tenant_id"]
analysis = await _check_analysis_access(db, analysis_id, tenant_id)
return await _enrich_analysis(db, analysis)
@router.get("/{analysis_id}/graph")
async def get_network_graph(
analysis_id: int,
db: aiosqlite.Connection = Depends(db_dependency),
current_user: dict = Depends(get_current_user),
):
"""Volle Graphdaten (Entities + Relations)."""
tenant_id = current_user["tenant_id"]
analysis = await _check_analysis_access(db, analysis_id, tenant_id)
analysis = await _enrich_analysis(db, analysis)
cursor = await db.execute(
"SELECT * FROM network_entities WHERE network_analysis_id = ?", (analysis_id,)
)
entities_raw = await cursor.fetchall()
entities = []
for e in entities_raw:
ed = dict(e)
# JSON-Felder parsen
try:
ed["aliases"] = json.loads(ed.get("aliases", "[]"))
except (json.JSONDecodeError, TypeError):
ed["aliases"] = []
try:
ed["metadata"] = json.loads(ed.get("metadata", "{}"))
except (json.JSONDecodeError, TypeError):
ed["metadata"] = {}
ed["corrected_by_opus"] = bool(ed.get("corrected_by_opus", 0))
entities.append(ed)
cursor = await db.execute(
"SELECT * FROM network_relations WHERE network_analysis_id = ?", (analysis_id,)
)
relations_raw = await cursor.fetchall()
relations = []
for r in relations_raw:
rd = dict(r)
try:
rd["evidence"] = json.loads(rd.get("evidence", "[]"))
except (json.JSONDecodeError, TypeError):
rd["evidence"] = []
relations.append(rd)
return {"analysis": analysis, "entities": entities, "relations": relations}
@router.post("/{analysis_id}/regenerate", dependencies=[Depends(require_writable_license)])
async def regenerate_network(
analysis_id: int,
background_tasks: BackgroundTasks,
db: aiosqlite.Connection = Depends(db_dependency),
current_user: dict = Depends(get_current_user),
):
"""Analyse neu generieren."""
from agents.entity_extractor import extract_and_relate_entities
from main import ws_manager
tenant_id = current_user["tenant_id"]
await _check_analysis_access(db, analysis_id, tenant_id)
# Bestehende Daten löschen
await db.execute(
"DELETE FROM network_relations WHERE network_analysis_id = ?", (analysis_id,)
)
await db.execute(
"""DELETE FROM network_entity_mentions WHERE entity_id IN
(SELECT id FROM network_entities WHERE network_analysis_id = ?)""",
(analysis_id,),
)
await db.execute(
"DELETE FROM network_entities WHERE network_analysis_id = ?", (analysis_id,)
)
await db.execute(
"""UPDATE network_analyses
SET status = 'generating', entity_count = 0, relation_count = 0, data_hash = NULL
WHERE id = ?""",
(analysis_id,),
)
await db.commit()
background_tasks.add_task(extract_and_relate_entities, analysis_id, tenant_id, ws_manager)
return {"status": "generating", "message": "Neugenerierung gestartet"}
@router.get("/{analysis_id}/check-update")
async def check_network_update(
analysis_id: int,
db: aiosqlite.Connection = Depends(db_dependency),
current_user: dict = Depends(get_current_user),
):
"""Prüft ob neue Daten verfügbar (Hash-Vergleich)."""
tenant_id = current_user["tenant_id"]
analysis = await _check_analysis_access(db, analysis_id, tenant_id)
current_hash = await _calculate_data_hash(db, analysis_id)
stored_hash = analysis.get("data_hash")
has_update = stored_hash is not None and current_hash != stored_hash
return {"has_update": has_update}
@router.put("/{analysis_id}", dependencies=[Depends(require_writable_license)])
async def update_network_analysis(
analysis_id: int,
body: NetworkAnalysisUpdate,
db: aiosqlite.Connection = Depends(db_dependency),
current_user: dict = Depends(get_current_user),
):
"""Name oder Lagen aktualisieren."""
tenant_id = current_user["tenant_id"]
user_id = current_user["id"]
analysis = await _check_analysis_access(db, analysis_id, tenant_id)
if analysis["created_by"] != user_id:
raise HTTPException(status_code=403, detail="Nur der Ersteller kann bearbeiten")
if body.name is not None:
await db.execute(
"UPDATE network_analyses SET name = ? WHERE id = ?",
(body.name, analysis_id),
)
if body.incident_ids is not None:
if len(body.incident_ids) < 1:
raise HTTPException(status_code=400, detail="Mindestens eine Lage auswählen")
placeholders = ",".join("?" for _ in body.incident_ids)
cursor = await db.execute(
f"SELECT id FROM incidents WHERE id IN ({placeholders}) AND tenant_id = ?",
(*body.incident_ids, tenant_id),
)
found_ids = {r["id"] for r in await cursor.fetchall()}
missing = set(body.incident_ids) - found_ids
if missing:
raise HTTPException(status_code=400, detail=f"Lagen nicht gefunden: {', '.join(str(i) for i in missing)}")
await db.execute(
"DELETE FROM network_analysis_incidents WHERE network_analysis_id = ?",
(analysis_id,),
)
for iid in body.incident_ids:
await db.execute(
"INSERT INTO network_analysis_incidents (network_analysis_id, incident_id) VALUES (?, ?)",
(analysis_id, iid),
)
await db.commit()
cursor = await db.execute("SELECT * FROM network_analyses WHERE id = ?", (analysis_id,))
row = await cursor.fetchone()
return await _enrich_analysis(db, dict(row))
@router.delete("/{analysis_id}", dependencies=[Depends(require_writable_license)])
async def delete_network_analysis(
analysis_id: int,
db: aiosqlite.Connection = Depends(db_dependency),
current_user: dict = Depends(get_current_user),
):
"""Analyse löschen (CASCADE räumt auf)."""
tenant_id = current_user["tenant_id"]
user_id = current_user["id"]
analysis = await _check_analysis_access(db, analysis_id, tenant_id)
if analysis["created_by"] != user_id:
raise HTTPException(status_code=403, detail="Nur der Ersteller kann löschen")
await db.execute("DELETE FROM network_analyses WHERE id = ?", (analysis_id,))
await db.commit()
return {"message": "Netzwerkanalyse gelöscht"}
@router.get("/{analysis_id}/export")
async def export_network(
analysis_id: int,
format: str = Query("json", pattern="^(json|csv)$"),
db: aiosqlite.Connection = Depends(db_dependency),
current_user: dict = Depends(get_current_user),
):
"""Export als JSON oder CSV."""
tenant_id = current_user["tenant_id"]
analysis = await _check_analysis_access(db, analysis_id, tenant_id)
analysis = await _enrich_analysis(db, analysis)
cursor = await db.execute(
"SELECT * FROM network_entities WHERE network_analysis_id = ?", (analysis_id,)
)
entities = [dict(r) for r in await cursor.fetchall()]
cursor = await db.execute(
"SELECT * FROM network_relations WHERE network_analysis_id = ?", (analysis_id,)
)
relations = [dict(r) for r in await cursor.fetchall()]
entity_map = {e["id"]: e for e in entities}
safe_name = (analysis.get("name", "export") or "export").replace(" ", "_")
if format == "json":
content = json.dumps(
{"analysis": analysis, "entities": entities, "relations": relations},
ensure_ascii=False, indent=2, default=str,
)
return StreamingResponse(
io.BytesIO(content.encode("utf-8")),
media_type="application/json",
headers={"Content-Disposition": f'attachment; filename="netzwerk_{safe_name}.json"'},
)
output = io.StringIO()
writer = csv.writer(output)
writer.writerow(["source", "target", "category", "label", "weight", "description"])
for rel in relations:
src = entity_map.get(rel.get("source_entity_id"), {})
tgt = entity_map.get(rel.get("target_entity_id"), {})
writer.writerow([
src.get("name", ""), tgt.get("name", ""),
rel.get("category", ""), rel.get("label", ""),
rel.get("weight", 1), rel.get("description", ""),
])
return StreamingResponse(
io.BytesIO(output.getvalue().encode("utf-8")),
media_type="text/csv",
headers={"Content-Disposition": f'attachment; filename="netzwerk_{safe_name}.csv"'},
)