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
|
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
|
||||||
~15–20 % erreichte. `generate_image` und `read_article` bleiben bewusst
|
~15–20 % 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
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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):
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
],
|
],
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -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")
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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():
|
||||||
|
|
|
||||||
|
|
@ -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
Loading…
Add table
Add a link
Reference in a new issue