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:
@@ -458,9 +458,13 @@ class AgentOrchestrator:
|
||||
task.cancel()
|
||||
logger.info("Agenten-Orchestrator gestoppt")
|
||||
|
||||
async def enqueue_refresh(self, incident_id: int, trigger_type: str = "manual", user_id: int = None) -> bool:
|
||||
async def enqueue_refresh(self, incident_id: int, trigger_type: str = "manual", user_id: int = None, collect_only: bool = False) -> bool:
|
||||
"""Refresh-Auftrag in die Lane der Organisation stellen. Gibt False zurueck
|
||||
wenn die Lage dort bereits wartet oder gerade laeuft."""
|
||||
wenn die Lage dort bereits wartet oder gerade laeuft.
|
||||
|
||||
collect_only=True (Studio-Baustein "Sammeln"): sammelt nur Artikel, ueberspringt
|
||||
Analyse und Faktencheck.
|
||||
"""
|
||||
if incident_id in self._queued_ids or incident_id in self._current_tasks:
|
||||
logger.info(f"Refresh fuer Lage {incident_id} uebersprungen: bereits aktiv/in Queue")
|
||||
return False
|
||||
@@ -476,7 +480,7 @@ class AgentOrchestrator:
|
||||
self._lane_workers[lane_key] = asyncio.create_task(self._worker(lane_key, queue))
|
||||
logger.info(f"Neue Worker-Lane fuer Organisation {lane_key} gestartet")
|
||||
self._queued_ids.add(incident_id)
|
||||
queue.put_nowait((incident_id, trigger_type, user_id))
|
||||
queue.put_nowait((incident_id, trigger_type, user_id, collect_only))
|
||||
queue_size = queue.qsize()
|
||||
logger.info(f"Refresh fuer Lage {incident_id} eingereiht (Lane {lane_key}, Queue: {queue_size}, Trigger: {trigger_type})")
|
||||
|
||||
@@ -582,11 +586,15 @@ class AgentOrchestrator:
|
||||
return
|
||||
continue
|
||||
|
||||
if len(item) == 3:
|
||||
if len(item) == 4:
|
||||
incident_id, trigger_type, user_id, collect_only = item
|
||||
elif len(item) == 3:
|
||||
incident_id, trigger_type, user_id = item
|
||||
collect_only = False
|
||||
else:
|
||||
incident_id, trigger_type = item
|
||||
user_id = None
|
||||
collect_only = False
|
||||
self._queued_ids.discard(incident_id)
|
||||
# Session-Start EINMAL setzen — bleibt ueber Multi-Pass/Retry hinweg stabil
|
||||
self._current_tasks[incident_id] = datetime.utcnow().strftime('%Y-%m-%dT%H:%M:%SZ')
|
||||
@@ -610,14 +618,14 @@ class AgentOrchestrator:
|
||||
try:
|
||||
# Research-Lagen: Automatisch 3 Durchläufe nur beim ersten Refresh
|
||||
incident_type, has_summary = await self._get_incident_info(incident_id)
|
||||
use_multi_pass = incident_type == "research" and not has_summary
|
||||
use_multi_pass = incident_type == "research" and not has_summary and not collect_only
|
||||
|
||||
for attempt in range(3):
|
||||
try:
|
||||
if use_multi_pass:
|
||||
await self._run_research_multi_pass(incident_id, trigger_type=trigger_type, user_id=user_id)
|
||||
else:
|
||||
await self._run_refresh(incident_id, trigger_type=trigger_type, retry_count=attempt, user_id=user_id)
|
||||
await self._run_refresh(incident_id, trigger_type=trigger_type, retry_count=attempt, user_id=user_id, collect_only=collect_only)
|
||||
last_error = None
|
||||
break # Erfolg
|
||||
except asyncio.CancelledError:
|
||||
@@ -783,7 +791,7 @@ class AgentOrchestrator:
|
||||
await db.close()
|
||||
return visibility, created_by, tenant_id
|
||||
|
||||
async def _run_refresh(self, incident_id: int, trigger_type: str = "manual", retry_count: int = 0, user_id: int = None, _suppress_complete: bool = False, _pass_info: dict = None):
|
||||
async def _run_refresh(self, incident_id: int, trigger_type: str = "manual", retry_count: int = 0, user_id: int = None, _suppress_complete: bool = False, _pass_info: dict = None, collect_only: bool = False):
|
||||
"""Führt einen kompletten Refresh-Zyklus durch."""
|
||||
import aiosqlite
|
||||
from database import get_db
|
||||
@@ -1433,7 +1441,8 @@ class AgentOrchestrator:
|
||||
logger.warning(f"Quellen-Statistiken konnten nicht aktualisiert werden: {e}")
|
||||
|
||||
# Schritt 3+4: Analyse und Faktencheck PARALLEL
|
||||
if new_count > 0 or not previous_summary:
|
||||
# collect_only (Studio-Baustein "Sammeln"): ueberspringt Analyse/Faktencheck.
|
||||
if (new_count > 0 or not previous_summary) and not collect_only:
|
||||
is_first_summary = not previous_summary
|
||||
|
||||
# Snapshot des alten Lagebilds sichern BEVOR parallele Verarbeitung startet
|
||||
@@ -1775,9 +1784,14 @@ class AgentOrchestrator:
|
||||
pass
|
||||
sources_json = json.dumps(sources, ensure_ascii=False) if sources else previous_sources_json
|
||||
|
||||
# summary_at haelt fest, WANN das Lagebild entstand (Studio-Freshness).
|
||||
# JETZT stempeln, nicht 'now' vom Lauf-Beginn: sonst saehen die in diesem
|
||||
# Lauf gesammelten Artikel neuer aus als der Bericht, der sie schon enthaelt.
|
||||
summary_now = datetime.now(TIMEZONE).strftime('%Y-%m-%d %H:%M:%S')
|
||||
await db.execute(
|
||||
"UPDATE incidents SET summary = ?, sources_json = ?, executive_summary = NULL, updated_at = ? WHERE id = ?",
|
||||
(new_summary, sources_json, now, incident_id),
|
||||
"UPDATE incidents SET summary = ?, sources_json = ?, executive_summary = NULL, "
|
||||
"updated_at = ?, summary_at = ? WHERE id = ?",
|
||||
(new_summary, sources_json, summary_now, summary_now, incident_id),
|
||||
)
|
||||
|
||||
# Beim ersten Refresh: Snapshot des neuen Lagebilds erstellen
|
||||
|
||||
257
src/agents/stage_runners.py
Normale Datei
257
src/agents/stage_runners.py
Normale Datei
@@ -0,0 +1,257 @@
|
||||
"""
|
||||
Modulare Pipeline-Bausteine fuers Studio.
|
||||
|
||||
Fuehrt einzelne Pipeline-Stufen ISOLIERT auf dem vorhandenen DB-Bestand aus,
|
||||
statt des durchlaufenden orchestrator._run_refresh. Das grosse _run_refresh
|
||||
bleibt unangetastet (dient weiter als "kompletter Lauf").
|
||||
|
||||
Nutzt die bereits reinen Agenten (AnalyzerAgent/FactCheckerAgent) und bildet nur
|
||||
die Lade-/Persistenz-Logik aus _run_refresh nach.
|
||||
|
||||
Verhalten: KOMPLETT NEU (ersetzt das aktive Ergebnis), Historie bleibt einsehbar:
|
||||
- Analyse -> altes Lagebild wird als incident_snapshots archiviert, dann neu gesetzt
|
||||
- Faktencheck -> alter Faktenstand wird als fact_check_runs archiviert, dann ersetzt
|
||||
|
||||
Phase 1: analyze, factcheck. (Spaeter: collect, geoparse, network.)
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
from datetime import datetime
|
||||
|
||||
from config import TIMEZONE
|
||||
from database import get_db
|
||||
|
||||
logger = logging.getLogger("osint.stages")
|
||||
|
||||
# Im Studio einzeln startbare Bausteine (Phase 1)
|
||||
STAGES = {"analyze", "factcheck"}
|
||||
|
||||
# Status-Registry je Incident (Single-Flight) + Task-Referenzen (GC-Schutz)
|
||||
_STATE: dict[int, dict] = {}
|
||||
_TASKS: set = set()
|
||||
|
||||
|
||||
def _now() -> str:
|
||||
return datetime.now(TIMEZONE).strftime("%Y-%m-%d %H:%M:%S")
|
||||
|
||||
|
||||
def get_state(incident_id: int) -> dict | None:
|
||||
return _STATE.get(incident_id)
|
||||
|
||||
|
||||
def is_running(incident_id: int) -> bool:
|
||||
st = _STATE.get(incident_id)
|
||||
return bool(st and st.get("status") == "running")
|
||||
|
||||
|
||||
async def _load_incident(db, incident_id: int) -> dict | None:
|
||||
cur = await db.execute("SELECT * FROM incidents WHERE id = ?", (incident_id,))
|
||||
row = await cur.fetchone()
|
||||
return dict(row) if row else None
|
||||
|
||||
|
||||
async def _output_language(db, tenant_id) -> str:
|
||||
from services.org_settings import get_org_language, language_display
|
||||
iso = await get_org_language(db, tenant_id) if tenant_id else "de"
|
||||
return language_display(iso)
|
||||
|
||||
|
||||
async def _count(db, sql: str, params) -> int:
|
||||
row = await (await db.execute(sql, params)).fetchone()
|
||||
return (row[0] if row else 0) or 0
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Baustein: Analyse (Lagebild / Recherchebericht) — komplett neu
|
||||
# ---------------------------------------------------------------------------
|
||||
async def _run_analysis(db, inc: dict, user_id) -> dict:
|
||||
from agents.analyzer import AnalyzerAgent, build_fact_context_block
|
||||
from services.license_service import charge_usage_to_tenant
|
||||
|
||||
incident_id = inc["id"]
|
||||
tenant_id = inc.get("tenant_id")
|
||||
incident_type = inc.get("type") or "adhoc"
|
||||
title = inc.get("title") or ""
|
||||
description = inc.get("description") or ""
|
||||
prev_summary = inc.get("summary") or ""
|
||||
prev_sources = inc.get("sources_json")
|
||||
now = _now()
|
||||
output_language = await _output_language(db, tenant_id)
|
||||
|
||||
# 1) Altes Lagebild als Snapshot archivieren (Historie), bevor es ersetzt wird
|
||||
if prev_summary:
|
||||
acnt = await _count(db, "SELECT COUNT(*) FROM articles WHERE incident_id = ?", (incident_id,))
|
||||
fcnt = await _count(db, "SELECT COUNT(*) FROM fact_checks WHERE incident_id = ?", (incident_id,))
|
||||
await db.execute(
|
||||
"""INSERT INTO incident_snapshots
|
||||
(incident_id, summary, sources_json, article_count, fact_check_count,
|
||||
refresh_log_id, created_at, tenant_id)
|
||||
VALUES (?,?,?,?,?,?,?,?)""",
|
||||
(incident_id, prev_summary, prev_sources, acnt, fcnt, None, now, tenant_id),
|
||||
)
|
||||
await db.commit()
|
||||
|
||||
# 2) Alle Artikel + bestehende Fakten laden (Frische-Bias bei adhoc)
|
||||
order = "published_at IS NULL, published_at DESC" if incident_type == "adhoc" else "collected_at DESC"
|
||||
arts = [dict(r) for r in await (await db.execute(
|
||||
f"SELECT * FROM articles WHERE incident_id = ? ORDER BY {order}", (incident_id,)
|
||||
)).fetchall()]
|
||||
if not arts:
|
||||
raise ValueError("Keine Artikel vorhanden — bitte zuerst sammeln.")
|
||||
|
||||
facts = [dict(r) for r in await (await db.execute(
|
||||
"SELECT id, claim, status, sources_count, evidence FROM fact_checks WHERE incident_id = ?",
|
||||
(incident_id,),
|
||||
)).fetchall()]
|
||||
fact_ctx = ""
|
||||
try:
|
||||
fact_ctx = build_fact_context_block(facts, [], incident_type)
|
||||
except Exception as e:
|
||||
logger.warning(f"Faktenkontext fuer Analyse fehlgeschlagen: {e}")
|
||||
|
||||
# 3) Analyse komplett neu ueber ALLE Artikel
|
||||
analyzer = AnalyzerAgent()
|
||||
analysis, usage = await analyzer.analyze(
|
||||
title, description, arts, incident_type,
|
||||
fact_context_block=fact_ctx, output_language=output_language,
|
||||
)
|
||||
if usage:
|
||||
try:
|
||||
await charge_usage_to_tenant(db, tenant_id, usage, source="analysis")
|
||||
except Exception:
|
||||
pass
|
||||
if not analysis or not (analysis.get("summary") or "").strip():
|
||||
raise ValueError("Analyse lieferte kein Lagebild.")
|
||||
|
||||
summary = analysis.get("summary") or ""
|
||||
sources = analysis.get("sources") or []
|
||||
for s in sources:
|
||||
if isinstance(s.get("nr"), str):
|
||||
try:
|
||||
s["nr"] = int(s["nr"])
|
||||
except ValueError:
|
||||
pass
|
||||
sources_json = json.dumps(sources, ensure_ascii=False) if sources else prev_sources
|
||||
|
||||
# summary_at = Entstehungszeit des Lagebilds. JETZT stempeln, nicht 'now' vom Beginn
|
||||
# des Bausteins: die Analyse laeuft Minuten, und der Stempel muss den Stand abdecken,
|
||||
# der tatsaechlich verarbeitet wurde. (updated_at wird auch beim Sammeln gesetzt und
|
||||
# taugt als Bericht-Zeitpunkt ohnehin nicht.)
|
||||
summary_now = _now()
|
||||
await db.execute(
|
||||
"UPDATE incidents SET summary = ?, sources_json = ?, executive_summary = NULL, "
|
||||
"updated_at = ?, summary_at = ? WHERE id = ?",
|
||||
(summary, sources_json, summary_now, summary_now, incident_id),
|
||||
)
|
||||
await db.commit()
|
||||
return {"articles": len(arts), "summary_len": len(summary), "sources": len(sources)}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Baustein: Faktencheck — komplett neu (alter Stand als Lauf archiviert)
|
||||
# ---------------------------------------------------------------------------
|
||||
async def _run_factcheck(db, inc: dict, user_id) -> dict:
|
||||
from agents.factchecker import FactCheckerAgent, deduplicate_new_facts
|
||||
from services.license_service import charge_usage_to_tenant
|
||||
|
||||
incident_id = inc["id"]
|
||||
tenant_id = inc.get("tenant_id")
|
||||
incident_type = inc.get("type") or "adhoc"
|
||||
title = inc.get("title") or ""
|
||||
now = _now()
|
||||
output_language = await _output_language(db, tenant_id)
|
||||
|
||||
# 1) Aktuellen Faktenstand als Lauf archivieren (Historie)
|
||||
cur_facts = [dict(r) for r in await (await db.execute(
|
||||
"SELECT claim, status, sources_count, evidence, is_notification, checked_at, status_history "
|
||||
"FROM fact_checks WHERE incident_id = ? ORDER BY id", (incident_id,)
|
||||
)).fetchall()]
|
||||
if cur_facts:
|
||||
await db.execute(
|
||||
"INSERT INTO fact_check_runs (incident_id, tenant_id, created_at, facts_json, fact_count) VALUES (?,?,?,?,?)",
|
||||
(incident_id, tenant_id, now, json.dumps(cur_facts, ensure_ascii=False), len(cur_facts)),
|
||||
)
|
||||
await db.commit()
|
||||
|
||||
# 2) Alle Artikel laden
|
||||
arts = [dict(r) for r in await (await db.execute(
|
||||
"SELECT * FROM articles WHERE incident_id = ? ORDER BY collected_at DESC", (incident_id,)
|
||||
)).fetchall()]
|
||||
if not arts:
|
||||
raise ValueError("Keine Artikel vorhanden — bitte zuerst sammeln.")
|
||||
|
||||
# 3) Faktencheck komplett neu
|
||||
fc = FactCheckerAgent()
|
||||
facts, usage = await fc.check(title, arts, incident_type, output_language=output_language)
|
||||
if usage:
|
||||
try:
|
||||
await charge_usage_to_tenant(db, tenant_id, usage, source="factcheck")
|
||||
except Exception:
|
||||
pass
|
||||
facts = deduplicate_new_facts(facts or [])
|
||||
|
||||
# 4) Bestehende Fakten ersetzen
|
||||
await db.execute("DELETE FROM fact_checks WHERE incident_id = ?", (incident_id,))
|
||||
for f in facts:
|
||||
init_hist = json.dumps([{"status": f.get("status", "developing"), "at": now}])
|
||||
await db.execute(
|
||||
"""INSERT INTO fact_checks
|
||||
(incident_id, claim, status, sources_count, evidence, is_notification, tenant_id, status_history, checked_at)
|
||||
VALUES (?,?,?,?,?,?,?,?,?)""",
|
||||
(incident_id, f.get("claim", ""), f.get("status", "developing"),
|
||||
f.get("sources_count", 0), f.get("evidence"), f.get("is_notification", 0),
|
||||
tenant_id, init_hist, now),
|
||||
)
|
||||
await db.commit()
|
||||
return {"facts": len(facts), "archived": len(cur_facts), "articles": len(arts)}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Orchestrierung: Start (Single-Flight) + Status
|
||||
# ---------------------------------------------------------------------------
|
||||
_RUNNERS = {"analyze": _run_analysis, "factcheck": _run_factcheck}
|
||||
|
||||
_LABELS = {"analyze": "Analyse", "factcheck": "Faktencheck"}
|
||||
|
||||
|
||||
def start_stage(incident_id: int, stage: str, user_id) -> bool:
|
||||
"""Startet einen Baustein im Hintergrund. False, wenn schon einer laeuft."""
|
||||
if stage not in STAGES:
|
||||
raise ValueError(f"Unbekannter Baustein: {stage}")
|
||||
if is_running(incident_id):
|
||||
return False
|
||||
_STATE[incident_id] = {
|
||||
"stage": stage, "label": _LABELS.get(stage, stage),
|
||||
"status": "running", "started_at": _now(),
|
||||
"finished_at": None, "error": None, "result": None,
|
||||
}
|
||||
t = asyncio.create_task(_execute(incident_id, stage, user_id))
|
||||
_TASKS.add(t)
|
||||
t.add_done_callback(_TASKS.discard)
|
||||
return True
|
||||
|
||||
|
||||
async def _execute(incident_id: int, stage: str, user_id):
|
||||
db = await get_db()
|
||||
started_at = _STATE.get(incident_id, {}).get("started_at")
|
||||
try:
|
||||
inc = await _load_incident(db, incident_id)
|
||||
if not inc:
|
||||
raise ValueError("Lage nicht gefunden")
|
||||
res = await _RUNNERS[stage](db, inc, user_id)
|
||||
_STATE[incident_id] = {
|
||||
"stage": stage, "label": _LABELS.get(stage, stage), "status": "done",
|
||||
"started_at": started_at, "finished_at": _now(), "error": None, "result": res,
|
||||
}
|
||||
logger.info(f"Baustein {stage} Lage {incident_id} fertig: {res}")
|
||||
except Exception as e:
|
||||
logger.warning(f"Baustein {stage} Lage {incident_id} Fehler: {e}", exc_info=True)
|
||||
_STATE[incident_id] = {
|
||||
"stage": stage, "label": _LABELS.get(stage, stage), "status": "error",
|
||||
"started_at": started_at, "finished_at": _now(), "error": str(e)[:400], "result": None,
|
||||
}
|
||||
finally:
|
||||
await db.close()
|
||||
In neuem Issue referenzieren
Einen Benutzer sperren