mirror of
https://github.com/Jeuners/astra-vision.git
synced 2026-09-09 15:02:35 +02:00
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:
parent
6a391e943f
commit
1656d26ea6
8 changed files with 101 additions and 17 deletions
19
README.md
19
README.md
|
|
@ -87,10 +87,21 @@ 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.
|
||||
~15–20 % erreichte.
|
||||
|
||||
Schlagzeilen werden nummeriert angezeigt (1., 2., 3. …), und derselbe
|
||||
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
|
||||
|
||||
|
|
|
|||
|
|
@ -28,7 +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
|
||||
from astra.triggers import detect_article_reference, detect_news_topic, trigger_tool
|
||||
|
||||
ROOT = Path(__file__).resolve().parent.parent
|
||||
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):
|
||||
await worker.cancel()
|
||||
|
||||
last_headlines: list[dict] = []
|
||||
|
||||
@aggregators.user().event_handler("on_user_turn_stopped")
|
||||
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})
|
||||
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")
|
||||
async def assistant_turn(aggregator, message):
|
||||
|
|
|
|||
|
|
@ -47,12 +47,14 @@ def build_tools(config: Settings, notify: Callable[[dict], None], media_store: d
|
|||
notify({"type": "tool_error", "text": str(exc)})
|
||||
await params.result_callback({"error": str(exc)})
|
||||
return
|
||||
numbered = list(enumerate(entries, start=1))
|
||||
notify(
|
||||
{
|
||||
"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": [
|
||||
{"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",
|
||||
"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
|
||||
],
|
||||
}
|
||||
)
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ its attention — a simple keyword match is ~100% reliable for the same job.
|
|||
"""
|
||||
|
||||
import json
|
||||
import re
|
||||
import uuid
|
||||
|
||||
from astra.feeds import TOPICS
|
||||
|
|
@ -19,6 +20,20 @@ _TOPIC_ALIASES = {
|
|||
"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."""
|
||||
|
|
@ -31,17 +46,39 @@ def detect_news_topic(text: str) -> str | 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
|
||||
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
|
||||
return None
|
||||
|
||||
call_id = f"trigger_{uuid.uuid4().hex[:8]}"
|
||||
captured: dict = {}
|
||||
|
||||
class _DirectParams:
|
||||
pass
|
||||
|
|
@ -50,6 +87,7 @@ async def trigger_tool(context, tool_name: str, arguments: dict) -> None:
|
|||
params.arguments = arguments
|
||||
|
||||
async def result_callback(result, *, properties=None):
|
||||
captured["result"] = result
|
||||
context.add_messages(
|
||||
[
|
||||
{
|
||||
|
|
@ -73,3 +111,4 @@ async def trigger_tool(context, tool_name: str, arguments: dict) -> None:
|
|||
|
||||
params.result_callback = result_callback
|
||||
await schema.handler(params)
|
||||
return captured.get("result")
|
||||
|
|
|
|||
|
|
@ -99,8 +99,8 @@ async def test_read_news_tool_reports_headlines(monkeypatch):
|
|||
assert "KI-Durchbruch" in notifications[2]["text"]
|
||||
assert "Neuer Chip" in notifications[2]["text"]
|
||||
assert notifications[2]["items"] == [
|
||||
{"source": "heise online", "title": "KI-Durchbruch", "link": "https://x"},
|
||||
{"source": "Golem.de", "title": "Neuer Chip", "link": "https://y"},
|
||||
{"number": 1, "source": "heise online", "title": "KI-Durchbruch", "link": "https://x"},
|
||||
{"number": 2, "source": "Golem.de", "title": "Neuer Chip", "link": "https://y"},
|
||||
]
|
||||
assert params.results[0]["status"] == "ok"
|
||||
assert len(params.results[0]["headlines"]) == 2
|
||||
|
|
|
|||
|
|
@ -1,6 +1,22 @@
|
|||
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():
|
||||
|
|
|
|||
|
|
@ -78,7 +78,7 @@ function addToolResult(text, items) {
|
|||
speaker.textContent = "Astra";
|
||||
article.append(speaker);
|
||||
if (items?.length) {
|
||||
const list = document.createElement("ul");
|
||||
const list = document.createElement("ol");
|
||||
list.className = "tool-result-list";
|
||||
for (const item of items) {
|
||||
const li = document.createElement("li");
|
||||
|
|
|
|||
File diff suppressed because one or more lines are too long
Loading…
Add table
Add a link
Reference in a new issue