feat(stage12): implement Research Orchestrator
This commit is contained in:
844
src/nsct/orchestration/orchestrator.py
Normal file
844
src/nsct/orchestration/orchestrator.py
Normal file
@@ -0,0 +1,844 @@
|
||||
"""Research Orchestrator — Hauptklasse für Stage 12.
|
||||
|
||||
Orchestriert die gesamte Research-Pipeline (PLANNING → SEARCHING → FETCHING →
|
||||
EXTRACTING → ANALYZING → COMPARING → SYNTHESIZING → COMPLETED) mit Budget-
|
||||
Tracking, State-Machine-Validierung und Fallback-Logik.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import time
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any
|
||||
from uuid import UUID, uuid4
|
||||
|
||||
from nsct.agents.planner import MockResearchPlanner, ResearchPlanner
|
||||
from nsct.config import AppSettings
|
||||
from nsct.crawler.manager import CrawlerManager
|
||||
from nsct.models.claim import Claim
|
||||
from nsct.orchestration.budget import BudgetExhaustedError, BudgetTracker, HardBudgetConfig
|
||||
from nsct.orchestration.models import ResearchRun
|
||||
from nsct.orchestration.state import ResearchRunState, StateMachine
|
||||
from nsct.providers.abstract import MultiProviderSearch, SearchProvider
|
||||
|
||||
try:
|
||||
from nsct.stages.stage9_synthesis import SynthesisStage
|
||||
except ImportError:
|
||||
SynthesisStage = None # type: ignore[misc,assignment]
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class ResearchOrchestrator:
|
||||
"""Haupt-Orchestrator für den NSCT Research-Pipeline (Stage 12).
|
||||
|
||||
Führt die gesamte Pipeline von PLANNING bis COMPLETED sequenziell
|
||||
oder schrittweise (run_step) aus. Trackt Budget, validiert States und
|
||||
bietet Fallback-Logik für fehlende Dienste.
|
||||
"""
|
||||
|
||||
# ---------------------------------------------------------------
|
||||
# Lifecycle
|
||||
# ---------------------------------------------------------------
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
config: AppSettings,
|
||||
research_id: UUID,
|
||||
query: str,
|
||||
budget_config: HardBudgetConfig | None = None,
|
||||
depth: str = "normal",
|
||||
) -> None:
|
||||
"""Initialisiere den Orchestrator.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
config : AppSettings
|
||||
Zentrale Anwendungskonfiguration.
|
||||
research_id : UUID
|
||||
Parent-Research-ID (gruppierung).
|
||||
query : str
|
||||
Die Forschungsfrage / Query.
|
||||
budget_config : HardBudgetConfig | None
|
||||
Optionales Budget. Wird aus config abgeleitet, wenn None.
|
||||
depth : str
|
||||
Suchtiefe ("quick", "normal", "deep").
|
||||
"""
|
||||
self._config = config
|
||||
self._research_id = research_id
|
||||
self._query = query
|
||||
self._depth = depth
|
||||
|
||||
# Budget
|
||||
if budget_config is not None:
|
||||
self._budget_config = budget_config
|
||||
else:
|
||||
self._budget_config = HardBudgetConfig()
|
||||
self._budget_tracker = BudgetTracker(self._budget_config)
|
||||
|
||||
# State Machine & Run
|
||||
self._state_machine = StateMachine(ResearchRunState.CREATED)
|
||||
self._run: ResearchRun | None = None
|
||||
|
||||
# Pipeline-Zwischenspeicher
|
||||
self._plan: dict[str, Any] | None = None
|
||||
self._search_results: list[dict[str, Any]] = [] # URLs
|
||||
self._sources: list[dict[str, Any]] = []
|
||||
self._claims: list[Claim] = []
|
||||
|
||||
# Sub-Components (lazy init)
|
||||
self._planner: ResearchPlanner | MockResearchPlanner | None = None
|
||||
self._crawler: CrawlerManager | None = None
|
||||
self._multi_search: MultiProviderSearch | None = None
|
||||
self._llm_provider = None
|
||||
|
||||
# ---------------------------------------------------------------
|
||||
# Properties
|
||||
# ---------------------------------------------------------------
|
||||
|
||||
@property
|
||||
def state(self) -> ResearchRunState:
|
||||
"""Aktueller Zustand der State Machine."""
|
||||
return self._state_machine.current_state
|
||||
|
||||
@property
|
||||
def run_id(self) -> UUID:
|
||||
"""UUID des aktuellen Research-Runs."""
|
||||
if self._run is None:
|
||||
return uuid4()
|
||||
return self._run.id
|
||||
|
||||
@property
|
||||
def budget_tracker(self) -> BudgetTracker:
|
||||
"""BudgetTracker des aktuellen Runs."""
|
||||
return self._budget_tracker
|
||||
|
||||
@property
|
||||
def is_completed(self) -> bool:
|
||||
"""True, wenn State == COMPLETED."""
|
||||
return self._state_machine.current_state == ResearchRunState.COMPLETED
|
||||
|
||||
@property
|
||||
def is_running(self) -> bool:
|
||||
"""True, wenn sich die Maschine in einem nicht-terminalen State befindet."""
|
||||
return not self._state_machine.is_terminal()
|
||||
|
||||
# ---------------------------------------------------------------
|
||||
# Private: State Machine
|
||||
# ---------------------------------------------------------------
|
||||
|
||||
def _transition_to(self, state_name: str) -> bool:
|
||||
"""Transition via StateMachine + Logging.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
state_name : str
|
||||
Zielzustand als String (z.B. "planning").
|
||||
|
||||
Returns
|
||||
-------
|
||||
bool
|
||||
True wenn Übergang erfolgreich, False sonst.
|
||||
"""
|
||||
result = self._state_machine.transition(state_name)
|
||||
if result:
|
||||
logger.info("State transition: %s -> %s", self._state_machine.current_state.value, state_name)
|
||||
else:
|
||||
logger.warning("State transition %s -> %s rejected", self._state_machine.current_state.value, state_name)
|
||||
return result
|
||||
|
||||
def _mark_failed(self, reason: str) -> bool:
|
||||
"""Setze State auf FAILED + logge Error.
|
||||
|
||||
Fällt auf die State Machine zurück, wenn der direkte Übergang
|
||||
zum aktuellen State erlaubt ist; sonst erzwingt er FAILED.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
reason : str
|
||||
Fehlerbeschreibung.
|
||||
|
||||
Returns
|
||||
-------
|
||||
bool
|
||||
True wenn Zustand erfolgreich auf FAILED gesetzt wurde.
|
||||
"""
|
||||
logger.error("Research failed: %s (state=%s)", reason, self._state_machine.current_state.value)
|
||||
|
||||
# Der State Machine _apply erzwingt FAILED direkt,
|
||||
# weil _mark_failed als "Sondertransition" gedacht ist.
|
||||
if self._state_machine.current_state in (
|
||||
ResearchRunState.COMPLETED,
|
||||
ResearchRunState.FAILED,
|
||||
ResearchRunState.CANCELLED,
|
||||
):
|
||||
return False
|
||||
|
||||
self._state_machine.current_state = ResearchRunState.FAILED # type: ignore[assignment]
|
||||
return True
|
||||
|
||||
# ---------------------------------------------------------------
|
||||
# Private: Budget
|
||||
# ---------------------------------------------------------------
|
||||
|
||||
def _check_budget(self) -> None:
|
||||
"""Budget-Check vor jedem LLM-/Download-Aufruf.
|
||||
|
||||
Raises
|
||||
------
|
||||
BudgetExhaustedError
|
||||
Wenn ein Limit erreicht ist.
|
||||
"""
|
||||
self._budget_tracker.check_budget()
|
||||
|
||||
# ---------------------------------------------------------------
|
||||
# Private: Sub-Component Init
|
||||
# ---------------------------------------------------------------
|
||||
|
||||
def _get_planner(self) -> ResearchPlanner | MockResearchPlanner:
|
||||
"""Planner instantiieren (LLM) oder Fallback (Mock)."""
|
||||
if self._planner is not None:
|
||||
return self._planner
|
||||
|
||||
try:
|
||||
from nsct.providers.llm import get_provider
|
||||
from nsct.providers.metrics import ProviderMetrics
|
||||
|
||||
metrics = ProviderMetrics()
|
||||
llm_prov = get_provider(self._config, metrics)
|
||||
self._planner = ResearchPlanner(config=self._config, llm_provider=llm_prov, metrics=metrics)
|
||||
logger.info("Using LLM-based ResearchPlanner")
|
||||
except Exception as exc:
|
||||
logger.warning("LLM Planner unavailable (%s) — falling back to MockResearchPlanner", exc)
|
||||
self._planner = MockResearchPlanner(config=self._config)
|
||||
return self._planner
|
||||
|
||||
def _get_crawler(self) -> CrawlerManager:
|
||||
"""CrawlerManager instantiieren oder Fallback."""
|
||||
if self._crawler is not None:
|
||||
return self._crawler
|
||||
|
||||
try:
|
||||
self._crawler = CrawlerManager()
|
||||
except Exception as exc:
|
||||
logger.warning("Crawler init failed (%s) — crawler will return empty docs", exc)
|
||||
self._crawler = CrawlerManager()
|
||||
return self._crawler
|
||||
|
||||
def _get_multi_search(self) -> MultiProviderSearch:
|
||||
"""MultiProviderSearch mit den konfigurierten Providern."""
|
||||
if self._multi_search is not None:
|
||||
return self._multi_search
|
||||
|
||||
providers: list[SearchProvider] = []
|
||||
try:
|
||||
from nsct.providers.searxng import SearXNGProvider
|
||||
if self._config.searxng_base_url:
|
||||
provider = SearXNGProvider(base_url=self._config.searxng_base_url)
|
||||
providers.append(provider)
|
||||
except ImportError:
|
||||
pass
|
||||
except Exception as exc:
|
||||
logger.warning("SearXNG provider init failed (%s) — search will return empty", exc)
|
||||
|
||||
self._multi_search = MultiProviderSearch(providers=providers if providers else None)
|
||||
if not providers:
|
||||
logger.warning("No search providers configured — search will return empty results")
|
||||
return self._multi_search
|
||||
|
||||
def _get_llm_provider(self):
|
||||
"""LLM-Provider für Claim Extraction."""
|
||||
if self._llm_provider is not None:
|
||||
return self._llm_provider
|
||||
|
||||
try:
|
||||
from nsct.providers.llm import get_provider
|
||||
from nsct.providers.metrics import ProviderMetrics
|
||||
|
||||
metrics = ProviderMetrics()
|
||||
self._llm_provider = get_provider(self._config, metrics)
|
||||
except Exception as exc:
|
||||
logger.warning("LLM provider unavailable (%s) — extraction will produce no claims", exc)
|
||||
self._llm_provider = None
|
||||
return self._llm_provider
|
||||
|
||||
# ---------------------------------------------------------------
|
||||
# Public: Pipeline
|
||||
# ---------------------------------------------------------------
|
||||
|
||||
async def start(self) -> ResearchRun:
|
||||
"""Erstelle den ResearchRun und initialisiere die Pipeline.
|
||||
|
||||
Setzt State auf CREATED, erstellt ResearchRun-Instanz,
|
||||
initialisiert Budget-Tracker und State Machine.
|
||||
|
||||
Returns
|
||||
-------
|
||||
ResearchRun
|
||||
Das erstellte Run-Objekt.
|
||||
"""
|
||||
# Budget init
|
||||
self._budget_tracker.increment_llm_requests(1) # planner call
|
||||
self._budget_tracker.increment_search(1) # initial search plan
|
||||
|
||||
# ResearchRun erstellen
|
||||
self._run = self._create_research_run()
|
||||
self._transition_to("created")
|
||||
|
||||
logger.info(
|
||||
"Research run started: id=%s, query=%s, depth=%s",
|
||||
self._run.id,
|
||||
self._query[:60],
|
||||
self._depth,
|
||||
)
|
||||
return self._run
|
||||
|
||||
async def run(self) -> dict[str, Any]:
|
||||
"""Führe die gesamte Pipeline sequenziell aus.
|
||||
|
||||
Pipeline:
|
||||
CREATED → PLANNING → SEARCHING → FETCHING → EXTRACTING
|
||||
→ ANALYZING → COMPARING → SYNTHESIZING → COMPLETED / FAILED
|
||||
|
||||
Returns
|
||||
-------
|
||||
dict
|
||||
Das Ergebnis der Pipeline (Report oder Error-Info).
|
||||
"""
|
||||
# Start falls noch nicht passiert
|
||||
if self._run is None:
|
||||
await self.start()
|
||||
|
||||
# Pipeline-Schritte nacheinander
|
||||
steps = [
|
||||
"planning",
|
||||
"searching",
|
||||
"fetching",
|
||||
"extracting",
|
||||
"analyzing",
|
||||
"comparing",
|
||||
"synthesizing",
|
||||
]
|
||||
|
||||
for step_name in steps:
|
||||
try:
|
||||
# Budget prüfen vor jedem Schritt
|
||||
self._check_budget()
|
||||
|
||||
result = await self.run_step(step_name)
|
||||
|
||||
# Wenn ein Schritt FAILED meldet, Pipeline brechen
|
||||
if result.get("success") is False:
|
||||
error_msg = result.get("error", f"Step {step_name} failed")
|
||||
self._mark_failed(error_msg)
|
||||
return {
|
||||
"success": False,
|
||||
"error": error_msg,
|
||||
"failed_step": step_name,
|
||||
"report": None,
|
||||
}
|
||||
|
||||
# Zeit-Tracking
|
||||
self._budget_tracker.record_time_elapsed()
|
||||
|
||||
except BudgetExhaustedError:
|
||||
logger.error("Budget exhausted during step %s", step_name)
|
||||
self._mark_failed(f"Budget exhausted at step: {step_name}")
|
||||
raise
|
||||
|
||||
except Exception as exc:
|
||||
logger.error("Unhandled error in step %s: %s", step_name, exc)
|
||||
self._mark_failed(str(exc))
|
||||
return {
|
||||
"success": False,
|
||||
"error": str(exc),
|
||||
"failed_step": step_name,
|
||||
"report": None,
|
||||
}
|
||||
|
||||
# Alle Schritte erfolgreich → COMPLETED
|
||||
self._transition_to("completed")
|
||||
self._budget_tracker.record_time_elapsed()
|
||||
|
||||
report = {
|
||||
"success": True,
|
||||
"report": self._get_report(),
|
||||
"state": self._state_machine.current_state.value,
|
||||
"budget_usage": self._budget_tracker.get_usage(),
|
||||
}
|
||||
|
||||
logger.info("Research pipeline completed: %d claims, state=%s", len(self._claims), report["state"])
|
||||
return report
|
||||
|
||||
async def run_step(self, step_name: str) -> dict[str, Any]:
|
||||
"""Führe EINEN einzelnen Pipeline-Schritt aus.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
step_name : str
|
||||
Name des Schritts (z.B. "planning", "searching").
|
||||
|
||||
Returns
|
||||
-------
|
||||
dict
|
||||
Ergebnis-Dict mit success, data, error.
|
||||
"""
|
||||
step_name = step_name.lower().strip()
|
||||
|
||||
try:
|
||||
handler = getattr(self, f"_step_{step_name}", None)
|
||||
if handler is None:
|
||||
logger.warning("Unknown step: %s", step_name)
|
||||
return {"success": False, "error": f"Unknown step: {step_name}", "data": {}}
|
||||
|
||||
self._check_budget()
|
||||
result = await handler()
|
||||
return result
|
||||
|
||||
except BudgetExhaustedError:
|
||||
self._mark_failed(f"Budget exhausted during {step_name}")
|
||||
raise
|
||||
|
||||
except Exception as exc:
|
||||
logger.error("Error in step %s: %s", step_name, exc)
|
||||
self._mark_failed(str(exc))
|
||||
return {"success": False, "error": str(exc), "data": {}}
|
||||
|
||||
# ---------------------------------------------------------------
|
||||
# Private: Pipeline Steps
|
||||
# ---------------------------------------------------------------
|
||||
|
||||
async def _step_planning(self) -> dict[str, Any]:
|
||||
"""PLANNING: Erstelle Recherchestrategie mit dem Planner."""
|
||||
self._transition_to("planning")
|
||||
|
||||
try:
|
||||
planner = self._get_planner()
|
||||
if isinstance(planner, MockResearchPlanner):
|
||||
logger.info("Using MockResearchPlanner (LLM not available)")
|
||||
plan = planner.plan(self._query, language="de")
|
||||
else:
|
||||
self._budget_tracker.increment_llm_requests(1)
|
||||
plan = await planner.plan(self._query, language="de")
|
||||
|
||||
self._plan = plan
|
||||
|
||||
logger.info("Planning complete: topic=%s, queries=%d",
|
||||
plan.get("topic", ""), len(plan.get("queries", [])))
|
||||
|
||||
# Update run metadata (create a new frozen instance)
|
||||
if self._run is not None:
|
||||
self._run = self._run.model_copy(
|
||||
update={
|
||||
"research_plan": plan,
|
||||
"updated_at": datetime.utcnow(),
|
||||
}
|
||||
)
|
||||
|
||||
return {"success": True, "data": {"plan": plan}, "plan": plan}
|
||||
|
||||
except Exception as exc:
|
||||
logger.error("Planning failed: %s", exc)
|
||||
# Fallback: Create a minimal plan
|
||||
fallback = self._get_fallback_plan()
|
||||
self._plan = fallback
|
||||
# Store error in _run metadata via new instance
|
||||
if self._run is not None:
|
||||
self._run = self._run.model_copy(
|
||||
update={
|
||||
"plan_error": str(exc),
|
||||
"updated_at": datetime.utcnow(),
|
||||
}
|
||||
)
|
||||
return {"success": True, "data": {"plan": fallback}, "plan": fallback, "warning": "Used fallback plan"}
|
||||
|
||||
async def _step_searching(self) -> dict[str, Any]:
|
||||
"""SEARCHING: Führe Suchanfragen durch und sammle URLs."""
|
||||
self._transition_to("searching")
|
||||
|
||||
try:
|
||||
multi_search = self._get_multi_search()
|
||||
plan_queries = self._plan.get("queries", []) if self._plan else []
|
||||
|
||||
if not plan_queries:
|
||||
# Fallback: verwende die ursprüngliche Query
|
||||
plan_queries = [{"query": self._query, "purpose": "general", "category": "general", "language": "de"}]
|
||||
|
||||
all_results = []
|
||||
self._budget_tracker.increment_search(len(plan_queries))
|
||||
|
||||
# Führe jede Query parallel aus
|
||||
search_tasks = []
|
||||
for q in plan_queries:
|
||||
query_text = q.get("query", self._query)
|
||||
search_tasks.append(multi_search.search(query_text, language="de", max_results=5))
|
||||
|
||||
raw_results = await asyncio.gather(*search_tasks, return_exceptions=True)
|
||||
|
||||
for raw in raw_results:
|
||||
if isinstance(raw, Exception):
|
||||
logger.warning("Search query failed: %s", raw)
|
||||
continue
|
||||
if isinstance(raw, list):
|
||||
all_results.extend(raw)
|
||||
|
||||
# Dedupliziere und extrahiere URLs
|
||||
seen_urls = set()
|
||||
self._search_results = []
|
||||
for r in all_results:
|
||||
url = r.url if hasattr(r, "url") else r.get("url", "")
|
||||
if url and url not in seen_urls:
|
||||
seen_urls.add(url)
|
||||
result_dict = {
|
||||
"url": url,
|
||||
"title": getattr(r, "title", "") or r.get("title", ""),
|
||||
"snippet": getattr(r, "snippet", "") or r.get("snippet", ""),
|
||||
"provider": getattr(r, "provider", "") or r.get("provider", ""),
|
||||
}
|
||||
self._search_results.append(result_dict)
|
||||
|
||||
logger.info("Search complete: %d unique URLs collected", len(self._search_results))
|
||||
return {"success": True, "data": {"urls": self._search_results}, "url_count": len(self._search_results)}
|
||||
|
||||
except Exception as exc:
|
||||
logger.warning("Searching failed (graceful fallback): %s — returning empty results", exc)
|
||||
self._search_results = []
|
||||
return {"success": True, "data": {"urls": []}, "url_count": 0, "warning": str(exc)}
|
||||
|
||||
async def _step_fetching(self) -> dict[str, Any]:
|
||||
"""FETCHING: Crawle gesammelte URLs und extrahiere Inhalt."""
|
||||
self._transition_to("fetching")
|
||||
|
||||
try:
|
||||
if not self._search_results:
|
||||
logger.warning("No URLs to fetch — skipping fetching step")
|
||||
self._sources = []
|
||||
return {"success": True, "data": {"sources": []}, "source_count": 0}
|
||||
|
||||
crawler = self._get_crawler()
|
||||
urls = [r["url"] for r in self._search_results]
|
||||
|
||||
# Budget: Abschätzung der Download-Größe
|
||||
self._budget_tracker.increment_download_bytes(len(urls) * 500_000) # ~500KB pro Seite
|
||||
self._budget_tracker.increment_sources(len(urls))
|
||||
|
||||
docs = await crawler.fetch_and_extract_many(urls)
|
||||
self._budget_tracker.record_time_elapsed()
|
||||
|
||||
# In sources-Dicts umwandeln
|
||||
self._sources = []
|
||||
for i, doc in enumerate(docs):
|
||||
src = {
|
||||
"id": str(uuid4()),
|
||||
"url": doc.url or "",
|
||||
"title": doc.title or "",
|
||||
"domain": doc.url.split("//")[-1].split("/")[0] if doc.url else "",
|
||||
"content": doc.text or "",
|
||||
"links": doc.links or [],
|
||||
"metadata": doc.metadata or {},
|
||||
"error": doc.metadata.get("error", "") if doc.metadata else "",
|
||||
}
|
||||
self._sources.append(src)
|
||||
|
||||
# Track bytes
|
||||
content_len = len(doc.text or "")
|
||||
self._budget_tracker.increment_download_bytes(content_len)
|
||||
|
||||
successful = [s for s in self._sources if not s.get("error")]
|
||||
logger.info("Fetching complete: %d/%d sources successfully fetched", len(successful), len(self._sources))
|
||||
return {
|
||||
"success": True,
|
||||
"data": {"sources": self._sources},
|
||||
"source_count": len(self._sources),
|
||||
"successful_count": len(successful),
|
||||
}
|
||||
|
||||
except Exception as exc:
|
||||
logger.warning("Fetching failed (graceful fallback): %s — returning empty sources", exc)
|
||||
self._sources = []
|
||||
return {"success": True, "data": {"sources": []}, "source_count": 0, "warning": str(exc)}
|
||||
|
||||
async def _step_extracting(self) -> dict[str, Any]:
|
||||
"""EXTRACTING: Extrahiere Claims aus den extrahierten Quellen."""
|
||||
self._transition_to("extracting")
|
||||
|
||||
try:
|
||||
llm_provider = self._get_llm_provider()
|
||||
|
||||
if llm_provider is None or not self._sources:
|
||||
logger.warning("No LLM provider or no sources — extracting produces no claims")
|
||||
self._claims = []
|
||||
return {"success": True, "data": {"claims": []}, "claim_count": 0}
|
||||
|
||||
self._budget_tracker.increment_llm_requests(len(self._sources))
|
||||
|
||||
# Stage 5: Claim Extraction
|
||||
try:
|
||||
from nsct.stages.stage5_extract_claims import Stage5Extractor
|
||||
|
||||
extractor = Stage5Extractor(
|
||||
llm_provider=llm_provider,
|
||||
config=self._config,
|
||||
research_run_id=self._run.id if self._run else uuid4(),
|
||||
sources=self._sources,
|
||||
)
|
||||
self._claims = await extractor.extract()
|
||||
except NameError:
|
||||
# stage5_extract_claims nicht importierbar
|
||||
logger.warning("Stage5Extractor not available — skipping claim extraction")
|
||||
self._claims = []
|
||||
|
||||
# Update Run
|
||||
if self._run is not None:
|
||||
self._run = self._run.model_copy(
|
||||
update={
|
||||
"claim_count": len(self._claims),
|
||||
"updated_at": datetime.utcnow(),
|
||||
}
|
||||
)
|
||||
|
||||
logger.info("Extraction complete: %d claims", len(self._claims))
|
||||
return {"success": True, "data": {"claims": [c.model_dump() for c in self._claims]}, "claim_count": len(self._claims)}
|
||||
|
||||
except Exception as exc:
|
||||
logger.warning("Claim extraction failed (graceful fallback): %s", exc)
|
||||
self._claims = []
|
||||
return {"success": True, "data": {"claims": []}, "claim_count": 0, "warning": str(exc)}
|
||||
|
||||
async def _step_analyzing(self) -> dict[str, Any]:
|
||||
"""ANALYZING: Platzhalter — Stage 6/7/8 werden später eingebunden.
|
||||
|
||||
Derzeit: Validiere Claims und sammle Metadaten.
|
||||
"""
|
||||
self._transition_to("analyzing")
|
||||
|
||||
logger.info("ANALYZING step: placeholder — Stage 6/7/8 pending integration")
|
||||
|
||||
# Grundlegende Claim-Validierung
|
||||
claim_stats = {
|
||||
"total": len(self._claims),
|
||||
"by_type": {},
|
||||
"avg_confidence": 0.0,
|
||||
}
|
||||
|
||||
if self._claims:
|
||||
type_counts: dict[str, int] = {}
|
||||
total_conf = 0.0
|
||||
for c in self._claims:
|
||||
t = c.claim_type.value if hasattr(c.claim_type, "value") else str(c.claim_type)
|
||||
type_counts[t] = type_counts.get(t, 0) + 1
|
||||
total_conf += c.confidence
|
||||
claim_stats["avg_confidence"] = round(total_conf / len(self._claims), 3)
|
||||
claim_stats["by_type"] = type_counts
|
||||
else:
|
||||
claim_stats["by_type"] = {}
|
||||
|
||||
self._run_metadata = claim_stats
|
||||
if self._run is not None:
|
||||
self._run = self._run.model_copy(
|
||||
update={
|
||||
"metadata": {**self._run.metadata, "analysis": claim_stats},
|
||||
"updated_at": datetime.utcnow(),
|
||||
}
|
||||
)
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"data": {"claim_stats": claim_stats},
|
||||
"claim_count": len(self._claims),
|
||||
}
|
||||
|
||||
async def _step_comparing(self) -> dict[str, Any]:
|
||||
"""COMPARING: Platzhalter — Stage 8 Evidence Scoring.
|
||||
|
||||
Derzeit: Leere Comparison, nur Logging.
|
||||
"""
|
||||
self._transition_to("comparing")
|
||||
|
||||
logger.info("COMPARING step: placeholder — Stage 8 Evidence Scoring pending integration")
|
||||
|
||||
# Placeholder: keine Comparison-Daten
|
||||
self._comparison_data: dict[str, Any] = {
|
||||
"total_claims": len(self._claims),
|
||||
"comparisons": [],
|
||||
"evidence_scores": {},
|
||||
}
|
||||
|
||||
if self._run is not None:
|
||||
self._run = self._run.model_copy(
|
||||
update={
|
||||
"metadata": {**self._run.metadata, "comparison": self._comparison_data},
|
||||
"updated_at": datetime.utcnow(),
|
||||
}
|
||||
)
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"data": self._comparison_data,
|
||||
}
|
||||
|
||||
async def _step_synthesizing(self) -> dict[str, Any]:
|
||||
"""SYNTHESIZING: Erzeuge Synthese-Bericht mit SynthesisStage."""
|
||||
self._transition_to("synthesizing")
|
||||
|
||||
try:
|
||||
# LLM-Anfrage zählen
|
||||
self._budget_tracker.increment_llm_requests(1)
|
||||
|
||||
if SynthesisStage is None:
|
||||
logger.warning("SynthesisStage not available — generating fallback report")
|
||||
return self._fallback_synthesis()
|
||||
|
||||
llm_provider = self._get_llm_provider()
|
||||
if llm_provider is None:
|
||||
logger.warning("No LLM provider for synthesis — generating fallback report")
|
||||
return self._fallback_synthesis()
|
||||
|
||||
# Claims als Dicts für SynthesisStage
|
||||
claims_dicts = [c.model_dump() for c in self._claims]
|
||||
|
||||
stage = SynthesisStage(
|
||||
research_run_id=self._run.id if self._run else uuid4(),
|
||||
llm_provider=llm_provider,
|
||||
config=self._config,
|
||||
claims=claims_dicts,
|
||||
)
|
||||
|
||||
result = await stage.execute()
|
||||
|
||||
if result.success:
|
||||
logger.info("Synthesis complete: report generated successfully")
|
||||
report = result.data if result.data else {}
|
||||
self._budget_tracker.record_time_elapsed()
|
||||
return {
|
||||
"success": True,
|
||||
"data": report,
|
||||
"report": report,
|
||||
}
|
||||
else:
|
||||
logger.warning("Synthesis returned success=False — generating fallback")
|
||||
return self._fallback_synthesis_data(result.errors)
|
||||
|
||||
except Exception as exc:
|
||||
logger.error("Synthesis failed: %s — generating fallback", exc)
|
||||
return self._fallback_synthesis()
|
||||
|
||||
def _fallback_synthesis(self) -> dict[str, Any]:
|
||||
"""Fallback Synthese wenn SynthesisStage nicht verfügbar ist."""
|
||||
logger.info("Generating fallback synthesis report")
|
||||
|
||||
fallback_report = {
|
||||
"success": True,
|
||||
"data": self._get_report(),
|
||||
"report": self._get_report(),
|
||||
"warning": "Fallback synthesis (LLM unavailable)",
|
||||
}
|
||||
self._budget_tracker.record_time_elapsed()
|
||||
return fallback_report
|
||||
|
||||
def _fallback_synthesis_data(self, errors: list[str]) -> dict[str, Any]:
|
||||
"""Fallback report aus gescheiterter Synthese."""
|
||||
report = self._get_report()
|
||||
fallback = {
|
||||
"success": True,
|
||||
"data": report,
|
||||
"report": report,
|
||||
"synthesis_errors": errors,
|
||||
"warning": "Synthesis degraded to fallback",
|
||||
}
|
||||
self._budget_tracker.record_time_elapsed()
|
||||
return fallback
|
||||
|
||||
# ---------------------------------------------------------------
|
||||
# Private: Helpers
|
||||
# ---------------------------------------------------------------
|
||||
|
||||
def _create_research_run(self) -> ResearchRun:
|
||||
"""Erstelle eine ResearchRun-Instanz mit allen relevanten Feldern.
|
||||
|
||||
Returns
|
||||
-------
|
||||
ResearchRun
|
||||
Das neue Run-Objekt.
|
||||
"""
|
||||
budget_json = None
|
||||
if self._budget_config is not None:
|
||||
budget_json = self._budget_config.model_dump_json()
|
||||
|
||||
run = ResearchRun(
|
||||
id=uuid4(),
|
||||
research_id=self._research_id,
|
||||
query=self._query,
|
||||
state="created",
|
||||
budget_config_json=budget_json,
|
||||
metadata={
|
||||
"depth": self._depth,
|
||||
"created_by": "orchestrator",
|
||||
},
|
||||
)
|
||||
self._run = run
|
||||
return run
|
||||
|
||||
def _get_report(self) -> dict[str, Any]:
|
||||
"""Erzeuge den finalen Report aus allen Zwischenspeichern."""
|
||||
report: dict[str, Any] = {
|
||||
"run_id": self._run.id if self._run else None,
|
||||
"query": self._query,
|
||||
"state": self._state_machine.current_state.value,
|
||||
"plan": self._plan,
|
||||
"search_results": self._search_results,
|
||||
"sources": self._sources,
|
||||
"claims": [c.model_dump() for c in self._claims],
|
||||
"claim_count": len(self._claims),
|
||||
"source_count": len(self._sources),
|
||||
"budget_usage": self._budget_tracker.get_usage(),
|
||||
"duration_seconds": (
|
||||
time.monotonic() - self._budget_tracker._start_time
|
||||
if hasattr(self._budget_tracker, "_start_time")
|
||||
else 0
|
||||
),
|
||||
}
|
||||
|
||||
if self._comparison_data:
|
||||
report["comparison"] = self._comparison_data
|
||||
if self._run is not None:
|
||||
report["metadata"] = self._run.metadata
|
||||
|
||||
return report
|
||||
|
||||
def _get_fallback_plan(self) -> dict[str, Any]:
|
||||
"""Minimaler Fallback-Plan falls der Planner komplett versagt."""
|
||||
return {
|
||||
"topic": self._query[:120],
|
||||
"queries": [
|
||||
{
|
||||
"query": self._query,
|
||||
"purpose": "Falls Planner ausgefallen: Basis-Query",
|
||||
"category": "general",
|
||||
"language": "de",
|
||||
}
|
||||
],
|
||||
"fallback": True,
|
||||
"error": "Planner completely failed — using minimal fallback",
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------
|
||||
# Lifecycle
|
||||
# ---------------------------------------------------------------
|
||||
|
||||
async def reset(self) -> None:
|
||||
"""Setze den Orchestrator zurück (zustand → CREATED)."""
|
||||
self._state_machine.reset(ResearchRunState.CREATED)
|
||||
self._plan = None
|
||||
self._search_results = []
|
||||
self._sources = []
|
||||
self._claims = []
|
||||
self._planner = None
|
||||
self._crawler = None
|
||||
self._multi_search = None
|
||||
self._llm_provider = None
|
||||
self._budget_tracker = BudgetTracker(self._budget_config)
|
||||
logger.info("Orchestrator reset to CREATED state")
|
||||
Reference in New Issue
Block a user