diff --git a/dev/test_openwebui_filters.py b/dev/test_openwebui_filters.py index a16cbac..c149fb0 100644 --- a/dev/test_openwebui_filters.py +++ b/dev/test_openwebui_filters.py @@ -117,6 +117,67 @@ class StabilityGuardTests(unittest.IsolatedAsyncioTestCase): self.assertLess(len(result["messages"][1]["content"]), 3000) +class AutoToolSelectorTests(unittest.IsolatedAsyncioTestCase): + async def asyncSetUp(self): + self.module = _load("auto_tool_selector") + self.selector = self.module.Filter() + + async def _select(self, prompt: str, existing=None): + body = { + "model": "mikeai-medium", + "messages": [{"role": "user", "content": prompt}], + } + if existing is not None: + body["tool_ids"] = existing + return await self.selector.inlet(body) + + async def test_homeassistant_is_selected_for_room_temperature(self): + result = await self._select("Wie warm ist es gerade in der Küche?") + self.assertEqual( + result["tool_ids"], ["server:mcp:homeassistant-local"] + ) + + async def test_weather_uses_web_not_homeassistant(self): + result = await self._select("Soll es heute in Rastatt regnen?") + self.assertEqual(result["tool_ids"], ["server:mcp:web-local"]) + + async def test_unraid_uses_readonly_not_mua(self): + result = await self._select( + "Welche Docker-Container laufen aktuell auf Unraid?" + ) + self.assertEqual( + result["tool_ids"], ["server:mcp:unraid-readonly-local"] + ) + self.assertNotIn("server:mcp:mua", result["tool_ids"]) + + async def test_github_and_explicit_web_are_bounded_to_two(self): + result = await self._select( + "Prüfe dieses GitHub Repository und suche zusätzlich im Netz nach Nutzerstimmen." + ) + self.assertEqual( + result["tool_ids"], + ["server:mcp:github-local", "server:mcp:web-local"], + ) + + async def test_manual_tool_is_preserved(self): + result = await self._select( + "Prüfe Sonarr.", ["server:mcp:manually-selected"] + ) + self.assertEqual( + result["tool_ids"], + ["server:mcp:manually-selected", "server:mcp:arr-local"], + ) + + async def test_plain_chat_gets_no_tools(self): + result = await self._select("Erkläre mir den Unterschied zwischen RAM und SSD.") + self.assertNotIn("tool_ids", result) + + async def test_selection_adds_write_safety_rule(self): + result = await self._select("Zeige mir die Home Assistant Automatisierungen.") + self.assertEqual(result["messages"][0]["role"], "system") + self.assertIn("Availability is not authorization", result["messages"][0]["content"]) + + class MetricsTests(unittest.IsolatedAsyncioTestCase): async def test_metrics_file_contains_no_chat_content_or_ids(self): module = _load("local_performance_metrics") diff --git a/docs/CURRENT_REFERENCE.md b/docs/CURRENT_REFERENCE.md index 68c62d0..7485b72 100644 --- a/docs/CURRENT_REFERENCE.md +++ b/docs/CURRENT_REFERENCE.md @@ -166,6 +166,14 @@ Aktuell existieren funktionale Adapter für: - Unraid read-only - eigener Unraid-Administrationsserver +OpenWebUI bindet diese Kataloge nicht pauschal an jedes Modellprofil. Der +lokale `MikeAI Auto Tool Selector` ergänzt anhand der jüngsten Nutzernachricht +höchstens zwei passende MCP-Verbindungen pro Anfrage. Dadurch bleiben normale +Chats schemafrei und kurze Profile verlieren keinen unnötigen Kontext. MUA +mit erweiterten Verwaltungsrechten bleibt von der Automatik ausgeschlossen; +eine automatisch bereitgestellte Verbindung erteilt niemals Schreibrechte +oder eine Änderungsfreigabe. + Der GitHub-Container läuft produktiv. Token-Datei, interner Streamable-HTTP-Handshake, fehlende Host-Portfreigabe und exakt vier read-only Werkzeuge wurden am 23. August 2026 verifiziert. diff --git a/docs/DISASTER_RECOVERY.md b/docs/DISASTER_RECOVERY.md index 157997a..bb6360d 100644 --- a/docs/DISASTER_RECOVERY.md +++ b/docs/DISASTER_RECOVERY.md @@ -55,6 +55,9 @@ laufen, sondern alle fachlichen Funktionen geprüft wurden. - [ ] rohe `qwen-*`-Routermodelle sind ausgeblendet - [ ] Medium ist die gespeicherte Standardauswahl - [ ] Filter und Quick Actions sind allen fünf Presets zugeordnet +- [ ] Auto Tool Selector wählt bei harmlosen Testfragen Web, GitHub, Home + Assistant, ARR, Navidrome, Unraid read-only und Athena korrekt +- [ ] normale Unterhaltung erhält kein MCP; MUA wird niemals automatisch gewählt - [ ] SearXNG und TinySearch gesund - [ ] Websuche liefert kompakte, quellengebundene Ergebnisse @@ -67,7 +70,8 @@ laufen, sondern alle fachlichen Funktionen geprüft wurden. - [ ] offizieller GitHub-MCP gesund; exakt vier read-only Repository-Werkzeuge - [ ] GitHub-Token liegt nur in `/etc/mike-ai/github-mcp.env` (0600), nicht in OpenWebUI - [ ] Unraid read-only Diagnose geprüft -- [ ] schreibende Werkzeuge standardmäßig nicht geladen +- [ ] schreibende Werkzeuge standardmäßig nicht geladen; automatische Auswahl + gilt nicht als Änderungsfreigabe - [ ] Tool-Schemas bleiben innerhalb des festgelegten Kontextbudgets - [ ] kein Secret erscheint in Toolantworten oder Logs - [ ] `PLATFORM_OVERVIEW.md`, `QWEN_OPERATOR_CONTEXT.md` und der Operator- diff --git a/docs/OPERATIONS.md b/docs/OPERATIONS.md index c9b8656..462978d 100644 --- a/docs/OPERATIONS.md +++ b/docs/OPERATIONS.md @@ -9,14 +9,21 @@ Reihenfolge ist absichtlich festgelegt: `reasoning_effort=none`. 2. `Thinking`, Priorität 20: läuft nur bei aktiviertem Brain-Schalter und überschreibt den Standard mit Low, Medium oder High. -3. `MikeAI Stability Guard`, Priorität 30: begrenzt einzelne und gesamte +3. `MikeAI Auto Tool Selector`, Priorität 25: betrachtet ausschließlich die + jüngste Nutzernachricht und stellt pro Anfrage höchstens zwei passende MCPs + bereit. Er erkennt Web, GitHub, Home Assistant, Sonarr/Radarr, Navidrome, + Unraid-Diagnose und Athena-Plattformwissen. Manuell gewählte Werkzeuge + bleiben erhalten. MUA mit erweiterten Verwaltungsrechten wird nie + automatisch zugeschaltet. Die Auswahl eines MCP ist ausdrücklich keine + Freigabe für eine Zustandsänderung. +4. `MikeAI Stability Guard`, Priorität 30: begrenzt einzelne und gesamte Werkzeugresultate, verdichtet bei Bedarf zuerst alte Tool-Ausgaben und Dialogteile und stoppt identische beziehungsweise ausufernde Tool-Schleifen. -4. `MikeAI Secret Redaction`, Priorität 40: entfernt übliche API-Keys, Tokens, +5. `MikeAI Secret Redaction`, Priorität 40: entfernt übliche API-Keys, Tokens, Passwörter, JWTs und private Schlüssel aus Tool-Ergebnissen, bevor sie das Modell erreichen, sowie aus fertigen Modellantworten. Nutzereingaben und Authentifizierungswege werden nicht verändert. -5. `MikeAI Spoken Tool Status`, Priorität 80: erkennt den ersten echten +6. `MikeAI Spoken Tool Status`, Priorität 80: erkennt den ersten echten Werkzeugaufruf eines Antwortlaufs und löst im Browser genau eine kurze, passende Ansage für Web, Unraid, Home Assistant, Medienverwaltung oder sonstige Werkzeuge aus. Die fünf MP3-Clips sind vorgerendert und liegen @@ -24,7 +31,7 @@ Reihenfolge ist absichtlich festgelegt: weder XTTS noch das Werkzeug. Sie wird nur abgespielt, wenn der Benutzer in OpenWebUI die automatische Sprachausgabe aktiviert hat. Endet ein sehr schneller Lauf innerhalb der kurzen Wartezeit, wird die Ansage verworfen. -6. `MikeAI Local Performance Metrics`, Priorität 90: erfasst nach Abschluss +7. `MikeAI Local Performance Metrics`, Priorität 90: erfasst nach Abschluss ausschließlich technische Zahlen wie Laufzeit, Tokenzähler, Token/s und Tool-Anzahl. Nutzer-, Chat- und Nachrichten-IDs sowie sämtliche Textinhalte werden weder geschrieben noch gehasht gespeichert. @@ -49,9 +56,17 @@ Dadurch erscheinen ausschließlich `MikeAI · Fast`, `MikeAI · Medium`, fünf erhalten die geprüften Filter und Quick Actions sowie Piper-Stimme `alloy`. Vision ist bei Fast, Medium, Large und Uncensored aktiviert; Ultra bleibt bewusst text-only. Bildgenerierung wird erst als Fähigkeit freigeschaltet, -wenn ein echter Generator-Worker im Stack aktiv ist. Websuche und Codewerkzeuge -bleiben verfügbar, werden aber nicht automatisch jeder Nachricht beigelegt, -damit Werkzeugschemas den Kontext nicht unnötig füllen. +wenn ein echter Generator-Worker im Stack aktiv ist. Kein MCP ist statisch an +jedes Profil gebunden. Der Auto Tool Selector stellt nur die zur jüngsten +Anfrage passenden Werkzeuge bereit, damit irrelevante Schemas weder Kontext +verbrauchen noch die Werkzeugwahl des Modells verschlechtern. Eine manuelle +Auswahl im Chat bleibt zusätzlich möglich. + +Die Auswahl arbeitet bewusst regelbasiert und lokal. Sie sendet keine Texte an +einen Klassifizierungsdienst, speichert keine Prompts und führt selbst keine +Werkzeugaktion aus. Erkennt sie keine eindeutige Absicht, wird kein MCP +automatisch ergänzt. Schreibende oder kritische Rechte werden weiterhin durch +das jeweilige MCP, Bestätigungsregeln und die manuelle MUA-Auswahl begrenzt. Alle fünf Modelle erhalten außerdem dieselbe Evidenzregel: Aussagen über aktuelle externe oder Systemzustände benötigen im aktuellen Turn einen diff --git a/platform/openwebui/filters/auto_tool_selector.py b/platform/openwebui/filters/auto_tool_selector.py new file mode 100644 index 0000000..7d45e9e --- /dev/null +++ b/platform/openwebui/filters/auto_tool_selector.py @@ -0,0 +1,233 @@ +""" +title: MikeAI Auto Tool Selector +author: MikeAI +version: 1.0.0 +description: Selects a small, relevant set of MCP servers for each user request. +""" + +from __future__ import annotations + +import re +import unicodedata +from pydantic import BaseModel + + +class Filter: + class Valves(BaseModel): + priority: int = 25 + enabled: bool = True + max_automatic_tools: int = 2 + show_selection_status: bool = True + + TOOL_IDS = { + "web": "server:mcp:web-local", + "github": "server:mcp:github-local", + "homeassistant": "server:mcp:homeassistant-local", + "arr": "server:mcp:arr-local", + "unraid": "server:mcp:unraid-readonly-local", + "navidrome": "server:mcp:navidrome-local", + "platform": "server:mcp:athena-platform", + } + + LABELS = { + "web": "Web", + "github": "GitHub", + "homeassistant": "Home Assistant", + "arr": "Sonarr/Radarr", + "unraid": "Unraid-Diagnose", + "navidrome": "Navidrome", + "platform": "Athena-Plattformwissen", + } + + def __init__(self): + self.valves = self.Valves() + self.toggle = False + + @staticmethod + def _normalise(text: str) -> str: + text = unicodedata.normalize("NFKC", text or "").casefold() + return re.sub(r"\s+", " ", text).strip() + + @staticmethod + def _matches(text: str, patterns: tuple[str, ...]) -> bool: + return any(re.search(pattern, text, flags=re.IGNORECASE) for pattern in patterns) + + @staticmethod + def _latest_user_text(body: dict) -> str: + for message in reversed(body.get("messages") or []): + if message.get("role") != "user": + continue + content = message.get("content", "") + if isinstance(content, str): + return content + if isinstance(content, list): + return " ".join( + part.get("text", "") + for part in content + if isinstance(part, dict) and isinstance(part.get("text"), str) + ) + return str(content) + return "" + + @staticmethod + def _add_system_rule(body: dict, labels: list[str]) -> None: + rule = ( + "MikeAI automatic tool selection made these capabilities available for " + f"this request: {', '.join(labels)}. Availability is not authorization. " + "First inspect read-only state. Never perform a state-changing action " + "unless the user's current message explicitly requests that exact change " + "and every required preview, confirmation, backup, and validation rule " + "of the tool is satisfied. Do not call unrelated tools merely because " + "they are available. If the selected tool cannot verify the claim, say so." + ) + messages = body.setdefault("messages", []) + for message in messages: + if message.get("role") == "system" and isinstance(message.get("content"), str): + message["content"] += "\n\n" + rule + return + messages.insert(0, {"role": "system", "content": rule}) + + def _classify(self, raw_text: str) -> list[str]: + text = self._normalise(raw_text) + if not text: + return [] + + selected: list[str] = [] + + platform = self._matches( + text, + ( + r"\bathena\b", r"\bmikeai\b", r"\bki[- ]host\b", + r"\bai[- ]profile[- ]router\b", r"\bprofil[- ]router\b", + r"\brecovery[- ](?:koffer|bundle|skript)\b", + r"\b(?:disaster|bare metal)[- ]recovery\b", + r"\b(?:installations?|reinstall|setup)[- ]skript\b", + r"\bplattform(?:wissen|dokumentation)?\b", + ), + ) + homeassistant = self._matches( + text, + ( + r"\bhome\s*assistant\b", r"\bhomeassistant\b", r"\bhass\b", + r"\bautomatisierung(?:en)?\b", r"\bentit[aä]t(?:en)?\b", + r"\b(?:sensor|light|switch|climate|automation)\.[\w.-]+", + r"\b(?:temperatur|luftfeuchtigkeit|wie warm)\b.*\b" + r"(?:k[uü]che|wohnzimmer|schlafzimmer|bad|toilette|keller|haus)\b", + ), + ) + arr = self._matches( + text, + ( + r"\bsonarr\b", r"\bradarr\b", r"\barr[- ]mcp\b", + r"\b(?:staffel|episode|folge)(?:n)?\b.*\b" + r"(?:fehl|such|download|indexer|release|serie)\w*", + r"\b(?:serie|film)\b.*\b(?:sonarr|radarr|indexer|release)\b", + ), + ) + navidrome = self._matches( + text, + ( + r"\bnavidrome\b", r"\bmusikbibliothek\b", r"\bplaylist\b", + r"\b(?:titel|song|track)\b.*\b(?:[aä]hnlich|empfehl|playlist|bibliothek)\w*", + r"\b(?:album|k[uü]nstler|artist)\b.*\b(?:navidrome|bibliothek|playlist)\b", + ), + ) + unraid = self._matches( + text, + ( + r"\bunraid\b", r"\bhome\s*server\b", r"\bhomeserver\b", + r"\bparity\b", r"\barray\b", r"\bmua\b", + r"\bdocker[- ]container\b", r"\bcontainer(?:n)?\b.*\b" + r"(?:docker|log|update|l[aä]uft|laufen|gestoppt|unraid)\b", + r"\b(?:docker|container)\b.*\b(?:unraid|homeserver|nas)\b", + ), + ) + github = self._matches( + text, + ( + r"\bgithub\b", r"github\.com/", r"\brepositor(?:y|ium|ies)\b", + r"\bgit[- ]repo\b", r"\bsource[- ]tree\b", + r"\b(?:readme|quellcode|sourcecode|code)\b.*\b(?:repo|github)\b", + ), + ) + + # A specialist source is more precise than public web search. Platform + # wins over a generic Docker mention when Athena is explicitly named. + if platform: + selected.append("platform") + elif homeassistant: + selected.append("homeassistant") + elif arr: + selected.append("arr") + elif navidrome: + selected.append("navidrome") + elif unraid: + selected.append("unraid") + elif github: + selected.append("github") + + explicit_web = self._matches( + text, + ( + r"\b(?:im|ins|das|dem) (?:internet|netz|web)\b", + r"\bwebsuche\b", r"\bonline (?:such|nachschau|recherch|pr[uü]f)\w*", + r"\b(?:internet|web) (?:such|nachschau|recherch|pr[uü]f)\w*", + ), + ) + current_public = self._matches( + text, + ( + r"\b(?:wetter|regen|regnet|regnen|regenradar|vorhersage)\b", + r"\b(?:nachrichten|news|schlagzeilen)\b", + r"\b(?:preis|preise|verf[uü]gbar|verf[uü]gbarkeit)\b", + r"\b(?:neueste|neuestes|neuerungen|release)\b", + ), + ) + if (explicit_web or (current_public and not selected)) and "web" not in selected: + selected.append("web") + + # A direct GitHub reference should still use GitHub even if broader web + # research is also requested. + if github and "github" not in selected: + selected.insert(0, "github") + + return selected[: max(0, self.valves.max_automatic_tools)] + + async def _notify(self, emitter, labels: list[str]) -> None: + if emitter is None or not self.valves.show_selection_status: + return + try: + await emitter( + { + "type": "status", + "data": { + "description": "Automatisch bereitgestellt: " + ", ".join(labels), + "done": True, + }, + } + ) + except Exception: + pass + + async def inlet(self, body: dict, __event_emitter__=None, **kwargs) -> dict: + if not self.valves.enabled: + return body + + selected = self._classify(self._latest_user_text(body)) + if not selected: + return body + + existing = body.get("tool_ids") + if not isinstance(existing, list): + existing = [] + merged = list(existing) + for category in selected: + tool_id = self.TOOL_IDS[category] + if tool_id not in merged: + merged.append(tool_id) + body["tool_ids"] = merged + + labels = [self.LABELS[category] for category in selected] + self._add_system_rule(body, labels) + await self._notify(__event_emitter__, labels) + return body diff --git a/platform/openwebui/install-filters.sh b/platform/openwebui/install-filters.sh index 0f554d4..55ccc55 100755 --- a/platform/openwebui/install-filters.sh +++ b/platform/openwebui/install-filters.sh @@ -9,7 +9,7 @@ ACTION_DIR=$(cd "$(dirname "${BASH_SOURCE[0]}")/actions" && pwd) die() { printf 'FEHLER: %s\n' "$*" >&2; exit 1; } [[ $EUID -eq 0 ]] || die "Bitte als root ausführen." -for file in reasoning_default_off.py thinking.py stability_guard.py secret_redaction.py spoken_tool_status.py local_performance_metrics.py; do +for file in reasoning_default_off.py thinking.py auto_tool_selector.py stability_guard.py secret_redaction.py spoken_tool_status.py local_performance_metrics.py; do [[ -s $FILTER_DIR/$file ]] || die "Filterdatei fehlt: $file" done [[ -s $ACTION_DIR/quick_actions.py ]] || die "Actiondatei fehlt: quick_actions.py" @@ -67,9 +67,9 @@ if not {"key", "value", "updated_at"} <= config_columns: owner = requested_owner if not owner: existing = con.execute( - "select user_id from function where id in (?, ?, ?, ?, ?, ?, ?) order by id limit 1", + "select user_id from function where id in (?, ?, ?, ?, ?, ?, ?, ?) order by id limit 1", ( - "reasoning_default_off", "thinking", "stability_guard", + "reasoning_default_off", "thinking", "auto_tool_selector", "stability_guard", "secret_redaction", "spoken_tool_status", "local_performance_metrics", "quick_actions", ), @@ -88,6 +88,11 @@ now = int(time.time()) functions = [ ("reasoning_default_off", "Reasoning Default Off", "filter", 10, filter_dir, ""), ("thinking", "Thinking", "filter", 20, filter_dir, ""), + ( + "auto_tool_selector", "MikeAI Auto Tool Selector", "filter", 25, filter_dir, + "Stellt pro Anfrage höchstens zwei passende MCP-Werkzeuge bereit. " + "Die Auswahl ist keine Freigabe für schreibende Aktionen.", + ), ("stability_guard", "MikeAI Stability Guard", "filter", 30, filter_dir, ""), ("secret_redaction", "MikeAI Secret Redaction", "filter", 40, filter_dir, ""), ( @@ -312,7 +317,7 @@ with con: ), ) print( - "OpenWebUI konfiguriert: Default Off=10, Thinking=20, " + "OpenWebUI konfiguriert: Default Off=10, Thinking=20, Auto Tool Selector=25, " "Stability Guard=30, Secret Redaction=40, Spoken Tool Status=80, Local Metrics=90, " "Quick Actions=100, Folgefragen=aus, Piper-TTS=aktiv, Werkzeugwahl=optimiert" ) diff --git a/platform/openwebui/install-models.sh b/platform/openwebui/install-models.sh index 44cbbe6..98b0862 100644 --- a/platform/openwebui/install-models.sh +++ b/platform/openwebui/install-models.sh @@ -93,17 +93,16 @@ now = int(time.time()) filter_ids = [ "reasoning_default_off", "thinking", + "auto_tool_selector", "stability_guard", "secret_redaction", "local_performance_metrics", ] -# Open WebUI addresses a global MCP server as server:mcp:. -# Attach the bounded web relay and, when its dedicated secret exists, the four -# read-only official GitHub tools. This is not Open WebUI's built-in web search. -default_tool_ids = ["server:mcp:web-local"] -if github_enabled: - default_tool_ids.append("server:mcp:github-local") +# The Auto Tool Selector attaches a bounded, relevant MCP set per request. +# Keeping this list empty prevents unrelated schemas from consuming context. +# Users can still enable additional tools manually in a chat. +default_tool_ids = [] def capabilities(vision: bool) -> dict: return { @@ -310,8 +309,7 @@ with con: "tags": [{"name": tag} for tag in profile["tags"]], "toolIds": default_tool_ids, # Built-in features remain available but are not forced on every - # request. Only the bounded web MCP and four read-only GitHub - # repository tools are attached by default. + # request. The Auto Tool Selector supplies relevant MCPs per turn. "defaultFeatureIds": [], "filterIds": filter_ids, "actionIds": ["quick_actions"],