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
This commit is contained in:
Jeuner 2026-09-07 18:56:20 +02:00
parent 6a391e943f
commit 1656d26ea6
8 changed files with 101 additions and 17 deletions

View file

@ -87,10 +87,21 @@ den `read_news`-Handler direkt auf — derselbe Handler, dieselbe UI,
nur ohne die unzuverlässige LLM-Entscheidung dazwischen. Das Ergebnis nur ohne die unzuverlässige LLM-Entscheidung dazwischen. Das Ergebnis
landet als Tool-Roundtrip im Kontext, damit die nächste LLM-Antwort es 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 kennt. Live getestet: 2/2 zuverlässig, wo die reine LLM-Entscheidung nur
~1520 % erreichte. `generate_image` und `read_article` bleiben bewusst ~1520 % erreichte.
reine LLM-Tools, weil sie keine feste Trigger-Phrase haben (Bildwunsch
und "erzähl mehr" sind zu variabel für ein Keyword-Muster) und Schlagzeilen werden nummeriert angezeigt (1., 2., 3. …), und derselbe
`generate_image` ohnehin zuverlässig funktioniert. Mechanismus kennt einen zweiten Trigger: `detect_article_reference()`
erkennt Formulierungen wie "Artikel 2", "Artikel Nummer drei" oder
"zweiter Artikel" im Nutzertext, löst die Nummer gegen die zuletzt
gezeigte Liste auf und ruft `read_article` direkt mit dem passenden Link
auf — ganz ohne dass das Modell selbst den richtigen Link kennen oder
sich für das Werkzeug entscheiden muss. Live getestet über einen echten
Zwei-Turn-Dialog ("News Hilden" → "Hole mir Detail zu Artikel zwei"):
korrekt aufgelöst, echter Artikeltext abgerufen.
`generate_image` bleibt bewusst ein reines LLM-Tool, weil ein Bildwunsch
zu variabel für ein Keyword-Muster ist und ohnehin zuverlässig
funktioniert.
## Starten ## Starten

View file

@ -28,7 +28,7 @@ from astra.core import (
) )
from astra.documents import extract_pdf_text from astra.documents import extract_pdf_text
from astra.inference import Models, on_executor from astra.inference import Models, on_executor
from astra.triggers import detect_news_topic, trigger_tool from astra.triggers import detect_article_reference, detect_news_topic, trigger_tool
ROOT = Path(__file__).resolve().parent.parent ROOT = Path(__file__).resolve().parent.parent
MAX_UPLOAD_BYTES = 15 * 1024 * 1024 MAX_UPLOAD_BYTES = 15 * 1024 * 1024
@ -152,13 +152,23 @@ async def run_voice(connection, models, config, voice_state, voice_name, context
async def disconnected(transport, client): async def disconnected(transport, client):
await worker.cancel() await worker.cancel()
last_headlines: list[dict] = []
@aggregators.user().event_handler("on_user_turn_stopped") @aggregators.user().event_handler("on_user_turn_stopped")
async def user_turn(aggregator, strategy, message): async def user_turn(aggregator, strategy, message):
if message.content: if message.content:
notify({"type": "transcript", "role": "user", "text": message.content}) notify({"type": "transcript", "role": "user", "text": message.content})
topic = detect_news_topic(message.content) topic = detect_news_topic(message.content)
if topic: if topic:
await trigger_tool(context, "read_news", {"topic": topic}) result = await trigger_tool(context, "read_news", {"topic": topic})
if result and result.get("status") == "ok":
last_headlines[:] = result.get("headlines", [])
return
number = detect_article_reference(message.content)
if number is not None:
match = next((h for h in last_headlines if h.get("number") == number), None)
if match:
await trigger_tool(context, "read_article", {"url": match["link"]})
@aggregators.assistant().event_handler("on_assistant_turn_stopped") @aggregators.assistant().event_handler("on_assistant_turn_stopped")
async def assistant_turn(aggregator, message): async def assistant_turn(aggregator, message):

View file

@ -47,12 +47,14 @@ def build_tools(config: Settings, notify: Callable[[dict], None], media_store: d
notify({"type": "tool_error", "text": str(exc)}) notify({"type": "tool_error", "text": str(exc)})
await params.result_callback({"error": str(exc)}) await params.result_callback({"error": str(exc)})
return return
numbered = list(enumerate(entries, start=1))
notify( notify(
{ {
"type": "tool_result", "type": "tool_result",
"text": "\n".join(f"- ({entry.source}) {entry.title}" for entry in entries), "text": "\n".join(f"{i}. ({e.source}) {e.title}" for i, e in numbered),
"items": [ "items": [
{"source": e.source, "title": e.title, "link": e.link} for e in entries {"number": i, "source": e.source, "title": e.title, "link": e.link}
for i, e in numbered
], ],
} }
) )
@ -60,8 +62,14 @@ def build_tools(config: Settings, notify: Callable[[dict], None], media_store: d
{ {
"status": "ok", "status": "ok",
"headlines": [ "headlines": [
{"source": e.source, "title": e.title, "summary": e.summary, "link": e.link} {
for e in entries "number": i,
"source": e.source,
"title": e.title,
"summary": e.summary,
"link": e.link,
}
for i, e in numbered
], ],
} }
) )

View file

@ -6,6 +6,7 @@ its attention — a simple keyword match is ~100% reliable for the same job.
""" """
import json import json
import re
import uuid import uuid
from astra.feeds import TOPICS from astra.feeds import TOPICS
@ -19,6 +20,20 @@ _TOPIC_ALIASES = {
"wirtschaft": "wirtschaft", "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: def detect_news_topic(text: str) -> str | None:
"""Return a configured feed topic if `text` looks like a news request.""" """Return a configured feed topic if `text` looks like a news request."""
@ -31,17 +46,39 @@ def detect_news_topic(text: str) -> str | None:
return "nachrichten" if "nachrichten" in TOPICS else None return "nachrichten" if "nachrichten" in TOPICS else None
async def trigger_tool(context, tool_name: str, arguments: dict) -> 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 """Call an already-registered tool's handler directly, then inject the
round-trip into `context` so the LLM's next completion already sees it 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. 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 [] tools = getattr(context.tools, "standard_tools", None) or []
schema = next((t for t in tools if t.name == tool_name), None) schema = next((t for t in tools if t.name == tool_name), None)
if schema is None: if schema is None:
return return None
call_id = f"trigger_{uuid.uuid4().hex[:8]}" call_id = f"trigger_{uuid.uuid4().hex[:8]}"
captured: dict = {}
class _DirectParams: class _DirectParams:
pass pass
@ -50,6 +87,7 @@ async def trigger_tool(context, tool_name: str, arguments: dict) -> None:
params.arguments = arguments params.arguments = arguments
async def result_callback(result, *, properties=None): async def result_callback(result, *, properties=None):
captured["result"] = result
context.add_messages( context.add_messages(
[ [
{ {
@ -73,3 +111,4 @@ async def trigger_tool(context, tool_name: str, arguments: dict) -> None:
params.result_callback = result_callback params.result_callback = result_callback
await schema.handler(params) await schema.handler(params)
return captured.get("result")

View file

@ -99,8 +99,8 @@ async def test_read_news_tool_reports_headlines(monkeypatch):
assert "KI-Durchbruch" in notifications[2]["text"] assert "KI-Durchbruch" in notifications[2]["text"]
assert "Neuer Chip" in notifications[2]["text"] assert "Neuer Chip" in notifications[2]["text"]
assert notifications[2]["items"] == [ assert notifications[2]["items"] == [
{"source": "heise online", "title": "KI-Durchbruch", "link": "https://x"}, {"number": 1, "source": "heise online", "title": "KI-Durchbruch", "link": "https://x"},
{"source": "Golem.de", "title": "Neuer Chip", "link": "https://y"}, {"number": 2, "source": "Golem.de", "title": "Neuer Chip", "link": "https://y"},
] ]
assert params.results[0]["status"] == "ok" assert params.results[0]["status"] == "ok"
assert len(params.results[0]["headlines"]) == 2 assert len(params.results[0]["headlines"]) == 2

View file

@ -1,6 +1,22 @@
import pytest import pytest
from astra.triggers import detect_news_topic, trigger_tool from astra.triggers import detect_article_reference, detect_news_topic, trigger_tool
def test_detect_article_reference_matches_digit():
assert detect_article_reference("Hole mir Detail zu Artikel 2") == 2
assert detect_article_reference("Artikel Nummer 4 bitte") == 4
def test_detect_article_reference_matches_number_words():
assert detect_article_reference("Erzähl mir mehr zu Artikel zwei") == 2
assert detect_article_reference("zweiter Artikel bitte") == 2
assert detect_article_reference("die dritte Schlagzeile interessiert mich") == 3
def test_detect_article_reference_returns_none_without_article_word():
assert detect_article_reference("Wie ist das Wetter?") is None
assert detect_article_reference("Erzähl mir die Nachrichten.") is None
def test_detect_news_topic_matches_hilden(): def test_detect_news_topic_matches_hilden():

View file

@ -78,7 +78,7 @@ function addToolResult(text, items) {
speaker.textContent = "Astra"; speaker.textContent = "Astra";
article.append(speaker); article.append(speaker);
if (items?.length) { if (items?.length) {
const list = document.createElement("ul"); const list = document.createElement("ol");
list.className = "tool-result-list"; list.className = "tool-result-list";
for (const item of items) { for (const item of items) {
const li = document.createElement("li"); const li = document.createElement("li");

File diff suppressed because one or more lines are too long