mirror of
https://github.com/Jeuners/astra-vision.git
synced 2026-09-09 15:02:35 +02:00
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:
parent
b0282900d2
commit
6a391e943f
4 changed files with 162 additions and 0 deletions
12
README.md
12
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
|
||||
|
|
|
|||
|
|
@ -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
75
astra/triggers.py
Normal 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
71
tests/test_triggers.py
Normal 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 == []
|
||||
Loading…
Add table
Add a link
Reference in a new issue