feat(studio): Phase 2 - Studio-Bausteine, Backend-Kern (Sammeln/Analyse/Faktencheck)
Backend fuer die modularen Studio-Pipeline-Bausteine, alles ueber Claude.
DB (additiv, idempotent):
- Tabellen incident_events + fact_check_runs (+ Indizes)
- Funktion log_incident_event()
- Spalten incidents.summary_at, incidents.executive_summary, articles.geoparsed_at
agents/stage_runners.py (neu): isolierte Bausteine analyze + factcheck auf dem
vorhandenen Bestand, komplett neu, Historie bleibt (Snapshot/fact_check_runs).
orchestrator.py: collect_only in die Lane-Multi-Tenant-Queue eingebaut (enqueue_refresh
4-Tupel, _worker-Entpacken, Multi-Pass-Gate, _run_refresh-Signatur + Analyse/FC-Skip).
Zusaetzlich summary_at beim Schreiben des Lagebilds gestempelt (Studio-Freshness).
incidents.py: Endpunkte POST /{id}/run/{stage} (collect|analyze|factcheck),
GET /{id}/run-status, GET /{id}/freshness, GET /{id}/factcheck-runs[/{run_id}].
Busy-Check auf Online-Orchestrator (_current_tasks), Tenant-Zugriffspruefung.
Kein lokaler LLM-Unterbau, kein lokales Modell. /ask + /events folgen in Phase 3.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Dieser Commit ist enthalten in:
@@ -5,7 +5,7 @@ from models import IncidentCreate, IncidentUpdate, IncidentResponse, IncidentLis
|
||||
from auth import get_current_user
|
||||
from middleware.license_check import require_writable_license
|
||||
from database import db_dependency, get_db
|
||||
from datetime import datetime
|
||||
from datetime import datetime, timezone
|
||||
from config import TIMEZONE
|
||||
import asyncio
|
||||
import aiosqlite
|
||||
@@ -1128,6 +1128,184 @@ async def cancel_refresh(
|
||||
return {"status": "cancelling" if cancelled else "not_running"}
|
||||
|
||||
|
||||
# --- Modulare Pipeline-Bausteine (nur Studio): einzelne Stufen isoliert ------
|
||||
@router.post("/{incident_id}/run/{stage}")
|
||||
async def run_stage(
|
||||
incident_id: int,
|
||||
stage: str,
|
||||
current_user: dict = Depends(require_writable_license),
|
||||
db: aiosqlite.Connection = Depends(db_dependency),
|
||||
):
|
||||
"""Einzelnen Pipeline-Baustein auf dem vorhandenen Datenbestand starten
|
||||
(collect | analyze | factcheck). Komplett neu; Historie bleibt erhalten."""
|
||||
from agents import stage_runners
|
||||
from agents.orchestrator import orchestrator
|
||||
user_id = current_user["id"]
|
||||
tenant_id = current_user.get("tenant_id")
|
||||
await _check_incident_access(db, incident_id, user_id, tenant_id)
|
||||
|
||||
orch_busy = (incident_id in getattr(orchestrator, "_current_tasks", {})) or \
|
||||
(incident_id in getattr(orchestrator, "_queued_ids", set()))
|
||||
busy_msg = "Es laeuft bereits eine Aktualisierung/ein Baustein fuer diese Lage."
|
||||
|
||||
# "Sammeln" nutzt die bewaehrte Sammel-Pipeline (Orchestrator, collect_only)
|
||||
if stage == "collect":
|
||||
if stage_runners.is_running(incident_id):
|
||||
raise HTTPException(status_code=409, detail=busy_msg)
|
||||
ok = await orchestrator.enqueue_refresh(
|
||||
incident_id, trigger_type="collect", user_id=user_id, collect_only=True)
|
||||
if not ok:
|
||||
raise HTTPException(status_code=409, detail=busy_msg)
|
||||
return {"started": True, "stage": "collect", "via": "refresh"}
|
||||
|
||||
# Analyse/Faktencheck: isolierte Bausteine (stage_runners)
|
||||
if stage not in stage_runners.STAGES:
|
||||
raise HTTPException(status_code=400, detail=f"Unbekannter Baustein: {stage}")
|
||||
if orch_busy:
|
||||
raise HTTPException(status_code=409, detail=busy_msg)
|
||||
started = stage_runners.start_stage(incident_id, stage, user_id)
|
||||
if not started:
|
||||
raise HTTPException(status_code=409, detail=busy_msg)
|
||||
return {"started": True, "stage": stage}
|
||||
|
||||
|
||||
def _app_ts_to_utc(ts) -> str | None:
|
||||
"""App-Zeitstempel (lokale Zeitzone) -> UTC-String, fuer den Vergleich mit
|
||||
articles.collected_at (das SQLite in UTC setzt). Ohne Umrechnung waere der
|
||||
Vergleich im Sommer zwei Stunden falsch."""
|
||||
if not ts:
|
||||
return None
|
||||
s = str(ts).strip().replace("T", " ")[:19]
|
||||
try:
|
||||
dt = datetime.strptime(s, "%Y-%m-%d %H:%M:%S")
|
||||
except ValueError:
|
||||
return None
|
||||
return dt.replace(tzinfo=TIMEZONE).astimezone(timezone.utc).strftime("%Y-%m-%d %H:%M:%S")
|
||||
|
||||
|
||||
@router.get("/{incident_id}/freshness")
|
||||
async def get_freshness(
|
||||
incident_id: int,
|
||||
current_user: dict = Depends(get_current_user),
|
||||
db: aiosqlite.Connection = Depends(db_dependency),
|
||||
):
|
||||
"""Datenstand je Artefakt: erzeugt? wie alt? wie viele Artikel kamen seither dazu?
|
||||
|
||||
Grundlage fuer die Veraltet-Anzeige der Studio-Karten ("12 neue Artikel seit dem
|
||||
letzten Lagebild").
|
||||
"""
|
||||
await _check_incident_access(db, incident_id, current_user["id"], current_user.get("tenant_id"))
|
||||
|
||||
async def one(sql: str, params=()):
|
||||
row = await (await db.execute(sql, params)).fetchone()
|
||||
return row[0] if row else None
|
||||
|
||||
async def newer_than(app_ts) -> int:
|
||||
utc = _app_ts_to_utc(app_ts)
|
||||
if not utc:
|
||||
return 0
|
||||
return await one(
|
||||
"SELECT COUNT(*) FROM articles WHERE incident_id = ? AND collected_at > ?",
|
||||
(incident_id, utc),
|
||||
) or 0
|
||||
|
||||
articles = await one("SELECT COUNT(*) FROM articles WHERE incident_id = ?", (incident_id,)) or 0
|
||||
|
||||
inc = await (await db.execute(
|
||||
"SELECT summary, summary_at, updated_at FROM incidents WHERE id = ?", (incident_id,)
|
||||
)).fetchone()
|
||||
has_summary = bool(inc and (inc["summary"] or "").strip())
|
||||
# summary_at ist die Entstehungszeit des Lagebilds; updated_at nur der Notnagel.
|
||||
summary_at = (inc["summary_at"] or inc["updated_at"]) if inc else None
|
||||
|
||||
facts = await one("SELECT COUNT(*) FROM fact_checks WHERE incident_id = ?", (incident_id,)) or 0
|
||||
fc_at = await one("SELECT MAX(checked_at) FROM fact_checks WHERE incident_id = ?", (incident_id,))
|
||||
|
||||
geo_pending = await one(
|
||||
"SELECT COUNT(*) FROM articles WHERE incident_id = ? AND geoparsed_at IS NULL",
|
||||
(incident_id,),
|
||||
) or 0
|
||||
geo_at = await one(
|
||||
"SELECT MAX(geoparsed_at) FROM articles WHERE incident_id = ?", (incident_id,)
|
||||
)
|
||||
|
||||
return {
|
||||
"articles": articles,
|
||||
"summary": {
|
||||
"exists": has_summary,
|
||||
"last": summary_at if has_summary else None,
|
||||
"pending": await newer_than(summary_at) if has_summary else articles,
|
||||
},
|
||||
"factcheck": {
|
||||
"exists": facts > 0,
|
||||
"facts": facts,
|
||||
"last": fc_at,
|
||||
"pending": await newer_than(fc_at) if facts else articles,
|
||||
},
|
||||
"geoparse": {
|
||||
"exists": geo_at is not None,
|
||||
"last": geo_at,
|
||||
"pending": geo_pending,
|
||||
},
|
||||
"snapshots": await one(
|
||||
"SELECT COUNT(*) FROM incident_snapshots WHERE incident_id = ?", (incident_id,)
|
||||
) or 0,
|
||||
"events": await one(
|
||||
"SELECT COUNT(*) FROM incident_events WHERE incident_id = ?", (incident_id,)
|
||||
) or 0,
|
||||
}
|
||||
|
||||
|
||||
@router.get("/{incident_id}/run-status")
|
||||
async def run_stage_status(
|
||||
incident_id: int,
|
||||
current_user: dict = Depends(get_current_user),
|
||||
db: aiosqlite.Connection = Depends(db_dependency),
|
||||
):
|
||||
"""Status des zuletzt/aktuell laufenden Bausteins."""
|
||||
from agents import stage_runners
|
||||
await _check_incident_access(db, incident_id, current_user["id"], current_user.get("tenant_id"))
|
||||
return {"state": stage_runners.get_state(incident_id)}
|
||||
|
||||
|
||||
@router.get("/{incident_id}/factcheck-runs")
|
||||
async def list_factcheck_runs(
|
||||
incident_id: int,
|
||||
current_user: dict = Depends(get_current_user),
|
||||
db: aiosqlite.Connection = Depends(db_dependency),
|
||||
):
|
||||
"""Faktencheck-Historie (archivierte Laeufe mit Zeitstempel)."""
|
||||
await _check_incident_access(db, incident_id, current_user["id"], current_user.get("tenant_id"))
|
||||
cur = await db.execute(
|
||||
"SELECT id, created_at, fact_count FROM fact_check_runs WHERE incident_id = ? ORDER BY created_at DESC, id DESC LIMIT 50",
|
||||
(incident_id,),
|
||||
)
|
||||
return {"runs": [dict(r) for r in await cur.fetchall()]}
|
||||
|
||||
|
||||
@router.get("/{incident_id}/factcheck-runs/{run_id}")
|
||||
async def factcheck_run_detail(
|
||||
incident_id: int,
|
||||
run_id: int,
|
||||
current_user: dict = Depends(get_current_user),
|
||||
db: aiosqlite.Connection = Depends(db_dependency),
|
||||
):
|
||||
"""Faktenstand eines archivierten Laufs."""
|
||||
await _check_incident_access(db, incident_id, current_user["id"], current_user.get("tenant_id"))
|
||||
cur = await db.execute(
|
||||
"SELECT facts_json, created_at FROM fact_check_runs WHERE id = ? AND incident_id = ?",
|
||||
(run_id, incident_id),
|
||||
)
|
||||
row = await cur.fetchone()
|
||||
if not row:
|
||||
raise HTTPException(status_code=404, detail="Lauf nicht gefunden.")
|
||||
try:
|
||||
facts = json.loads(row["facts_json"] or "[]")
|
||||
except (ValueError, TypeError):
|
||||
facts = []
|
||||
return {"created_at": row["created_at"], "facts": facts}
|
||||
|
||||
|
||||
|
||||
def _slugify(text: str) -> str:
|
||||
"""Dateinamen-sicherer Slug aus Titel."""
|
||||
|
||||
In neuem Issue referenzieren
Einen Benutzer sperren