From 6a391e943f07b796c2c6644f2f6d352bf52dbf61 Mon Sep 17 00:00:00 2001 From: Jeuner <62662523+Jeuners@users.noreply.github.com> Date: Mon, 7 Sep 2026 18:43:47 +0200 Subject: [PATCH] feat: deterministic keyword trigger for read_news, bypassing the LLM MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reale Nutzung zeigte das gemessene Problem live: "Gerne! Ich lese dir die aktuellen Nachrichten aus Hilden vor." — dann nichts, weil das Modell den Tool-Call nur ankündigte statt ihn auszuführen (erwartbar bei ~15-20% Trefferquote mit drei konkurrierenden Tools). astra/triggers.py prüft den transkribierten Nutzertext direkt auf "nachrichten"/"news" + optional einen Themen-Alias und ruft bei Treffer den bereits registrierten read_news-Handler direkt auf — Wiederverwendung desselben Handlers (gleiche UI: activity/tool_start/tool_result), nur ohne die LLM-Entscheidung dazwischen. Das Ergebnis wird als Tool- Roundtrip in den Kontext injiziert, damit die reguläre LLM-Antwort davon weiß. Live verifiziert mit der exakten Nutzerphrase ("Gib mir bitte an der Liste der aktuellen News Hilden.") über echte synthetisierte Sprache: 2/2 Durchläufe zeigten zuverlässig vier echte, klickbare Hilden- Schlagzeilen, auch als die STT "News" und "Hilden" einmal zu einem Wort zusammenzog (Teilstring-Matching fängt das ab). generate_image und read_article bleiben reine LLM-Tools, da ihnen eine feste Trigger-Phrase fehlt. NICHT gepusht — weiterhin nur lokal, wie angewiesen. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01LVgSHNHdRx3UNTBodFmhRA --- README.md | 12 +++++++ astra/server.py | 4 +++ astra/triggers.py | 75 ++++++++++++++++++++++++++++++++++++++++++ tests/test_triggers.py | 71 +++++++++++++++++++++++++++++++++++++++ 4 files changed, 162 insertions(+) create mode 100644 astra/triggers.py create mode 100644 tests/test_triggers.py diff --git a/README.md b/README.md index 0679363..1a2a86d 100644 --- a/README.md +++ b/README.md @@ -80,6 +80,18 @@ mehrdeutig ist). Bildgenerierung bleibt bei ~100 % zuverlässig. Bekannte Grenze eines kleinen lokalen Modells bei Tool-Konkurrenz, kein Bug — die saubere Trennung war eine bewusste Architekturentscheidung. +**`astra/triggers.py`** umgeht diese Grenze gezielt für `read_news`: ein +einfacher Keyword-Check (`nachrichten`/`news` + optional ein Themen-Alias +wie `hilden`/`technik`/`wirtschaft`) im transkribierten Nutzertext ruft +den `read_news`-Handler direkt auf — derselbe Handler, dieselbe UI, +nur ohne die unzuverlässige LLM-Entscheidung dazwischen. Das Ergebnis +landet als Tool-Roundtrip im Kontext, damit die nächste LLM-Antwort es +kennt. Live getestet: 2/2 zuverlässig, wo die reine LLM-Entscheidung nur +~15–20 % erreichte. `generate_image` und `read_article` bleiben bewusst +reine LLM-Tools, weil sie keine feste Trigger-Phrase haben (Bildwunsch +und "erzähl mehr" sind zu variabel für ein Keyword-Muster) und +`generate_image` ohnehin zuverlässig funktioniert. + ## Starten ```bash diff --git a/astra/server.py b/astra/server.py index a131b40..c706ddb 100644 --- a/astra/server.py +++ b/astra/server.py @@ -28,6 +28,7 @@ from astra.core import ( ) from astra.documents import extract_pdf_text from astra.inference import Models, on_executor +from astra.triggers import detect_news_topic, trigger_tool ROOT = Path(__file__).resolve().parent.parent MAX_UPLOAD_BYTES = 15 * 1024 * 1024 @@ -155,6 +156,9 @@ async def run_voice(connection, models, config, voice_state, voice_name, context async def user_turn(aggregator, strategy, message): if message.content: notify({"type": "transcript", "role": "user", "text": message.content}) + topic = detect_news_topic(message.content) + if topic: + await trigger_tool(context, "read_news", {"topic": topic}) @aggregators.assistant().event_handler("on_assistant_turn_stopped") async def assistant_turn(aggregator, message): diff --git a/astra/triggers.py b/astra/triggers.py new file mode 100644 index 0000000..cfead19 --- /dev/null +++ b/astra/triggers.py @@ -0,0 +1,75 @@ +"""Deterministic keyword triggers that call an already-registered tool's +handler directly, bypassing the LLM's own decision to call it. Exists +because read_news, measured, is only called by the LLM in ~15-20% of +matching requests once generate_image and read_article also compete for +its attention — a simple keyword match is ~100% reliable for the same job. +""" + +import json +import uuid + +from astra.feeds import TOPICS + +_NEWS_WORDS = ("nachrichten", "news") +_TOPIC_ALIASES = { + "hilden": "hilden", + "technik": "tech", + "tech": "tech", + "ki": "tech", + "wirtschaft": "wirtschaft", +} + + +def detect_news_topic(text: str) -> str | None: + """Return a configured feed topic if `text` looks like a news request.""" + lowered = text.lower() + if not any(word in lowered for word in _NEWS_WORDS): + return None + for alias, topic in _TOPIC_ALIASES.items(): + if alias in lowered and topic in TOPICS: + return topic + return "nachrichten" if "nachrichten" in TOPICS else None + + +async def trigger_tool(context, tool_name: str, arguments: dict) -> None: + """Call an already-registered tool's handler directly, then inject the + round-trip into `context` so the LLM's next completion already sees it + as answered instead of having to decide to call the tool itself. + """ + tools = getattr(context.tools, "standard_tools", None) or [] + schema = next((t for t in tools if t.name == tool_name), None) + if schema is None: + return + + call_id = f"trigger_{uuid.uuid4().hex[:8]}" + + class _DirectParams: + pass + + params = _DirectParams() + params.arguments = arguments + + async def result_callback(result, *, properties=None): + context.add_messages( + [ + { + "role": "assistant", + "content": "", + "tool_calls": [ + { + "id": call_id, + "type": "function", + "function": {"name": tool_name, "arguments": arguments}, + } + ], + }, + { + "role": "tool", + "tool_call_id": call_id, + "content": json.dumps(result, ensure_ascii=False), + }, + ] + ) + + params.result_callback = result_callback + await schema.handler(params) diff --git a/tests/test_triggers.py b/tests/test_triggers.py new file mode 100644 index 0000000..3752f4d --- /dev/null +++ b/tests/test_triggers.py @@ -0,0 +1,71 @@ +import pytest + +from astra.triggers import detect_news_topic, trigger_tool + + +def test_detect_news_topic_matches_hilden(): + assert detect_news_topic("Gib mir die aktuellen News Hilden") == "hilden" + assert detect_news_topic("Erzähl mir die Nachrichten aus Hilden.") == "hilden" + + +def test_detect_news_topic_matches_tech_aliases(): + assert detect_news_topic("Was gibt es für Nachrichten aus der Technik?") == "tech" + assert detect_news_topic("News zu KI?") == "tech" + + +def test_detect_news_topic_falls_back_to_general(): + assert detect_news_topic("Erzähl mir die Nachrichten.") == "nachrichten" + + +def test_detect_news_topic_returns_none_without_trigger_word(): + assert detect_news_topic("Wie ist das Wetter heute?") is None + assert detect_news_topic("Erzeuge ein Bild von einer Katze.") is None + + +class FakeSchema: + def __init__(self, name, handler): + self.name = name + self.handler = handler + + +class FakeStandardTools: + def __init__(self, schemas): + self.standard_tools = schemas + + +class FakeContext: + def __init__(self, schemas): + self.tools = FakeStandardTools(schemas) + self.messages: list[dict] = [] + + def add_messages(self, messages): + self.messages.extend(messages) + + +@pytest.mark.asyncio +async def test_trigger_tool_calls_handler_and_injects_round_trip(): + calls = [] + + async def handler(params): + calls.append(params.arguments) + await params.result_callback({"status": "ok", "headlines": ["a", "b"]}) + + context = FakeContext([FakeSchema("read_news", handler)]) + await trigger_tool(context, "read_news", {"topic": "hilden"}) + + assert calls == [{"topic": "hilden"}] + assert len(context.messages) == 2 + assistant_msg, tool_msg = context.messages + assert assistant_msg["role"] == "assistant" + assert assistant_msg["tool_calls"][0]["function"]["name"] == "read_news" + assert assistant_msg["tool_calls"][0]["function"]["arguments"] == {"topic": "hilden"} + assert tool_msg["role"] == "tool" + assert tool_msg["tool_call_id"] == assistant_msg["tool_calls"][0]["id"] + assert "headlines" in tool_msg["content"] + + +@pytest.mark.asyncio +async def test_trigger_tool_is_a_noop_for_unknown_tool_name(): + context = FakeContext([FakeSchema("read_news", lambda params: None)]) + await trigger_tool(context, "does_not_exist", {}) + assert context.messages == []