feat: deterministic keyword trigger for read_news, bypassing the LLM

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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LVgSHNHdRx3UNTBodFmhRA
This commit is contained in:
Jeuner 2026-09-07 18:43:47 +02:00
parent b0282900d2
commit 6a391e943f
4 changed files with 162 additions and 0 deletions

View file

@ -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
~1520 % 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

View file

@ -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):

75
astra/triggers.py Normal file
View file

@ -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)

71
tests/test_triggers.py Normal file
View file

@ -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 == []