#!/usr/bin/env python3 import sys sys.path.insert(0, '/home/openclaw/.openclaw/workspace/bin') sys.path.insert(0, '/home/openclaw/.openclaw/workspace/lib') try: from buenzli_wrapper import buenzli_response USE_BUENZLI = True except: USE_BUENZLI = False def buenzli_response(t): return t try: from structured_router import classify_intent USE_STRUCTURED_ROUTER = True except: USE_STRUCTURED_ROUTER = False def classify_intent(m): return None USE_BUENZLI = False def buenzli_response(t): return t try: from intent_families import classify_intent_family, collect_intent_families USE_INTENT_FAMILIES = True except Exception: USE_INTENT_FAMILIES = False def classify_intent_family(_message, service_hints=None): return {'family': ''} try: from playbook_store import format_playbook_context USE_PLAYBOOKS = True except Exception: USE_PLAYBOOKS = False def format_playbook_context(_message, analysis=None, limit=None): return '' try: from plan_first import build_plan, render_plan_context USE_PLAN_FIRST = True except Exception: USE_PLAN_FIRST = False def build_plan(_message, _analysis, memory_ctx='', retrieval_ctx='', playbook_ctx=''): return None def render_plan_context(_plan): return '' try: from intent_composition import compose_family_plan USE_INTENT_COMPOSITION = True except Exception: USE_INTENT_COMPOSITION = False def compose_family_plan(_message, _analysis, family_candidates=None): return {'composed': False} try: from composition_policy import choose_plan USE_COMPOSITION_POLICY = True except Exception: USE_COMPOSITION_POLICY = False def choose_plan(base_tools, proposed_tools, family_candidates=None): return {'selected_tools': list(proposed_tools), 'policy': 'fallback_no_policy'} try: from result_state import persist_result_state as _persist_result_state except Exception: def _persist_result_state( message, analysis, final_text, evidence_items, *, should_skip_heavy_persistence, update_topic_state, compact_episode_text, remember_episode, record_incident_if_useful, ): if should_skip_heavy_persistence(message, analysis, evidence_items): return update_topic_state(message, analysis, final_text) remember_episode(compact_episode_text(message, analysis, final_text, evidence_items)) record_incident_if_useful(message, analysis, final_text, evidence_items) try: from final_answer_policy import should_skip_verify as _should_skip_verify except Exception: def _should_skip_verify(analysis, evidence_items, tool_kinds): single_final = ( len(evidence_items) == 1 and tool_kinds.get(str(evidence_items[0].get('tool') or '')) == 'final' and bool(evidence_items[0].get('grounded')) ) if single_final: return ( analysis.get('role') in {'ops', 'research'} or analysis.get('task_type') in {'status', 'summarize', 'memory'} or str(evidence_items[0].get('tool') or '') in {'memory_profile', 'light_status', 'url_research', 'public_services_health', 'emby_user_provision', 'web_root_cause'} ) last_ev = evidence_items[-1] if evidence_items else {} last_tool = str(last_ev.get('tool') or '') prep_tools_ok = {'final', 'memory', 'evidence'} composed_final = ( len(evidence_items) >= 2 and bool(analysis.get('force_sequential')) and bool(analysis.get('composition_reason')) and tool_kinds.get(last_tool) == 'final' and bool(last_ev.get('grounded')) and all(bool(item.get('grounded')) for item in evidence_items[:-1]) and all(tool_kinds.get(str(item.get('tool') or '')) in prep_tools_ok for item in evidence_items[:-1]) ) return composed_final and last_tool in {'personal_assist', 'user_service_access_diagnose', 'web_root_cause', 'url_research', 'general_answer', 'light_status'} try: from sequential_runner import run_sequential_tools as _run_sequential_tools except Exception: def _run_sequential_tools( message, tools, analysis, *, is_complex_task, run_tool, evidence_object, trace_stage, sequential_tool_budget, summarize_sequential_context, ): collected = [] seq_ctx = '' is_seq = bool(analysis.get('force_sequential')) or is_complex_task(message, analysis) failed_ev = None for name in tools: msg = f"{message}\n\nBisherige Analyseergebnisse:\n{seq_ctx}" if is_seq and seq_ctx else message prev_budget = os.environ.get('BIBABOT_TOOL_TIMEOUT') budget = sequential_tool_budget(name, is_seq) try: if budget: os.environ['BIBABOT_TOOL_TIMEOUT'] = str(budget) rc, out, err = run_tool(name, msg) finally: if prev_budget is None: os.environ.pop('BIBABOT_TOOL_TIMEOUT', None) else: os.environ['BIBABOT_TOOL_TIMEOUT'] = prev_budget ev = evidence_object(name, rc, out, err) collected.append(ev) if out and is_seq: seq_ctx = summarize_sequential_context(collected) if rc == 124: trace_stage('sequential_timeout', name, f'budget={budget}') failed_ev = ev break if rc != 0: trace_stage('sequential_tool_error', name, (err or '')[:200]) failed_ev = ev break trace_stage('multi_tool_collected', f'seq={is_seq}', str([(item.get("tool"), len(item.get("output", ""))) for item in collected])) return {'collected': collected, 'failed_ev': failed_ev, 'is_sequential': is_seq} try: from composition_selector import apply_composition_selection as _apply_composition_selection except Exception: def _apply_composition_selection(analysis, composition, family_candidates, *, choose_plan, use_policy=True): if not composition or not composition.get('composed'): return analysis, None, None before_comp = dict(analysis) if use_policy: policy = choose_plan( before_comp.get('tools') or [], composition.get('tools') or [], family_candidates, composition_reason=composition.get('reason', ''), ) or {} else: policy = {'selected_tools': list(composition.get('tools') or []), 'policy': 'no_policy'} updated = dict(analysis) updated.update( role=composition.get('role', analysis.get('role')), task_type=composition.get('task_type', analysis.get('task_type')), tools=policy.get('selected_tools') or composition.get('tools') or analysis.get('tools'), needs_setup_context=bool(composition.get('needs_setup_context', analysis.get('needs_setup_context'))), needs_memory=bool(composition.get('needs_memory', analysis.get('needs_memory'))), force_sequential=bool(composition.get('force_sequential')), composition_reason=composition.get('reason', ''), composition_policy=policy.get('policy', ''), ) return updated, before_comp, {**composition, 'policy': policy.get('policy', '')} try: from intent_normalizer import normalize_intent_family as _normalize_intent_family except Exception: def _normalize_intent_family(message, analysis, family_info=None, *, classify_intent_family, service_hints): tools = list(analysis.get('tools') or []) primary = tools[0] if tools else '' family = (family_info or classify_intent_family(message, service_hints)).get('family', '') if family == 'url_research': analysis.update(role='research', task_type='summarize', tools=['url_research'], needs_setup_context=False, needs_web_research=False, confidence=max(float(analysis.get('confidence', 0.0)), 0.96)) return analysis if family == 'web_diagnose': analysis.update(role='ops', task_type='diagnose', tools=['web_root_cause'], needs_setup_context=True, needs_web_research=False, confidence=max(float(analysis.get('confidence', 0.0)), 0.95)) return analysis if family in {'memory_store', 'memory_lookup'}: analysis.update(role='personal', task_type='memory', tools=['memory_profile'], needs_memory=True, needs_web_research=False, confidence=max(float(analysis.get('confidence', 0.0)), 0.93)) return analysis if family == 'live_status': analysis.update(role='ops', task_type='status', tools=['light_status'], needs_setup_context=True, needs_web_research=False, confidence=max(float(analysis.get('confidence', 0.0)), 0.95)) return analysis if family == 'emby_provision': analysis.update(role='ops', task_type='howto', tools=['emby_user_provision'], needs_setup_context=True, needs_web_research=False, confidence=max(float(analysis.get('confidence', 0.0)), 0.95)) return analysis if family == 'user_access': analysis.update(role='ops', task_type='diagnose', tools=['user_service_access_diagnose'], needs_setup_context=True, needs_web_research=False, confidence=max(float(analysis.get('confidence', 0.0)), 0.94)) return analysis if family == 'stable_knowledge' and primary in {'web_research', 'url_research'}: analysis.update(role='general', task_type='explain', tools=['general_answer'], needs_web_research=False, confidence=max(float(analysis.get('confidence', 0.0)), 0.9)) return analysis if family == 'personal_help' and primary not in {'personal_assist', 'expert_write'}: analysis.update(role='personal', task_type='assist', tools=['personal_assist'], needs_memory=bool((family_info or {}).get('needs_memory')), needs_web_research=False, confidence=max(float(analysis.get('confidence', 0.0)), 0.9)) return analysis if family == 'personal_help' and primary == 'personal_assist' and not (family_info or {}).get('needs_memory'): analysis['needs_memory'] = False return analysis try: from meta_controller import shadow_decision as _shadow_decision, log_shadow_decision as _log_shadow_decision USE_META_CONTROLLER = True except Exception: USE_META_CONTROLLER = False def _shadow_decision(message, analysis, family_candidates, tool_registry): return {} def _log_shadow_decision(log_path, decision_row): return None try: from outcome_logging import ( log_intent_family_shadow as _log_intent_family_shadow, log_intent_composition as _log_intent_composition, record_task_outcome as _record_task_outcome, ) except Exception: def _log_intent_family_shadow(message, family_info, before_tools, after_tools, *, log_path, collect_intent_families, service_hints): family = str((family_info or {}).get('family') or '') if not family: return try: log_path.parent.mkdir(parents=True, exist_ok=True) try: candidates = [item.get('family') for item in collect_intent_families(message, service_hints) if item.get('family')] except Exception: candidates = [family] row = { 'ts': datetime.now(timezone.utc).isoformat(), 'message': message, 'family': family, 'family_candidates': candidates, 'before_tool': before_tools[0] if before_tools else '', 'after_tool': after_tools[0] if after_tools else '', 'overridden': (before_tools[:1] != after_tools[:1]), } with log_path.open('a', encoding='utf-8') as f: f.write(json.dumps(row, ensure_ascii=False) + '\n') except Exception: pass def _log_intent_composition(message, family_candidates, analysis_before, analysis_after, composition, *, log_path): if not composition or not composition.get('composed'): return try: log_path.parent.mkdir(parents=True, exist_ok=True) row = { 'ts': datetime.now(timezone.utc).isoformat(), 'message': message, 'family_candidates': [item.get('family') for item in family_candidates if item.get('family')], 'before_tools': list(analysis_before.get('tools') or []), 'after_tools': list(analysis_after.get('tools') or []), 'reason': composition.get('reason', ''), 'policy': composition.get('policy', analysis_after.get('composition_policy', '')), 'force_sequential': bool(analysis_after.get('force_sequential')), } with log_path.open('a', encoding='utf-8') as f: f.write(json.dumps(row, ensure_ascii=False) + '\n') except Exception: pass def _record_task_outcome(message, analysis, final_text, evidence_items, *, status='success', error_text='', log_path, classify_intent_family, collect_intent_families, service_hints, refresh_composition_policy_async): try: log_path.parent.mkdir(parents=True, exist_ok=True) grounded_items = [item for item in evidence_items if item.get('grounded')] try: candidates = [item.get('family') for item in collect_intent_families(message, service_hints) if item.get('family')] except Exception: candidates = [] row = { 'ts': datetime.now(timezone.utc).isoformat(), 'status': status, 'message': message, 'role': analysis.get('role'), 'task_type': analysis.get('task_type'), 'planned_tools': list(analysis.get('tools') or []), 'used_tools': [item.get('tool') for item in evidence_items], 'family': (classify_intent_family(message, service_hints) or {}).get('family', ''), 'family_candidates': candidates, 'grounded_count': len(grounded_items), 'evidence_count': len(evidence_items), 'answer_len': len((final_text or '').strip()), 'needs_memory': bool(analysis.get('needs_memory')), 'needs_setup_context': bool(analysis.get('needs_setup_context')), 'error_text': (error_text or '')[:300], 'composition_reason': str(analysis.get('composition_reason') or ''), 'composition_policy': str(analysis.get('composition_policy') or ''), } with log_path.open('a', encoding='utf-8') as f: f.write(json.dumps(row, ensure_ascii=False) + '\n') if row.get('composition_reason') or row.get('composition_policy'): refresh_composition_policy_async() except Exception: pass try: from response_policy import ( postprocess_final_answer as _policy_postprocess_final_answer, should_skip_heavy_persistence, can_fast_exit_single_tool, should_skip_quality_feedback, build_lightweight_composed_answer, sequential_tool_budget, summarize_sequential_context, ) except Exception: def _policy_postprocess_final_answer(_message, _analysis, final_text): return (final_text or '').strip() def should_skip_heavy_persistence(_message, _analysis, _evidence_items): return False def can_fast_exit_single_tool(_analysis, _evidence_items, memory_ctx='', retrieval_ctx=''): return False def should_skip_quality_feedback(_analysis, _evidence_items): return False def build_lightweight_composed_answer(_message, _analysis, _evidence_items): return '' def sequential_tool_budget(_name, is_sequential, default=12): return default if is_sequential else 0 def summarize_sequential_context(_evidence_items, max_chars=900): return '' import argparse import json import os import re import subprocess import sys import tempfile from datetime import datetime, timezone from pathlib import Path ROOT = Path('/home/openclaw/.openclaw/workspace') LLM_MAC = ROOT / 'bin' / 'llm-mac' LLM_LOCAL = ROOT / 'bin' / 'llm-answer-local' AGENT_MEMORY = Path(os.environ.get('BIBABOT_AGENT_MEMORY_BIN', str(ROOT / 'bin' / 'agent-memory'))) CONTEXT_PACK = ROOT / 'bin' / 'context-pack' LOG_PATH = ROOT / 'logs' / 'orchestrator-events.jsonl' INTENT_FAMILY_LOG = ROOT / 'logs' / 'intent-family-shadow.jsonl' TASK_OUTCOME_LOG = ROOT / 'logs' / 'task-outcomes.jsonl' INTENT_COMPOSITION_LOG = ROOT / 'logs' / 'intent-composition.jsonl' META_CONTROLLER_LOG = ROOT / 'logs' / 'meta-controller-shadow.jsonl' COMPOSITION_POLICY_REFRESH = ROOT / 'bin' / 'composition-policy-refresh' TOPIC_STATE = ROOT / 'memory' / 'topic-state.json' TOPIC_HISTORY = ROOT / 'memory' / 'topic-history.jsonl' PROFILE_STORE = ROOT / 'memory' / 'profile.jsonl' def trace_stage(*parts): if os.environ.get('BIBABOT_TRACE', '0') != '1': return try: print('[trace]', *parts, file=sys.stderr, flush=True) except Exception: pass TOOLS = { 'memory_profile': {'description': 'Persoenliches Gedaechtnis lesen oder speichern. Nutze das fuer Fragen ueber Vorlieben, Stil, Identitaet oder wenn der Nutzer explizit etwas merken will.', 'kind': 'final'}, 'briefing': {'description': 'Liefert ein aktuelles Lagebild zu Haus, Homelab, Erinnerungen und Agent-Gesundheit.', 'kind': 'final'}, 'setup_lookup': {'description': 'Liest das echte Homelab-Inventar: welche Dienste es gibt, wo sie laufen, wie man an Logs kommt und welche Hosts relevant sind.', 'kind': 'evidence'}, 'public_services_health': {'description': 'Prueft oeffentliche Services, Hosts und aktuelle Warnings/Fehler.', 'kind': 'final'}, 'log_hotspots': {'description': 'Analysiert die haeufigsten Fehler in immich/nextcloud/traefik Logs.', 'kind': 'final'}, 'host_slow_diagnose': {'description': 'Diagnose fuer langsame Hosts oder Services anhand CPU, RAM, Disk, Netzwerk und Logs.', 'kind': 'final'}, 'kasm_diagnose': {'description': 'Diagnose fuer Kasm-Zugriff, Kasm-Deployment und Erreichbarkeit.', 'kind': 'final'}, 'user_service_access_diagnose': {'description': 'Diagnostiziert warum eine bestimmte Person oder ein Benutzer nicht auf einen bestimmten Dienst zugreifen kann. Erst Dienstgesundheit, dann Auth-/Rechtepfad pruefen.', 'kind': 'final'}, 'web_root_cause': {'description': 'Diagnose warum eine gehostete Website nicht erreichbar ist.', 'kind': 'final'}, 'sonos_library_diagnose': {'description': 'Diagnose fuer Sonos lokale Musikbibliothek auf Unraid/NAS/SMB. Erst Sonos-Mechanismus verstehen, dann Share/Pfad/Zugriff auf Unraid pruefen.', 'kind': 'final'}, 'emby_user_provision': {'description': 'Erklaert und verifiziert, wie Emby-Benutzer in deinem Setup angelegt werden sollen, insbesondere ueber LDAP/Authentik statt lokale Emby-User.', 'kind': 'final'}, 'light_status': {'description': 'Zeigt welche Lichter aktuell eingeschaltet sind.', 'kind': 'final'}, 'light_usage': {'description': 'Analysiert Lichtnutzung und Automatisierungspotenzial.', 'kind': 'final'}, 'light_control': {'description': 'Schaltet Lichter ein oder aus. Das ist eine echte Aktion mit Seiteneffekt.', 'kind': 'action'}, 'web_research': {'description': 'Recherchiert im Web mit Quellen. Nutze das fuer Fakten, unbekannte Begriffe, Named Entities oder aktuelle Informationen.', 'kind': 'final'}, 'url_research': {'description': 'Analysiert eine konkrete URL oder Webseite.', 'kind': 'final'}, 'compare_selfhosted': {'description': 'Vergleicht Self-Hosted-Loesungen mit Blick auf die bestehende Infrastruktur.', 'kind': 'final'}, 'security_plan': {'description': 'Erstellt einen priorisierten Sicherheitsplan fuer Homelab/Reverse Proxy/SSH/Secrets/Backups.', 'kind': 'final'}, 'media_advice': {'description': 'Hilft bei Medien-Workflows wie Lidarr, Emby, Audiobooks, Audiobookshelf.', 'kind': 'final'}, 'personal_assist': {'description': 'Persoenliche Hilfe wie Mails, Nachrichten, Erinnerungen, Einkaufslisten, To-dos.', 'kind': 'final'}, 'dev_build': {'description': 'Programmier- und Build-Aufgaben: Tools, Skripte, Apps, Implementierung.', 'kind': 'final'}, 'general_answer': {'description': 'Allgemeine hilfreiche Antwort fuer Erklaerungen, How-tos, Rezepte, Listen und alltaegliche Fragen.', 'kind': 'final'}, 'agent_introspect': {'description': 'Selbstbeobachtung: Pruefe eigene Konfidenz und markiere Unsicherheiten vor der Ausgabe', 'kind': 'evidence'}, 'agent_self_correct': {'description': 'Lerne aus Fehlern und vermeide bekannte Fehlermuster', 'kind': 'evidence'}, 'agent_reason': {'description': 'Komplexes Multi-Step Reasoning mit Checkpoints und Backtracking', 'kind': 'evidence'}, 'agent_anticipate': {'description': 'Vorhersage von User-Intentions fuer proaktive Unterstuetzung', 'kind': 'evidence'}, 'agent_emotion': {'description': 'Emotionale Intelligenz fuer adaptive Kommunikation', 'kind': 'evidence'}, 'agent_simulate': {'description': 'Mentale Simulation fuer Konsequenzen-Abschaetzung vor Aktionen', 'kind': 'evidence'}, 'agent_synthesize': {'description': 'Cross-Domain Synthese fuer kreative Problemloesung', 'kind': 'evidence'}, 'ops_exec': {'description': 'Fuehrt Befehle auf Homelab-Hosts aus und analysiert Ausgabe', 'kind': 'action', 'script': 'ops-exec-action'}, 'ops_install': {'description': 'Installiert und konfiguriert Software, testet und verifiziert', 'kind': 'action', 'script': 'ops-install-action'}, 'ops_api': {'description': 'Fragt Homelab REST-APIs ab: HA, Docker, Emby, Nextcloud, Authentik', 'kind': 'final', 'script': 'ops-api-query'}, 'ops_deep_analyze': {'description': 'Tiefe Analyse: Logs + Befehle + Web-Recherche + Synthese', 'kind': 'final', 'script': 'ops-deep-analyze'}, 'qdrant_search': {'description': 'Semantische Suche in der lokalen Wissensdatenbank (KB-Dateien, Homelab-Doku, Setup-Infos).', 'kind': 'evidence'}, 'expert_write': {'description': 'Experte fuer kreatives Schreiben, Journalismus, Artikel, Emails, Storytelling, technische Doku.', 'kind': 'final'}, 'expert_code': {'description': 'Senior Software Architect: Code-Architektur, Review, Debugging, Performance, Security.', 'kind': 'final'}, 'expert_research': {'description': 'Tiefer Research-Analyst: Multi-Quellen-Synthese, Fact-Checking, investigative Analyse.', 'kind': 'final'}, 'expert_edit': {'description': 'Professionelles Lektorat: Grammatik, Stil, Struktur, Klarheit — Text sofort verwendbar.', 'kind': 'final'}, 'expert_strategy': {'description': 'Strategy Consultant: Business-Planung, OKRs, Roadmaps, Priorisierung, Management-Frameworks.', 'kind': 'final'}, 'goals_manage': {'description': 'Verwaltet Ziele, Projekte und Wochen-Prioritaeten. Hinzufuegen, anzeigen, aktualisieren.', 'kind': 'final'}, 'contacts_manage': {'description': 'Verwaltet Kontakte, Follow-ups und Geburtstage. Hinzufuegen, anzeigen, suchen.', 'kind': 'final'}, } def env_models(name: str, default: list[str]) -> list[str]: raw = os.environ.get(name, '').strip() if not raw: return list(default) return [item.strip() for item in raw.split(',') if item.strip()] or list(default) ANALYSIS_MODELS = env_models('BIBABOT_ANALYSIS_MODELS', ['qwen2.5:3b', 'qwen2.5:1.5b', 'llama3.1:8b']) VERIFY_MODELS = env_models('BIBABOT_VERIFY_MODELS', ['qwen2.5:3b', 'gemma3:27b-it-qat', 'gemma3:12b']) FOLLOWUP_MODELS = env_models('BIBABOT_FOLLOWUP_MODELS', ['gemma3:12b', 'qwen2.5:3b', 'llama3.1:8b']) GENERAL_CHAIN_DEFAULT = 'qwen2.5:3b,qwen2.5:1.5b,llama3.1:8b,mistral-small3.1:latest' FINAL_ROLE_MAP = {'ops': 'ops', 'research': 'research', 'personal': 'personal', 'dev': 'dev', 'general': 'general'} ROLE_HINTS = {'ops', 'research', 'personal', 'dev', 'general'} SERVICE_HINTS = {'kasm', 'emby', 'sonos', 'unraid', 'traefik', 'nextcloud', 'immich', 'lidarr', 'radarr', 'sonarr', 'authentik', 'ldap', 'home assistant', 'homeassistant', 'tailscale', 'emby', 'wiki', 'share', 'smb'} SERVICE_HINTS_PATTERN = re.compile(r'\b(' + '|'.join(re.escape(h) for h in SERVICE_HINTS if ' ' not in h) + r')\b') FOLLOWUP_STOP = {'welche', 'welcher', 'welches', 'wie', 'was', 'warum', 'wieso', 'und', 'dafuer', 'dafür', 'dazu', 'damit', 'relevant', 'genau', 'nochmal', 'bitte'} GROUNDED_FASTPATH_TOOLS = {'briefing', 'setup_lookup', 'public_services_health', 'log_hotspots', 'host_slow_diagnose', 'kasm_diagnose', 'user_service_access_diagnose', 'web_root_cause', 'sonos_library_diagnose', 'emby_user_provision', 'light_status', 'light_usage', 'compare_selfhosted', 'security_plan', 'url_research', 'personal_assist', 'goals_manage', 'contacts_manage'} INCIDENT_MEMORY_TOOLS = {'public_services_health', 'log_hotspots', 'host_slow_diagnose', 'kasm_diagnose', 'user_service_access_diagnose', 'web_root_cause', 'sonos_library_diagnose', 'emby_user_provision'} EVIDENCE_KIND_BY_TOOL = { 'memory_profile': 'memory', 'briefing': 'status', 'setup_lookup': 'setup', 'public_services_health': 'diagnostic', 'log_hotspots': 'diagnostic', 'host_slow_diagnose': 'diagnostic', 'kasm_diagnose': 'diagnostic', 'user_service_access_diagnose': 'diagnostic', 'web_root_cause': 'diagnostic', 'sonos_library_diagnose': 'diagnostic', 'emby_user_provision': 'diagnostic', 'light_status': 'status', 'light_usage': 'diagnostic', 'light_control': 'action', 'web_research': 'web', 'url_research': 'web', 'compare_selfhosted': 'web', 'security_plan': 'web', 'media_advice': 'diagnostic', 'personal_assist': 'general', 'dev_build': 'general', 'general_answer': 'general', 'agent_introspect': 'introspection', 'agent_self_correct': 'learning', 'agent_reason': 'reasoning', 'agent_anticipate': 'prediction', 'agent_emotion': 'emotion', 'agent_simulate': 'simulation', 'agent_synthesize': 'synthesis', 'expert_write': 'general', 'expert_code': 'general', 'expert_research': 'web', 'expert_edit': 'general', 'expert_strategy': 'general', } def norm(text: str) -> str: text = (text or '').lower() text = text.replace('ä', 'ae').replace('ö', 'oe').replace('ü', 'ue').replace('ß', 'ss') return re.sub(r'\s+', ' ', text).strip() def normalize_inbound_message(text: str) -> str: clean = (text or '').replace('\r', '').strip() clean = re.sub(r'^\[[^\]\n]{8,80}\]\s*', '', clean) clean = re.sub(r'^\s*Current time:\s.*$', '', clean, flags=re.I | re.M) return clean.strip() def is_complex_task(message: str, analysis: dict) -> bool: """True when a task needs sequential steps where each step uses the previous result.""" low = norm(message) if analysis.get('role') not in {'ops', 'dev'}: return False if analysis.get('task_type') not in {'action', 'diagnose', 'build'}: return False # Explicit multi-step language: diagnose → then fix if re.search(r'(analysier|prüf|check|diagnose).{1,40}(und|dann|anschliessend).{1,40}(fix|reparier|beheb|restart|install|konfigur|deploy)', low): return True # Fix → then verify if re.search(r'(fix|install|deploy|reparier).{1,40}(und|dann).{1,40}(test|verif|prüf|zeig|sicher)', low): return True # Explicit deep/thorough analysis if re.search(r'(schritt.fuer.schritt|vollstaendig.*analysier|tiefgreifend|root.cause|ursache.*finden|warum.*kaputt|warum.*fehler|alles.*beheben|komplett.*analysier)', low): return True # Multiple action verbs = implied multi-step verbs = sum(1 for v in ['analysier', 'diagnose', 'fixe', 'reparier', 'installier', 'restart', 'konfigur', 'deploy', 'verif', 'beheb'] if v in low) return verbs >= 2 def run(cmd, timeout=120, env=None): try: if isinstance(cmd, (list, tuple)): cmd = [c.replace('\x00', '') if isinstance(c, str) else c for c in cmd] budget = (os.environ.get('BIBABOT_TOOL_TIMEOUT') or '').strip() if budget: try: timeout = min(int(timeout), max(1, int(budget))) except Exception: pass run_env = None if env is not None: run_env = os.environ.copy() run_env.update(env) proc = subprocess.run(cmd, capture_output=True, text=True, timeout=timeout, env=run_env) return proc.returncode, (proc.stdout or '').strip(), (proc.stderr or '').strip() except subprocess.TimeoutExpired: return 124, '', 'timeout' def call_llm(model: str, system: str, user: str, timeout: int = 180): return run([str(LLM_MAC), 'chat', model, system, user], timeout=timeout) def call_llm_schema(model: str, schema: dict, system: str, user: str, timeout: int = 180): with tempfile.NamedTemporaryFile('w', suffix='.schema.json', delete=False, encoding='utf-8') as tmp: json.dump(schema, tmp, ensure_ascii=False) schema_path = tmp.name try: return run([str(LLM_MAC), 'chat-schema', model, schema_path, system, user], timeout=timeout) finally: try: os.unlink(schema_path) except OSError: pass def model_exists(model: str) -> bool: rc, _out, _err = run([str(LLM_MAC), 'has', model], timeout=20) return rc == 0 ANALYSIS_SCHEMA = { "type": "object", "properties": { "role": {"type": "string", "enum": ["ops", "research", "personal", "dev", "general"]}, "task_type": {"type": "string", "enum": ["diagnose", "explain", "compare", "howto", "write", "action", "status", "build", "memory"]}, "needs_setup_context": {"type": "boolean"}, "needs_web_research": {"type": "boolean"}, "needs_memory": {"type": "boolean"}, "dangerous_action": {"type": "boolean"}, "tools": {"type": "array", "items": {"type": "string"}}, "reasoning_focus": {"type": "array", "items": {"type": "string"}}, "confidence": {"type": "number"} }, "required": ["role", "task_type", "needs_setup_context", "needs_web_research", "needs_memory", "dangerous_action", "tools", "reasoning_focus", "confidence"], "additionalProperties": False } FOLLOWUP_SCHEMA = { "type": "object", "properties": { "is_followup": {"type": "boolean"}, "root_message": {"type": "string"}, "reply_mode": {"type": "string", "enum": ["direct", "compare", "steps", "single_choice", "diagnose"]}, "focus": {"type": "string"} }, "required": ["is_followup", "root_message", "reply_mode", "focus"], "additionalProperties": False } VERIFY_SCHEMA = { "type": "object", "properties": { "verdict": {"type": "string", "enum": ["keep", "revise", "retry"]}, "grounded": {"type": "boolean"}, "addresses_request": {"type": "boolean"}, "wrong_entity_jump": {"type": "boolean"}, "retry_tool": {"type": "string"}, "missing_points": {"type": "array", "items": {"type": "string"}}, "rationale": {"type": "string"}, "revised_answer": {"type": "string"}, }, "required": ["verdict", "grounded", "addresses_request", "wrong_entity_jump", "retry_tool", "missing_points", "rationale", "revised_answer"], "additionalProperties": False } def extract_json(text: str): if not text: return None start = text.find('{') while start != -1: depth = 0 in_str = False esc = False for idx in range(start, len(text)): ch = text[idx] if in_str: if esc: esc = False elif ch == '\\': esc = True elif ch == '"': in_str = False continue if ch == '"': in_str = True elif ch == '{': depth += 1 elif ch == '}': depth -= 1 if depth == 0: snippet = text[start:idx + 1] try: return json.loads(snippet) except Exception: break start = text.find('{', start + 1) return None def tool_catalog_text() -> str: return '\n'.join(f'- {name}: {meta["description"]}' for name, meta in TOOLS.items()) def topic_terms(text: str): terms = [] for tok in re.findall(r'[a-z0-9._-]{3,}', norm(text)): if tok in FOLLOWUP_STOP: continue terms.append(tok) return list(dict.fromkeys(terms))[:12] def load_topic_state(): if not TOPIC_STATE.exists(): return None try: data = json.loads(TOPIC_STATE.read_text(encoding='utf-8')) except Exception: return None return data if isinstance(data, dict) else None def load_topic_history(limit: int = 12): if not TOPIC_HISTORY.exists(): return [] items = [] try: lines = TOPIC_HISTORY.read_text(encoding='utf-8', errors='ignore').splitlines() except Exception: return [] for line in lines[-limit:]: line = line.strip() if not line: continue try: data = json.loads(line) except Exception: continue if isinstance(data, dict): items.append(data) return items def append_topic_history(payload: dict): try: TOPIC_HISTORY.parent.mkdir(parents=True, exist_ok=True) with TOPIC_HISTORY.open('a', encoding='utf-8') as f: f.write(json.dumps(payload, ensure_ascii=False) + '\n') except Exception: pass def topic_state_match_score(message: str, state: dict | None): if not state: return 0.0 score = 0.0 qterms = topic_terms(message) combined = ' '.join([str(state.get('message') or ''), ' '.join(state.get('keywords') or []), str(state.get('summary') or '')]) hay = norm(combined) for term in qterms: if term in hay: score += 2.0 if is_followup_query(message): score += 1.4 if state.get('analysis', {}).get('role') in {'ops', 'dev'} and any(s in hay for s in SERVICE_HINTS): score += 0.4 return score def recent_topic_event(message: str): candidates = [] state = load_topic_state() if state: candidates.append(state) candidates.extend(load_topic_history()) best = None best_score = 0.0 seen = set() for item in reversed(candidates): key = (str(item.get('ts') or ''), str(item.get('message') or ''), str(item.get('summary') or '')) if key in seen: continue seen.add(key) score = topic_state_match_score(message, item) if score > best_score: best = item best_score = score if best_score >= 1.4: return best return None def resolve_followup(message: str, state: dict | None): if not state or not is_followup_query(message): return None system = ( 'Du loest Anschlussfragen fuer J.A.R.V.I.S. auf. Nutze nur den gegebenen Themenkontext. ' 'Gib strikt JSON gemaess Schema aus.' ) summary = str(state.get('summary') or '').strip() root = str(state.get('root_message') or state.get('message') or '').strip() user = ( f'Aktuelles Thema:\n{root}\n\n' f'Bisheriger Kerninhalt:\n{summary}\n\n' f'Neue Nutzerfrage:\n{message}\n\n' 'Bestimme, ob die neue Frage eine Anschlussfrage zum Thema ist, welches Ursprungsproblem gemeint ist und wie knapp geantwortet werden soll.' ) for model in FOLLOWUP_MODELS: if not model_exists(model): continue rc, out, _err = call_llm_schema(model, FOLLOWUP_SCHEMA, system, user, timeout=45) if rc != 0 or not out: continue try: data = json.loads(out) except Exception: continue if isinstance(data, dict): return data return None def update_topic_state(message: str, analysis: dict, final_text: str): if not isinstance(analysis, dict): return tools = [t for t in analysis.get('tools', []) if t in TOOLS and TOOLS[t]['kind'] != 'action'] if not tools: return previous = load_topic_state() or {} summary_lines = [] summary_cap = 4 if analysis.get('role') == 'general' and analysis.get('task_type') in {'howto', 'compare', 'explain'}: summary_cap = 14 elif analysis.get('task_type') in {'compare', 'build'}: summary_cap = 10 for line in (final_text or '').splitlines(): line = line.strip() if not line: continue summary_lines.append(line) if len(summary_lines) >= summary_cap: break root_message = message prev_match = topic_state_match_score(message, previous) if previous else 0.0 if prev_match >= 1.4 and previous.get('root_message'): root_message = str(previous.get('root_message')).strip() or message payload = { 'ts': datetime.now(timezone.utc).isoformat(), 'root_message': root_message, 'message': message, 'keywords': topic_terms((root_message or message) + ' ' + message + ' ' + ' '.join(tools)), 'summary': '\n'.join(summary_lines), 'analysis': { 'role': analysis.get('role'), 'task_type': analysis.get('task_type'), 'needs_setup_context': bool(analysis.get('needs_setup_context')), 'needs_web_research': bool(analysis.get('needs_web_research')), 'needs_memory': bool(analysis.get('needs_memory')), 'tools': tools[:3], }, } try: TOPIC_STATE.parent.mkdir(parents=True, exist_ok=True) TOPIC_STATE.write_text(json.dumps(payload, ensure_ascii=False), encoding='utf-8') append_topic_history(payload) except Exception: pass def current_topic_context(message: str): state = recent_topic_event(message) if not state: return '' tools = ', '.join(state.get('analysis', {}).get('tools', [])[:3]) summary = str(state.get('summary') or '').strip() out = [f"Aktives Thema: {str(state.get('root_message') or state.get('message') or '').strip()} | tools={tools}"] if summary: out.append(summary) return '\n'.join(out) def is_followup_query(message: str) -> bool: low = norm(message) if re.search(r'https?://', message): return False if any(h in low for h in SERVICE_HINTS): return False pronouns = ['dafuer', 'dafuer', 'dazu', 'damit', 'darauf', 'darin', 'darueber', 'davon'] if any(p in low for p in pronouns): return True # 'diese/dieses/dieser' as standalone pronouns → followup, but NOT as articles before nouns/temporal words TEMPORAL_NOUNS = r'(monat|jahr|tag|abend|morgen|sommer|winter|herbst|fruehjahr|fruhling|woche|quartal|semester|moment|mal|abschnitt)' if re.search(r'\b(dies|dieses|diese[rn]?)\b', low) and not re.search(r'\bdiesen?\s+' + TEMPORAL_NOUNS, low) and not re.search(r'\bdieses\s+' + TEMPORAL_NOUNS, low) and not re.search(r'\bdieser?\s+' + TEMPORAL_NOUNS, low): return True if low.startswith('und '): return True if re.search(r'^welche[rns]? davon\b', low): return True return False def recent_dialogue_event(message: str): if not LOG_PATH.exists(): return None try: lines = LOG_PATH.read_text(encoding='utf-8', errors='ignore').splitlines()[-12:] except Exception: return None query_terms = topic_terms(message) ranked = [] for idx, line in enumerate(reversed(lines), start=1): line = line.strip() if not line: continue try: data = json.loads(line) except Exception: continue if not isinstance(data, dict) or not data.get('analysis', {}).get('tools'): continue if norm(str(data.get('message') or '')) == norm(message): continue tools = [t for t in data.get('analysis', {}).get('tools', []) if t in TOOLS and TOOLS[t]['kind'] != 'action'] if not tools: continue data['analysis']['tools'] = tools hay = norm(str(data.get('message') or '') + ' ' + ' '.join(TOOLS[t]['description'] for t in tools)) score = 0.0 for term in query_terms: if term in hay: score += 1.8 score += max(0.0, 0.35 - idx * 0.03) ranked.append((score, data)) if not ranked: return None ranked.sort(key=lambda x: x[0], reverse=True) if ranked[0][0] <= 0: return None return ranked[0][1] def recent_dialogue_context(): if not LOG_PATH.exists(): return '' try: lines = LOG_PATH.read_text(encoding='utf-8', errors='ignore').splitlines()[-4:] except Exception: return '' parts = [] for line in lines: try: data = json.loads(line) except Exception: continue msg = str(data.get('message') or '').strip() tools = ', '.join(data.get('analysis', {}).get('tools', [])[:3]) if msg: parts.append(f'- Letzte Anfrage: {msg} | tools={tools}') return '\n'.join(parts[:3]) def analyze_with_llm(message: str, role_hint: str | None, memory_ctx: str = '', retrieval_ctx: str = ''): system = ( 'Du bist der Planungs-Kern von J.A.R.V.I.S. Verstehe zuerst den Mechanismus der Anfrage und waehle dann passende Tools. ' 'Verlasse dich nicht auf Keyword-Aehnlichkeit. Waehle nur Tools, die wirklich zum Problem passen. ' 'Beispiele: Sonos + Unraid Bibliothek -> sonos_library_diagnose. "Was ist Sitterdorf?" -> web_research. ' 'Bei mehrstufigen Aufgaben: waehle 2 Tools in der richtigen Reihenfolge (erst Info, dann Analyse/Aktion). ' 'Analyse+Fix: [ops_api, ops_deep_analyze]. Installation+Test: [ops_install, ops_exec]. ' 'Einfache Statusfragen brauchen nur 1 Tool. ' 'Gib nur JSON aus.' ) hint_text = f'Bevorzugte Rolle: {role_hint}. Nutze sie als weichen Hinweis, aber nicht wenn sie fachlich offensichtlich falsch waere.\n' if role_hint else '' context_parts = [] if memory_ctx: context_parts.append('Relevantes Nutzer-/Korrektur-Gedaechtnis:\n' + memory_ctx) if retrieval_ctx: context_parts.append('Relevantes lokales Setup-/Wissens-Retrieval:\n' + retrieval_ctx) topic_ctx = current_topic_context(message) if topic_ctx: context_parts.append('Aktiver Themenkontext:\n' + topic_ctx) context_block = ('\n\n'.join(context_parts) + '\n\n') if context_parts else '' user = ( f'Anfrage:\n{message}\n\n' f'{hint_text}' f'{context_block}' 'Verfuegbare Tools:\n' f'{tool_catalog_text()}\n\n' 'JSON-Schema:\n' '{"role":"ops|research|personal|dev|general",' '"task_type":"diagnose|explain|compare|howto|write|action|status|build|memory",' '"needs_setup_context":true,' '"needs_web_research":false,' '"needs_memory":false,' '"dangerous_action":false,' '"tools":["tool_name"],' '"reasoning_focus":["kurzer Punkt"],' '"confidence":0.0}\n\n' 'Regeln:\n' '- Waehle maximal 3 Tools.\n' '- Wenn die Anfrage zuerst Sachverstaendnis braucht, waehle das passende Sach-/Diagnosetool vor lokalen Checks.\n' '- Fuer alltaegliche Erklaerungen/Rezepte/How-tos ohne aktuelle Fakten reicht general_answer.\n' '- Fuer Named-Entity-, Orts- oder Faktenfragen mit Halluzinationsrisiko nimm web_research.\n' '- Fuer Vergleiche von lokal/self-hosted/selbst gehosteten Loesungen nimm compare_selfhosted.\n' '- Fuer Coding-/Build-Aufgaben nimm dev_build.\n' '- Fuer Mail/Nachricht/Reminder nimm personal_assist.\n' '- Fuer Fragen ueber Benutzerprofil/Vorlieben oder "merke dir" nimm memory_profile.\n' '- Fuer Lichter nur bei echter Anweisung light_control, sonst light_status oder light_usage.\n' '- Nimm setup_lookup nur wenn echtes Homelab-/Host-/Service-Wissen gebraucht wird.\n' '- Wenn Anfrage sowohl Diagnose als auch Fix will: [ops_api, ops_deep_analyze].\n' '- Wenn Anfrage Installation + Verifikation will: [ops_install, ops_exec].\n' '- Bei "was soll ich tun", "wie kann ich", "fix das" nach einer Fehlerdiagnose: ops_deep_analyze benutzen.\n' ) for model in ANALYSIS_MODELS: if not model_exists(model): continue rc, out, _err = call_llm_schema(model, ANALYSIS_SCHEMA, system, user, timeout=90) if rc == 0 and out: try: parsed = json.loads(out) except Exception: parsed = extract_json(out) if isinstance(parsed, dict): return parsed return None def heuristic_analysis(message: str, role_hint: str | None = None): low = norm(message) # === EXPERT-EDIT: Highest priority — lectorat/proofread patterns before anything else === # These are unambiguous writing-help patterns that must not be caught by service/ops rules if re.search(r'(korrigier.{0,20}(text|folgenden|meinen|diesen|den)|' r'lektorier|lektorat|proofreading|proofread|' r'verbessere.{0,20}(text|meinen text|folgenden text|diesen text|schreibstil)|' r'ueberarbeite.{0,20}(text|meinen|diesen|folgenden)|' r'überarbeite.{0,20}(text|meinen|diesen|folgenden)|' r'rechtschreibung.*pruefen|grammatik.*pruefen|pruef.*grammatik|pruef.*rechtschreibung|' r'stilistisch.*verbessern|stilverbesserung|besser.*formulier|umformulier|' r'klingt.*besser|klingt.*natuerlicher|klingt.*professioneller|' r'feedback.*text|feedback.*meinem text|text.*feedback)', low) and not any(s in low for s in ('docker', 'code', 'skript')): analysis = { 'role': 'general', 'task_type': 'write', 'needs_setup_context': False, 'needs_web_research': False, 'needs_memory': False, 'dangerous_action': False, 'tools': ['expert_edit'], 'reasoning_focus': [], 'confidence': 0.95, } return analysis # === FIX: CREATIVE-WRITE EARLY INTERCEPT (höchste Priorität) === # Creative writing -> personal_assist (tweet, gedicht, witz, bio, instagram post, etc.) # MUSS VOR goals_manage und expert_write kommen! if re.search(r'(\btweet\b|erstelle.*tweet|tweet.*erstelle|schreib.*tweet|twitter.*post)', low): return {'role': 'personal', 'task_type': 'write', 'tools': ['personal_assist'], 'needs_memory': True, 'needs_setup_context': False, 'needs_web_research': False, 'dangerous_action': False, 'confidence': 0.94} if re.search(r'(\bgedicht\b|schreib.*gedicht|erstelle.*gedicht)', low): return {'role': 'personal', 'task_type': 'write', 'tools': ['personal_assist'], 'needs_memory': True, 'needs_setup_context': False, 'needs_web_research': False, 'dangerous_action': False, 'confidence': 0.94} if re.search(r'(\bwitz\b|schreib.*witz|erstelle.*witz|witz.*schreib)', low): return {'role': 'personal', 'task_type': 'write', 'tools': ['personal_assist'], 'needs_memory': True, 'needs_setup_context': False, 'needs_web_research': False, 'dangerous_action': False, 'confidence': 0.94} if re.search(r'(instagram.*post|facebook.*post|kurze.*bio|\bbio\b.*github)', low): return {'role': 'personal', 'task_type': 'write', 'tools': ['personal_assist'], 'needs_memory': True, 'needs_setup_context': False, 'needs_web_research': False, 'dangerous_action': False, 'confidence': 0.94} # GOALS-MANAGE: Ziel/Projekt-Verwaltung (explizite Verwaltungsbefehle — nicht Strategie-Anfragen) # FIX: Ausschluss von "tweet" und creative writing bereits oben abgehandelt if re.search(r'(fuege.*ziel|ziel.*hinzufuegen|neues ziel|setze.*ziel|wochen.*prio|prioritaet.*setze|constraint.*hinzufueg|ziel.*abgehakt|ziel.*erledigt|zeig.*ziele|was sind.*ziele|aktuelle.*ziele|zeig.*projekte)', low) or (re.search(r'(projekt.*erstell|neues.*projekt|projekt.*hinzufuegen)', low) and re.search(r'(jarvis|agent|merke|speicher|trag.*ein)', low)): return {'role': 'personal', 'task_type': 'memory', 'tools': ['goals_manage'], 'needs_memory': False, 'needs_setup_context': False, 'needs_web_research': False, 'dangerous_action': False, 'confidence': 0.93} # CONTACTS-MANAGE: Kontakt-Verwaltung if re.search(r'(neuer kontakt|kontakt.*hinzufuegen|merke.*kontakt|wer hat.*geburtstag|geburtstage.*naechste|follow.?up.*faellig|follow.?up.*hinzufueg|letzter kontakt.*mit|zeig.*kontakte|habe.*kontaktiert|schuldige.*follow|offene.*follow)', low): return {'role': 'personal', 'task_type': 'memory', 'tools': ['contacts_manage'], 'needs_memory': False, 'needs_setup_context': False, 'needs_web_research': False, 'dangerous_action': False, 'confidence': 0.93} # === FIX: Pre-Check Fast-Path für Rezepte (höchste Priorität) === # Diese Patterns müssen VOR allen anderen Checks kommen, um ungrounded general_answer zu vermeiden # Fix RECIPE-ROUTING-FINAL: Vereinfachte, robuste Patterns ohne \b am Ende # ── PRO-WRITE EARLY INTERCEPT ───────────────────────────────────────────── # Professionelle Kommunikation -> expert_write (vor personal_assist) # FIX: "bewerbung" nur wenn formell/professionell (anschreiben, motivationsschreiben, etc.) # "schreibe mir eine bewerbung" -> personal_assist (persönliche Hilfe, nicht formelle Dokumentation) _pro_write_types = r'(anschreiben|motivationsschreiben|cover.?letter|kundigungsschreiben|kuendigungsschreiben|offizielle.*anfrage|formeller.*brief|formelle.*email|gehaltsverhandlung.*email|gehalts.*email|email.*(chef|vorgesetzt|manager|leitung|boss).*(gehal|bonus|kuendigung|versetz|urlaub)|email.*wegen.*(gehal|kuendigung|bonus|versetz)|kundigung.*verfass|kündigung.*verfass|offizielle.*stellungnahme|pressemitteilung|pitch.*deck|investor.*email|business.*proposal|vertrags.*entwurf|meeting.*protokoll|protokoll.*erstell)' _pro_trigger = r'(kannst du|hilf mir|erstell|formulier|schreib|verfass|mach mir|ich brauche|bitte.*erstell|entwirf)' if re.search(_pro_write_types, low) and re.search(_pro_trigger, low): return {'role': 'research', 'task_type': 'write', 'tools': ['expert_write'], 'needs_memory': False, 'needs_setup_context': False, 'needs_web_research': False, 'dangerous_action': False, 'confidence': 0.91} # FIX BEWERBUNG: "schreibe mir eine bewerbung" -> personal_assist (persönliche Hilfe) # "bewerbung als X" oder "anschreiben fuer X" -> expert_write (formell) if re.search(r'(schreib.*mir.*bewerbung|bewerbung.*schreib|hilf.*bewerbung|bewerbung.*hilf)', low) and not re.search(r'(anschreiben|motivationsschreiben|formell|professionell)', low): return {'role': 'personal', 'task_type': 'write', 'tools': ['personal_assist'], 'needs_memory': True, 'needs_setup_context': False, 'needs_web_research': False, 'dangerous_action': False, 'confidence': 0.92} # INTERNAL-TOOL-IDEAS: brainstorming for internal tools should stay general/dev, not recipe/personal if re.search(r'(nenne|gib|zeig).{0,20}(ideen|vorschlaege|vorschläge|optionen)', low) and re.search(r'(internes? tool|internes? werkzeug|zugriffspruefung|zugriffsprüfung|access tool)', low): return {'role': 'general', 'task_type': 'explain', 'tools': ['general_answer'], 'needs_memory': False, 'needs_setup_context': False, 'needs_web_research': False, 'dangerous_action': False, 'confidence': 0.94} # WICHTIG: Patterns enden mit \s oder .* damit sie auch mit Leerzeichen/Folgetext matchen RECIPE_FASTPATH = ( # Basis: "Gib mir X Rezept/Ideen/Vorschlaege..." r'^(gib mir|gib|zeig mir|zeig|bringe mir|bringe|schick mir|schick)\s+(ein|eine|einen|\d+)\s+(rezept|ideen|vorschlaege|vorschläge)', r'^(gib mir|gib|zeig mir|zeig)\s+(rezept|ideen|vorschlaege|vorschläge)', # "Rezept fuer...", "Ideen fuer..." r'(rezept|rezepte|ideen|vorschlaege|vorschläge)\s+(fuer|für)', # "schnelles Abendessen", "was kochen" r'(schnelles|einfaches|gutes)\s+(abendessen|mittagessen|fruehstueck|frühstück|essen|gericht)', r'(abendessen|mittagessen|fruehstueck|frühstück)\s+(ideen|vorschlaege|vorschläge|rezepte)', r'(was|was soll ich)\s+(kochen|essen|zum essen|zum abendessen|zum mittagessen)', # Spezifische Gerichte r'(omelett|omelette|omeletten|pfannkuchen|spaghetti|pasta|risotto|curry|suppe|salat|bowl|wraps)', ) if any(re.search(p, low) for p in RECIPE_FASTPATH) and not any(h in low for h in ('homelab', 'server', 'docker', 'container', 'menu', 'code', 'script', 'programm')): return {'role': 'personal', 'task_type': 'howto', 'tools': ['personal_assist'], 'needs_memory': True, 'needs_setup_context': False, 'needs_web_research': False, 'dangerous_action': False, 'confidence': 0.95} # Pattern-Definitionen am Anfang der Funktion (Fix: RECIPE_PATTERNS muss vor Verwendung definiert sein) RECIPE_PATTERNS = ( r'(gib mir.*rezept|rezept fuer|rezept f.r|wie mache ich|wie bereite ich)', r'(ideen.*abendessen|schnelles abendessen|was koche ich|was esse ich|abendessen.*ideen|abendessen.*vorschlag)', r'(omelett|omelette|omeletten|pasta|spaghetti|pfannkuchen|wraps|shakshuka|risotto|curry|suppe|salat|bowl)', r'(zutaten|kochen|koch|backen|essen fuer|was koche|was esse|schnell.*essen|einfach.*kochen)', r'(hunger|hungrig|mahlzeit|gericht|gerichte|speise|speisen)', # Fix RECIPE-ROUTING-11: "Gib mir X fuer Y" - allgemeines Pattern für Rezept-Anfragen r'^(gib mir|gib)\s+\w+\s+(fuer|für)\s+\w+', # Fix RECIPE-ROUTING-12: "Gib mir X Ideen fuer Y" - erlaube beliebigen Text nach "fuer" r'^(gib mir|gib)\s+\d+\s+ideen\s+(fuer|für)\b', ) # (WRITE-CREATIVE Patterns jetzt oben unter CREATIVE-WRITE EARLY INTERCEPT) SCIENCE_KB_PATTERNS = ( r'(himmel.*blau|blauer himmel|warum.*himmel)', r'(sonne.*rot|sonne.*orange|sonnenuntergang.*rot|sonnenuntergang.*orange|untergehende sonne)', r'(mondfinsternis.*rot|roter mond|blutmond|mond.*rot)', r'(rayleigh.*streuung|lichtstreuung|streuung.*licht)', r'(mond.*grösser|mond.*größer|mondtäuschung|mondtaeuschung)', ) GENERAL_KB_PATTERNS = ( r'(was ist docker|was ist ein docker|was.*erklaer.*docker|docker vs.*vergleich|unterschied.*docker)', r'(was ist ein reverse proxy|was ist reverse proxy|reverse proxy.*was)', r'(was ist tailscale|tailscale.*was|vpn.*tailscale)', r'(unraid.*truenass|truenass.*unraid|unraid vs|truenass vs)', r'(unterschied.*wetter.*klima|wetter.*klima.*unterschied|was ist.*wetter|was ist.*klima)', r'(photosynthese|photo.*synthese|pflanzen.*licht|licht.*pflanzen)', r'(lichtjahr|licht jahr|was ist.*lichtjahr)', r'(fleisch.*auftauen|auftauen.*fleisch|fleisch.*tiefkühl|fleisch.*gefrier)', r'(waschmittel.*pulver|waschmittel.*flüssig|waschmittel.*pods|pulver.*flüssig)', r'(kühlschrank.*temperatur|kühlschrank.*grad|gefrierfach.*temperatur)', r'(schnittwunde.*erste hilfe|erste hilfe.*schnitt|schnitt.*wunde.*was tun)', r'(wasser.*pro tag|wieviel wasser|wie viel wasser.*trinken)', r'(schlaf.*dauer|schlaf.*stunden|wie lange.*schlafen|schlafdauer)', r'(3-säulen.*system|drei säulen|ahv.*pensionskasse|säule 3a|säule 3b)', r'(was ist ein etf|etf.*was|etf.*fonds|exchange traded)', r'(du.*sie.*deutsch|sie.*du.*deutsch|wann du.*wann sie)', # Neue Patterns für erweiterte KB_GENERAL (Fix K1-K12) r'(berechne.*prozent|trinkgeld|prozent.*berechnen|prozentrechnung)', r'(tage bis|wie viele tage|tage.*weihnachten|tage.*geburtstag)', r'(stunden.*jahr|zeitrechnung|minuten.*jahr|stunden pro)', r'(vitamin d|vitamin-d|mangel.*vitamin)', r'(kopfschmerzen|kopf.*tut weh|migräne|migrane|schmerzen.*kopf)', r'(gestresst|stress.*was tun|entspannung|entspannen)', r'(schweiz.*gegründet|bundesstaat.*schweiz|gründung.*schweiz|schweiz.*geschichte)', r'(http.*status|status code|503|502|404|500.*error|http.*fehler)', r'(passwort.*ändern|passwoerter.*aendern|warum.*passwort)', r'(python.*error|typeerror|python.*fehler|was bedeutet.*error)', r'(übersetze|uebersetze|auf englisch|auf italienisch|auf deutsch|wie sagt man)', r'(buch.*empfehl|buch.*schlafen|gutes buch|lesen.*schlafen)', # Neue Patterns für erweiterte KB_GENERAL (Fix K13-K24) r'(was ist.*ip.*adresse|ip.*adresse.*was|ip-adresse|meine ip)', r'(was ist.*dns|dns.*was|domain name system)', r'(was ist.*vpn|vpn.*was|virtual private)', r'(router.*switch|unterschied.*router|access point.*unterschied)', r'(makronaehrstoff|makro.*nährstoff|kohlenhydrate.*protein|protein.*fett)', r'(glykaemisch|glykämisch|gi.*wert|glykaemischer index)', r'(keto.*diaet|keto.*diät|ketose|ketogene)', r'(was ist.*inflation|inflation.*was|verbraucherpreisindex)', r'(zinseszins|zins.*zins|zinsrechnung)', r'(aktien.*etf|etf.*aktien|einzelaktie.*etf)', r'(waschmaschine.*funktioniert|wie funktioniert.*waschmaschine)', r'(kuehlschrank.*funktioniert|kühlschrank.*funktioniert|wie funktioniert.*kühlschrank)', r'(backpulver.*natron|natron.*hefe|backpulver.*hefe|unterschied.*backpulver)', r'(jahreszeiten.*warum|warum.*jahreszeiten|warum gibt es.*jahreszeiten)', r'(biologisch.*oekologisch|biologisch.*ökologisch|unterschied.*bio)', r'(was ist.*oekosystem|oekosystem.*was|was ist.*ökossystem|ökossystem.*was)', # Neue Patterns für erweiterte KB_GENERAL (Fix K25-K48) - Sport, Technik, Kochen, Psychologie r'\b(hiit|high intensity interval|interval training)\b', r'\bkrafttraining\b|\bkraft.*training\b|\bmuskeltraining\b', r'(ausdauer.*kraft|kraft.*ausdauer|unterschied.*ausdauer)', r'(oled.*led|led.*oled|qled.*oled|oled.*qled|unterschied.*fernseher)', r'(energie.*klasse|energieeffizienz|stromverbrauch.*gerät|was bedeuten.*klassen)', r'(usb.*a|usb.*c|lightning|unterschied.*usb|usb.*unterschied)', r'(sous.*vide|sousvide|unterschied.*sous|vakuum.*garen)', r'(salzen.*vor|salzen.*nach|fleisch.*salzen|wann.*salzen)', r'(maillard.*reaktion|maillard|braten.*kruste|röstaromen)', r'(sarkasmus.*ironie|ironie.*sarkasmus|unterschied.*sarkasmus)', r'(gaslighting|gas.*lighting|manipulation.*wahrnehmung)', r'(dunning.*kruger|dunningkruger|kompetenz.*überschätz|selbsteinschätzung)', r'(confirmation.*bias|bestätigungsfehler|bestaetigungsfehler|bias.*bestätigung)', r'(himmel.*blau.*warum|warum.*himmel.*blau|rayleigh)', r'(meteor.*meteorit|meteorit.*asteroid|unterschied.*meteor)', r'(eis.*schwimmt|schwimmt.*eis|eis.*wasser|warum.*eis)', r'(freizügigkeitskonto|freizuegigkeitskonto|säule.*2.*konto|pensionskasse.*konto)', r'(ahv.*lücke|ahv.*luecke|beitragslücke|beitragsluecke|rente.*lücke)', r'(schaltjahr|schalt.*jahr|warum.*schaltjahr|29.*februar)', r'(gmt.*utc|utc.*gmt|unterschied.*gmt|coordinated.*universal)', # Neue Patterns für erweiterte KB_GENERAL (Fix K49-K64) - Auto, Reisen, Büro r'(benzin.*diesel|diesel.*benzin|unterschied.*benzin|unterschied.*diesel|was ist.*diesel)', r'(ampel.*farbe|ampel.*bedeutung|rot.*gelb.*grün|ampel.*regel)', r'(rechts.*vor.*links|vorfahrt.*rechts|wer.*vorfahrt)', r'(tempolimit|geschwindigkeit.*auto|wie schnell.*fahren|autobahn.*tempo)', r'(handgepäck|aufgegabengepäck|flüssigkeiten.*flug|was.*handgepäck)', r'(jetlag|jet.*lag|zeitverschiebung|umstellung.*zeit)', r'(reisepass|personalausweis|unterschied.*pass|pass.*ausweis)', r'(visum|visa|einreise.*erlaubnis|touristenvisum|schengen.*visum)', r'(pomodoro.*technik|pomodoro|25.*minuten.*arbeit|produktiv.*technik)', r'(pdf.*word|word.*pdf|unterschied.*pdf|dokument.*pdf)', r'(cloud.*speicher|cloud.*storage|google.*drive|dropbox|icloud)(?!.*next)', r'(e.*mail.*etikette|email.*etikette|betreff.*mail|anrede.*mail)', ) # Fix KB-SCIENCE-NEW: Neue Patterns für KB_GENERAL_SCIENCE (Physik, Chemie, Astronomie) SCIENCE_NEW_PATTERNS = ( r'(newton.*gesetz|newtonsches.*gesetz|trägheit|actio.*reactio|f.*=.*m.*a)', r'(energie.*formen|kinetische.*energie|potentielle.*energie|thermische.*energie)', r'(lichtgeschwindigkeit|schallgeschwindigkeit|wie schnell.*licht|wie schnell.*schall)', r'(elektromagnetisch.*spektrum|radio.*welle|mikrowelle|infrarot|ultraviolett|röntgen|gamma)', r'(aggregatzustand|fest.*flüssig.*gas|plasma.*zustand|zustand.*materie)', ) # Fix KB-HEALTH: Gesundheit & Wohlbefinden Patterns für KB_HEALTH_WELLNESS HEALTH_KB_PATTERNS = ( r'(schlaf.*dauer|schlaf.*stunden|wie lange.*schlafen|optimale.*schlaf|schlafhygiene)', r'(power.*nap|powernap|nickerchen|mittagsschlaf|kurz.*schlaf)', r'(wasser.*trinken|wieviel.*wasser|wie viel.*wasser|wasser.*pro.*tag|hydratation)', r'(vitamin.*d|vitamin-d|magnesium|eisen.*mangel|mineralien|vitamine)', r'(herzfrequenz|puls.*zone|training.*zone|fitness.*zone|ausdauer.*zone)', r'(schritte.*tag|schritte.*pro|10\.000.*schritte|schrittzahl)', r'(cortisol|stress.*hormon|stress.*reduktion|entspannung.*technik)', r'(burnout|burn.*out|ueberlastung|überlastung|erschöpfung)', r'(ballaststoffe|zucker.*konsum|makronaehrstoffe|makro.*nährstoff)', r'(erste.*hilfe|schnittwunde|verbrennung|bewusstlos|bewusstlosigkeit)', r'(schlaf.*qualitaet|besser.*schlafen|einschlaf.*tip|durchschlaf)', r'(dehydration|dehydriert|durst|flüssigkeits.*mangel|wasser.*bedarf)', r'(ph.*wert|ph.*skala|sauer.*basisch|säure.*lauge|zitronensaft.*ph)', r'(periodensystem|chemisches.*element|wasserstoff|kohlenstoff|sauerstoff|eisen|gold)', r'(zelle.*biologie|zellmembran|zytoplasma|zellkern|mitochondrien|dna.*erklärung)', r'(photosynthese|pflanzen.*licht|co2.*wasser.*licht|chloroplasten)', r'(dns.*basen|adenin.*thymin|guanin.*cytosin|doppelhelix|genetik.*einfach)', r'(sonnensystem|planet.*reihenfolge|merkur.*venus.*erde|jupiter.*saturn|lichtjahr)', r'(erde.*durchmesser|mond.*entfernung|astronomische.*einheit|ae.*entfernung)', ) # Fix KB-EVERYDAY: Neue Patterns für KB_EVERYDAY_KNOWLEDGE (Kochen, Haushalt, Gesundheit) EVERYDAY_KB_PATTERNS = ( r'(garzeit|kochzeit|nudeln.*kochen|reis.*kochen|kartoffeln.*kochen|eier.*kochen)', r'(fleisch.*temperatur|kern.*temperatur|braten.*grad|steak.*medium)', r'(haltbarkeit|wie lange.*haltbar|kühlschrank.*tage|aufbewahren)', r'(flecken.*entfernen|rotwein.*fleck|kaffee.*fleck|fett.*entfernen|blut.*entfernen)', r'(putzen.*tip|reinigung.*tip|hausputz|sauber.*machen|mikrofaser)', r'(notruf|erste.*hilfe|bewusstlos|blutung|verbrennung|erste.*hilfe.*maßnahme)', r'(vitamin.*funktion|vitamin.*quelle|vitamin.*a|vitamin.*c|vitamin.*d|vitamin.*b)', r'(steuerklasse|steuerklasse.*i|steuerklasse.*iii|brutto.*netto|abkürzung.*kv)', r'(passwort.*sicher|sicheres.*passwort|2fa|zwei.*faktor|passwort.*manager)', r'(datei.*größe|kb.*mb.*gb|kilobyte|megabyte|gigabyte|terabyte)', r'(internet.*geschwindigkeit|dsl.*kabel|glasfaser|5g.*geschwindigkeit)', r'(müll.*trennung|gelber.*sack|biomüll|papier.*tonne|glas.*container)', r'(energie.*sparen|strom.*sparen|led.*lampe|kühlschrank.*sparen)', ) # Fix KB-AUTO: Neue Patterns für KB_AUTOMOBILITY (Autos, Fahrzeuge, Mobilität) AUTO_KB_PATTERNS = ( r'(benzin.*diesel|diesel.*benzin|unterschied.*benzin|unterschied.*diesel|was ist.*diesel|e10.*benzin|super.*e10)', r'(ölwechsel|öl.*wechsel|oelwechsel|oel.*wechsel|ölstand|oelstand|öl.*prüfen|oel.*pruefen)', r'(reifen.*druck|reifendruck|reifen.*wechsel|winterreifen|sommerreifen|reifen.*profil|profiltiefe)', r'(tanken|tankstelle|benzin.*preis|diesel.*preis|tank.*kosten|sprit.*kosten)', r'(elektroauto|e-auto|eauto|ladestation|schnellladen|reichweite|akku.*auto|batterie.*auto)', r'(führerschein|fuehrerschein|fahrerlaubnis|führerschein.*kategorie|fuehrerschein.*kategorie)', r'(tempolimit|geschwindigkeit.*auto|wie schnell.*fahren|autobahn.*tempo|tempo.*limit)', r'(auto.*kaufen|auto.*verkaufen|gebrauchtwagen|neuwagen|wertverlust|auto.*kosten)', r'(versicherung.*auto|auto.*versicherung|kasko|vollkasko|haftpflicht|kfz.*versicherung)', r'(promillegrenze|promille.*auto|alkohol.*fahren|trunkenheit|blutalkohol)', r'(wartung.*auto|auto.*wartung|inspektion.*auto|auto.*inspektion|service.*auto)', r'(ampel.*farbe|ampel.*bedeutung|rot.*gelb.*grün|ampel.*regel|vorfahrt.*regel)', r'(rechts.*vor.*links|vorfahrt.*rechts|wer.*vorfahrt|vorfahrtsregel)', r'(unfall.*auto|auto.*unfall|pannen|abschleppen|kfz.*schaden)', r'(mietwagen|car.*sharing|carsharing|mobility|auto.*mieten)', r'(motor.*funktioniert|wie.*funktioniert.*motor|ottomotor|dieselmotor|hybrid.*auto)', r'(antrieb.*auto|frontantrieb|heckantrieb|allrad|quattro|xDrive|4matic)', r'(verbrauch.*auto|auto.*verbrauch|liter.*100km|kraftstoff.*verbrauch)', ) analysis = { 'role': role_hint if role_hint in ROLE_HINTS else 'general', 'task_type': 'explain', 'needs_setup_context': False, 'needs_web_research': False, 'needs_memory': False, 'dangerous_action': False, 'tools': [], 'reasoning_focus': ['Verstehe zuerst das eigentliche Objekt und den Mechanismus der Anfrage.'], 'confidence': 0.35, } if re.search(r'https?://', message): analysis.update(role='research', task_type='explain', tools=['url_research'], needs_web_research=True, confidence=0.82) return analysis if is_followup_query(message): prev = recent_topic_event(message) or recent_dialogue_event(message) if prev: prev_analysis = prev.get('analysis', {}) prev_tools = prev_analysis.get('tools', []) # Fix 9: don't inherit url_research for ops/setup queries OPS_SIGNALS = ('hoste ich', 'mein setup', 'meine infrastruktur', 'welche services', 'welche hosts', 'wo laeuft', 'oeffentlich', 'hosts laufen', 'laufen sie', 'gerade fehler') if prev_tools == ['url_research'] and any(s in low for s in OPS_SIGNALS): analysis.update(role='ops', task_type='status', tools=['setup_lookup'], needs_setup_context=True, confidence=0.87) return analysis resolved = resolve_followup(message, prev) if prev.get('summary') else None focus = str((resolved or {}).get('focus') or str(prev.get('message') or '')).strip() analysis.update( role=prev_analysis.get('role', role_hint if role_hint in ROLE_HINTS else 'general'), task_type=prev_analysis.get('task_type', 'explain'), tools=prev_tools[:3], needs_setup_context=bool(prev_analysis.get('needs_setup_context')), needs_web_research=bool(prev_analysis.get('needs_web_research')), needs_memory=bool(prev_analysis.get('needs_memory')), confidence=0.92 if resolved and resolved.get('is_followup') else 0.9, reasoning_focus=[f'Kurze Follow-up-Anfrage; nutze den zuletzt verifizierten Kontext: {focus}'], ) return analysis if re.search(r'^(bitte\s+)?(merke dir|merk dir|denk daran|behalte im kopf)\b', low) or re.search(r'was weisst du (alles |so )?(ueber|über) mich', low) or re.search(r'was weisst du über mich', message.lower()) or re.search(r'(mein profil|mein gespeichertes profil|profil anzeigen|profil zeigen|gespeichertes profil)', low) or re.search(r'\b(was ist mir .* wichtig|wie sollst du (mir )?antworten|welche vorlieben|welchen stil|welche art hilfe wuensche ich mir|welche art hilfe wünsche ich mir|welche praeferenzen hast du von mir gespeichert|welche präferenzen hast du von mir gespeichert)\b', low) or (re.search(r'was weisst du', low) and re.search(r'vorlieben', low)) or (re.search(r'wie sollst du mir antworten', low) and re.search(r'kennst', low)): analysis.update(role='personal', task_type='memory', tools=['memory_profile'], needs_memory=True, confidence=0.92) return analysis if '?' not in message and re.search(r'\b(ich will|ich bevorzuge|mir ist wichtig|achte darauf|das ist falsch|es sollte|er sollte|wenn es um .* geht|ich .* wichtig finde)\b', low) and not re.search(r'\b(installieren|einrichten|deployen|konfigurieren|aufsetzen|einbinden|migrieren|upgraden)\b', low) and not re.search(r'(homelab|server|services).{0,50}(sicher|security|absichern|haerten)', low) and not re.search(r'(sicher genug|ausreichend sicher|homelab.*sicher|server.*sicher)', low) and not re.search(r'\b(wechseln|migrieren|umsteigen|umziehen|umstellen)\b', low) and not re.search(r'\bvon\b.{1,30}\b(auf|zu)\b.{1,30}\b(wechseln|migrieren|umsteigen|umziehen|umstellen)\b', low) and not re.search(r'(abnehmen|ernaehrungsplan|ernaehrung.*plan|trainingsplan|fitness.*plan|sport.*plan|diaet.*plan|kalorien.*plan|mahlzeiten.*plan|essensplan|gewicht.*verlieren|gewicht.*reduzier)', low): analysis.update(role='personal', task_type='memory', tools=['memory_profile'], needs_memory=True, confidence=0.94) return analysis # Fix MEMORY-ROUTING-1: Persönliche Präferenz-Fragen ("Welche Art X mag ich?", "Was bevorzuge ich?") # WICHTIG: Ausschluss für Gesundheitsbeschwerden ("ich habe kopfschmerzen") und Planung ("was habe ich geplant") if re.search(r'\b(welche|was)\b.*\b(ich|mein|meine|mir)\b', low) and re.search(r'\b(mag|bevorzuge|nehme|trinke|esse|kaufe|nutze|verwende|sammle|interessiere)\b', low) and not re.search(r'\b(habe|hatte|bekomme|fuehle|fühle|spüre|spuere)\b.*\b(kopfschmerzen|schmerzen|weh|krank|beschwerden|geplant|vor|termin|woche)\b', low) and not re.search(r'(homelab|server|docker|container|service|host|setup|install|konfig)', low): analysis.update(role='personal', task_type='memory', tools=['memory_profile'], needs_memory=True, confidence=0.93) return analysis if 'briefing' in low or 'lagebild' in low or 'was ist gerade wichtig' in low: analysis.update(role='ops', task_type='status', tools=['briefing'], needs_setup_context=True, confidence=0.92) return analysis if re.search(r'\b(dnssec|markdown|html|backup|snapshot|ram|ssd)\b', low) and not re.search(r'(aktuell|heute|neueste|latest)', low): if re.search(r'(was ist|erklaer|erkläre|in einfachen worten|in drei saetzen|in drei sätzen|unterschied|vergleiche? kurz)', low): analysis.update(role='general', task_type='explain', tools=['general_answer'], needs_memory=False, confidence=0.95) return analysis if re.search(r'\b(sonarr|radarr)\b', low) and re.search(r'(unterschied|vergleiche?|vergleich)', low): analysis.update(role='general', task_type='explain', tools=['general_answer'], needs_memory=False, confidence=0.95) return analysis # Fix PROMETHEUS-INSTALL: Service-Installation + Verifikation -> ops_install + ops_exec (Multi-step) # Fix INSTALL-VERIFY: "installieren und danach verifizieren" -> Multi-step Pattern if re.search(r'(prometheus|grafana|node.*exporter|cadvisor|loki|promtail|influxdb|telegraf|zabbix|nagios)', low) and re.search(r'(installieren|install|aufsetzen|deployen|einrichten)', low) and re.search(r'(verifizieren|verifizier|testen|prüfen|pruefen|checken|sicherstellen|danach|anschliessend|anschließend)', low): analysis.update(role='ops', task_type='build', tools=['ops_install', 'ops_exec'], needs_setup_context=True, dangerous_action=True, confidence=0.96) return analysis # Fix PROBLEM-FOLLOWUP: "Was war das Problem" nach Diagnose -> ops_deep_analyze if re.search(r'^(was war das problem|was war das|welches problem|welches war das problem|das problem)', low) and re.search(r'(gefunden|diagnose|analyse|vorher|eben|gerade|du|hast)', low): analysis.update(role='ops', task_type='diagnose', tools=['ops_deep_analyze'], needs_setup_context=True, confidence=0.94) return analysis if any(hint in low for hint in SERVICE_HINTS if hint not in {"share", "smb"}) and "sonos" not in low and (re.search(r'\b(?:kann|can)\s+(?!mein\b|meine\b|my\b|der\b|die\b|das\b)[a-z0-9_.@-]{2,40}\s+nicht\b', low) or re.search(r'\b[a-z0-9_.@-]{2,40}\b.*(kann sich nicht|kommt nicht rein|hat keinen zugriff|hat kein zugriff|kann nicht zugreifen|kann sich nicht anmelden|sieht .* nicht|findet .* nicht|entdeckt .* nicht|hat keine berechtigung|permission|forbidden|denied)', low) or re.search(r'\bwarum\s+kann\s+[a-z0-9_.@-]{2,40}\s+sich\s+nicht\b', low) or re.search(r'\bwarum\s+kann\s+[a-z0-9_.@-]{2,40}\s+nicht\b', low)) and re.search(r'(zugreifen|zugriff|anmelden|login|einloggen|rein|access|sieht .* nicht|findet .* nicht|entdeckt .* nicht|berechtigung|permission|forbidden|denied|mfa|duo|2fa)', low): analysis.update(role='ops', task_type='diagnose', tools=['user_service_access_diagnose'], needs_setup_context=True, confidence=0.95) return analysis if any(hint in low for hint in SERVICE_HINTS if hint not in {'share', 'smb'}) and re.search(r'(sieht .* nicht|findet .* nicht|entdeckt .* nicht|fehlt|fehlen|bibliothek|library|\bserie\b|\bstaffel\b|\bfolge\b|\bepisod|\bfilm\b|ordner|ressource)', low) and 'sonos' not in low and not re.search(r'\b(sonarr|radarr|lidarr)\b', low) and not re.search(r'\b(soll ich|oder|vs|versus|vergleich|einsetzen|nehmen|waehlen)\b', low): analysis.update(role='ops', task_type='diagnose', tools=['user_service_access_diagnose'], needs_setup_context=True, confidence=0.93) return analysis if 'sonos' in low and re.search(r'(bibliothek|library|musik|music|share|freigabe|smb|nas|unraid|zugreifen|zugriff|findet|erreicht|scan)', low): analysis.update(role='ops', task_type='diagnose', tools=['sonos_library_diagnose'], needs_setup_context=True, confidence=0.97) return analysis # CAS-1: mass user failure after config change → diagnose (not provision) if re.search(r'(alle benutzer|alle user|niemand kann|keiner kann|kein benutzer|alle mitglieder).*(anmelden|einloggen|zugreifen|verbinden|nutzen)', low) or re.search(r'(anmelden|einloggen).*(alle|niemand|keiner|kein benutzer)', low) or re.search(r'(ldap|authentik|sso).*(aenderung|update|neuer|geaendert|provider|einstellung).*(anmelden|einloggen|zugriff|nutzen)', low) or re.search(r'(neuen? ldap|ldap.*provider|authentik.*ldap).*(benutzer|user|anmelden|einloggen)', low): analysis.update(role='ops', task_type='diagnose', tools=['user_service_access_diagnose'], needs_setup_context=True, confidence=0.92) return analysis if 'emby' in low and re.search(r'(einrichten|anlegen|neu(?:en|e|er)?.*benutzer|neuen.*account|ldap|authentik|wie bisherige|wie die bisherigen|wie die bestehenden|bestehenden benutzer|standard)', low): analysis.update(role='ops', task_type='howto', tools=['emby_user_provision'], needs_setup_context=True, confidence=0.96) return analysis # Fix EMBY-GENERAL: Emby + Problem/Integration/Streaming/Abbruch -> setup_lookup (not general_answer) # Ausschluss: Vergleiche (vs, oder, besser, empfehl) -> compare_selfhosted if 'emby' in low and re.search(r'(bricht|ab|problem|fehler|streaming|stream|integration|integriere|media player|player|transcoding|hw.*accel|hardware.*accel|absturz|crashed|hängt|haengt|nicht mehr|kein bild|kein ton)', low) and not re.search(r'(vs|versus|oder.*besser|besser.*oder|empfiehl|soll ich.*oder)', low): analysis.update(role='ops', task_type='diagnose', tools=['setup_lookup'], needs_setup_context=True, confidence=0.95) return analysis if re.search(r'(schalte|mach|ausschalten|einschalten|deaktivier|aktivier|turn off|turn on|dimm|dimmen|heller|dunkler|helligkeit)', low) and re.search(r'(licht|lichter|lights|wohnzimmer|buero|badezimmer|kueche|esszimmer|bedroom|kitchen|living|manuel|katja|alle)', low) and not re.search(r'(welche|welches|status|gerade|aktuell|eingeschaltet|eingeschalten|an sind|an ist)', low): analysis.update(role='ops', task_type='action', tools=['light_control'], dangerous_action=True, confidence=0.97) return analysis if re.search(r'(welche lichter|welches licht|welche lampen|welche beleuchtung|welche lichtbereiche|lichtbereiche|gerade.*licht|aktuell.*licht|aktuelle.*beleuchtung|zimmerlampen|status.*licht|licht.*status|wie.*licht.*status|lights.*status|status.*lights)', low): analysis.update(role='ops', task_type='status', tools=['light_status'], needs_setup_context=True, confidence=0.98) return analysis if 'licht' in low and re.search(r'(daten|automatis|wann|wie lange)', low): analysis.update(role='ops', task_type='diagnose', tools=['light_usage'], needs_setup_context=True, confidence=0.88) return analysis # Fix W1: Erweiterte Web-Diagnose Patterns für interne Seiten/Doku if (re.search(r'[a-z0-9.-]+\.[a-z]{2,}', low) and re.search(r'(nicht oeffnen|nicht öffnen|nicht laden|webseite|website|url|zugriff|erreichbar|ausfällt|ausfaellt)', low)) or (re.search(r'(subdomain|sub-domain|hostname|hostnamen|host|domain|wiki|wissensseite|wissens-url|doku-seite|dokuseite|dokumentationsseite|dokumentation|documentation|docs?|interne.*seite|intern.*seite|wissens.*seite)', low) and re.search(r'(nicht laden|nicht oeffnen|nicht öffnen|nicht aufzugehen|nicht auf|aufgehen|von aussen|von außen|von ausserhalb|ausserhalb|extern|erreichbar|oeffnen|ausfällt|ausfaellt|welche zwei infos|welche zwei angaben|brauchst du zuerst|was brauchst du zuerst|welche.*infos|welche.*angaben|brauchst du|was brauchst)', low)): analysis.update(role='ops', task_type='diagnose', tools=['web_root_cause'], needs_setup_context=True, confidence=0.92) return analysis # LOG-COMPLEX: "häufigsten fehler", "fehler-patterns", "wochenbericht" if re.search(r'(haeufigsten?\s*fehler|fehler.{0,20}haeufig|welche fehler|fehler.?patterns|fehler.?muster)', low) or re.search(r'(fehler.{0,30}(letzten|24|stunden|tagen|woche|monat)|wochenbericht.*fehler|log.*auswertung|log.*analyse|logs.*auswerter)', low) or re.search(r'(fehler.*tauchen.*logs|logs.*fehler|logs.*letzten|zeig.*fehler.*logs|fehler.*logs.*letzten)', low): analysis.update(role='ops', task_type='diagnose', tools=['log_hotspots'], needs_setup_context=True, confidence=0.90) return analysis # Fix PIHOLE-REPORT: PiHole DNS-Report/Statistik-Anfragen -> setup_lookup (not general_answer) # Fix PIHOLE-CASE: Case-insensitive matching für Pihole/PiHole/pihole # Fix PIHOLE-BOUNDARY: Match auch "Pihole-Report" (Bindestrich ist keine Wortgrenze) if re.search(r'\bpihole[-_]?|\bpi[-_]?hole|\bpi-hole\b', low) and re.search(r'(report|statistik|statistiken|anfragen|blockiert|geblockt|dns.*heute|heute.*dns|wie viele|anzahl)', low): analysis.update(role='ops', task_type='status', tools=['setup_lookup'], needs_setup_context=True, confidence=0.94) return analysis if re.search(r'\b(logs?)\b', low) and re.search(r'(immich|nextcloud|traefik)', low): analysis.update(role='ops', task_type='diagnose', tools=['log_hotspots'], needs_setup_context=True, confidence=0.92) return analysis if re.search(r'(langsam|langsamer|performance|cpu|ram|disk|netzwerk)', low) and 'host' in low: analysis.update(role='ops', task_type='diagnose', tools=['host_slow_diagnose'], needs_setup_context=True, confidence=0.9) return analysis if 'kasm' in low and re.search(r'(warum|wieso|zugreifen|kann|problem|fehler)', low): analysis.update(role='ops', task_type='diagnose', tools=['kasm_diagnose'], needs_setup_context=True, confidence=0.9) return analysis if re.search(r'(homelab|reverse proxy|reverse-proxy|ssh|secrets|backups?|sicherer|sicherheit|security)', low) and re.search(r'(massnahmenplan|massnahmenplan|\bplan\b|priorisiert|haerten|haerten|recherchiere)', low) and not re.search(r'(urlaub|reise|trip|ferien).{0,30}planen', low): analysis.update(role='research', task_type='howto', tools=['security_plan'], needs_setup_context=True, needs_web_research=True, confidence=0.95) return analysis if re.search(r'(hoerbuch|hörbuch|audiobook|audiobooks?)', low) and re.search(r'(server|system|tool|dienst|plattform|plattformen|option|optionen|vergleich|vergleiche|gegeneinander|abwaegen|abwägen|loesung|lösung|empfehl|waehlen|wählen|priorisieren|aufsetzen|homelab|setup|stack)', low): analysis.update(role='research', task_type='compare', tools=['compare_selfhosted'], needs_web_research=True, confidence=0.95) return analysis if any(t in low for t in ('pihole', 'adguard', 'vaultwarden', 'bitwarden', 'jellyfin', 'plex', 'navidrome', 'paperless', 'gitea', 'forgejo', 'uptime-kuma', 'portainer', 'heimdall', 'dashdot', 'overseerr', 'bazarr', 'prowlarr', 'readarr', 'kavita')) and re.search(r'(besser|oder|vs|vergleich|vergleiche|unterschied|welches|empfiehl|nehmen|waehlen|wählen|statt|anstatt)', low): analysis.update(role='research', task_type='compare', tools=['compare_selfhosted'], needs_web_research=True, confidence=0.87) return analysis # MIG-VS: "X vs Y" / "soll ich X oder Y" service comparison _cs = r'(nextcloud|seafile|jellyfin|emby|plex|vaultwarden|bitwarden|portainer|traefik|nginx|proxmox|unraid|truenas|synology|docker|podman|gitea|forgejo|immich|photoprism|audiobookshelf|navidrome|lidarr|sonarr|radarr)' if re.search(_cs + r'\s*(vs\.?|versus)\s*' + _cs, low) or re.search(r'(soll ich|lohnt sich|empfiehlst du).{0,60}' + _cs + r'.{0,40}(oder|vs|anstatt|statt)', low) or re.search(r'(soll ich|lohnt sich|empfiehlst du).{0,60}' + _cs + r'.{0,60}' + _cs, low) or re.search(r'(was ist besser|welches ist besser|was empfiehlst).{0,80}' + _cs, low) and not re.search(r'\bfuer homelab\b', low) or re.search(_cs + r'.{0,40}(oder|vs|versus).{0,40}' + _cs + r'.{0,50}(einsetzen|verwenden|nehmen|waehlen|nutzen)', low): analysis.update(role='ops', task_type='compare', tools=['compare_selfhosted'], needs_setup_context=False, confidence=0.91) return analysis # MIG: migration / platform switch / "von X auf Y wechseln" _mig_svcs = r'(unraid|proxmox|docker|truenas|synology|qnap|ubuntu|debian|jellyfin|emby|nextcloud|vaultwarden|bitwarden|portainer|seafile|plex|gitea|forgejo)' if re.search(r'\bvon\b.{1,40}\b(auf|zu|nach)\b.{1,40}\b(wechseln|migrieren|umsteigen|umziehen|umstellen)\b', low) or re.search(r'\bvon\b\s+' + _mig_svcs + r'.{0,20}\b(auf|zu)\b', low) or re.search(_mig_svcs + r'.{1,30}\b(wechseln|migrieren|umsteigen|umziehen|umstellen)\b', low) or re.search(r'(was muss ich beachten.*(wechseln|migrieren|umsteigen)|was verliere ich dabei|was aendert sich beim wechsel)', low): analysis.update(role='ops', task_type='compare', tools=['compare_selfhosted'], needs_setup_context=True, confidence=0.90) return analysis # EXPERT-RESEARCH early intercept: deep/comprehensive research queries → expert_research before compare_selfhosted if re.search(r'(recherchiere.{0,30}(tiefgr|tiefsinnig|vollst|komplett|umfassend|detailliert)|' r'marktanalyse|wettbewerbsanalyse|kompetitor.*analyse|competitive.*analysis|' r'investigier|investigative|vollstaendige.*analyse|comprehensive.*analysis|' r'fact.?check|fakten.*pruefen|quellen.*pruefen|verifizier.*fakten|' r'recherche.*bericht|research.*report|due.*diligence)', low): analysis.update(role='research', task_type='explain', tools=['expert_research'], needs_web_research=True, confidence=0.93) return analysis if re.search(r'(selbst zu hosten|selbst hosten|selber hosten|self-hosted|self hosted)', low): analysis.update(role='research', task_type='compare', tools=['compare_selfhosted'], needs_web_research=True, confidence=0.84) return analysis # Fix PROTO-SYNTH: Protokoll/Technologie-Erklaerung + was nutzt [Service] konkret # MUSS VOR allgemeinem 'vergleich' Pattern kommen! # Catches: "Unterschied OAuth2/OIDC/SAML + was nutzt Authentik?" if any(h in low for h in SERVICE_HINTS) and re.search( r'(oauth|oidc|saml|jwt|openid|ldap|' r'protokoll.*unterschied|unterschied.*protokoll|auth.*protokoll|' r'was ist.*unterschied|unterschied.*zwischen|erklaer.*protokoll|erklär.*protokoll|' r'was ist.*oauth|was ist.*oidc|was ist.*saml|was ist.*ldap)', low) and re.search( r'(nutzt|verwendet|benutzt|einsetzt|konkret|im einsatz|wie setzt.*ein|wie verwendet|was.*nutzt|was.*verwendet)', low): analysis.update(role='ops', task_type='explain', tools=['ops_deep_analyze'], needs_setup_context=True, confidence=0.91) return analysis # Fix ARCH-REASONING: Architektur/Abhängigkeits/Kaskaden-Fragen -> ops_deep_analyze # Catches: 'was passiert wenn X ausfällt', 'wie hängen X und Y zusammen', 'Abhängigkeitskette' if any(h in low for h in SERVICE_HINTS) and re.search( r'(abhaengigkeits?kette|abhaengigkeiten|zusammenspiel|wie haengen|wie hängen|was passiert wenn|' r'wenn .{0,30} ausfaellt|wenn .{0,30} ausfällt|wenn .{0,30} down|cascad|kaskad|' r'zusammenhaeng|zusammenhäng|wie.*funktioniert.*zusammen|login.*flow|login-flow|auth.*flow|' r'welche.*abhaengig|welche.*abhängig|ich plane .{0,40} zu deployen)', low): analysis.update(role='ops', task_type='explain', tools=['ops_deep_analyze'], needs_setup_context=True, confidence=0.93) return analysis if re.search(r'(recherchiere|vergleich|vergleiche)', low): wants_selfhosted = bool(re.search(r'(hosten|hosting|lokal|on-prem|onprem|selbst|self)', low)) analysis.update(role='research', task_type='compare', tools=['compare_selfhosted'] if wants_selfhosted else ['web_research'], needs_web_research=True, confidence=0.82) return analysis if re.search(r'(welche services|welche dienste|welche apps|welche anwendungen|welche app|welche anwendung|was hoste ich|hoste ich aktuell|oeffentlich|öffentlich|extern erreichbar|oeffentlich erreichbar|öffentlich erreichbar|gesund|oeffentliche app|öffentliche app|oeffentliche anwendung|öffentliche anwendung)', low) and re.search(r'(fehler|warnings?|warnungen|probleme|kaputt|status|zustand|gesund|gestoert|gestört|stoerung|störung|instabil|instabilsten|unreachable|down)', low): analysis.update(role='ops', task_type='status', tools=['public_services_health'], needs_setup_context=True, confidence=0.95) return analysis if re.search(r'(hoste ich|mein setup|meine infrastruktur|wo laeuft|welche hosts|welche services|oeffentlich)', low): analysis.update(role='ops', task_type='status', tools=['setup_lookup'], needs_setup_context=True, confidence=0.86) return analysis # Fix K13: Homelab service + HTTP error code -> setup_lookup (before GENERAL_KB_PATTERNS) if any(s in low for s in ('traefik', 'nextcloud', 'immich', 'emby', 'authentik', 'nginx')) and re.search(r'(502|503|404|500|401|403|error|fehler|zurück|zurueck|gibt|return)', low): analysis.update(role='ops', task_type='diagnose', tools=['setup_lookup'], needs_setup_context=True, confidence=0.94) return analysis # Fix STATUS-REPORT: "Statusbericht", "Bericht", "Übersicht" + Homelab/Setup -> setup_lookup if re.search(r'(statusbericht|status.*bericht|bericht|übersicht|uebersicht|zustand|gesamtzustand)', low) and re.search(r'(homelab|setup|infrastruktur|services|dienste|server|host|system)', low): analysis.update(role='ops', task_type='status', tools=['setup_lookup'], needs_setup_context=True, confidence=0.95) return analysis # Fix NC-OPT: "kannst du mir [SERVICE] optimieren" -> setup_lookup (not web_research) if re.search(r'(kannst du mir|kannst du|hilf mir|unterstütze mich|unterstuetze mich).{0,30}(optimieren|verbessern|tunen|konfigurieren|einrichten|aufräumen|aufrauemen)', low) and any(s in low for s in ('nextcloud', 'immich', 'traefik', 'authentik', 'emby', 'unraid', 'proxmox', 'docker', 'nginx', 'tailscale', 'home assistant', 'homeassistant', 'kasm', 'pihole', 'adguard', 'wireguard', 'sonarr', 'radarr', 'lidarr', 'audiobookshelf')): analysis.update(role='ops', task_type='setup', tools=['setup_lookup'], needs_setup_context=True, confidence=0.92) return analysis # Fix POWER-OUTAGE: Stromausfall + Homelab/Server -> setup_lookup (Diagnose nötig) if re.search(r'(stromausfall|strom.*aus|blackout|strom.*weg|strom.*unterbrechung|strom.*wieder|nach.*strom)', low) and re.search(r'(homelab|server|dienste|services|nicht mehr|komplett|funktioniert|erreichbar|neustart|restart)', low): analysis.update(role='ops', task_type='diagnose', tools=['setup_lookup'], needs_setup_context=True, confidence=0.96) return analysis # Fix 13: Recipe/cooking questions covered by KB_RECIPES.md -> direct general_answer # Fix R1: Erweiterte Rezept-Patterns für bessere Abdeckung # Fix STAT4: past-tense food/activity queries → memory_profile (not recipe) if re.search(r'(was habe ich|was hatte ich|was.*ich.*(gemacht|getan|gelernt|gelesen|getrunken|gegessen|geschlafen|trainiert))', low) and re.search(r'(letzte woche|gestern|letzten|vorige woche|letztem monat|letztes jahr|heute morgen|heute frueh)', low): analysis.update(role='personal', task_type='memory', tools=['memory_profile'], needs_memory=True, confidence=0.89) return analysis RECIPE_PATTERNS_EXTENDED = RECIPE_PATTERNS + ( r'(gib mir.*rezept|gib.*rezept|rezept.*bitte|rezept fuer|rezept f.r)', r'(omeletten|omelette|omelett|eier.*kochen|eier.*braten)', r'(schnell.*kochen|einfach.*kochen|was.*kochen|was.*essen)', ) if any(re.search(p, low) for p in RECIPE_PATTERNS_EXTENDED) and not any(h in low for h in ('homelab', 'server', 'docker', 'container', 'menu', 'code', 'script', 'programm')) and not re.search(r'(normalerweise|gewoehnlich|meistens|morgens|abends|taeglich|stets)', low): analysis.update(role='personal', task_type='howto', tools=['personal_assist'], needs_memory=True, confidence=0.93) return analysis # Also catch broader food queries (Fix 7 restored) if re.search(r'(ideen|abendessen|mittagessen|fruehstueck|rezept|zutaten|kochen|backen|was essen|was koche|was esse|zum essen|schnelles essen)', low) and not any(h in low for h in ('homelab', 'server', 'docker', 'container', 'menu')) and not re.search(r'(normalerweise|gewoehnlich|meistens|oft|immer|morgens|abends|taeglich|stets|ueblicherweise|typisch)', low): analysis.update(role='personal', task_type='howto', tools=['personal_assist'], needs_memory=True, confidence=0.90) return analysis # Fix WEB-ENTITY: Definition/Entity questions that general-local-assist rejects -> web_research FIRST # These patterns must come BEFORE general_answer patterns to avoid ungrounded results # BUT: Skip if query matches KB patterns (KB_GENERAL or KB_SCIENCE) to avoid double-routing # Fix SETUP-PRE: Homelab/Setup-Queries müssen VOR WEB_ENTITY_PATTERN geprüft werden SETUP_PRE_PATTERN = r'(hoste ich|mein setup|meine infrastruktur|welche services|welche hosts|wo laeuft|welche hosts|welche services|oeffentlich|öffentlich|extern erreichbar|oeffentlich erreichbar|öffentlich erreichbar)' if re.search(SETUP_PRE_PATTERN, low) and re.search(r'(ich|mein|meine|mir)', low): analysis.update(role='ops', task_type='status', tools=['setup_lookup'], needs_setup_context=True, confidence=0.92) return analysis WEB_ENTITY_PATTERN = r'^(was ist|wer ist|wo ist|wo liegt|wo befindet sich|wie leben|wie lebt|wo leben|wo lebt|was essen|was frisst|wie gross ist|wie groß ist|wie alt wird|wie funktioniert)\b' if re.search(WEB_ENTITY_PATTERN, low): # Check if this is covered by KB patterns - if so, let KB patterns handle it kb_covered = False # Check against GENERAL_KB_PATTERNS (need to check before they're defined, so inline check) # Fix KB-COVERAGE: Erweiterte Liste deckt alle GENERAL_KB_PATTERNS ab kb_general_terms = ['docker', 'reverse proxy', 'tailscale', 'truenass', 'wetter', 'klima', 'photosynthese', 'lichtjahr', 'fleisch.*auftauen', 'waschmittel', 'kühlschrank', 'schnittwunde', 'wasser.*trinken', 'schlaf.*dauer', 'säulen.*system', 'etf', 'du.*sie.*deutsch', 'prozent', 'trinkgeld', 'kaffee.*kochen', 'tee.*zubereiten', 'eier.*kochen', 'pasta.*kochen', 'bohnen.*einweichen', 'reis.*kochen', 'avocado.*reif', 'brot.*aufbewahren', 'zitronen.*haltbar', 'butter.*haltbar', 'öl.*haltbar', 'honig.*haltbar', 'käse.*haltbar', 'zwiebeln.*haltbar', 'knoblauch.*haltbar', # Neue KB-General Begriffe (K1-K48) 'tage bis', 'stunden.*jahr', 'vitamin d', 'kopfschmerzen', 'gestresst', 'schweiz.*gegründet', 'http.*status', 'passwort.*ändern', 'python.*error', 'übersetze', 'buch.*empfehl', 'ip.*adresse', 'dns', 'vpn', 'router.*switch', 'makronaehrstoff', 'glykaemisch', 'keto', 'inflation', 'zinseszins', 'aktien.*etf', 'waschmaschine', 'kuehlschrank.*funktioniert', 'backpulver', 'jahreszeiten', 'biologisch.*oekologisch', 'oekosystem', 'hiit', 'krafttraining', 'ausdauer', 'oled', 'energie.*klasse', 'usb', 'sous.*vide', 'salzen', 'maillard', 'sarkasmus', 'gaslighting', 'dunning', 'confirmation.*bias', 'meteor', 'eis.*schwimmt', 'freizügigkeitskonto', 'ahv.*lücke', 'schaltjahr', 'gmt.*utc'] for term in kb_general_terms: if re.search(term.replace('.*', '.*'), low): kb_covered = True break # Check against SCIENCE_KB_PATTERNS if not kb_covered: science_terms = ['rayleigh', 'lichtstreuung', 'himmel.*blau', 'mondfinsternis', 'blutmond', 'mondtäuschung', 'mond.*grösser', 'mond.*größer'] for term in science_terms: if re.search(term, low): kb_covered = True break if not kb_covered: # Tech architecture topics -> expert_code (not web_research) _tech_entity_topics = r'(microservice|event.driven|cqrs|saga.pattern|message.queue|rabbitmq|kafka|kubernetes|k8s|terraform|ansible|design.pattern|dependency.injection|solid.prinzip|clean.architektur|hexagonal|rest.*graphql|graphql.*rest|websocket|grpc|jwt.*flow|oauth.*flow|openid.*flow|ci.cd|gitflow|feature.flag|blue.green|canary.deploy|serverless|nosql.*sql|sharding|datenbank.*indexing|orm.*raw.*sql|async.*await|threading.*async|race.condition|deadlock|idempotent|eventual.consistency|cap.theorem|domain.driven|ddd|bounded.context)' if re.search(_tech_entity_topics, low): analysis.update(role='dev', task_type='explain', tools=['qdrant_search', 'expert_code'], confidence=0.89) return analysis analysis.update(role='research', task_type='explain', tools=['web_research'], needs_web_research=True, confidence=0.85) return analysis # Fix 10: Science questions covered by KB_SCIENCE_COMMON.md -> direct general_answer # Fix S1: Erweiterte Science-Patterns für Mondfinsternis, Sonnenfarbe SCIENCE_KB_PATTERNS_EXTENDED = SCIENCE_KB_PATTERNS + ( r'(mondfinsternis.*rot|mondfinsternis.*kupfer|roter mond|kupferfarbener mond|mond.*rot|mond.*kupfer)', r'(sonne.*rot|sonne.*orange|sonnenuntergang.*rot|sonnenuntergang.*orange|untergehende sonne|sonne.*untergang.*farbe)', r'(mond.*grösser|mond.*größer|mondtäuschung|mondtaeuschung|mond.*groß|mond.*gross)', r'(himmel.*blau|blauer himmel|warum.*himmel|licht.*himmel)', ) if any(re.search(p, low) for p in SCIENCE_KB_PATTERNS_EXTENDED): analysis.update(role='general', task_type='explain', tools=['general_answer'], needs_memory=False, confidence=0.95) return analysis # Fix KB-SCIENCE-NEW: Neue Science-Patterns für erweiterte KB_GENERAL_SCIENCE if any(re.search(p, low) for p in SCIENCE_NEW_PATTERNS): analysis.update(role='general', task_type='explain', tools=['general_answer'], needs_memory=False, confidence=0.95) return analysis # Fix 15: General knowledge questions covered by KB_GENERAL.md -> direct general_answer if any(re.search(p, low) for p in GENERAL_KB_PATTERNS): analysis.update(role='general', task_type='explain', tools=['general_answer'], needs_memory=False, confidence=0.95) return analysis # Fix KB-EVERYDAY: Alltagswissen-Fragen für KB_EVERYDAY_KNOWLEDGE if any(re.search(p, low) for p in EVERYDAY_KB_PATTERNS): analysis.update(role='general', task_type='explain', tools=['general_answer'], needs_memory=False, confidence=0.95) return analysis # Fix KB-HEALTH: Gesundheit & Wohlbefinden Fragen für KB_HEALTH_WELLNESS if any(re.search(p, low) for p in HEALTH_KB_PATTERNS): analysis.update(role='general', task_type='explain', tools=['general_answer'], needs_memory=False, confidence=0.95) return analysis # Fix KB-LANGUAGES: Sprach- & Übersetzungsfragen für KB_LANGUAGES LANGUAGE_KB_PATTERNS = ( r'(übersetze|uebersetze|auf englisch|auf deutsch|auf italienisch|auf französisch|wie sagt man|wie heisst|wie heißt)', r'(du.*sie.*deutsch|sie.*du.*deutsch|wann du.*wann sie|anrede.*deutsch|höflich.*anreden)', r'(grammatik|zeitformen|konjugation|deklinieren|tenses|present|past|future|perfect)', r'(false friends|falsche freunde|übersetzungsfehler|übersetzung.*fehler|false.*friend)', r'(sprache.*lernen|lernen.*sprache|vokabeln|aussprache|sprachniveau|cefr|a1|b1|c1.*sprache)', r'(email.*etikette|email.*betreff|schlussformel|grussformel|anrede.*email)', r'(smalltalk|konversation|kommunikation.*tip|gespräch.*führen|small.*talk)', r'(ciao.*bedeutung|buongiorno|bonjour.*bedeutung|grüße.*italienisch|grüße.*französisch)', ) if any(re.search(p, low) for p in LANGUAGE_KB_PATTERNS): analysis.update(role='general', task_type='explain', tools=['general_answer'], needs_memory=False, confidence=0.95) return analysis # Fix KB-FINANCE: Finanz- & Geldanlagefragen für KB_FINANCE FINANCE_KB_PATTERNS = ( r'\b(etf|etfs|aktienfond|indexfond|msci world|msci|sp 500|dax|nasdaq)\b', r'(wie.*anlegen|geld.*anlegen|anlage.*tip|wohin.*geld|sparen.*tip|budget.*tip)', r'(tagesgeld|festgeld|sparbuch|zinsen|zinssatz|konto.*gebühr|bank.*gebühr)', r'(steuerklasse|abgeltungssteuer|freistellungsauftrag|steuer.*freibetrag|kapitalertrag)', r'(versicherung.*tip|haftpflicht|hausrat|berufsunfähigkeit|rechtschutz)', r'(notgroschen|notfall.*geld|3 monatsgehälter|6 monatsgehälter)', r'(bitcoin|ethereum|krypto|cryptowährung|wallet|blockchain|coin)', r'(depot.*eröffnen|broker.*vergleich|neo.*broker|trade republic|scalable)', r'(50/30/20|fünfzig dreißig zwanzig|budget.*regel|sparrate)', ) if any(re.search(p, low) for p in FINANCE_KB_PATTERNS): analysis.update(role='general', task_type='explain', tools=['general_answer'], needs_memory=False, confidence=0.95) return analysis # Fix KB-LEGAL: Recht & Verbraucherfragen für KB_LEGAL_CONSUMER LEGAL_KB_PATTERNS = ( r'(kündigungsfrist|kündigen|vertrag.*kündigen|kündigung)', r'(widerrufsrecht|rückgabe|umtausch|14 tage|14 tage.*recht)', r'(gewährleistung|garantie|reklamation|mängel|mangel)', r'(mietrecht|mieterhöhung|kaution|miete.*recht)', r'(arbeitsrecht|urlaub.*recht|kündigungsschutz|arbeitsvertrag)', r'(bußgeld|bussgeld|punkte.*führerschein|führerschein.*punkte|verkehrsrecht)', r'(dsgvo|datenschutz|auskunft.*daten|recht.*vergessen)', r'(erbrecht|testament|pflichtteil|erbe.*recht)', r'(verbraucherrecht|mängelrüge|schadensersatz|verbraucher)', ) if any(re.search(p, low) for p in LEGAL_KB_PATTERNS): analysis.update(role='general', task_type='explain', tools=['general_answer'], needs_memory=False, confidence=0.95) return analysis # Fix KB-TRAVEL: Reise & Transport Fragen für KB_TRAVEL_TRANSPORT.md TRAVEL_KB_PATTERNS = ( r'(bahn|zug|db|sparbpreis|bahncard|quer-durchs-land|deutschlandticket|verspätung|anschluss|zug.*verpasst)', r'(handgepäck|handgepaeck|flüssigkeit|fluessigkeit|packen|koffer|reiseapotheke|reise.*packen)', r'(flug|fluggastrecht|eu261|überbuchung|ueberbuchung|annullierung|flug.*annulliert|flug.*verspätet)', r'(mietwagen|auto.*mieten|vignette|umweltzone|crit.air|critair|grüne.*plakette|gruene.*plakette)', r'(flixbus|fernbus|bus.*fahren|bus.*reise)', r'(reise.*buchen|urlaub.*buchen|beste.*buchungszeit|wann.*buchen|früh.*buchen|frueh.*buchen)', r'(reisedokument|reisepass|personalausweis|visum|einreise|impfung|reise.*versicherung)', ) if any(re.search(p, low) for p in TRAVEL_KB_PATTERNS): analysis.update(role='general', task_type='explain', tools=['general_answer'], needs_memory=False, confidence=0.95) return analysis # Fix KB-COOKING: Kochtechniken & Küchenwissen für KB_COOKING_TECHNIQUES COOKING_KB_PATTERNS = ( r'(braten|schmoren|dünsten|duensten|blanchieren|sous.*vide|garen|garzeit|gar.*zeit)', r'(julienne|brunoise|würfeln|wuerfeln|schneiden|hacken|messer.*schärfen|messer.*schaerfen)', r'(mehlschwitze|roux|binden.*sauce|reduzieren.*sauce|montieren.*butter)', r'(kerntemperatur|kern.*temperatur|fleisch.*temperatur|braten.*grad|steak.*medium)', r'(marinieren|marinade|fleisch.*marinieren|fleisch.*würzen|fleisch.*wuerzen)', r'(hefeteig|rührteig|biskuitteig|mürbeteig|blätterteig|gären|gaeren|backen)', r'(mise en place|zubereiten|kochtechnik|küchenwissen|kuechenwissen)', r'(sauce.*machen|sosse.*machen|fond.*machen|braten.*anbraten)', r'(gemüse.*kochen|gemuese.*kochen|gemüse.*garen|gemuese.*garen|blanchieren)', r'(fleisch.*ruhen|fleisch.*ruhen.*lassen|braten.*ruhen)', ) if any(re.search(p, low) for p in COOKING_KB_PATTERNS): analysis.update(role='general', task_type='explain', tools=['general_answer'], needs_memory=False, confidence=0.95) return analysis # Fix KB-PETS: Haustiere & Tierhaltung Fragen für KB_PETS_ANIMALS.md PETS_KB_PATTERNS = ( r'\b(hund|hunde|welpe|welpen|rasse|hunderasse|hunderassen|hundeerziehung|hundetraining)\b', r'\b(katze|katzen|kitten|kastration|katzenklo|kratzbaum|freigänger|freigaenger)\b', r'\b(haustier|haustiere|tierhaltung|tierarzt|tiergesundheit|impfung.*tier|entwurmung)\b', r'(kaninchen|meerschweinchen|hamster|maus|maeuse|vogel|voegel|wellensittich|aquarium|fisch|fische)', r'(hundefutter|katzenfutter|tierfutter|barf|trockenfutter|nassfutter|futter)', r'(hund.*temperament|katze.*charakter|welpe.*erziehen|hund.*trainieren)', r'(giftig.*hund|giftig.*katze|schokolade.*hund|schokolade.*katze|lilien.*katze)', r'(zecken|zeckenschutz|floh|flöhe|flöhe|entwurmung|impfung.*hund|impfung.*katze)', r'(hund.*auslauf|katze.*auslauf|gassi|spazieren|hund.*bewegung)', r'(aquarium.*einrichten|aquarium.*wasserwerte|fische.*haltung|aquaristik)', r'(tierverhalten|hundesprache|katzensprache|hund.*kommunikation|katze.*kommunikation)', r'(hundesteuer|hundepflichtversicherung|tier.*versicherung|chip.*tier)', ) if any(re.search(p, low) for p in PETS_KB_PATTERNS): analysis.update(role='general', task_type='explain', tools=['general_answer'], needs_memory=False, confidence=0.95) return analysis # Fix KB-AUTO: Auto & Mobilität Fragen für KB_AUTOMOBILITY.md if any(re.search(p, low) for p in AUTO_KB_PATTERNS): analysis.update(role='general', task_type='explain', tools=['general_answer'], needs_memory=False, confidence=0.95) return analysis # Fix KB-HOUSE-GARDEN: Haus & Garten Wissen für KB_HOUSE_GARDEN.md HOUSE_GARDEN_PATTERNS = ( r'\b(monstera|sansevieria|ficus|calathea|zamioculcas|pflanze|pflanzen|zimmerpflanze|zimmerpflanzen)\b', r'\b(sukkulente|sukkulenten|kakteen|kaktus)\b', r'\b(giessen|giessen|giess|gieß|gieße|wasser.*pflanze|pflanze.*wasser)\b', r'\b(umtopfen|neuer.*topf|topf.*wechseln|pflanze.*umtopfen)\b', r'\b(garten|beet|rasen|mähen|maehen|rasen.*mähen|rasen.*maehen)\b', r'\b(tomaten|gemüse|gemuese|kräuter|kraeuter|hochbeet|aussaat|ernte)\b', r'\b(unkraut|schädling|schaedling|blattlaus|schnecke|mehltau|krankheit.*pflanze)\b', r'\b(kompost|kompostieren|komposthaufen|komposterde)\b', r'\b(gartengerät|gartengeraet|spaten|schere|schaufel|rechen|hacke)\b', r'\b(frühling|fruehling|sommer|herbst|winter|saison|jahreszeit.*garten)\b', r'\b(streichen|wand.*streichen|farbe.*wand|anstrich|renovieren)\b', r'\b(löcher.*wand|loch.*wand|wand.*loch|reparieren.*wand|dübel|duebel)\b', r'\b(wasserhahn|hahn.*tropft|tropfender.*hahn|armatur.*reparieren)\b', r'\b(drainage|topf.*drainage|pflanze.*drainage|wurzelfäule|wurzelfaeule)\b', r'\b(lichtbedarf|sonne.*pflanze|pflanze.*sonne|halbschatten|schattenpflanze)\b', r'\b(garzeit|kochzeit|nudeln.*kochen|reis.*kochen|kartoffeln.*kochen|eier.*kochen)\b', r'\b(fleisch.*temperatur|kern.*temperatur|braten.*grad|steak.*medium)\b', r'\b(haltbarkeit|wie lange.*haltbar|kühlschrank.*tage|aufbewahren)\b', r'\b(flecken.*entfernen|rotwein.*fleck|kaffee.*fleck|fett.*entfernen|blut.*entfernen)\b', r'\b(putzen.*tip|reinigung.*tip|hausputz|sauber.*machen|mikrofaser)\b', ) if any(re.search(p, low) for p in HOUSE_GARDEN_PATTERNS) and not any(s in low for s in ('homelab', 'server', 'docker', 'container', 'service', 'host', 'setup', 'infrastruktur', 'nextcloud', 'traefik', 'proxmox', 'unraid')): analysis.update(role='general', task_type='explain', tools=['general_answer'], needs_memory=False, confidence=0.95) return analysis # Fix KB-SPORT-FITNESS: Sport, Fitness & Bewegung für KB_SPORT_FITNESS.md # WICHTIG: Patterns müssen spezifisch sein - keine Planungs- oder Homelab-Queries matchen SPORT_FITNESS_PATTERNS = ( # Spezifische Sportarten/Disziplinen (nicht "sport" allein) r'\b(krafttraining|cardio|ausdauertraining)\b', r'\b(laufen\b|joggen\b|radfahren\b|schwimmen\b|marathon\b|triathlon\b|ironman\b)(?!\s*sollten|\s*soll|\s*werden|\s*können|\s*koennen)', r'\b(yoga\b|pilates\b|crossfit\b|calisthenics\b|bodybuilding\b|powerlifting\b)', r'\b(kampfsport\b|boxen\b|kickboxen\b|muay thai\b|bjj\b|judo\b|mma\b|wrestling\b)', r'\b(teamsport\b|fussball\b|basketball\b|volleyball\b|handball\b|eishockey\b)', # Spezifische Übungen/Equipment r'\b(kniebeuge\b|bankdrücken\b|kreuzheben\b|deadlift\b|squat\b|bench press\b)', r'\b(klimmzug\b|liegestütz\b|liegestuetz\b|pushup\b|pullup\b|muscle up\b|plank\b)', r'\b(hantel\b|hanteln\b|kettlebell\b|widerstandsband\b|resistance band\b)', r'\b(laufschuhe|sport.*schuhe|schuhe.*laufen)', # Trainingskonzepte (nicht "training" allein) r'\b(workout\b|trainingsplan\b|trainingsplanung\b|progressive überlastung)', r'\b(wiederholungen\b|sätze\b|satz\b|satz.*zahl|wiederholungszahl)', r'\b(herzfrequenz\b|puls\b|puls.*zone|zone 2|zone 3|hiit\b|liss\b)', r'\b(aufwaermen|aufwärmen|mobilität|dehnübung|dehnuebung)', # Ernährung spezifisch für Sport r'\b(protein.*muskel|muskel.*protein|kreatin\b|supplement\b)', r'\b(ernährung.*sport|sport.*ernährung|fitness.*ernährung)', # Verletzungen/Regeneration r'\b(verletzung.*sport|sport.*verletzung|muskelkater\b|regeneration\b)', r'\b(schlaf.*muskel|muskel.*schlaf|muskel.*wachstum)', ) # Nur matchen wenn KEINE Planungs- oder Homelab-Keywords vorhanden if any(re.search(p, low) for p in SPORT_FITNESS_PATTERNS): if not any(s in low for s in ('homelab', 'server', 'docker', 'container', 'service', 'host', 'setup', 'infrastruktur', 'plane', 'planen', 'woche', 'monat', 'januar', 'februar', 'märz', 'april', 'mai', 'juni', 'juli', 'august', 'september', 'oktober', 'november', 'dezember')): analysis.update(role='general', task_type='explain', tools=['general_answer'], needs_memory=False, confidence=0.95) return analysis # Fix DNS-PROBLEM: DNS-Auflösungsprobleme im Homelab → setup_lookup (not general_answer) if re.search(r'(dns|dns.*aufloesung|dns.*auflösung|domain.*nicht|namensauflösung)', low) and re.search(r'(funktioniert.*nicht|problem|fehler|nicht.*erreichbar|timeout)', low): analysis.update(role='ops', task_type='diagnose', tools=['setup_lookup'], needs_setup_context=True, confidence=0.94) return analysis # Fix KB-EVERYDAY-TECH: Alltägliche Technik & Gadgets für KB_EVERYDAY_TECH.md EVERYDAY_TECH_PATTERNS = ( r'\b(iphone|android|smartphone|handys?|tablets?|ipad)\b', r'\b(speicher voll|akku|batterie|laden|ladekabel|face id|touch id|fingerabdruck)\b', r'\b(screenshot|bildschirmfoto|drucken|tastenkürzel|shortcut)\b', r'\b(passwort|passwort-manager|2fa|zwei-faktor|sicher)\b', r'\b(phishing|spam|betrug|virus|malware|schutz)\b', r'\b(wlan|wifi|internet|router|verbindung|netzwerk)\b', r'\b(bluetooth|kopfhörer|airpods|lautsprecher|boxen)\b', r'\b(streaming|netflix|spotify|qualität|daten|auflösung)\b', r'\b(drucker|scanner|drucken|tinte|laser)\b', r'\b(e-mail|email|anhang|mail|posteingang)\b', r'\b(whatsapp|signal|telegram|messenger|chat)\b', r'\b(update|software|app|programm|installieren|deinstallieren)\b', r'\b(cloud|icloud|google drive|dropbox|backup|speichern)\b', r'\b(usb|kabel|ladegerät|adapter|hdmi|displayport)\b', r'\b(airplay|chromecast|bildschirm.*spiegeln|screen mirroring)\b', r'\b(alexa|google assistant|siri|smart home|smarte steckdose)\b', r'\b(thermostat|heizung.*steuern|smart.*heizung)\b', r'\b(fotos?|bilder|videos?|galerie|kamera)\b', r'\b(datei.*größe|kb|mb|gb|kilobyte|megabyte|gigabyte)\b', r'\b(energie.*klasse|stromverbrauch|verbrauch.*gerät)\b', r'\b(oled|qled|led|fernseher|tv|bildschirm|monitor)\b', r'\b(pdf|word|dokument|datei.*konvertieren|konvertieren)\b', r'\b(pomodoro|produktiv|zeitmanagement|zeit.*managen)\b', ) if any(re.search(p, low) for p in EVERYDAY_TECH_PATTERNS) and not any(s in low for s in ('homelab', 'server', 'docker', 'container', 'service', 'host', 'setup', 'infrastruktur', 'nextcloud', 'traefik', 'proxmox', 'unraid')) and not re.search(r'(skalier|von.*auf.*user|performance.*app|bottleneck|load.balanc)', low) and not re.search(r'(email.*(chef|vorgesetzt|manager|leitung|gehal|bonus)|wegen.*gehal)', low): analysis.update(role='general', task_type='explain', tools=['general_answer'], needs_memory=False, confidence=0.95) return analysis # Fix KB-EVERYDAY-PROBLEMS: Alltags-Probleme & Life Hacks für KB_EVERYDAY_PROBLEMS.md EVERYDAY_PROBLEMS_PATTERNS = ( r'(smartphone.*lädt|handy.*lädt|lädt.*nicht|ladeproblem|ladekabel|akku.*problem)', r'(laptop.*heiss|laptop.*laut|laptop.*heiß|lüfter.*laut|cpu.*100)', r'(wlan.*langsam|internet.*langsam|wifi.*langsam|verbindung.*schlecht)', r'(bluetooth.*koppelt|koppelt.*nicht|bluetooth.*problem)', r'(fleck.*entfernen|flecken.*entfernen|kaffeefleck|rotweinfleck|fettfleck)', r'(abfluss.*verstopft|verstopft.*abfluss|rohr.*verstopft|abfluss.*reinigen)', r'(mikrowelle.*reinigen|mikrowelle.*sauber|mikrowelle.*putzen)', r'(kühlschrank.*stinkt|kühlschrank.*geruch|kühlschrank.*muffig)', r'(kleidung.*trocknen|schnell.*trocknen|nass.*trocknen)', r'(schuhe.*trocknen|schuhe.*nass|schuhe.*feucht)', r'(reißverschluss.*klemmt|reißverschluss.*hakt|zipper.*klemmt)', r'(messer.*schärfen|messer.*scharf|stumpf.*messer)', r'(wein.*öffnen|korkenzieher|flasche.*öffnen|weinflasche)', r'(eier.*schälen|eier.*pellen|schälen.*eier)', r'(knoblauch.*hände|knoblauchgeruch|geruch.*hände)', r'(avocado.*reif|avocado.*reifen|avocado.*schneller)', r'(einschlafen|nicht.*müde|schlafen.*nicht|schlaf.*problem)', r'(sonnenbrand|verbrannt|haut.*rot|sonne.*verbrannt)', r'(schnupfen|erkältet|erkältung|krank|husten|kopfschmerzen)', r'(stich|insekt|mückenstich|bienenstich|juckt|jucken)', r'(batterie.*tot|auto.*springt|starten.*nicht|starthilfe)', r'(koffer.*packen|packen.*koffer|effizient.*packen)', r'(jetlag|zeitverschiebung|müde.*reise|schlaf.*reise)', r'(papierstau|drucker.*stau|drucker.*problem)', r'(passwort.*vergessen|einloggen.*nicht|zugang.*vergessen)', r'(life.*hack|lifehack|trick|tipp|hilfe|funktioniert.*nicht|geht.*nicht|kaputt)', ) if any(re.search(p, low) for p in EVERYDAY_PROBLEMS_PATTERNS) and not any(s in low for s in ('homelab', 'server', 'docker', 'container', 'service', 'host', 'setup', 'infrastruktur', 'nextcloud', 'traefik', 'proxmox', 'unraid')): analysis.update(role='general', task_type='explain', tools=['general_answer'], needs_memory=False, confidence=0.95) return analysis # Fix KB-HOWTO-EXPLAIN: Alltags-Erklärungen & How-Tos für KB_HOWTO_EXPLAIN.md HOWTO_EXPLAIN_PATTERNS = ( r'(wie sage ich|wie lehne ich|wie gebe ich.*feedback|wie sage ich.*zu spät)', r'(wie lehne ich.*ab|höflich.*ablehnen|ablehnen.*einladung)', r'(konstruktives feedback|feedback.*geben|kritik.*äußern)', r'(wie putze ich|badezimmer.*putzen|putzen.*badezimmer)', r'(wie wasche ich.*bettwäsche|bettwäsche.*waschen)', r'(wie entferne ich.*kalk|kalk.*entfernen|kalk.*armatur)', r'(wie lagere ich.*gemüse|gemüse.*lagern|gemüse.*kühlschrank)', r'(wie erstelle ich.*passwort|sicheres passwort|passwort.*erstellen)', r'(wie sichere ich.*daten|daten.*sichern|backup.*regel)', r'(wie erkenne ich.*phishing|phishing.*erkennen|phishing.*mail)', r'(wie gehe ich.*schlafstörung|schlafstörung.*umgehen|schlafhygiene)', r'(wie trinke ich.*mehr wasser|mehr wasser.*trinken)', r'(wie mache ich.*pause|effektive pause|pomodoro)', r'(wie erstelle ich.*haushaltsplan|haushaltsplan.*erstellen|50/30/20)', r'(wie sortiere ich.*unterlagen|unterlagen.*sortieren|wes-system)', r'(schlafhygiene|einschlafen|schlaf.*tip|besser.*schlafen)', r'(trinken.*wasser|wasser.*trinken|mehr.*wasser)', r'(haushaltsplan|budget.*plan|finanz.*plan)', r'(unterlagen.*sortieren|dokumente.*sortieren|papier.*sortieren)', ) if any(re.search(p, low) for p in HOWTO_EXPLAIN_PATTERNS) and not any(s in low for s in ('homelab', 'server', 'docker', 'container', 'service', 'host', 'setup', 'infrastruktur', 'nextcloud', 'traefik', 'proxmox', 'unraid')): analysis.update(role='general', task_type='explain', tools=['general_answer'], needs_memory=False, confidence=0.95) return analysis # Fix STAT: Personal tracking/stats queries → memory_profile # Fix STAT-BOOKS: "wie viele buecher habe ich..." Pattern if re.search(r'(wie viel.*(km|kilometer).*ich|\bkm\b.*gelaufen|laufstatistik|wie oft.*gelaufen|' r'schlafzeiten|schlafdauer|wann.*geschlafen|letzte.*schlaf|wie lange.*geschlafen|' r'trainingsstatistik|zeig.*meine.*statistik|meine.*trainings|wie oft.*trainiert|' r'wie viele.*buecher.*(ich|habe|gelesen)|buecher.*(ich|habe).*(gelesen|dieses jahr|letztes jahr)|gelesen.*dieses jahr|' r'meine.*lekture|wie viele.*(ich|habe).*(gelesen|geschrieben|gemacht|getan).*(jahr|monat))', low): analysis.update(role='personal', task_type='memory', tools=['memory_profile'], needs_memory=True, confidence=0.89) return analysis # Fix STAT-PASTTENSE: what did I do/eat last week → memory_profile (not recipe) if re.search(r'(was habe ich.*(letzte woche|gestern|letzten|vorige woche|letztem monat).*(gegessen|getrunken|gemacht|getan|gelernt)|' r'was.*ich.*(letzten|letzte woche|gestern).*(gegessen|getrunken|gemacht|getan))', low): analysis.update(role='personal', task_type='memory', tools=['memory_profile'], needs_memory=True, confidence=0.89) return analysis # Fix 1: Personal preference questions -> memory_profile # Fix PREF-BOOKS: "was ist mein lieblingsbuch" Pattern if re.search(r'(was esse ich|was trinke ich|was hore ich|was lese ich|was schaue ich)', low) and re.search(r'(normalerweise|gewoehnlich|meistens|oft|immer|morgens|abends|taeglich|stets|ueblicherweise|typisch)', low): analysis.update(role='personal', task_type='memory', tools=['memory_profile'], needs_memory=True, confidence=0.91) return analysis if re.search(r'\b(welche art|welche|was fuer|was fur|wie viel|wieviel|welchen|am liebsten)\b', low) and re.search(r'\b(ich|mein|meine|mir)\b', low) and re.search(r'\b(mag|bevorzuge|trinke|esse|hore|hoere|lese|moechte|mochte|vorliebe|gewohnheit|kaffee|tee|musik|buch|buecher|filme|sport|essen|gericht|liebsten|lieber)\b', low): analysis.update(role='personal', task_type='memory', tools=['memory_profile'], needs_memory=True, confidence=0.9) return analysis # Fix 17: Personal location + known places → memory_profile if re.search(r"(sitterdorf|thurgau.*erlen|erlen.*thurgau)", low) and not re.search(r"(komme ich|fahrt|strecke|route|bahn|zug|bus|wie.*nach|von.*nach|entfernung|navigation)", low): analysis.update(role='personal', task_type='memory', tools=['memory_profile'], needs_memory=True, confidence=0.93) return analysis if re.search(r'(wo wohne ich|wo lebe ich|meine adresse|mein wohnort|wo wohn|wo leb)', low) and re.search(r'\b(ich|mir|mein|wir)\b', low): analysis.update(role='personal', task_type='memory', tools=['memory_profile'], needs_memory=True, confidence=0.92) return analysis # ── TECH-EXPLAIN INTERCEPT ──────────────────────────────────────────────── # Tech-Themen bei erklaer/wie funktioniert -> expert_code (nicht general_answer) _tech_explain_topics = r'(microservice|event.driven|cqrs|saga.pattern|message.queue|rabbitmq|kafka|kubernetes|k8s|terraform|ansible|grafana|prometheus|design.pattern|dependency.injection|solid.prinzip|clean.architektur|hexagonal|rest.*graphql|graphql.*rest|websocket|grpc|jwt.*flow|oauth.*flow|openid.*flow|ci.cd.*pipeline|gitflow|feature.flag|a.b.testing|blue.green|canary.deploy|serverless|lambda.funktion|nosql.*sql|sharding|indexing.*datenbank|orm.*raw.*sql|async.*await|promise.*callback|threading.*async|race.condition|deadlock|idempotent|eventual.consistency|cap.theorem)' if re.search(r'^(erklaer|erklaere|wie funktioniert|wie funk|was ist der unterschied|unterschied zwischen|was sind die vor|vor.*und nachteil)', low) and re.search(_tech_explain_topics, low): analysis.update(role='dev', task_type='explain', tools=['qdrant_search', 'expert_code'], confidence=0.89) return analysis # SKALIER INTERCEPT: Skalierungs- und App-Design-Fragen -> expert_strategy+code if re.search(r'(wie skalier|wie.*von.*auf.*user|wie.*app.*skalier|horizontal.*skalier|vertical.*skalier|load.*balanc|wie.*performance.*verbessern.*app|wie.*app.*schneller|bottleneck.*app|database.*skalier)', low) and not any(s in low for s in ('homelab', 'nextcloud', 'emby', 'traefik')): analysis.update(role='dev', task_type='explain', tools=['expert_strategy'], confidence=0.88) return analysis if re.search(r'^(warum|weshalb|wieso|wie entsteht|wie funktioniert|erklaer|erklaere)\b', low) and not any(h in low for h in ('server', 'host', 'docker', 'kasm', 'ssl', 'cert', 'dns', 'traefik', 'nginx', 'container', 'homelab', 'setup', 'infrastruktur', 'authentik', 'tailscale', 'proxmox', 'unraid', 'linux', 'nextcloud', 'immich', 'emby', 'lidarr', 'radarr', 'sonarr', 'audiobookshelf')): analysis.update(role='general', task_type='explain', tools=['general_answer'], needs_memory=False, confidence=0.78) return analysis # Fix DOCKER-MAINT-2: docker maintenance/cleanup → setup_lookup (MUST come before JARVIS patterns) if re.search(r'(docker|container)', low) and re.search(r'(aufraeumen|aufraumen|prune|bereinigen|cleanup|clean.*up)', low): analysis.update(role='ops', task_type='setup', tools=['setup_lookup'], needs_setup_context=True, confidence=0.94) return analysis # CONV-EXEC: Container action commands (restart/stop/start) → ops_exec if re.search(r'(neu starten|neustart|restart|stoppen|starten|kill|beenden|stop|start).{0,30}(container|docker|service)', low) or re.search(r'(container|docker|service).{0,30}(neu starten|neustart|restart|stoppen|starten)', low): analysis.update(role='ops', task_type='action', tools=['ops_exec'], needs_setup_context=False, confidence=0.91) return analysis # Fix DOCKER-ROUTING-2: Docker/Container Inventar-Queries → setup_lookup (not ops_api) # "zeig mir alle docker container", "docker list", "welche container laufen" if re.search(r'(zeig mir|zeige mir|zeig|zeige|liste|list|alle|welche).{0,15}(docker|container)', low) and re.search(r'(docker|container|laufen|vorhanden|installiert|hoste)', low): analysis.update(role='ops', task_type='status', tools=['setup_lookup'], needs_setup_context=True, confidence=0.94) return analysis # Fix DOCKER-ROUTING-3: Docker compose/maintenance → setup_lookup if re.search(r'(docker|compose).{0,20}(update|upgrade|aktualisieren|durchfuehren|ausfuehren)', low): analysis.update(role='ops', task_type='setup', tools=['setup_lookup'], needs_setup_context=True, confidence=0.93) return analysis # Fix NC-ROUTING: "kannst du [SERVICE] prüfen" → setup_lookup (Inventar/Config, nicht Live-API) # WICHTIG: prufen (mit u) und pruefen (mit ue) beide abdecken if re.search(r'(kannst du|koenntest du).{0,30}(prufen|prüfen|pruefen|checken|testen|ueberpruefen|ueberprüfen|uberprufen|überprüfen)', low) and any(s in low for s in ('nextcloud', 'immich', 'traefik', 'authentik', 'emby', 'unraid', 'proxmox', 'docker', 'nginx', 'tailscale', 'home assistant', 'homeassistant', 'kasm', 'pihole', 'adguard', 'wireguard', 'sonarr', 'radarr', 'lidarr', 'audiobookshelf')): analysis.update(role='ops', task_type='setup', tools=['setup_lookup'], needs_setup_context=True, confidence=0.92) return analysis # CONV-OPS: Conversational queries with embedded ops commands → ops_api # "kannst du mal schauen ob container laufen" / "was läuft auf dem server" if re.search(r'(kannst du|koenntest du|magst du|bitte|koennte man|schau mal|prüf mal|pruef mal|check mal|zeig mir|zeige mir|schau ob|pruef ob|check ob)', low) and re.search(r'(container|docker|server|services?|homelab|nextcloud|sonarr|radarr|emby|vaultwarden|pihole|ollama|traefik)', low): analysis.update(role='ops', task_type='status', tools=['ops_api'], needs_setup_context=False, confidence=0.90) return analysis # CONV-RUNNING: "was läuft (gerade|auf)" → ops_api live status if re.search(r'(was laueft|was läuft|was laeuft|wer laueft|wer läuft).{0,30}(server|docker|container|homelab|gerade|tower|ubuntu)', low) or re.search(r'(server|homelab|docker).{0,20}(was laueft|was läuft|was laeuft|aktiv|online|running|up|stehen)', low): analysis.update(role='ops', task_type='status', tools=['ops_api'], needs_setup_context=False, confidence=0.90) return analysis # CONV-HOWIS: "wie geht es dem/der [service]" → ops_api live check if re.search(r'(wie geht es|wie steht es|wie ist es um).{0,20}(container|docker|nextcloud|sonarr|radarr|emby|vaultwarden|pihole|ollama|traefik|postgresql|service|server)', low): analysis.update(role='ops', task_type='status', tools=['ops_api'], needs_setup_context=False, confidence=0.89) return analysis # CONV-STATUS: "wie läuft/steht es mit" specific services → ops_api if re.search(r'(wie laueft|wie läuft|wie steht).{0,30}(nextcloud|sonarr|radarr|emby|vaultwarden|pihole|ollama|traefik|docker|container)', low): analysis.update(role='ops', task_type='status', tools=['ops_api'], needs_setup_context=False, confidence=0.89) return analysis # JARVIS: complex diagnose+fix tasks → ops_deep_analyze directly (it already does infra checks) if re.search(r'(unhealthy|kaputt|defekt|fehler|exited|problem).{0,50}(container|service|docker)', low) and \ re.search(r'(wie|was|fix|reparier|beheb|loesung|was soll|wie kann|wie loes|was tun|schritte|vorgehen)', low): analysis.update(role='ops', task_type='diagnose', tools=['ops_deep_analyze'], needs_setup_context=False, confidence=0.94) return analysis # JARVIS: explicit deep / root-cause analysis → ops_deep_analyze if re.search(r'(tiefe.*analyse|deep.*analyse|vollstaendig.*analysier|root.*cause|ursache.*finden|warum.*kaputt|warum.*fehler|detailliert.*analysier|grundursache)', low): analysis.update(role='ops', task_type='diagnose', tools=['ops_deep_analyze'], needs_setup_context=False, confidence=0.93) return analysis # JARVIS: unhealthy container diagnose → ops_deep_analyze for full log analysis if re.search(r'(unhealthy|health.*container|container.*health|kaputt.*container|container.*kaputt|defekt.*container)', low): analysis.update(role='ops', task_type='diagnose', tools=['ops_deep_analyze'], needs_setup_context=False, confidence=0.95) return analysis # JARVIS: docker RAM/memory/volume/disk analysis → ops_api if re.search(r'(container.*ram|container.*memory|container.*speicher|ram.*container|docker.*volume|docker.*disk|docker.*platz|volume.*groesse|volume.*size|docker.*speicher)', low): analysis.update(role='ops', task_type='diagnose', tools=['ops_api'], needs_setup_context=False, confidence=0.93) return analysis # JARVIS: TLS/cert check → ops_api if re.search(r'(tls.*zertifikat|zertifikat.*ablauf|cert.*ablauf|traefik.*tls|traefik.*cert|letsencrypt.*ablauf|acme.*check|https.*cert)', low): analysis.update(role='ops', task_type='diagnose', tools=['ops_api'], needs_setup_context=False, confidence=0.93) return analysis # JARVIS: security audit docker → ops_api if re.search(r'(security.*audit|docker.*sicherheit|docker.*audit|container.*sicherheit|privileged.*container|container.*root|sicherheitsaudit)', low): analysis.update(role='ops', task_type='diagnose', tools=['ops_api'], needs_setup_context=False, confidence=0.94) return analysis # JARVIS: pihole / DNS stats → ops_api if re.search(r'(pihole|pi-hole|pi hole|dns.*blockiert|blockierte.*domains|dns.*report|dns.*statistik)', low): analysis.update(role='ops', task_type='status', tools=['ops_api'], needs_setup_context=False, confidence=0.95) return analysis # JARVIS: postgresql / database → ops_api if re.search(r'(postgresql|postgres.*datenbank|datenbank.*groesse|db.*groesse|postgresql.*check|postgres.*query|datenbank.*report)', low): analysis.update(role='ops', task_type='status', tools=['ops_api'], needs_setup_context=False, confidence=0.94) return analysis # JARVIS: full homelab status report → public_services_health if re.search(r'(vollstaendiger.*statusbericht|homelab.*statusbericht|statusbericht.*homelab|homelab.*report|vollstaendiger.*bericht|alle.*services.*status)', low): analysis.update(role='ops', task_type='status', tools=['public_services_health'], needs_setup_context=True, confidence=0.93) return analysis # Fix DOCKER-INVENTORY: docker/container INVENTORY queries → setup_lookup (not ops_api) # These are "what do I have" questions, not "what's the current live status" if re.search(r'(docker|container)', low) and re.search(r'(zeig mir alle|alle docker|wie viele|wie viel|count|anzahl|liste aller|list all)', low): analysis.update(role='ops', task_type='status', tools=['setup_lookup'], needs_setup_context=True, confidence=0.92) return analysis # Fix 18 (upgraded): docker/container queries with live data → ops_api if re.search(r'(docker|container|pod|stack|compose)', low) and re.search(r'(zeig|status|liste|list|alle|laufen|running|starten|stoppen|restart|logs|pruef|check|unhealthy|health|ram|memory|speicher|volume|disk|platz|security|sicherheit|audit|privileged|zertifikat|cert|tls)', low): analysis.update(role='ops', task_type='status', tools=['ops_api'], needs_setup_context=False, confidence=0.91) return analysis # Fix T1: IP-Adresse / Netzwerk-Info Abfragen → setup_lookup if re.search(r'(ip adresse|ip-adresse|tailscale ip|tailscale-ip|meine ip|welche ip|zeig.*ip|lautet.*ip|ip.*tailscale)', low): analysis.update(role='ops', task_type='status', tools=['setup_lookup'], needs_setup_context=True, confidence=0.87) return analysis # Fix OPS-PROC: server process/monitoring queries -> setup_lookup if re.search(r'(ubuntu|server|host|proxmox|unraid|docker)', low) and re.search(r'(prozesse|laufende services|ps aux|top.*cpu|systemctl list|running.*services|logs.*anzeigen|zeige.*logs|logs.*zeigen)', low): analysis.update(role='ops', task_type='status', tools=['setup_lookup'], needs_setup_context=True, confidence=0.86) return analysis # AMB-SERVER-SLOW: personal + server slow → ops signal dominates if re.search(r'(server|proxmox|homelab|vm|container|service).*(langsam|traege|haengt|keine reaktion|hohe last|ueberlastet|cpu last|ram last|speicher last)', low) or re.search(r'(langsam|traege).*(server|proxmox|homelab|vm|container|service)', low): analysis.update(role='ops', task_type='diagnose', tools=['setup_lookup'], needs_setup_context=True, confidence=0.88) return analysis # Fix OPS-SETUP: homelab config/setup/install tasks → setup_lookup if any(s in low for s in ('proxmox', 'traefik', 'nextcloud', 'authentik', 'nginx', 'unraid', 'docker', 'ubuntu', 'tailscale', 'immich', 'homelab', 'sonarr', 'radarr', 'lidarr', 'audiobookshelf', 'emby', 'home assistant', 'homeassistant', 'kasm', 'wireguard', 'fail2ban', 'pihole', 'adguard')) and re.search(r'(einrichten|installieren|konfigurieren|aufsetzen|erstelle.*vm|neue.*vm|reverse proxy.*host|synchronisiere|deploye|setup|upgrade|migrieren|einbinden|integrieren|verbinden|kalibrieren|anpassen|profil.*erstellen|snapshot|auflisten|aufliste|sichern|restore|wiederherstellen|backup.*job|clonen|klonen)', low): analysis.update(role='ops', task_type='howto', tools=['setup_lookup'], needs_setup_context=True, confidence=0.87) return analysis # AMB-SECURITY-DOMINATES: homelab security signal dominates personal request if re.search(r'(homelab|server|services).*(sicher|security|absichern|geschuetzt|firewall|angriff)', low) or re.search(r'(sicher genug|ausreichend sicher|gut geschuetzt|sicherheit.*homelab|homelab.*sicherheit)', low): analysis.update(role='ops', task_type='setup', tools=['setup_lookup'], needs_setup_context=True, confidence=0.88) return analysis # SEC-INCIDENT: suspicious requests, brute force, intrusion detection if re.search(r'(komische|verdaechtige|seltsame|unbekannte|fremde).*(requests?|anfragen|ips?|zugriffe|verbindungen)', low) or re.search(r'(chinesische|russische|unbekannte).*(ips?|adresse|anfragen)|ip.*(china|russland|unknown)', low) or re.search(r'(\d+)\s*mal.*(erfolglos|fehlgeschlagen|versucht|failed).*(einlog|einzulog|anmeld|zugriff|login|loggen)', low) or re.search(r'(brute.?force|einbruchsversuch|hack.{0,20}versuch|angriff|intruder|eindringling)', low) or re.search(r'(moeglicher angriff|möglicher angriff|unter angriff|wird angegriffen|jemand.*(versucht|probiert).*(einlog|hack|zugriff))', low) or re.search(r'(viele.*(401|403|4\d\d).*(unbekannt|fremd|verdaechtig)|log.*verdaechtig|logs.*(angriff|hack))', low): analysis.update(role='ops', task_type='diagnose', tools=['setup_lookup'], needs_setup_context=True, confidence=0.91) return analysis # Fix SEC-HARDEN: security hardening queries → setup_lookup if re.search(r'(server|ssh|linux|ubuntu|proxmox|host|system|container|homelab).*(absichern|sichern|haerten|hartung|haertung|hardening|absicherung|security)', low) or re.search(r'(ssh|system|server|linux).*(hartung|haertung|haerten)\b', low) or re.search(r'\b(ssh|fail2ban|firewall|ufw|iptables).*(einrichten|konfigurieren?|absichern|aufsetzen)', low) or re.search(r'\b(konfiguriere?|einrichte?|richte.{0,10}ein).{0,20}\b(fail2ban|ufw|iptables|firewall|ssh)\b', low) or re.search(r'\b(fail2ban|ufw|iptables|firewall)\b.{0,80}\b(sperren|blockieren|bannen|gesperrt|so dass|damit)', low): analysis.update(role='ops', task_type='setup', tools=['setup_lookup'], needs_setup_context=True, confidence=0.87) return analysis # SET-INTEGRATION: "integriere X mit Y", n8n automation, webhooks if re.search(r'(integriere?|verbinde?|koppel?|synchro).{0,20}(home.?assistant|emby|nextcloud|sonarr|radarr|n8n|telegram|slack|webhook)', low) or re.search(r'(home.?assistant|emby|nextcloud|n8n).{0,30}(integriere?|verbinde?|koppel?)', low) or re.search(r'(n8n.{0,30}(webhook|trigger|workflow|automatisier|notification|benachrichtig))', low) or re.search(r'(automation.{0,30}(homelab|services)|homelab.{0,30}automation)', low): analysis.update(role='ops', task_type='setup', tools=['setup_lookup'], needs_setup_context=True, confidence=0.88) return analysis # Fix DOCK-MAINT: docker maintenance tasks → setup_lookup if re.search(r'\bdocker\b', low) and re.search(r'(volumes?.*aufraeumen|aufraeumen.*volumes?|prune|bereinigen|compose.*update|compose.*upgrade|alle.*container.*aktualisier|container.*log|logs.*container|images.*aufraeumen)', low): analysis.update(role='ops', task_type='setup', tools=['setup_lookup'], needs_setup_context=True, confidence=0.87) return analysis # CAS-STROMAUSFALL: nach stromausfall/neustart homelab nicht hochgekommen if re.search(r'(stromausfall|stromunterbrechung|nach dem neustart|nach neustart|nach.*(reboot|restart)).*(homelab|server|services|vms?|container)', low) or re.search(r'(homelab|server|services).*(nicht.*(hochgekommen|gestartet|oben|erreichbar)|down|ausgefallen).*(stromausfall|neustart|reboot)', low) or re.search(r'(stromausfall.*(homelab|server)|homelab.*(stromausfall|ausgefallen|nicht.*hoch))', low): analysis.update(role='ops', task_type='diagnose', tools=['setup_lookup'], needs_setup_context=True, confidence=0.90) return analysis # Fix ERR: Raw error message input → setup_lookup if re.search(r'(econnrefused|connection refused|\[emerg\]|fatal.*failed|permission denied.*publickey|port.*allocated|address.*already.*in.*use|bind\(\).*failed|error response from daemon)', low): analysis.update(role='ops', task_type='diagnose', tools=['setup_lookup'], needs_setup_context=True, confidence=0.87) return analysis # Fix OLLAMA: ollama/LLM server queries → setup_lookup if re.search(r'\bollama\b', low) or (re.search(r'\bllm\b|\bmodell\b', low) and re.search(r'(mac.?studio|mac.?mini|server|lauft|startet|laeuft|welches)', low)): analysis.update(role='ops', task_type='setup', tools=['setup_lookup'], needs_setup_context=True, confidence=0.87) return analysis # Fix OPS-CRASH: generic homelab/server crash → setup_lookup if re.search(r'(homelab|server|host|alle.*services|gesamte.*infrastruktur)', low) and re.search(r'(abgestuerzt|abgestuerzt|nicht mehr erreichbar|komplett down|alles down|crash|ausgefallen|ausgefallen)', low): analysis.update(role='ops', task_type='diagnose', tools=['setup_lookup'], needs_setup_context=True, confidence=0.89) return analysis # Fix MED-1: sonarr/radarr/lidarr content/indexer issues → setup_lookup if re.search(r'\b(sonarr|radarr|lidarr|audiobookshelf)\b', low) and re.search(r'(findet|steckt|haengt|fehler|problem|indexer|episod|serie|film|qualitaet|profil|download|hinzufuegen|nicht|konfigur)', low): analysis.update(role='ops', task_type='diagnose', tools=['setup_lookup'], needs_setup_context=True, confidence=0.87) return analysis # Fix 19: service status queries → setup_lookup if re.search(r'(status meiner|status der|meine.*status|services.*status|dienste.*status|welche.*laufen|alle.*services)', low) and not re.search(r'(licht|lampe|beleuchtung)', low): analysis.update(role='ops', task_type='status', tools=['setup_lookup'], needs_setup_context=True, confidence=0.87) return analysis # Fix NET: network diagnostic queries → setup_lookup if re.search(r'(\bport\b.*(nicht erreichbar|geschlossen|blockiert|offen|listen|filtered)|' r'\bping\b.*(fehl|timeout|keine antwort|schlaegt|schlagt)|' r'\btraceroute\b|\bdns\b.*(funktioniert nicht|aufloesung|resolution|fehl|problem)|' r'\bnslookup\b|\bdig\b.*(domain|dns)|' r'netzwerk.*problem|netzwerkfehler|verbindung.*timeout|connection.*timeout)', low): analysis.update(role='ops', task_type='diagnose', tools=['setup_lookup'], needs_setup_context=True, confidence=0.87) return analysis # Fix SSL: SSL/TLS/certbot issues → setup_lookup if re.search(r'(ssl.*(zertifikat|cert|ablaufen|abgelaufen|fehler|ungueltig|invalid|handshake)|' r'zertifikat.*(abgelaufen|ablaufen|ungueltig|fehler|erneuern)|' r'\bcertbot\b|lets.?encrypt|letsencrypt|\bacme\b)', low): analysis.update(role='ops', task_type='diagnose', tools=['setup_lookup'], needs_setup_context=True, confidence=0.87) return analysis # Fix 20: homelab service fault/check → setup_lookup if any(s in low for s in ('nextcloud', 'immich', 'traefik', 'authentik', 'emby', 'lidarr', 'radarr', 'sonarr', 'audiobookshelf', 'unraid', 'proxmox', 'docker', 'nginx', 'tailscale', 'home assistant', 'homeassistant', 'kasm', 'pihole', 'adguard', 'wireguard')) and re.search(r'(nicht|problem|fehler|kaputt|down|error|pruef|prufen|check|zeig|status|warum|erreichbar|langsam|performance|ram|cpu|speicher|bricht|abbricht|haengt|haengt sich|crash|absturz|abgestuerzt|friert ein|not working|broken|offline|stuck|failed|issue|sensor.*offline|offline.*sensor|schlaegt fehl|schlagt fehl|funktioniert nicht|sync.*fehl|fehl.*sync|ldap.*fehl|fehl.*ldap)', low): analysis.update(role='ops', task_type='diagnose', tools=['setup_lookup'], needs_setup_context=True, confidence=0.88) return analysis # Fix HA: Home Assistant queries → setup_lookup (entitaten, dashboard, automatisierung) if re.search(r'\bhome.?assistant\b|homeassistant', low) and not re.search(r'(\blicht\b|\blampe\b|beleuchtung|schalte.*licht|licht.*ein|licht.*aus)', low): analysis.update(role='ops', task_type='setup', tools=['setup_lookup'], needs_setup_context=True, confidence=0.87) return analysis # PERS-PLAN: personal plans (nutrition, fitness, vacation) → personal_assist if re.search(r'(ernaehrungsplan|ernaehrung.*plan|diaet.*plan|kalorien.*plan|mahlzeiten.*plan|essensplan)', low) or re.search(r'(fitness.*plan|trainingsplan|sport.*plan|lauf.*plan|workout.*plan)', low) or re.search(r'(4.?wochen.*(ernaehrung|abnehmen|sport|training)|wochen.*plan.*(essen|ernaehrung|sport))', low) or re.search(r'(ich will.{0,30}abnehmen|abnehm.*plan|gewicht.*verlieren|gewicht.*reduzier)', low): analysis.update(role='personal', task_type='assist', tools=['personal_assist'], needs_memory=True, confidence=0.90) return analysis # PERS-WRITE: career writing (Bewerbung, Lebenslauf) → personal_assist if re.search(r'(bewerbung|lebenslauf|anschreiben|motivationsschreiben|cover.?letter|\bcv\b|resume)', low) or re.search(r'(schreib|erstell).{0,30}(bewerbung|lebenslauf|anschreiben)', low): analysis.update(role='personal', task_type='assist', tools=['personal_assist'], needs_memory=True, confidence=0.90) return analysis # PERS-TRAVEL: travel planning with specific destination → web_research if re.search(r'(reiseplan|reiseroute|reisefuehrer|reise.*planen).{0,60}(japan|usa|thailand|europa|italien|spanien|frankreich|portugal|griechenland|tuerkei|bali|dubai|kanada|australien|neuseeland|england|irland|norwegen|schweden|island)', low) or re.search(r'(reise|urlaub|trip).{0,30}(2|3|4|5|6|7|8|9|10|14|21)\s*(wochen|tage|nächte|naechte).{0,60}(japan|usa|thailand|europa)', low) or re.search(r'(hotels?.{0,30}sehenswuerdigkeiten|sehenswuerdigkeiten.{0,30}hotels?).{0,60}(japan|usa|thailand|europa|italien|spanien)', low): analysis.update(role='personal', task_type='research', tools=['web_research'], needs_memory=False, confidence=0.90) return analysis if re.search(r'(schreibe|erstelle|baue|implementiere|programmiere|deploy)', low) and re.search(r'(tool|app|skript|script|api|programm|code|python|node|bash|kasm)', low): analysis.update(role='dev', task_type='build', tools=['dev_build'], confidence=0.92) return analysis # ARCH: homelab architecture planning, backup-konzept, monitoring-stack, HA # ARCH-STRATEGY: 'wie gehe ich vor + homelab projekt' -> expert_strategy if re.search(r'(wie gehe ich vor|was ist der beste ansatz|wie am besten vorgehen|wie.*projekt.*planen|wie.*planen.*projekt)', low) and re.search(r'(homelab.?projekt|neues.*projekt|projekt.*aufbauen|projekt.*strukturier|projekt.*priorisier)', low): analysis.update(role='research', task_type='strategy', tools=['expert_strategy'], confidence=0.89) return analysis if re.search(r'(backup.?(konzept|strategie|plan|lösung|loesung)|backup.*erstell|vollstaendiges backup|komplettes backup)', low) or re.search(r'(monitoring.?stack|prometheus.*grafana|grafana.*prometheus|alertmanager|nagios.*homelab|zabbix.*homelab)', low) or re.search(r'(hochverfuegbarkeit|high.?availab|cluster.*(proxmox|nextcloud|homelab)|proxmox.*cluster|failover.*homelab)', low) or re.search(r'(zweiten?.*(proxmox|node|server).*hinzufueg|neuen?.*(proxmox|node).*hinzufueg|proxmox.*node.*migr)', low) or re.search(r'(homelab.*(aufbauen|planen|konzept|architektur|design|struktur|sicherer|absichern)|wie.*(homelab|server).*(aufbauen|planen))', low) or re.search(r'(backup.*konzept|sicherungskonzept|datensicherung.*konzept|disaster.*recovery)', low): analysis.update(role='ops', task_type='setup', tools=['setup_lookup'], needs_setup_context=True, confidence=0.88) return analysis # Fix PLAN: day planning and calendar actions → personal_assist if re.search(r'(tagesplan|tages.*plan|plan.*fuer.*heute|was.*heute.*plane|mein.*tag.*organisier)', low): analysis.update(role='personal', task_type='howto', tools=['personal_assist'], needs_memory=True, confidence=0.88) return analysis if re.search(r'(fuge.*kalender|kalender.*eintrag|termin.*kalender|kalender.*termin|kalender.*hinzufuegen|trage.*ein|fuege.*termin)', low): analysis.update(role='personal', task_type='write', tools=['personal_assist'], needs_memory=True, confidence=0.88) return analysis if re.search(r'(woche|wochenplanung|ueberblick|überblick|dranbleib|struktur)', low) and re.search(r'(ich|meine|mein|plane|planen|organisier|verlier|ueberblick|überblick)', low): analysis.update(role='personal', task_type='howto', tools=['personal_assist'], needs_memory=True, confidence=0.93) return analysis # Fix PROD-1: Produktivität/Wochenplanung mit "wie organisiere ich" Pattern if re.search(r'(wie|was).{0,10}(organisier|plane).{0,20}(woche|wochen|meine woche|meinen tag)', low) and re.search(r'(pragmatisch|ueberblick|überblick|struktur|dranbleib|verlier|fokus|produktiv)', low): analysis.update(role='personal', task_type='howto', tools=['personal_assist'], needs_memory=True, confidence=0.94) return analysis if re.search(r'(mail|email|e-mail|\bnachricht\b|erinnere|einkauf|einkaufsliste|todo|to-do|packliste|verfasse|verfass|dankesbrief|entschuldig|geburtstagsbrief)', low): analysis.update(role='personal', task_type='write', tools=['personal_assist'], needs_memory=True, confidence=0.9) return analysis # PERSONAL-MESSAGE: short everyday drafts should not escalate to expert_write if re.search(r'(kurze[nr]?|kurzen|kurze|kleine[nr]?|kleine|knappe[nr]?|knappe|kurz)\s+(entwurf|absage|antwort|nachricht)', low) or \ (re.search(r'(absage|terminverschieb|treffen absagen|termin absagen)', low) and re.search(r'(schreib|entwurf|kurz|kurzen|kurze)', low)): analysis.update(role='personal', task_type='write', tools=['personal_assist'], needs_memory=True, confidence=0.93) return analysis # TOOL-IDEAS: brainstorming for internal tools is not personal assistance if re.search(r'(idee[n]?|vorschlaege|vorschläge|optionen)', low) and re.search(r'(internes? tool|zugriffspruefung|zugriffsprüfung|internes? werkzeug)', low): analysis.update(role='general', task_type='explain', tools=['general_answer'], needs_memory=False, confidence=0.92) return analysis # Fix NAV2: Navigation / Reisefragen -> web_research if re.search(r'(wie komme ich.*nach|fahrt von.*nach|strecke von.*nach|route.*nach|zug.*nach|bus.*nach|wie lange.*nach|entfernung.*nach|anreise|abfahrt.*nach)', low): analysis.update(role='research', task_type='explain', tools=['web_research'], needs_web_research=True, confidence=0.87) return analysis # Fix N2: News/Nachrichten → web_research if re.search(r'(nachrichten|news|schlagzeilen|headline|meldungen)', low) and re.search(r'(aktuell|heute|neueste|latest|was sind die|letzte)', low): analysis.update(role='research', task_type='explain', tools=['web_research'], needs_web_research=True, confidence=0.86) return analysis # Fix SHOP: product/hardware recommendations → web_research if re.search(r'(empfiehl mir|empfehle mir|was ist (ein )?guter|welcher.*ist gut|welche.*ist gut|bestes.*fuer|beste.*fuer|gutes.*fuer|guten.*fuer)', low) and re.search(r'(monitor|festplatte|ssd|hdd|webcam|tastatur|maus|drucker|router|switch|kabel|nas|raspberry|grafikkarte|cpu|prozessor|ram|netzteil|gehaeuse|laptop|notebook|tablet|kopfhoerer|lautsprecher|kamera|mikrofon)', low): analysis.update(role='research', task_type='compare', tools=['web_research'], needs_web_research=True, confidence=0.84) return analysis # Fix W1: Wetter/Forecast → web_research (braucht aktuelle Daten) if re.search(r'(wetter|temperatur|regen|schnee|sonne|wind|forecast|niederschlag|grad.*celsius|celsius.*grad|wettervorhersage|wie wird.*morgen|wie ist.*wetter)', low): analysis.update(role='research', task_type='explain', tools=['web_research'], needs_web_research=True, confidence=0.88) return analysis # Fix POD: podcast/audiobook discovery → web_research if re.search(r'\bpodcast', low) or (re.search(r'\bhoerbuecher?\b|\baudiobooks?\b', low) and re.search(r'(finden|empfehle?|kostenlos|beste?|wo.*kann|listen|plattform)', low)): analysis.update(role='research', task_type='lookup', tools=['web_research'], confidence=0.87) return analysis if re.search(r'(aktuell|neueste|heute|latest)', low) and not re.search(r'(rezept|abendessen|mittagessen|fruehstueck|kochen|backen|essen|mahlzeit|gericht)', low) and not any(s in low for s in ('nextcloud', 'immich', 'traefik', 'authentik', 'emby', 'unraid', 'proxmox', 'docker', 'nginx', 'tailscale', 'server', 'ram', 'cpu', 'disk', 'speicher', 'container', 'homelab')) and not re.search(r'\b(schreibe?|verfasse?|erstelle?|formuliere?|entschuldig|dankesbrief|brief|nachricht schreiben)\b', low): analysis.update(role='research', task_type='explain', tools=['web_research', 'expert_write'], needs_web_research=True, confidence=0.75) return analysis # OPS-EXEC: execute commands on hosts if re.search(r'(fuehre?\s+aus|execute|lauf\s+aus|fuhre?\s+aus)', low) or \ (re.search(r'(teste?\s+ob|teste?\s+den|pruefe?\s+ob|pruefe?\s+den|ueberpruefe?)', low) and any(s in low for s in SERVICE_HINTS)): analysis.update(role='ops', task_type='action', tools=['ops_exec'], needs_setup_context=True, confidence=0.87) return analysis # OPS-INSTALL: install/configure software if re.search(r'(installiere?|setup\s+\w|richte\s+ein|aufsetzen|deploye?)', low) and \ not re.search(r'(wie\s+(installiere|richte|setze|deploye)|anleitung|erklaer)', low): analysis.update(role='ops', task_type='action', tools=['ops_install'], needs_setup_context=True, confidence=0.87) return analysis # OPS-API: query homelab APIs if re.search(r'(api\s+(abfrage|query|status)|zeig\s+mir\s+(alle\s+)?(container|entities|services|benutzer))', low) or \ re.search(r'(docker\s+(ps|stats|images)|liste\s+(alle\s+)?(container|images|volumes))', low): analysis.update(role='ops', task_type='final', tools=['ops_api'], needs_setup_context=True, confidence=0.88) return analysis # CAUSAL: warum/wieso/zusammenhang/ursache/seit/nach dem → deep causal analysis if re.search(r'\b(warum|wieso|weshalb|reason|ursache|zusammenhang|root.?cause)\b', low) and \ SERVICE_HINTS_PATTERN.search(low): analysis.update(role='ops', task_type='diagnose', tools=['ops_deep_analyze'], needs_setup_context=True, confidence=0.90) return analysis if re.search(r'\b(seit|after|nach dem|nach dem update|nach dem neustart|nach dem stromausfall)\b', low) and \ re.search(r'(nicht mehr|funktioniert nicht|geht nicht|down|offline|error|fehler|kaputt|problem)', low): analysis.update(role='ops', task_type='diagnose', tools=['ops_deep_analyze'], needs_setup_context=True, confidence=0.90) return analysis if re.search(r'(mehrere services|alle services|viele services|ploetzlich.*nicht|gleichzeitig.*nicht)', low): analysis.update(role='ops', task_type='diagnose', tools=['ops_deep_analyze'], needs_setup_context=True, confidence=0.89) return analysis # OPS-DEEP: complex multi-service analysis if re.search(r'(komplette?\s+analyse|tiefe?\s+analyse|vollstaendige?\s+diagnose|ursachenanalyse)', low) or \ (re.search(r'(analysiere?\s+(alle|komplett|alles)|wochenbericht|monatsbericht)', low) and any(s in low for s in SERVICE_HINTS)): analysis.update(role='ops', task_type='diagnose', tools=['ops_deep_analyze'], needs_setup_context=True, confidence=0.88) return analysis # Fix KB-ENTERTAINMENT: Entertainment, Gaming, Filme & Serien für KB_ENTERTAINMENT # Muss NACH personal/memory Patterns kommen, aber VOR dem Fallback ENTERTAINMENT_KB_PATTERNS = ( r'\b(elden ring|witcher|fortnite|minecraft|zelda|mario|pokemon)\b', r'\b(playstation|xbox|switch|steam|epic games|gog|battle\.net)\b', r'\b(netflix|disney\+|hbo|prime video|apple tv|paramount|peacock)\b', r'\b(spotify|apple music|tidal|deezer|youtube music)\b', r'\b(twitch|youtube|streamer|lets play|walkthrough|gameplay)\b', r'\b(mmorpg|rpg|fps|moba|battle royale|open world|indie game)\b', r'\b(gpu|grafikkarte|rtx 40|rx 7\d{3}|gaming pc|144hz|240hz)\b', r'(was.*spielen|spiel.*empfehl|gutes.*spiel|bestes.*spiel|spiel.*tip)', r'(was.*schauen|film.*empfehl|serie.*empfehl|guter.*film|beste.*serie)', r'(was.*hören|musik.*empfehl|gute.*musik|playlist.*tip|song.*empfehl)', r'(binge.*watch|staffel.*empfehl|folge.*empfehl|streaming.*tip)', ) if any(re.search(p, low) for p in ENTERTAINMENT_KB_PATTERNS): analysis.update(role='general', task_type='explain', tools=['general_answer'], needs_memory=False, confidence=0.95) return analysis # Fix KB-HISTORY: Geschichte & Kultur Fragen für KB_HISTORY_CULTURE # Muss NACH allen Setup/OPS Patterns kommen, aber VOR dem Fallback HISTORY_KB_PATTERNS = ( r'^(wann war|wer war|was war|wie war|wann lebte|wer lebte|wann starb|wer starb)\b', r'^(in welchem jahr|in welchem jahrhundert|in welcher epoche)\b', r'^(erster weltkrieg|zweiter weltkrieg|kalter krieg|französische revolution|industrielle revolution)\b', r'^(altes ägypten|römisches reich|antikes griechenland|mittelalter|renaissance)\b', r'^(karl der große|napoleon|luther|hitler|caesar|augustus|cleopatra)\b', r'^(pyramiden|kolosseum|chinesische mauer|eiffelturm|taj mahal)\b', r'^(leonardo da vinci|michelangelo|mozart|beethoven|bach|picasso)\b', r'^(sokrates|platon|aristoteles|philosophie|demokratie)\b', r'^(religion|christentum|islam|judentum|buddhismus|hinduismus)\b', r'^(bibel|koran|tora|torah)\b', r'^(v\.chr\.|n\.chr\.|vor christus|nach christus)\b', ) if any(re.search(p, low) for p in HISTORY_KB_PATTERNS): analysis.update(role='general', task_type='explain', tools=['general_answer'], needs_memory=False, confidence=0.95) return analysis # Fix KB-SMALLTALK: Smalltalk, Gesprächsthemen, Alltagsfragen für KB_SMALLTALK # Muss VOR dem Fallback kommen, damit Begrüßungen/Motivation nicht ungrounded landen SMALLTALK_KB_PATTERNS = ( r'^(wie geht|wie gehts|wie geht es dir|was machst du|was gibt es neues|was gibts neues)\b', r'^(erzähl mir|erzähl was|erzähl einen witz|erzähl mir was|erzähl mir einen witz)\b', r'^(langweilig|mir ist langweilig|unterhaltung|gespräch|plaudern|quatschen)\b', r'^(guten morgen|guten abend|gute nacht|hallo|hi|hey)\b', r'^(danke|vielen dank|danke dir|danke schön|danke schoen)\b', r'^(motivation|inspiration|aufmunterung|ermutigung|gib mir kraft)\b', r'^(tipp|ratschlag|empfehlung|was würdest du tun|was wuerdest du tun)\b', r'^(meinung|was denkst du|wie siehst du das|was sagst du dazu)\b', r'^(entschuldigung|sorry|verzeihung|entschuldige)\b', r'^(bis später|bis bald|tschüss|tschuesss|ciao|auf wiedersehen)\b', ) if any(re.search(p, low) for p in SMALLTALK_KB_PATTERNS): analysis.update(role='general', task_type='explain', tools=['general_answer'], needs_memory=False, confidence=0.95) return analysis # Fix KB-TECH-SUPPORT: Technik-Support & DIY Fragen für KB_TECH_SUPPORT.md # WICHTIG: Muss VOR dem Fallback kommen, damit Tech-Probleme nicht ungrounded landen TECH_SUPPORT_PATTERNS = ( r'\b(smartphone|handy|iphone|android|tablet|ipad)\b.*\b(lädt nicht|lädt nicht|akku|bildschirm|display|kaputt|defekt|problem)\b', r'\b(laptop|computer|pc|windows|mac|macbook)\b.*\b(langsam|hängt|hängt|friert|bluescreen|neustart|startet nicht)\b', r'\b(wlan|wifi|internet|router)\b.*\b(funktioniert nicht|geht nicht|langsam|verbindung|problem)\b', r'\b(drucker|scanner)\b.*\b(funktioniert nicht|geht nicht|verbindet|problem|fehler)\b', r'\b(fernseher|tv|fernbedienung|streaming|netflix)\b.*\b(funktioniert nicht|geht nicht|bild|ton|problem)\b', r'\b(usb|hdmi|kabel|anschluss)\b.*\b(funktioniert nicht|geht nicht|erkannt|problem)\b', r'\b(virus|malware|phishing|sicherheit|passwort vergessen)\b', r'\b(app|programm|software)\b.*\b(stürzt|ab|hängt|startet nicht|fehler)\b', r'\b(datei|ordner)\b.*\b(löschen|löschen|nicht öffnen|nicht öffnen|zugriff)\b', r'\b(windows|update|aktualisieren)\b.*\b(hängt|hängt|fehler|problem)\b', r'\b(waschmaschine|staubsauger|kühlschrank)\b.*\b(funktioniert nicht|geht nicht|problem|fehler|laut)\b', r'\b(diy|reparieren|fixen|hilfe|support)\b.*\b(technik|gerät|problem)\b', ) if any(re.search(p, low) for p in TECH_SUPPORT_PATTERNS) and not any(s in low for s in ('homelab', 'server', 'docker', 'container', 'service', 'host', 'setup', 'infrastruktur', 'nextcloud', 'traefik', 'proxmox', 'unraid')): analysis.update(role='general', task_type='explain', tools=['general_answer'], needs_memory=False, confidence=0.95) return analysis # ── IMPLICIT DOMAIN ROUTING (Thema, nicht Schluesselwort) ────────────────── # Kein "schreib einen Artikel" noetig — das Thema bestimmt den Skill. # IMPLICIT-CODE: Tech-Konzepte erklaeren / vergleichen / implementieren _tech_topics = ( r'(microservice|kubernetes|k8s|docker.compose|terraform|ansible|' r'rest.api|graphql|grpc|websocket|message.queue|rabbitmq|kafka|' r'sql.*nosql|nosql.*sql|relational.*datenbank|datenbank.*design|' r'design.pattern|entwurfsmuster|solid.*prinzip|dependency.*injection|' r'event.driven|cqrs|saga.*pattern|clean.*architektur|hexagonal|' r'ci.*cd.*pipeline|devops.*praxis|git.*workflow|branching.*modell|' r'algorithmus|komplexitaet|big.o|sortier|graphen.*algorithmus|' r'machine.*learning.*impl|neural.*network.*bau|embedding.*erstell|' r'api.*rate.limit|auth.*token.*flow|oauth.*impl|jwt.*erstell)' ) _explain_verbs = r'(erklaer|erklaere|was ist|wie funk|unterschied|vergleich|vor.*nach|wann.*nutzen|wann.*verwenden|wie.*besser|welches.*besser|empfehlung|wie.*impl|wie.*umsetz|zeig.*wie|beispiel.*fuer)' if re.search(_tech_topics, low) and re.search(_explain_verbs, low) and not any(s in low for s in SERVICE_HINTS): analysis.update(role='dev', task_type='explain', tools=['qdrant_search', 'expert_code'], confidence=0.87) return analysis # IMPLICIT-WRITE: Kommunikationsaufgaben ohne "schreib" Keyword _comm_types = r'(\bemail\b|\bbrief\b|anschreiben|bewerbung|motivationsschreiben|\bpitch\b|proposal|\bpraesentation\b|\bpresentation\b|executive.*summary|zusammenfassung.*fuer|bericht.*fuer|protokoll.*erstell|minutes.*of|meeting.*notes|stellungnahme|offizielle.*mitteilung|pressemitteilung|newsletter|\bslogan\b|produktbeschreibung|landing.*page.*text)' _comm_triggers = r'(kannst du|hilf mir|erstelle|formulier|schreib|verfass|mach mir|ich brauche|benotige|erstell mir|bitte.*erstell|wie.*formulier|wie.*sag ich|gib mir.*entwurf)' if re.search(_comm_types, low) and re.search(_comm_triggers, low) and not any(s in low for s in ('homelab', 'server', 'docker', 'code', 'skript')): analysis.update(role='research', task_type='write', tools=['expert_write'], confidence=0.89) return analysis # IMPLICIT-SYNTH: Komplexe Synthesis-Fragen → Web-Recherche + Expert-Schreiben _synth_patterns = r'(was.*beste.*2\d{3}|welche.*empfehlung.*2\d{3}|wie ist der stand|aktueller stand von|neueste.*entwicklung|wohin.*entwickelt sich|wie.*zukunft|state of the art|was denken experten|expertenmeinung zu|marktfuehrer|markt.*analyse|wettbewerbsvergleich|vor.*nachteile.*von.*und.*von)' if re.search(_synth_patterns, low) and not any(s in low for s in SERVICE_HINTS): analysis.update(role='research', task_type='explain', tools=['web_research', 'expert_write'], needs_web_research=True, confidence=0.86) return analysis # IMPLICIT-STRATEGY: Planungs- und Vorgehen-Fragen ohne explizites "OKR/SWOT" _plan_patterns = r'(wie.*aufbauen|wie gehe ich vor|schritt.*fuer.*schritt.*vorgehen|wie.*skalier|best.*approach.*fuer|bestes.*vorgehen|wie.*strukturier|wie.*priorisier|was.*zuerst|in welcher reihenfolge.*vorgehen|langfristig.*planen|strategie.*entwickeln|roadmap.*fuer|plan.*fuer.*naechsten|wie.*team.*aufstell|ressourcen.*einsetzen)' if re.search(_plan_patterns, low) and not any(s in low for s in ('kasm', 'emby', 'sonos', 'lidarr', 'radarr', 'sonarr', 'traefik', 'nextcloud', 'immich', 'authentik', 'ldap')) and not re.search(r'(diagnos|kaputt|fehler|defekt|nicht.*laueft|nicht.*laeuft|nicht.*erreichbar)', low): analysis.update(role='research', task_type='build', tools=['expert_strategy'], confidence=0.85) return analysis # IMPLICIT-EDIT: Text liegt vor und soll verbessert werden (ohne "lektoriere") # Erkennung: langer Text + Verbesserungswunsch _edit_triggers = r'(klingt.*gut|klingt das gut|ist das gut formuliert|passt das so|kannst du.*verbessern|kannst du.*anpassen|kannst du.*ueberarbeiten|bitte.*verbessern|bitte.*anpassen|mach.*besser|mach.*professioneller|mach.*klarer|zu lang|zu kurz|zu foermlich|zu informell|andere formulierung|anders formulieren)' if re.search(_edit_triggers, low) and len(low) > 20 and not any(s in low for s in ('homelab', 'server', 'docker', 'code', 'skript')): analysis.update(role='general', task_type='write', tools=['expert_edit'], confidence=0.87) return analysis # ── EXPERT DOMAINS ──────────────────────────────────────────────────────── # EXPERT-WRITE: Artikel, Blog, Journalismus, kreatives Schreiben, Storytelling if re.search(r'(schreib.{0,20}(artikel|blog|essay|bericht|kolumne|reportage|feature|story|geschichte|novelle|kurzgeschichte|prolog|epilog|gedicht|rede|speech|pitch|biographie|profil|portrait|caption|post|thread)|' r'verfasse.{0,20}(artikel|text|bericht|email|brief|nachricht|anschreiben|proposal|pitch|stellungnahme)|' r'erstell.{0,20}(artikel|blog.*post|newsletter|pressemitteilung|content|copy|slogan|headline|landing.*page|produktbeschreibung)|' r'schreib.{0,20}(mir|uns|einen|eine|guten|spannenden|interessanten|professionellen|kreativen|packenden|ueberzeugenden)|' r'ich brauche.{0,30}(artikel|text|story|email|anschreiben|pressemitteilung|newsletter|pitch|proposal)|' r'ghostwrite|narrativ|storytelling|lede|inverted pyramid|hook.*fuer.*text|teaser.*fuer)', low) and not any(s in low for s in ('homelab', 'docker', 'container', 'code', 'skript', 'script', 'programm', 'funktion', 'klasse')): analysis.update(role='research', task_type='write', tools=['expert_write'], confidence=0.92) return analysis # EXPERT-CODE: Code-Architektur, Code-Review, Senior-Dev-Aufgaben if re.search(r'(code.*review|review.*code|code.*analysier|analysier.*code|' r'architektur.*entscheiden|technische.*schulden|tech.*debt|refactor|refaktorier|' r'design.*pattern|entwurfsmuster|solid.*prinzip|clean.*code|clean.*architektur|' r'performance.*optimier|bottleneck.*find|profil.*code|security.*audit.*code|' r'algorithm.*optimier|komplexitaet.*reduzier|o.*notation|big.*o|' r'api.*design|api.*architektur|microservice.*design|monolith.*split|' r'test.*strategie|unit.*test.*schreib|integration.*test|tdd|bdd|' r'was.*best.*practice|beste.*praxis.*code|code.*qualitaet|' r'implementier.*fuer.*mich|schreib.*funktion|schreib.*klasse|schreib.*script|schreib.*skript|' r'bau.*mir.*eine.*app|erstell.*mir.*ein.*programm|erstell.*mir.*eine.*api|' r'wie.*implementier.*ich|wie.*programmier.*ich|wie.*baue.*ich|wie.*erstell.*ich.*code|' r'debug.*diesen|fehler.*in.*meinem.*code|warum.*gibt.*mein.*code|was.*falsch.*code)', low): analysis.update(role='dev', task_type='build', tools=['expert_code'], confidence=0.91) return analysis # EXPERT-RESEARCH: Tiefe Recherche, Marktanalyse, investigativ if re.search(r'(recherchiere.{0,30}(tiefgr|tiefsinnig|vollst|komplett|umfassend|detailliert)|' r'marktanalyse|wettbewerbsanalyse|kompetitor.*analyse|competitive.*analysis|' r'investigier|investigative|vollstaendige.*analyse|comprehensive.*analysis|' r'fact.?check|fakten.*pruefen|quellen.*pruefen|verifizier.*fakten|' r'recherche.*bericht|research.*report|due.*diligence|' r'was.*sagen.*experten|expertenmeinung|state.*of.*art|aktueller.*stand)', low): analysis.update(role='research', task_type='explain', tools=['expert_research'], needs_web_research=True, confidence=0.90) return analysis # EXPERT-EDIT: Lektorat, Korrekturlesen, Stilverbesserung if re.search(r'(korrigier.{0,20}(text|folgenden|meinen|diesen|den)|' r'lektorier|lektüre|proofreading|proofread|' r'verbessere.{0,20}(text|meinen text|folgenden text|diesen text|schreibstil)|' r'ueberarbeite.{0,20}(text|meinen|diesen|folgenden)|' r'überarbeite.{0,20}(text|meinen|diesen|folgenden)|' r'rechtschreibung.*pruefen|grammatik.*pruefen|pruef.*grammatik|pruef.*rechtschreibung|' r'stilistisch.*verbessern|stilverbesserung|besser.*formulier|umformulier|' r'klingt.*besser|klingt.*natuerlicher|klingt.*professioneller|' r'feedback.*text|feedback.*meinem text|text.*feedback)', low) and not any(s in low for s in ('docker', 'code', 'skript')): analysis.update(role='general', task_type='write', tools=['expert_edit'], confidence=0.92) return analysis # EXPERT-STRATEGY: Strategie, Business-Planung, Management if re.search(r'(businessplan|business.*plan|business.*strategie|go.*to.*market|gtm.*strategie|startup.*plan|gruendungsplan|' r'okr.*(erstell|roadmap|ziel)|erstell.*okr|erstell.*roadmap|' r'ziele.*definier|quartalsziele|jahresziele|' r'roadmap.*(erstell|plan)|produkt.*roadmap|feature.*priorisier|' r'swot.*analyse|swot.*erstell|staerken.*schwaechen|' r'entscheidungs.*matrix|priorisierungs.*matrix|rice.*scoring|' r'strategische.*planung|langfristige.*planung|wachstums.*strategie|' r'project.*charter|projektcharter|projektplanung|meilenstein.*plan|' r'ressourcen.*planung|kapazitaets.*planung|team.*aufbau|' r'wie.*skalier|skalierungs.*strategie|pivot.*entscheid)', low) and not any(s in low for s in ('docker', 'container')): analysis.update(role='research', task_type='build', tools=['expert_strategy'], confidence=0.91) return analysis if role_hint == 'ops': analysis.update(role='ops', task_type='diagnose', tools=['setup_lookup'], needs_setup_context=True, confidence=0.66) elif role_hint == 'research': analysis.update(role='research', task_type='explain', tools=['web_research'], needs_web_research=True, confidence=0.66) elif role_hint == 'personal': analysis.update(role='personal', task_type='write', tools=['personal_assist'], needs_memory=True, confidence=0.66) elif role_hint == 'dev': analysis.update(role='dev', task_type='build', tools=['dev_build'], confidence=0.66) else: analysis.update(role='general', task_type='howto', tools=['general_answer'], needs_memory=False, confidence=0.6) return analysis def normalize_analysis(data, message: str, role_hint: str | None): if not isinstance(data, dict): data = heuristic_analysis(message, role_hint) out = heuristic_analysis(message, role_hint) # Fix RECIPE-ROUTING-13: Wenn Heuristik hohe Confidence hat (Fast-Path), behalte ihre Tools # Heuristik-Fast-Paths (z.B. Rezepte) haben confidence >= 0.9, LLM hat confidence <= 0.8 if out.get('confidence', 0) >= 0.9: # Heuristik-Fast-Path: behalte Tools, erlaube nur reasoning_focus vom LLM out.update({k: v for k, v in data.items() if k in {'reasoning_focus'}}) else: out.update({k: v for k, v in data.items() if k in out or k in {'reasoning_focus', 'tools', 'confidence'}}) role = str(out.get('role') or (role_hint or 'general')).lower() if role not in FINAL_ROLE_MAP: role = role_hint if role_hint in FINAL_ROLE_MAP else 'general' out['role'] = role tools = out.get('tools') or [] clean_tools = [] for t in tools: name = str(t).strip() if name in TOOLS and name not in clean_tools: clean_tools.append(name) out['tools'] = clean_tools[:3] out['needs_setup_context'] = bool(out.get('needs_setup_context')) out['needs_web_research'] = bool(out.get('needs_web_research')) out['needs_memory'] = bool(out.get('needs_memory')) out['dangerous_action'] = bool(out.get('dangerous_action')) out['force_sequential'] = bool(out.get('force_sequential')) if not out['tools']: out['tools'] = heuristic_analysis(message, role_hint)['tools'] if out['needs_setup_context'] and 'setup_lookup' not in out['tools'] and out['role'] == 'ops' and not out['dangerous_action']: existing_kinds = {TOOLS[t]['kind'] for t in out['tools'] if t in TOOLS} if existing_kinds <= {'evidence'}: out['tools'] = (['setup_lookup'] + out['tools'])[:3] if out['needs_web_research'] and not any(t in out['tools'] for t in ['web_research', 'url_research', 'compare_selfhosted', 'security_plan']): out['tools'] = (['web_research'] + out['tools'])[:3] family_candidates = collect_intent_families(message, SERVICE_HINTS) if USE_INTENT_FAMILIES else [] family_info = family_candidates[0] if family_candidates else classify_intent_family(message, SERVICE_HINTS) before_tools = list(out.get('tools') or []) out = apply_intent_normalizer(message, out, family_info=family_info) if family_candidates: out['family_candidates'] = [item.get('family') for item in family_candidates if item.get('family')] composition = compose_family_plan(message, out, family_candidates) if USE_INTENT_COMPOSITION else {'composed': False} out, before_comp, logged_composition = _apply_composition_selection( out, composition, family_candidates, choose_plan=choose_plan, use_policy=USE_COMPOSITION_POLICY, ) if before_comp and logged_composition: log_intent_composition(message, family_candidates, before_comp, out, logged_composition) log_intent_family_shadow(message, family_info, before_tools, list(out.get('tools') or [])) if USE_META_CONTROLLER: _log_shadow_decision( META_CONTROLLER_LOG, _shadow_decision(message, out, family_candidates, TOOLS), ) return out def apply_intent_normalizer(message: str, analysis: dict, family_info: dict | None = None): return _normalize_intent_family( message, analysis, family_info, classify_intent_family=classify_intent_family, service_hints=SERVICE_HINTS, ) def log_intent_family_shadow(message: str, family_info: dict, before_tools: list[str], after_tools: list[str]): _log_intent_family_shadow( message, family_info, before_tools, after_tools, log_path=INTENT_FAMILY_LOG, collect_intent_families=collect_intent_families, service_hints=SERVICE_HINTS, ) def log_intent_composition(message: str, family_candidates: list[dict], analysis_before: dict, analysis_after: dict, composition: dict): _log_intent_composition( message, family_candidates, analysis_before, analysis_after, composition, log_path=INTENT_COMPOSITION_LOG, ) def write_temp(prefix: str, text: str): tmp = tempfile.NamedTemporaryFile('w', suffix=f'.{prefix}.txt', delete=False, encoding='utf-8') tmp.write(text) tmp.flush() tmp.close() return tmp.name def load_profile_entries_local(): if not PROFILE_STORE.exists(): return [] items = [] try: lines = PROFILE_STORE.read_text(encoding='utf-8', errors='ignore').splitlines() except Exception: return [] for line in lines: line = line.strip() if not line: continue try: data = json.loads(line) except Exception: continue if isinstance(data, dict) and data.get('memory_type') == 'profile': items.append(data) return items def profile_memory_context(query: str, limit: int = 4): entries = load_profile_entries_local() if not entries: return '' qnorm = norm(query) qterms = topic_terms(query) scored = [] for entry in entries: text = str(entry.get('text') or '').strip() if not text: continue en = norm(text) score = float(entry.get('importance', 0.5)) if qnorm and qnorm in en: score += 5.0 for term in qterms: if term in en: score += 1.7 cat = str(entry.get('category') or '') if cat == 'constraint': score += 0.7 if cat == 'setup' and any(h in qnorm for h in SERVICE_HINTS): score += 1.2 if cat == 'preference' and any(k in qnorm for k in ['antwort', 'stil', 'kurz', 'direkt', 'mag', 'bevorzug', 'gerne']): score += 1.0 if score >= 1.45: scored.append((score, entry)) if not scored: fallback = [] for entry in reversed(entries): cat = str(entry.get('category') or '') if cat in {'constraint', 'preference'}: fallback.append(entry) if len(fallback) >= min(limit, 2): break if not fallback: return '' picked = list(reversed(fallback)) else: picked = [] seen = set() for _score, entry in sorted(scored, key=lambda x: (x[0], x[1].get('last_seen_at', x[1].get('created_at', ''))), reverse=True): key = (entry.get('category', 'fact'), norm(str(entry.get('text') or ''))) if key in seen: continue seen.add(key) picked.append(entry) if len(picked) >= limit: break lines = ['Persoenlicher Kontext:'] for entry in picked: lines.append(f"- [{entry.get('category', 'fact')}|profile] {str(entry.get('text') or '').strip()}") return '\n'.join(lines) if len(lines) > 1 else '' def memory_context(message: str, analysis: dict | None = None): if not analysis or not AGENT_MEMORY.exists(): return '' role = str(analysis.get('role') or '') task_type = str(analysis.get('task_type') or '') needs_memory = bool(analysis.get('needs_memory')) or task_type == 'memory' if not needs_memory: return '' if task_type == 'memory': rc, out, _err = run([str(AGENT_MEMORY), 'context', message], timeout=10) if rc != 0 or not out or '(kein persoenlicher Kontext gespeichert)' in out: return '' return out base = '' if role in {'personal', 'general', 'ops'}: base = profile_memory_context(message) # Append goals + contacts context for personal/research roles or planning task types if role in {'personal', 'research'} or task_type in {'strategy', 'write', 'assist', 'plan'}: extra_parts = [] goals_bin = ROOT / 'bin' / 'goals-manage' contacts_bin = ROOT / 'bin' / 'contacts-manage' if goals_bin.exists(): rc_g, out_g, _ = run([str(goals_bin), 'context'], timeout=10) if rc_g == 0 and out_g and '(Noch keine' not in out_g and '(Keine' not in out_g: extra_parts.append(out_g) if contacts_bin.exists(): rc_c, out_c, _ = run([str(contacts_bin), 'show-due', 'days=3'], timeout=10) if rc_c == 0 and out_c and 'Keine Follow-ups' not in out_c: extra_parts.append('Follow-ups (3 Tage):\n' + out_c) rc_b, out_b, _ = run([str(contacts_bin), 'show-birthdays', 'days=7'], timeout=10) if rc_b == 0 and out_b and 'Keine Geburtstage' not in out_b: extra_parts.append('Geburtstage (7 Tage):\n' + out_b) if extra_parts: extras = '\n\n'.join(extra_parts) base = (base + '\n\n' + extras).strip() if base else extras return base def remember_user_signal(message: str): if not AGENT_MEMORY.exists(): return try: run([str(AGENT_MEMORY), 'ingest', message], timeout=8) except Exception: pass def remember_episode(message: str): if not AGENT_MEMORY.exists(): return try: run([str(AGENT_MEMORY), 'episode', message], timeout=8) except Exception: pass def remember_incident(text: str): if not AGENT_MEMORY.exists(): return try: run([str(AGENT_MEMORY), 'incident', text], timeout=10) except Exception: pass def should_fetch_retrieval(message: str, analysis: dict | None = None, role_hint: str | None = None) -> bool: low = norm(message) if analysis: role = analysis.get('role') needs_setup = bool(analysis.get('needs_setup_context')) needs_web = bool(analysis.get('needs_web_research')) task_type = analysis.get('task_type') if role == 'research' and needs_web and not needs_setup: return False if role in {'general', 'personal'} and not needs_setup and not any(h in low for h in SERVICE_HINTS): return False if role == 'research' and task_type == 'compare' and not needs_setup and not any(h in low for h in SERVICE_HINTS): return False if role_hint in {'ops', 'dev'}: return True if analysis and (analysis.get('needs_setup_context') or analysis.get('role') in {'ops', 'dev'}): return True if any(h in low for h in SERVICE_HINTS): return True if re.search(r'\b(set?up|host|service|dienste|logs?|share|smb|bibliothek|container|agent|bibabot|gedaechtnis|memory|router|subagent)\b', low): return True return False def retrieval_context(message: str, analysis: dict | None = None, role_hint: str | None = None): if not CONTEXT_PACK.exists() or not should_fetch_retrieval(message, analysis, role_hint): return '' queries = [message] if is_followup_query(message): topic = recent_topic_event(message) or load_topic_state() if topic: root = str(topic.get('root_message') or topic.get('message') or '').strip() if root and norm(root) != norm(message): queries.append(f'{root}\n{message}') if analysis: focus = ' '.join(str(x).strip() for x in analysis.get('reasoning_focus', [])[:2] if str(x).strip()) if focus: queries.append(f'{message}\n{focus}') low = norm(message) hinted = [term for term in SERVICE_HINTS if term in low] if hinted: queries.append(' '.join(dict.fromkeys(hinted + [message]))) lines = [] seen = set() for query in queries[:3]: rc, out, _err = run([str(CONTEXT_PACK), query], timeout=35) if rc != 0 or not out: continue for line in out.splitlines(): line = line.strip() if not line or line == 'Kontext-Snippets:' or line in seen: continue seen.add(line) lines.append(line) if len(lines) >= 8: return 'Kontext-Snippets:\n' + '\n'.join(lines) return 'Kontext-Snippets:\n' + '\n'.join(lines) if lines else '' def append_context(base: str, extra: str) -> str: extra = (extra or '').strip() base = (base or '').strip() if not extra: return base if not base: return extra if extra in base: return base return base + '\n\n' + extra def playbook_context(message: str, analysis: dict | None = None) -> str: if not USE_PLAYBOOKS: return '' try: return format_playbook_context(message, analysis=analysis) except Exception: return '' def apply_plan_first(analysis: dict, plan: dict | None) -> dict: if not plan: return analysis out = dict(analysis or {}) tool_override = [str(t).strip() for t in plan.get('tool_override') or [] if str(t).strip() in TOOLS] if tool_override: out['tools'] = tool_override[:3] focus = [] for item in (out.get('reasoning_focus') or []) + (plan.get('reasoning_focus') or []): text = str(item).strip() if text and text not in focus: focus.append(text) if focus: out['reasoning_focus'] = focus[:6] gaps = set(plan.get('evidence_gaps') or []) if 'setup_or_inventory' in gaps: out['needs_setup_context'] = True if 'personal_memory' in gaps: out['needs_memory'] = True out['plan_first_mode'] = plan.get('mode', 'plan_first') return out def response_contract(analysis: dict): task_type = analysis.get('task_type', 'explain') if task_type == 'diagnose': return 'Antwortvertrag: Erklaere kurz den relevanten Mechanismus, nenne die wahrscheinlichste Ursache, belege sie mit den vorhandenen Fakten und gib die naechsten sinnvollen Checks oder Fixes an.' if task_type == 'compare': return 'Antwortvertrag: Gib eine klare Empfehlung, vergleiche die besten Optionen mit konkreten Tradeoffs und begruende, warum die Empfehlung zur Situation passt.' if task_type == 'howto': return 'Antwortvertrag: Gib konkrete, brauchbare Schritte statt Meta-Erklaerungen. Nenne Voraussetzungen und typische Stolpersteine kurz.' if task_type == 'build': return 'Antwortvertrag: Zerlege das Vorhaben in sinnvolle Schritte, nenne den vorgesehenen Output und sage offen, was bereits verifiziert ist.' if task_type == 'status': return 'Antwortvertrag: Nenne zuerst den aktuellen Zustand und danach nur die Punkte, die wirklich Aufmerksamkeit brauchen.' if task_type == 'action': return 'Antwortvertrag: Sage klar, was getan wurde oder was blockiert war, und nenne den verifizierten Effekt.' if task_type == 'memory': return 'Antwortvertrag: Antworte persoenlich, konkret und nur mit wirklich gespeichertem Kontext.' return 'Antwortvertrag: Antworte direkt auf die eigentliche Frage, dann kurz die wichtigsten Begruendungen oder Beispiele.' def postprocess_final_answer(message: str, analysis: dict, final_text: str) -> str: return _policy_postprocess_final_answer(message, analysis, final_text) def compact_episode_text(message: str, analysis: dict, final_text: str, evidence_items: list[dict]) -> str: lines = [] for line in (final_text or '').splitlines(): line = line.strip() if not line or line.lower() in {'befund:', 'ursache:', 'wahrscheinlichste ursache:', 'naechste pruefungen/aktionen:', 'naechster schritt:', 'aktion:'}: continue lines.append(line) if len(lines) >= 3: break if not lines and evidence_items: lines = [item.get('summary', '') for item in evidence_items if item.get('summary')] snippet = ' | '.join(line for line in lines if line)[:700] return f'{message}\nAntwortkern: {snippet}' if snippet else message def evidence_object(tool: str, rc: int, output: str, err: str = ''): # Fix GENERAL-GROUNDEDNESS-5: rc kann None sein, was alle Heuristiken deaktiviert # Normalisiere rc zu int (0 = success, 1 = failure) if rc is None: rc = 0 if (output or '').strip() else 1 clean = (output or '').strip() # Fix EVIDENCE-STDERR-1: Wenn stdout leer ist, aber stderr substanzielle Antwort hat, nutze stderr # Problem: Tools wie research-web-answer geben bei Fehlern 'nicht_verifiziert' auf stderr aus # Lösung: Fallback auf stderr wenn stdout leer und stderr nicht nur Fehlermeldung ist if not clean and err and len(err.strip()) > 20 and 'nicht_verifiziert' not in err.lower(): clean = err.strip() low = norm(clean) kind = EVIDENCE_KIND_BY_TOOL.get(tool, 'general') grounded = False if tool in GROUNDED_FASTPATH_TOOLS: grounded = bool(clean) elif kind == 'web': # Fix WEB-GROUNDEDNESS-7: Entschärfte Heuristik für web_research # Problem: Zu strenge Kombination von Bedingungen hat 32% (12/37) als ungrounded markiert # Lösung: Einfachere, robustere Prüfung - akzeptiere mehr Antworten als grounded # Basis: Kein Fehler und substanzieller Output # Fix WEB-GROUNDEDNESS-8: Erkenne nicht_verifiziert überall im String, nicht nur am Anfang has_valid_output = bool(clean) and rc == 0 and not clean.startswith('(fehler:') and 'nicht_verifiziert' not in clean.lower() # Explizite Marker (Kurzantwort/Quellen Format von research-web-answer) # Fix WEB-GROUNDEDNESS-8: Prüfe ob nach Marker auch tatsächlich Inhalt kommt has_quellen = 'Quellen:' in clean and len(clean.split('Quellen:')[-1].strip()) > 10 # Kurzantwort muss gefolgt sein von substanziellem Inhalt (nicht nur Formatierung) if 'Kurzantwort:' in clean: after_marker = clean.split('Kurzantwort:')[-1].strip() # Entferne Markdown-Formatierung für die Prüfung after_clean = after_marker.replace('**', '').replace('*', '').replace('- ', '').strip() has_kurzantwort = len(after_clean) > 20 and '.' in after_clean else: has_kurzantwort = False has_explicit_markers = has_quellen or has_kurzantwort # Substanzieller Content (ENTSCHÄRFT: von 150 auf 100 Zeichen, von 2 auf 1 Sätze) has_substantial_content = ( has_valid_output and len(clean) > 100 and clean.count('.') >= 1 ) # Web-typische Strukturmerkmale (erweitert) web_markers = ['http', 'www.', '.de', '.com', '.org', ' - ', '•', '→', '1.', '2.', '3.', '- ', '**', '*'] has_web_structure = any(m in clean.lower() for m in web_markers) # Markdown-Struktur als Qualitätsindikator has_markdown_structure = has_valid_output and any(c in clean for c in ['**', '*', '- ', '1.', '2.', '###', '##']) # Mehrzeilige Antworten sind wahrscheinlich gut has_multiple_lines = has_valid_output and len(clean.splitlines()) >= 2 # Kombinierte Entscheidung (ENTSCHÄRFT: OR statt AND-Kombinationen) grounded = ( has_explicit_markers or (has_substantial_content and (has_web_structure or has_markdown_structure)) or (has_valid_output and len(clean) > 200 and has_multiple_lines) ) elif kind in {'memory', 'status', 'action'}: grounded = bool(clean) elif kind == 'general': # Fix PERSONAL-GROUNDEDNESS-1: Spezielle Heuristik für personal_assist # Problem: personal_assist Antworten (Rezepte, Wochenplanung, etc.) wurden als ungrounded markiert # Lösung: Eigene, weniger strenge Heuristik für personal_assist if tool == 'personal_assist': # personal_assist gibt strukturierte Antworten (Rezepte, Listen, How-tos) # Akzeptiere wenn: Output existiert, kein Fehler, und substanziell (>50 Zeichen) has_valid_output = bool(clean) and rc == 0 and not clean.startswith('(fehler:') and not clean.startswith('nicht_verifiziert') # Persönliche Assistenz-Marker (Rezepte, Listen, How-tos) # Fix PERS-GROUNDED-2: Erweiterte Marker für Abendessen-Ideen und Essens-Themen personal_markers = [ 'zutaten', 'zubereitung', 'zutatenliste', # Rezepte 'schritt', 'schritte', 'anleitung', 'vorgehen', # How-tos 'kurzbeschreibung', 'beschreibung', # Allgemein 'portione', 'portionen', # Rezepte 'tipps', 'hinweise', 'beispiel', # Listen/Tipps '1.', '2.', '3.', '- ', # Aufzählungen '**', '*', # Markdown-Formatierung 'hier sind', 'wichtige punkte', 'punkte', # Einleitungen 'optionen/hinweise', 'optionen', 'hinweise', # Rezept-Ende 'einfaches', 'grundrezept', # Rezept-Einleitung 'fuer', 'portionen', # Rezept-Header 'wochenplan', 'wochenplanung', 'pragmatisch', # Wochenplanung 'prioritaeten', 'prioritäten', 'kalenderblock', # Planung 'brain-dump', 'tagesabschluss', 'review', # Produktivität # Fix PERS-GROUNDED-2: Neue Marker für Abendessen-Ideen 'abendessen', 'abend-essen', 'ideen', 'schnell', 'einfach', # Essens-Ideen 'one-pan', 'one pan', 'pasta', 'bowl', 'wrap', 'sandwich', # Gerichtstypen 'protein', 'gemuese', 'gemüse', 'fleisch', 'fisch', 'tofu', # Zutaten-Kategorien 'gerichte', 'variationen', 'klassiker', 'optionen', # Listen-Typen 'meal-prep', 'meal prep', 'vorbereiten', 'vorkochen', # Planung 'min', 'minute', 'minuten', 'stunde', 'zeit', # Zeitangaben ] has_personal_structure = has_valid_output and any(m in low for m in personal_markers) # Substantielle Antworten sind grounded (ENTSCHÄRFT für personal) is_substantial = ( has_valid_output and (len(clean) > 50 and clean.count('.') >= 1) or (len(clean) > 30 and has_personal_structure) or (len(clean.splitlines()) >= 2 and has_personal_structure) ) # Mehrzeilige Antworten mit Struktur sind grounded has_multiple_lines = has_valid_output and len(clean.splitlines()) >= 2 has_markdown = has_valid_output and any(c in clean for c in ['**', '*', '- ', '1.', '2.', '###', '##']) # Kombinierte Entscheidung für personal_assist grounded = ( has_personal_structure or is_substantial or (has_multiple_lines and len(clean) > 40) or (has_markdown and len(clean) > 40) ) # Fix PERS-GROUNDED-3: Entferne early return - Summary wird jetzt unten erstellt # Der Code fällt durch zu den gemeinsamen Summary/Return-Blöcken am Ende der Funktion # Fix GENERAL-GROUNDEDNESS-6: Entschärfte Heuristik für general_answer # Problem: Zu strenge Prüfung hat viele valide Antworten als ungrounded markiert # Lösung: Akzeptiere mehr Antworten als grounded, besonders wenn sie aus general-local-assist kommen # Basis: Output existiert und ist kein Fehler has_valid_output = bool(clean) and rc == 0 and not clean.startswith('(fehler:') and not clean.startswith('nicht_verifiziert') # Heuristik 1: Strukturierte Inhalte (Rezepte, How-tos, Erklärungen) # Fix GENERAL-GROUNDEDNESS-7: Erweiterte Marker für general-local-assist Output-Format structure_markers = [ 'zutaten', 'zubereitung', 'zutatenliste', # Rezepte 'schritt', 'schritte', 'anleitung', 'vorgehen', # How-tos 'kurzbeschreibung', 'beschreibung', # Allgemein 'portione', 'portionen', # Rezepte 'vorteile', 'nachteile', 'fazit', # Vergleiche 'erklärung', 'erklaerung', 'warum', 'wie', # Erklärungen 'tipps', 'hinweise', 'beispiel', # Listen/Tipps '1.', '2.', '3.', '- ', # Aufzählungen '**', '*', # Markdown-Formatierung 'hier sind', 'wichtige punkte', 'punkte', # Einleitungen # Fix GENERAL-GROUNDEDNESS-7: Zusätzliche Marker für Rezept-Format 'optionen/hinweise', 'optionen', 'hinweise', # Rezept-Ende 'einfaches', 'grundrezept', # Rezept-Einleitung 'fuer', 'portionen', # Rezept-Header ] has_structure = has_valid_output and any(m in low for m in structure_markers) # Heuristik 2: Substantielle Antworten sind grounded (ENTSCHÄRFT) # Vorher: >100 Zeichen, 2+ Sätze # Jetzt: >80 Zeichen, 1+ Sätze (oder >50 Zeichen mit Struktur) # Fix GENERAL-GROUNDEDNESS-8: Mehrzeilige Antworten mit Struktur sind immer grounded is_substantial = ( has_valid_output and (len(clean) > 80 and clean.count('.') >= 1) or (len(clean) > 50 and any(m in low for m in structure_markers)) or # Fix: Mehrzeilige Antworten mit Aufzählungen sind grounded (len(clean.splitlines()) >= 3 and any(m in low for m in ['-', '1.', '2.', 'zutaten', 'zubereitung', 'kurzbeschreibung'])) ) # Heuristik 3: Definition/Erklärung-Form explanation_patterns = [ r'^\w+\s+ist\s+ein', # "Docker ist ein..." r'^\w+\s+ist\s+die', # "Das ist die..." r'^\w+\s+sind\s+', # "Y sind..." r'^ein\s+\w+\s+ist', # "Ein Lichtjahr ist..." r'^der\s+\w+\s+ist', # "Der Unterschied ist..." r'^die\s+\w+\s+ist', # "Die Antwort ist..." r'^\w+\s+bedeutet', # "X bedeutet..." ] has_explanation_form = has_valid_output and any(re.search(p, low) for p in explanation_patterns) # Heuristik 4: Vergleichs-Marker comparison_markers = [' vs ', ' versus ', 'unterschied', 'vergleich', ' vs. ', ' im vergleich zu ', ' gegenüber '] has_comparison = has_valid_output and any(m in low for m in comparison_markers) # Heuristik 5: Wissenschaft/Fakt-Marker fact_markers = [ 'lichtjahr', 'lichtjahre', 'photosynthese', 'dna', 'protein', 'kalorie', 'kilokalorie', 'kcal', 'gramm', 'meter', 'sekunde', 'prozent', '°c', 'grad', 'jahr', 'monat', 'tag', 'stunde', 'wasser', 'sauerstoff', 'kohlenstoff', 'stickstoff', 'erde', 'mond', 'sonne', 'planet', 'stern', 'galaxie', 'atom', 'molekül', 'molekuel', 'zelle', 'organismus', 'temperatur', 'druck', 'geschwindigkeit', 'masse', 'gewicht', ] has_fact_content = has_valid_output and any(m in low for m in fact_markers) # Heuristik 6: Allgemeine Erklärungs-Wörter explanation_words = ['weil', 'denn', 'daher', 'deshalb', 'folglich', 'somit', 'also'] has_explanation_words = has_valid_output and any(m in low for m in explanation_words) # Heuristik 7: Qualitäts-Indikatoren (NEU) # Antworten mit mehreren Zeilen oder Markdown sind wahrscheinlich gut has_multiple_lines = has_valid_output and len(clean.splitlines()) >= 2 has_markdown = has_valid_output and any(c in clean for c in ['**', '*', '- ', '1.', '2.', '###', '##']) # Kombinierte Entscheidung: grounded wenn mindestens eine Bedingung erfüllt (ENTSCHÄRFT) # Vorher: strengere Schwellen (60-80 Zeichen) # Jetzt: akzeptiere auch kurze aber strukturierte Antworten grounded = ( has_structure or is_substantial or (has_explanation_form and len(clean) > 50) or (has_comparison and len(clean) > 50) or (has_fact_content and len(clean) > 50) or (has_explanation_words and len(clean) > 60) or (has_multiple_lines and len(clean) > 60) or (has_markdown and len(clean) > 60) ) summary = '' for line in clean.splitlines(): line = line.strip() if not line or line.lower() in {'befund:', 'ursache:', 'aktion:', 'kurzantwort:', 'quellen:'}: continue summary = line[:220] break if not summary: summary = clean.splitlines()[0][:220] if clean else f'(fehler: {(err or "").strip()})' return { 'tool': tool, 'rc': rc, 'kind': kind, 'grounded': grounded, 'output': clean if clean else (err or '').strip(), 'error': (err or '').strip(), 'summary': summary, 'confidence': 0.92 if grounded else 0.72 if clean else 0.15, 'signals': [sig for sig in ['ldap', 'authentik', 'oidc', 'dns', 'smb', 'share', 'warning', 'error', 'quelle', 'quellen'] if sig in low], } def render_evidence(evidence_items: list[dict], memory_ctx: str = '', retrieval_ctx: str = '') -> str: blocks = [] if memory_ctx: blocks.append('Persoenlicher Kontext:\n' + memory_ctx) if retrieval_ctx: blocks.append(retrieval_ctx) if evidence_items: rendered = [] for idx, item in enumerate(evidence_items, start=1): rendered.append( f"Evidence {idx}: tool={item.get('tool')} kind={item.get('kind')} grounded={item.get('grounded')} rc={item.get('rc')}\n" f"{item.get('output') or item.get('error') or '(leer)'}" ) blocks.append('Evidence-Objekte:\n' + '\n\n'.join(rendered)) return '\n\n'.join(blocks) if blocks else '(keine)' def record_incident_if_useful(message: str, analysis: dict, final_text: str, evidence_items: list[dict]): if analysis.get('task_type') != 'diagnose': return grounded = [item for item in evidence_items if item.get('grounded') and item.get('tool') in INCIDENT_MEMORY_TOOLS] if not grounded: return subject = '' for token in [analysis.get('role', ''), *analysis.get('tools', [])]: token = str(token or '').strip() if token in {'ops', 'research', 'personal', 'dev', 'general'}: continue if token: subject = token break lead = '' for line in (final_text or '').splitlines(): line = line.strip() if not line or line.lower() in {'befund:', 'ursache:', 'aktion:'}: continue lead = line[:260] break if not lead: lead = grounded[0].get('summary', '')[:260] text = f"{message}\nLoesungskern: {lead}" remember_incident(text) def refresh_composition_policy_async(): if not COMPOSITION_POLICY_REFRESH.exists(): return try: subprocess.Popen( [str(COMPOSITION_POLICY_REFRESH)], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, start_new_session=True, ) except Exception: pass def record_task_outcome(message: str, analysis: dict, final_text: str, evidence_items: list[dict], status: str = 'success', error_text: str = ''): _record_task_outcome( message, analysis, final_text, evidence_items, status=status, error_text=error_text, log_path=TASK_OUTCOME_LOG, classify_intent_family=classify_intent_family, collect_intent_families=collect_intent_families, service_hints=SERVICE_HINTS, refresh_composition_policy_async=refresh_composition_policy_async, ) def persist_result_state(message: str, analysis: dict, final_text: str, evidence_items: list[dict]): _persist_result_state( message, analysis, final_text, evidence_items, should_skip_heavy_persistence=should_skip_heavy_persistence, update_topic_state=update_topic_state, compact_episode_text=compact_episode_text, remember_episode=remember_episode, record_incident_if_useful=record_incident_if_useful, ) def verify_answer(message: str, analysis: dict, draft: str, evidence_items: list[dict], memory_ctx: str = '', retrieval_ctx: str = ''): if not draft.strip(): return None if analysis.get('task_type') in {'action', 'memory'}: return {'verdict': 'keep', 'grounded': True, 'addresses_request': True, 'wrong_entity_jump': False, 'retry_tool': '', 'missing_points': [], 'rationale': 'Direkter Spezialpfad.', 'revised_answer': ''} if len(evidence_items) == 1 and evidence_items[0].get('tool') in GROUNDED_FASTPATH_TOOLS and evidence_items[0].get('grounded'): return {'verdict': 'keep', 'grounded': True, 'addresses_request': True, 'wrong_entity_jump': False, 'retry_tool': '', 'missing_points': [], 'rationale': 'Direkter grounded Tool-Fastpath.', 'revised_answer': ''} if len(evidence_items) == 1 and evidence_items[0].get('tool') == 'web_research': raw = evidence_items[0].get('output', '') if raw.strip().startswith('Kurzantwort:') and 'Quellen:' in raw: return {'verdict': 'keep', 'grounded': True, 'addresses_request': True, 'wrong_entity_jump': False, 'retry_tool': '', 'missing_points': [], 'rationale': 'Direkter Research-Fastpath mit Quellen.', 'revised_answer': ''} if analysis.get('role') == 'general' and len(evidence_items) == 1 and evidence_items[0].get('tool') == 'general_answer' and not memory_ctx and not retrieval_ctx: return {'verdict': 'keep', 'grounded': True, 'addresses_request': True, 'wrong_entity_jump': False, 'retry_tool': '', 'missing_points': [], 'rationale': 'Direkter General-Fastpath ohne riskante Evidenzspruenge.', 'revised_answer': ''} # Fix PERS-GROUNDED-3: Fastpath für personal_assist (wie general_answer) # Problem: personal_assist läuft durch LLM-Verifier und wird manchmal falsch als ungrounded markiert # Lösung: Direkter Fastpath wenn personal_assist grounded ist und keine Memory/Retrieval-Kontext if analysis.get('role') == 'personal' and len(evidence_items) == 1 and evidence_items[0].get('tool') == 'personal_assist' and evidence_items[0].get('grounded') and not memory_ctx and not retrieval_ctx: return {'verdict': 'keep', 'grounded': True, 'addresses_request': True, 'wrong_entity_jump': False, 'retry_tool': '', 'missing_points': [], 'rationale': 'Direkter Personal-Fastpath ohne riskante Evidenzspruenge.', 'revised_answer': ''} system = ( 'Du bist das Qualitaetspruef-Modul von J.A.R.V.I.S. ' 'Pruefe eine Antwort nur gegen Nutzerfrage, Analyse und vorhandene Evidence-Objekte. ' 'Eine schlechte Antwort springt auf falsche Objekte oder Mechanismen, etwa Sonos -> Audiobookshelf. ' 'Wenn die Antwort mit der vorhandenen Evidenz reparierbar ist, gib verdict=revise und liefere revised_answer. ' 'Wenn das falsche Tool verwendet wurde und ein anderes vorhandenes Tool klar besser passt, gib verdict=retry und retry_tool. ' 'Erfinde keine neuen Fakten.' ) user = ( f'Nutzerfrage:\n{message}\n\n' f'Analyse:\n{json.dumps(analysis, ensure_ascii=False)}\n\n' f'Evidenz:\n{render_evidence(evidence_items, memory_ctx=memory_ctx, retrieval_ctx=retrieval_ctx)}\n\n' f'Antwortentwurf:\n{draft}\n\n' 'Verfuegbare Tools fuer retry:\n' f'{tool_catalog_text()}\n\n' 'Pruefe: trifft die Antwort das eigentliche Objekt der Anfrage, beantwortet sie die Teilfragen, bleibt sie bei belegten Aussagen, und fehlt etwas Wesentliches?' ) for model in VERIFY_MODELS: if not model_exists(model): continue rc, out, _err = call_llm_schema(model, VERIFY_SCHEMA, system, user, timeout=60) if rc != 0 or not out: continue try: data = json.loads(out) except Exception: continue if not isinstance(data, dict): continue retry_tool = str(data.get('retry_tool') or '').strip() if retry_tool and retry_tool not in TOOLS: data['retry_tool'] = '' return data return None def finalize_answer(message: str, analysis: dict, evidence_items: list[dict], draft: str, memory_ctx: str = '', retrieval_ctx: str = '', allow_retry: bool = True, allow_synthesize: bool = True): tool_kinds = {name: cfg.get('kind', '') for name, cfg in TOOLS.items()} _skip_verify = _should_skip_verify(analysis, evidence_items, tool_kinds) review = ({} if _skip_verify else verify_answer(message, analysis, draft, evidence_items, memory_ctx=memory_ctx, retrieval_ctx=retrieval_ctx)) or {} verdict = review.get('verdict', 'keep') revised = str(review.get('revised_answer') or '').strip() retry_tool = str(review.get('retry_tool') or '').strip() if verdict == 'retry' and allow_retry and retry_tool and retry_tool not in [item.get('tool') for item in evidence_items] and TOOLS.get(retry_tool, {}).get('kind') != 'action': rc, out, err = run_tool(retry_tool, message) if rc == 0 and out: merged = evidence_items + [evidence_object(retry_tool, rc, out, err)] new_draft = synthesize(message, analysis, merged, memory_ctx=memory_ctx, retrieval_ctx=retrieval_ctx) return finalize_answer(message, analysis, merged, new_draft, memory_ctx=memory_ctx, retrieval_ctx=retrieval_ctx, allow_retry=False, allow_synthesize=False) if err: evidence_items = evidence_items + [evidence_object(retry_tool, rc, out, err)] if verdict == 'revise' and revised: return revised, evidence_items if verdict == 'keep': return draft, evidence_items if allow_synthesize and evidence_items and draft == evidence_items[-1].get('output', ''): new_draft = synthesize(message, analysis, evidence_items, memory_ctx=memory_ctx, retrieval_ctx=retrieval_ctx) if new_draft and new_draft != draft: return finalize_answer(message, analysis, evidence_items, new_draft, memory_ctx=memory_ctx, retrieval_ctx=retrieval_ctx, allow_retry=False, allow_synthesize=False) return draft, evidence_items def run_tool(name: str, message: str): trace_stage('run_tool', name) if name == 'memory_profile': low = norm(message) if re.search(r'^(bitte\s+)?(merke dir|merk dir|denk daran|behalte im kopf)\b', low): return run([str(AGENT_MEMORY), 'ingest', message], timeout=20) if '?' not in message and re.search(r'\b(ich will|ich bevorzuge|mir ist wichtig|achte darauf|das ist falsch|es sollte|er sollte|wenn es um .* geht|ich .* wichtig finde)\b', low): return run([str(AGENT_MEMORY), 'ingest', message], timeout=20) if 'was weisst du ueber mich' in low or 'was weisst du über mich' in message.lower() or 'mein profil' in low or 'gespeichertes profil' in low: return run([str(AGENT_MEMORY), 'summary'], timeout=20) return run([str(AGENT_MEMORY), 'answer', message], timeout=20) if name == 'briefing': return run([str(ROOT / 'bin' / 'agent-briefing')], timeout=70) if name == 'setup_lookup': return run([str(ROOT / 'bin' / 'ops-setup-qa'), message], timeout=70) if name == 'public_services_health': return run([str(ROOT / 'bin' / 'ops-public-services-health')], timeout=70) if name == 'log_hotspots': return run([str(ROOT / 'bin' / 'ops-log-hotspots'), message], timeout=90) if name == 'host_slow_diagnose': return run([str(ROOT / 'bin' / 'ops-host-slow-diagnose'), message], timeout=120) if name == 'kasm_diagnose': return run([str(ROOT / 'bin' / 'ops-kasm-diagnose'), message], timeout=120) if name == 'user_service_access_diagnose': return run([str(ROOT / 'bin' / 'ops-user-service-access-diagnose'), message], timeout=120) if name == 'web_root_cause': return run([str(ROOT / 'bin' / 'ops-web-root-cause'), message], timeout=120) if name == 'sonos_library_diagnose': return run([str(ROOT / 'bin' / 'ops-sonos-library-diagnose'), message], timeout=120) if name == 'emby_user_provision': return run([str(ROOT / 'bin' / 'ops-emby-user-provision-advice'), message], timeout=120) if name == 'light_status': return run([str(ROOT / 'bin' / 'ops-lights-on')], timeout=40) if name == 'light_usage': return run([str(ROOT / 'bin' / 'ops-light-usage-advice'), '168'], timeout=90) if name == 'light_control': return run([str(ROOT / 'bin' / 'home-lightctl'), message], timeout=40) if name == 'web_research': quick = ROOT / 'bin' / 'research-quick-answer' low = norm(message) if quick.exists() and re.search(r'^(was ist|wer ist|wo ist|wo liegt|wo befindet sich|warum|wieso|weshalb|wie leben|wie lebt|wo leben|wo lebt|was essen|was frisst|wie gross ist|wie groß ist|wie alt wird|wie funktioniert)\b', low): rc, out, err = run([str(quick), message], timeout=25) if rc == 0 and out.strip() and 'nicht_verifiziert' not in out.lower(): return rc, out, err # Fix WEB-RESEARCH-FALLBACK: Wenn Web-Recherche fehlschlägt, versuche general-local-assist # Problem: 26% der web_research Aufrufe waren ungrounded wegen Netzwerkfehlern # Lösung: Fallback auf lokales Wissen statt nicht_verifiziert zurückgeben rc, out, err = run([str(ROOT / 'bin' / 'research-web-answer'), message], timeout=180) # Fix WEB-STDERR-1: Prüfe auch stderr wenn stdout leer ist # Problem: research-web-answer gibt 'nicht_verifiziert' auf stderr aus bei Fehlern combined_output = out.strip() if out.strip() else err.strip() if rc != 0 or not combined_output or 'nicht_verifiziert' in combined_output.lower(): # Web-Recherche fehlgeschlagen - versuche general-local-assist als Fallback # Fix WEB-FALLBACK-1: Akzeptiere auch Exit Code 1 wenn substanzielle Antwort vorhanden # Problem: general-local-assist gibt oft rc=1 zurück auch bei brauchbarer Antwort env = os.environ.copy() env['BIBABOT_SELF_CRITIQUE'] = '0' env.setdefault('MAC_OLLAMA_MODEL_GENERAL', os.environ.get('BIBABOT_GENERAL_CHAIN', GENERAL_CHAIN_DEFAULT)) env['MAC_OLLAMA_NUM_CTX_CHAT'] = env.get('MAC_OLLAMA_NUM_CTX_CHAT', '24576') rc2, out2, err2 = run([str(ROOT / 'bin' / 'general-local-assist'), message], timeout=70, env=env) # Akzeptiere Antwort wenn: Exit Code 0 ODER (Exit Code 1 UND substanzielle Antwort >100 Zeichen) has_substantial_output = out2.strip() and len(out2.strip()) > 100 and 'nicht_verifiziert' not in out2.lower() if (rc2 == 0 and out2.strip()) or (rc2 == 1 and has_substantial_output): # Markiere als Web-Antwort mit Hinweis auf Fallback return 0, f"{out2.strip()}\n\n(Hinweis: Web-Recherche nicht verfügbar, Antwort basiert auf lokalem Wissen)", "" return rc, out, err if name == 'url_research': return run([str(ROOT / 'bin' / 'research-url-answer'), message], timeout=180) if name == 'compare_selfhosted': return run([str(ROOT / 'bin' / 'research-compare-selfhosted'), message], timeout=150) if name == 'security_plan': return run([str(ROOT / 'bin' / 'research-security-plan')], timeout=150) if name == 'media_advice': low = norm(message) mode = 'audiobooks' if 'lidarr' in low: mode = 'lidarr' elif 'emby' in low: mode = 'emby' return run([str(ROOT / 'bin' / 'ops-media-advice'), mode], timeout=90) if name == 'personal_assist': return run([str(ROOT / 'bin' / 'personal-local-assist'), message], timeout=150) if name == 'dev_build': return run([str(ROOT / 'bin' / 'dev-local-assist'), message], timeout=240) if name == 'general_answer': env = os.environ.copy() env['BIBABOT_SELF_CRITIQUE'] = '0' env['MAC_OLLAMA_CALL_TIMEOUT_SEC'] = '75' env.setdefault('MAC_OLLAMA_MODEL_GENERAL', os.environ.get('BIBABOT_GENERAL_CHAIN', GENERAL_CHAIN_DEFAULT)) env['MAC_OLLAMA_NUM_CTX_CHAT'] = env.get('MAC_OLLAMA_NUM_CTX_CHAT', '24576') return run([str(ROOT / 'bin' / 'general-local-assist'), message], timeout=100, env=env) if name == 'ops_exec': return run([str(ROOT / 'bin' / 'ops-exec-action'), message], timeout=120) if name == 'ops_install': return run([str(ROOT / 'bin' / 'ops-install-action'), message], timeout=180) if name == 'ops_api': return run([str(ROOT / 'bin' / 'ops-api-query'), message], timeout=100) if name == 'ops_deep_analyze': return run([str(ROOT / 'bin' / 'ops-deep-analyze'), message], timeout=130) if name == 'qdrant_search': return run([str(ROOT / 'bin' / 'qdrant-context-search'), message], timeout=40) if name == 'expert_write': return run([str(ROOT / 'bin' / 'expert-write-assist'), message], timeout=180) if name == 'expert_code': return run([str(ROOT / 'bin' / 'expert-code-assist'), message], timeout=180) if name == 'expert_research': return run([str(ROOT / 'bin' / 'expert-deep-research'), message], timeout=200) if name == 'expert_edit': return run([str(ROOT / 'bin' / 'expert-proofread'), message], timeout=180) if name == 'expert_strategy': return run([str(ROOT / 'bin' / 'expert-strategy-assist'), message], timeout=180) if name == 'goals_manage': return run([str(ROOT / 'bin' / 'goals-manage'), message], timeout=15) if name == 'contacts_manage': return run([str(ROOT / 'bin' / 'contacts-manage'), message], timeout=15) if name == 'agent_introspect': return run([str(ROOT / 'bin' / 'agent-introspect'), 'analyze'], timeout=30) if name == 'agent_self_correct': return run([str(ROOT / 'bin' / 'agent-self-correct'), 'check', message], timeout=30) if name == 'agent_reason': return run([str(ROOT / 'bin' / 'agent-reason'), 'start', message, '--max-depth', '10'], timeout=60) if name == 'agent_anticipate': return run([str(ROOT / 'bin' / 'agent-anticipate'), 'predict', message], timeout=30) if name == 'agent_emotion': return run([str(ROOT / 'bin' / 'agent-emotion'), 'analyze', message], timeout=30) if name == 'agent_simulate': return run([str(ROOT / 'bin' / 'agent-simulate'), 'action', message], timeout=45) if name == 'agent_synthesize': return run([str(ROOT / 'bin' / 'agent-synthesize'), 'solve', message, '--domains', 'computing,homelab'], timeout=60) # AUTO-LEARN TRIGGER if name.startswith("expert_") or name.startswith("kb_"): import subprocess as _sp _sp.run([str(ROOT / "bin" / "agent-learn"), "queue", name, "--priority", "5"], capture_output=True) return 1, '', f'unknown_tool:{name}' def synthesize(message: str, analysis: dict, evidence_items: list[dict], memory_ctx: str = '', retrieval_ctx: str = ''): role = FINAL_ROLE_MAP.get(analysis.get('role', 'general'), 'general') files = [] analysis_text = ['Orchestrator-Analyse:', f"- role={analysis.get('role')}", f"- task_type={analysis.get('task_type')}", f"- tools={', '.join(analysis.get('tools', []))}"] for item in analysis.get('reasoning_focus', [])[:4]: analysis_text.append(f'- focus={item}') files.append(write_temp('analysis', '\n'.join(analysis_text))) mem = memory_ctx or memory_context(message) if mem: files.append(write_temp('memory', mem)) if retrieval_ctx: files.append(write_temp('retrieval', retrieval_ctx)) files.append(write_temp('contract', response_contract(analysis))) files.append(write_temp('evidence', render_evidence(evidence_items))) for idx, item in enumerate(evidence_items, start=1): files.append(write_temp(f'tool{idx}', f"Tool: {item.get('tool')}\nKind: {item.get('kind')}\nGrounded: {item.get('grounded')}\n\n{item.get('output') or item.get('error') or '(leer)'}")) cmd = [str(LLM_LOCAL), '--role', role] for path in files: cmd.extend(['--extra-context-file', path]) cmd.append(message) _synth_env = os.environ.copy() _synth_env['BIBABOT_SELF_CRITIQUE'] = '0' _synth_env['MAC_OLLAMA_CALL_TIMEOUT_SEC'] = '60' # Use better synthesis models when expert tools are involved _expert_tools = {'expert_write', 'expert_code', 'expert_research', 'expert_edit', 'expert_strategy'} _has_expert = any(item.get('tool') in _expert_tools for item in evidence_items) if _has_expert: _synth_env['MAC_OLLAMA_MODEL_OPS'] = 'gemma3:27b-it-qat,qwen3:30b-a3b,mistral-small3.1:latest' _synth_env['MAC_OLLAMA_MODEL_GENERAL'] = 'gemma3:27b-it-qat,lfm2:24b,mistral-small3.1:latest' else: _synth_env['MAC_OLLAMA_MODEL_OPS'] = 'mistral-small3.1:latest,llama3.1:8b,qwen2.5:3b' _synth_env['MAC_OLLAMA_MODEL_GENERAL'] = 'mistral-small3.1:latest,llama3.1:8b,qwen2.5:3b' try: rc, out, err = run(cmd, timeout=90, env=_synth_env) finally: for path in files: try: os.unlink(path) except OSError: pass if rc == 0 and out: return out if evidence_items: return evidence_items[-1].get('output', '') or evidence_items[-1].get('error', '') return 'nicht_verifiziert: Kein belastbares Ergebnis erzeugt.' # ─── Quality Feedback Loop ──────────────────────────────────────────────────── FEEDBACK_TOOLS = {'expert_write', 'expert_code', 'expert_research', 'expert_edit', 'expert_strategy'} _FEEDBACK_ROLE_MAP = { 'expert_write': 'write', 'expert_code': 'code_expert', 'expert_research': 'research_deep', 'expert_edit': 'proofread', 'expert_strategy': 'strategy', } def judge_output(message: str, output: str) -> tuple[int, str, str]: """Ruft llm-judge auf und gibt (score, critique, improve) zurueck.""" judge_bin = ROOT / 'bin' / 'llm-judge' if not judge_bin.exists(): return 3, '', '' try: import json as _json rc, out, err = run([str(judge_bin), message[:1500], output[:3500]], timeout=35) if rc == 0 and out: start = out.find('{') end = out.rfind('}') + 1 if start >= 0 and end > start: d = _json.loads(out[start:end]) score = max(1, min(5, int(d.get('score', 3)))) return score, str(d.get('critique', '')), str(d.get('improve', '')) except Exception: pass return 3, '', '' def refine_output(message: str, role: str, original: str, critique: str, improve: str) -> str: """Verbessert die Antwort basierend auf der Qualitaetskritik.""" orig_ctx = 'Deine erste Antwort (zu verbessern):\n' + original crit_ctx = ( 'Qualitaetskritik vom Pruefer:\n' 'Note: unzureichend\n' 'Kritik: ' + critique + '\n' 'Verbesserungspunkte: ' + improve + '\n\n' 'Erstelle jetzt eine vollstaendige, verbesserte Version deiner Antwort.' ) orig_file = write_temp('refine_orig', orig_ctx) critique_file = write_temp('refine_critique', crit_ctx) try: cmd = [str(LLM_LOCAL), '--role', role, '--extra-context-file', orig_file, '--extra-context-file', critique_file, message] env = os.environ.copy() env['MAC_OLLAMA_MODEL_OPS'] = 'gemma3:27b-it-qat,qwen3:30b-a3b,mistral-small3.1:latest' env['MAC_OLLAMA_MODEL_GENERAL'] = 'gemma3:27b-it-qat,lfm2:24b,mistral-small3.1:latest' env['MAC_OLLAMA_CALL_TIMEOUT_SEC'] = '90' env['BIBABOT_SELF_CRITIQUE'] = '0' rc, out, err = run(cmd, timeout=120, env=env) if rc == 0 and out and len(out) > 80: return out except Exception: pass finally: for f in (orig_file, critique_file): try: os.unlink(f) except OSError: pass return original def quality_feedback_loop(message: str, analysis: dict, final_text: str, evidence_items: list[dict]) -> str: """Bewertet die Ausgabe mit llm-judge; bei Score < 3 wird sie mit dem Originalmodell verfeinert. Greift nur fuer expert_* Tools ein, alles andere bleibt unveraendert.""" used_tools = {item.get('tool') for item in evidence_items} if not used_tools & FEEDBACK_TOOLS: return final_text if not final_text or len(final_text) < 80: return final_text score, critique, improve = judge_output(message, final_text) trace_stage('quality_judge', f'score={score}', f'critique={critique[:60]}') if score >= 3: # Qualitaet ausreichend — keine Aenderung return final_text # Score 1 oder 2 → verfeinern tool = next((t for t in evidence_items if t.get('tool') in FEEDBACK_TOOLS), {}).get('tool', '') role = _FEEDBACK_ROLE_MAP.get(tool, 'general') trace_stage('quality_refine_start', f'tool={tool}', f'role={role}') refined = refine_output(message, role, final_text, critique, improve) trace_stage('quality_refine_done', f'len={len(refined)}') return refined def log_event(message: str, analysis: dict, evidence_items: list[dict], role_hint: str | None): try: LOG_PATH.parent.mkdir(parents=True, exist_ok=True) entry = { 'ts': datetime.now(timezone.utc).isoformat(), 'message': message, 'role_hint': role_hint, 'analysis': analysis, 'tools': [item.get('tool') for item in evidence_items], 'evidence': [ { 'tool': item.get('tool'), 'kind': item.get('kind'), 'grounded': item.get('grounded'), 'rc': item.get('rc'), 'summary': item.get('summary'), 'confidence': item.get('confidence'), } for item in evidence_items ], } with LOG_PATH.open('a', encoding='utf-8') as f: f.write(json.dumps(entry, ensure_ascii=False) + '\n') except Exception: pass AUTO_KB_LEARN = ROOT / "bin" / "auto-kb-learn" def auto_learn_async(message: str, answer: str): """Non-blocking background learning from research tasks.""" if not AUTO_KB_LEARN.exists(): return try: subprocess.Popen( [str(AUTO_KB_LEARN), message[:500], answer[:2000]], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, start_new_session=True, ) except Exception: pass def main(): p = argparse.ArgumentParser() p.add_argument('--role-hint', choices=sorted(ROLE_HINTS), default=None) p.add_argument('message') args = p.parse_args() message = normalize_inbound_message(args.message) role_hint = args.role_hint trace_stage('start', message) remember_user_signal(message) trace_stage('after_remember_user_signal') seed = heuristic_analysis(message, role_hint) trace_stage('seed', json.dumps(seed, ensure_ascii=False)) memory_ctx = memory_context(message, seed) trace_stage('memory_ctx', 'yes' if memory_ctx else 'no') retrieval_ctx = retrieval_context(message, seed, role_hint) trace_stage('retrieval_ctx', 'yes' if retrieval_ctx else 'no') playbook_ctx = playbook_context(message, seed) retrieval_ctx = append_context(retrieval_ctx, playbook_ctx) trace_stage('playbook_ctx', 'yes' if playbook_ctx else 'no') if seed.get('confidence', 0.0) >= 0.65 or seed.get('dangerous_action'): analysis = normalize_analysis(seed, message, role_hint) else: raw = analyze_with_llm(message, role_hint, memory_ctx=memory_ctx, retrieval_ctx=retrieval_ctx) analysis = normalize_analysis(raw or seed, message, role_hint) if not memory_ctx and analysis.get('needs_memory'): memory_ctx = memory_context(message, analysis) or memory_ctx retrieval_ctx = retrieval_context(message, analysis, role_hint) or retrieval_ctx playbook_ctx = playbook_ctx or playbook_context(message, analysis) retrieval_ctx = append_context(retrieval_ctx, playbook_ctx) plan = None if USE_PLAN_FIRST: try: plan = build_plan(message, analysis, memory_ctx=memory_ctx, retrieval_ctx=retrieval_ctx, playbook_ctx=playbook_ctx) except Exception: plan = None if plan: trace_stage('plan_first', json.dumps(plan, ensure_ascii=False)) analysis = apply_plan_first(analysis, plan) if not memory_ctx and analysis.get('needs_memory'): memory_ctx = memory_context(message, analysis) or memory_ctx refreshed = retrieval_context(message, analysis, role_hint) retrieval_ctx = append_context(refreshed or retrieval_ctx, playbook_ctx) retrieval_ctx = append_context(retrieval_ctx, render_plan_context(plan)) if os.environ.get('SUBAGENT_DEBUG', '0') == '1': print(json.dumps(analysis, ensure_ascii=False), file=sys.stderr) tools = analysis.get('tools', []) if not tools: tools = ['general_answer'] analysis['tools'] = tools trace_stage('tools', ','.join(tools)) if len(tools) == 1: rc, out, err = run_tool(tools[0], message) trace_stage('tool_result', tools[0], f'rc={rc}', f'out={len(out)}', f'err={len(err)}') evidence_items = [evidence_object(tools[0], rc, out, err)] # Auto-escalate: ops_api found critical issues + user wants solutions → add ops_deep_analyze _low = norm(message) if (tools[0] == 'ops_api' and rc == 0 and out and analysis.get('task_type') in {'diagnose', 'status'} and any(k in out.lower() for k in ['unhealthy', 'error', 'exited', 'failed']) and re.search(r'(fix|reparier|beheb|wie|was soll|was tun|wie kann|loesung|schritte|warum|analyse)', _low)): trace_stage('auto_escalate', 'ops_api→ops_deep_analyze') _deep_msg = f"{message}\n\nAPI Daten:\n{out[:800]}" _rc2, _out2, _err2 = run_tool('ops_deep_analyze', _deep_msg) if _rc2 == 0 and _out2: evidence_items = [evidence_object('ops_api', rc, out, err), evidence_object('ops_deep_analyze', _rc2, _out2, _err2)] draft = synthesize(message, analysis, evidence_items, memory_ctx=memory_ctx, retrieval_ctx=retrieval_ctx) final, used_outputs = finalize_answer(message, analysis, evidence_items, draft, memory_ctx=memory_ctx, retrieval_ctx=retrieval_ctx) final = postprocess_final_answer(message, analysis, final) if not should_skip_quality_feedback(analysis, used_outputs): final = quality_feedback_loop(message, analysis, final, used_outputs) log_event(message, analysis, used_outputs, role_hint) persist_result_state(message, analysis, final, used_outputs) if USE_BUENZLI: final = buenzli_response(final) record_task_outcome(message, analysis, final, used_outputs, status='success') print(final) return 0 log_event(message, analysis, evidence_items, role_hint) if rc == 0 and out: if can_fast_exit_single_tool(analysis, evidence_items, memory_ctx=memory_ctx, retrieval_ctx=retrieval_ctx): final, used_outputs = out, evidence_items else: final, used_outputs = finalize_answer(message, analysis, evidence_items, out, memory_ctx=memory_ctx, retrieval_ctx=retrieval_ctx) log_event(message, analysis, used_outputs, role_hint) final = postprocess_final_answer(message, analysis, final) if not should_skip_quality_feedback(analysis, used_outputs): final = quality_feedback_loop(message, analysis, final, used_outputs) persist_result_state(message, analysis, final, used_outputs) # Auto-learn from research results if tools[0] in {"web_research", "url_research", "compare_selfhosted"} and final and len(final) > 50: auto_learn_async(message, final) if USE_BUENZLI: final = buenzli_response(final) record_task_outcome(message, analysis, final, used_outputs, status='success') print(final) return 0 if out: record_task_outcome(message, analysis, out, evidence_items, status='tool_output_unverified') print(out) return 0 fallback_tools = [] if tools[0] == 'general_answer' and analysis.get('task_type') in {'explain', 'howto'} and '?' in message: fallback_tools = ['web_research'] elif tools[0] == 'web_research': fallback_tools = ['general_answer'] elif tools[0] == 'user_service_access_diagnose': fallback_tools = ['setup_lookup'] for fb in fallback_tools: frc, fout, ferr = run_tool(fb, message) if frc == 0 and fout: merged = evidence_items + [evidence_object(fb, frc, fout, ferr)] trace_stage('fallback_tool_result', fb, f'rc={frc}', f'out={len(fout)}', f'err={len(ferr)}') final, used_outputs = finalize_answer(message, analysis, merged, fout, memory_ctx=memory_ctx, retrieval_ctx=retrieval_ctx) log_event(message, analysis, used_outputs, role_hint) final = postprocess_final_answer(message, analysis, final) if not should_skip_quality_feedback(analysis, used_outputs): final = quality_feedback_loop(message, analysis, final, used_outputs) persist_result_state(message, analysis, final, used_outputs) if USE_BUENZLI: final = buenzli_response(final) record_task_outcome(message, analysis, final, used_outputs, status='success') print(final) return 0 if err: record_task_outcome(message, analysis, '', evidence_items, status='tool_failed', error_text=err) print(f'nicht_verifiziert: Tool {tools[0]} schlug fehl ({err}).') return 1 record_task_outcome(message, analysis, '', evidence_items, status='no_result') print('nicht_verifiziert: Kein belastbares Ergebnis erzeugt.') return 1 # Sequential execution: each tool gets accumulated context from previous steps _seq_result = _run_sequential_tools( message, tools, analysis, is_complex_task=is_complex_task, run_tool=run_tool, evidence_object=evidence_object, trace_stage=trace_stage, sequential_tool_budget=sequential_tool_budget, summarize_sequential_context=summarize_sequential_context, ) collected = list(_seq_result.get('collected') or []) _failed_ev = _seq_result.get('failed_ev') _is_seq = bool(_seq_result.get('is_sequential')) if _failed_ev is not None: _tool = _failed_ev.get('tool', '') _err = _failed_ev.get('error', '') or ('timeout' if _failed_ev.get('rc') == 124 else 'tool_failed') record_task_outcome(message, analysis, '', collected, status='tool_failed', error_text=f'{_tool}:{_err}') print(f'nicht_verifiziert: Kompositionsschritt {_tool} schlug fehl ({_err}).') return 1 # Skip expensive synthesize when final tool already produced a complete structured answer _last_ev = collected[-1] if collected else {} _last_out = _last_ev.get('output', '') _last_kind = TOOLS.get(_last_ev.get('tool', ''), {}).get('kind', '') _prep_direct = bool(analysis.get('composition_reason')) and _last_kind == 'final' and _last_out and all(bool(item.get('grounded')) for item in collected[:-1]) _light_composed = build_lightweight_composed_answer(message, analysis, collected) if _light_composed: draft = _light_composed trace_stage('synthesize_skipped', 'lightweight_composed_answer') elif _is_seq and _last_kind == 'final' and _last_out and (len(_last_out) > 100 or _prep_direct): draft = _last_out trace_stage('synthesize_skipped', 'final_tool_output_used_directly' if len(_last_out) > 100 else 'composition_final_output_used_directly') else: draft = synthesize(message, analysis, collected, memory_ctx=memory_ctx, retrieval_ctx=retrieval_ctx) trace_stage('after_synthesize', f'draft={len(draft)}') final, used_outputs = finalize_answer(message, analysis, collected, draft, memory_ctx=memory_ctx, retrieval_ctx=retrieval_ctx) final = postprocess_final_answer(message, analysis, final) final = quality_feedback_loop(message, analysis, final, used_outputs) log_event(message, analysis, used_outputs, role_hint) persist_result_state(message, analysis, final, used_outputs) if USE_BUENZLI: final = buenzli_response(final) record_task_outcome(message, analysis, final, used_outputs, status='success') print(final) return 0 if __name__ == '__main__': raise SystemExit(main())