"""Verdrahtung aller Komponenten (einmal pro Prozess, in Tests mit Fakes).""" from __future__ import annotations import json from collections.abc import Callable from dataclasses import dataclass from datetime import datetime from pathlib import Path from .canary import CanaryProber from .classifier import EmbeddingClassifier from .embeddings import Embedder, LlamaCppReranker, OpenAIEmbedder, Reranker from .executor import Executor from .healer.drivers import CompositeInfra, InfraDriver, Notifier, PortainerDriver, ProxmoxDriver, WebhookNotifier from .healer.engine import Healer, HealerConfig from .lifecycle import JobRunner, Lifecycle, LifecycleConfig, LoggingJobRunner, WebhookJobRunner from .llm import ChatClient, OpenAIChatClient from .maintenance.actions import ActionExecutor from .maintenance.agent import IntentMapper, LLMIntentMapper, MaintenanceAgent, MaintenanceConfig from .metrics import Metrics from .ontology import Ontology, OntologyFinding, build_snapshot, check, fetch_workflows from .policy import Policy from .registry import Registry from .router import Router from .runner import Runner from .settings import Settings from .skills import SkillLibrary from .store import Store from .util import now from .verifier import Verifier @dataclass class System: settings: Settings store: Store registry: Registry library: SkillLibrary embedder: Embedder chat: ChatClient router: Router executor: Executor verifier: Verifier runner: Runner policy: Policy lifecycle: Lifecycle actions: ActionExecutor maintenance: MaintenanceAgent healer: Healer metrics: Metrics prober: CanaryProber def _load_classifiers(directory: Path) -> dict[str, EmbeddingClassifier]: if not directory.is_dir(): return {} return {p.stem: EmbeddingClassifier.load(p) for p in sorted(directory.glob("*.json"))} def _optional(path: Path, loader: Callable, default): # type: ignore[no-untyped-def] return loader(path) if path.exists() else default def ontology_source_for(settings: Settings) -> Callable[[], list[dict]] | None: """Woher die n8n-Workflows für den Ontologie-Abgleich kommen: Datei (offline) oder n8n-API (nur lesend).""" if settings.ontology_snapshot: path = Path(settings.ontology_snapshot) def from_file() -> list[dict]: data = json.loads(path.read_text(encoding="utf-8")) return data.get("data", data) if isinstance(data, dict) else data return from_file if settings.n8n_host and settings.n8n_api_key: return lambda: fetch_workflows(settings.n8n_host, settings.n8n_api_key, timeout=settings.request_timeout) return None def ontology_checker(settings: Settings, store: Store, source: Callable[[], list[dict]] | None) -> Callable[[], list[OntologyFinding]] | None: source = source or ontology_source_for(settings) if source is None or not settings.ontology_path.exists(): return None def run() -> list[OntologyFinding]: onto = Ontology.from_file(settings.ontology_path) # bei jedem Lauf neu: Änderungen im Repo zählen sofort decided = {d["key"] for d in store.list_ontology_decisions()} return check(onto, build_snapshot(source()), decided=decided) return run def build_system( settings: Settings, *, embedder: Embedder | None = None, chat: ChatClient | None = None, reranker: Reranker | None = None, infra: InfraDriver | None = None, notifier: Notifier | None = None, jobs: JobRunner | None = None, intent_mapper: IntentMapper | None = None, ontology_source: Callable[[], list[dict]] | None = None, clock: Callable[[], float] = now, wallclock: Callable[[], datetime] = datetime.now, ) -> System: if settings.db_url.startswith("sqlite:///") and not settings.db_url.startswith("sqlite:///:"): Path(settings.db_url.removeprefix("sqlite:///")).parent.mkdir(parents=True, exist_ok=True) store = Store(settings.db_url) registry = Registry.from_file(settings.site_dir / "registry.yaml", store) library = SkillLibrary.from_dir(settings.skills_dir, store) chat = chat or OpenAIChatClient(settings.litellm_base_url, settings.litellm_api_key, settings.request_timeout) embedder = embedder or OpenAIEmbedder(settings.litellm_base_url, settings.embed_model, settings.litellm_api_key) if reranker is None and settings.rerank_url: reranker = LlamaCppReranker(settings.rerank_url, settings.rerank_model) cfg = settings.config_dir policy = _optional(cfg / "policy.yaml", Policy.from_file, Policy()) healer_cfg = _optional(cfg / "healer-rules.yaml", HealerConfig.from_file, HealerConfig()) maint_cfg = _optional(cfg / "maintenance.yaml", MaintenanceConfig.from_file, MaintenanceConfig()) life_cfg = _optional(cfg / "lifecycle.yaml", LifecycleConfig.from_file, LifecycleConfig()) metrics = Metrics() router = Router(registry, library, embedder, reranker) executor = Executor(library, chat, embedder, _load_classifiers(cfg / "classifiers")) verifier = Verifier(registry, chat) runner = Runner(router, executor, verifier, library, store, metrics) if jobs is None: use_webhook = settings.job_webhook_url and not settings.dry_run jobs = WebhookJobRunner(settings.job_webhook_url, settings.webhook_auth) if use_webhook else LoggingJobRunner() lifecycle = Lifecycle(store, registry, jobs, life_cfg) if infra is None and not settings.dry_run: portainer = PortainerDriver(settings.portainer_host, settings.portainer_key) \ if settings.portainer_host and settings.portainer_key else None proxmox = ProxmoxDriver(settings.proxmox_url, settings.proxmox_token_id, settings.proxmox_token_secret) \ if settings.proxmox_url and settings.proxmox_token_id else None infra = CompositeInfra(portainer, proxmox) if notifier is None and settings.notify_webhook_url: notifier = WebhookNotifier(settings.notify_webhook_url, settings.webhook_auth) actions = ActionExecutor(store=store, registry=registry, library=library, lifecycle=lifecycle, infra=infra, notifier=notifier, dry_run=settings.dry_run, enabled_infra_actions=set(healer_cfg.enabled_actions), on_skills_changed=router.refresh) if intent_mapper is None and registry.has_slot("tier1-general"): intent_mapper = LLMIntentMapper(chat, registry.slot("tier1-general").spec.litellm_model) maintenance = MaintenanceAgent(store=store, registry=registry, library=library, index=router.index, policy=policy, actions=actions, lifecycle=lifecycle, config=maint_cfg, intent_mapper=intent_mapper, ontology_check=ontology_checker(settings, store, ontology_source), clock=clock, wallclock=wallclock) healer = Healer(healer_cfg, store, actions, policy, propose=maintenance.propose, clock=clock) prober = CanaryProber(registry, chat, embedder_for={settings.embed_model: embedder}, metrics_registry=metrics.registry) return System(settings, store, registry, library, embedder, chat, router, executor, verifier, runner, policy, lifecycle, actions, maintenance, healer, metrics, prober)