astra-vision/astra/triggers.py
Jeuner 1656d26ea6 feat: numbered headlines + article-reference trigger ("Artikel 2")
Schlagzeilen aus read_news sind jetzt nummeriert (1., 2., 3. ...) statt
einer reinen Aufzählung, per <ol> im Frontend. astra/triggers.py bekommt
einen zweiten deterministischen Trigger, detect_article_reference():
erkennt "Artikel 2" / "Artikel Nummer drei" / "zweiter Artikel" /
"Detail zu Schlagzeile 4" (Ziffern und deutsche Zahlwörter 1-10) und
löst die Nummer gegen die zuletzt in dieser Session gezeigte
Schlagzeilenliste auf.

trigger_tool() gibt jetzt das Tool-Ergebnis zurück statt es nur in den
Kontext zu injizieren, damit server.py die read_news-Headlines (mit
Nummer + Link) für den Session-lokalen last_headlines-Zustand behalten
kann. Trifft der Artikel-Trigger, wird derselbe Mechanismus wie beim
News-Trigger genutzt, um read_article direkt mit dem passenden Link
aufzurufen.

Live per echtem Zwei-Turn-Dialog mit synthetisierter Sprache verifiziert
("Gib mir bitte die aktuellen News Hilden." → "Hole mir Detail zu
Artikel zwei."): vier nummerierte, klickbare Hilden-Schlagzeilen,
danach korrekt zu Artikel 2 aufgelöst und der echte volle Artikeltext
(Pedelec-Kurs-Details, Helmpflicht, Termine) abgerufen und angezeigt —
kein einziger erfundener Inhalt.

NICHT gepusht — weiterhin nur lokal, wie angewiesen.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LVgSHNHdRx3UNTBodFmhRA
2026-09-07 18:56:20 +02:00

114 lines
4 KiB
Python

"""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 re
import uuid
from astra.feeds import TOPICS
_NEWS_WORDS = ("nachrichten", "news")
_TOPIC_ALIASES = {
"hilden": "hilden",
"technik": "tech",
"tech": "tech",
"ki": "tech",
"wirtschaft": "wirtschaft",
}
_ARTICLE_WORDS = ("artikel", "schlagzeile")
_NUMBER_WORDS = {
"eins": 1, "eine": 1, "einen": 1, "erste": 1, "ersten": 1, "erster": 1,
"zwei": 2, "zweite": 2, "zweiten": 2, "zweiter": 2,
"drei": 3, "dritte": 3, "dritten": 3, "dritter": 3,
"vier": 4, "vierte": 4, "vierten": 4, "vierter": 4,
"fünf": 5, "fünfte": 5, "fünften": 5, "fünfter": 5,
"sechs": 6, "sechste": 6, "sechsten": 6, "sechster": 6,
"sieben": 7, "siebte": 7, "siebten": 7, "siebter": 7,
"acht": 8, "achte": 8, "achten": 8, "achter": 8,
"neun": 9, "neunte": 9, "neunten": 9, "neunter": 9,
"zehn": 10, "zehnte": 10, "zehnten": 10, "zehnter": 10,
} # fmt: skip
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
def detect_article_reference(text: str) -> int | None:
"""Return the 1-based article number if `text` asks for one, e.g.
"Artikel 2", "Artikel Nummer drei", "zweiter Artikel", "Detail zu
Schlagzeile 4". Resolution against the last shown headlines is the
caller's job — this only extracts the number.
"""
lowered = text.lower()
if not any(word in lowered for word in _ARTICLE_WORDS):
return None
match = re.search(r"(?:artikel|schlagzeile)\D{0,15}(\d+)", lowered)
if match:
return int(match.group(1))
for word, number in _NUMBER_WORDS.items():
if re.search(rf"(?:artikel|schlagzeile)\D{{0,15}}{word}\b", lowered):
return number
if re.search(rf"\b{word}\D{{0,15}}(?:artikel|schlagzeile)", lowered):
return number
return None
async def trigger_tool(context, tool_name: str, arguments: dict) -> 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.
Returns the tool's result dict (or None if the tool wasn't found).
"""
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 None
call_id = f"trigger_{uuid.uuid4().hex[:8]}"
captured: dict = {}
class _DirectParams:
pass
params = _DirectParams()
params.arguments = arguments
async def result_callback(result, *, properties=None):
captured["result"] = result
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)
return captured.get("result")