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:
406
src/routers/network_analysis.py
Normale Datei
406
src/routers/network_analysis.py
Normale Datei
@@ -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"'},
|
||||
)
|
||||
In neuem Issue referenzieren
Einen Benutzer sperren