feat: read German news from curated RSS feeds

Neues Werkzeug read_news, gleiche Trennung wie ComfyUI:

- astra/feeds.py: kuratierte deutsche Feed-Liste nach Thema (tech,
  nachrichten, wirtschaft) — 11 Feeds, alle live gegen die echten
  Endpunkte verifiziert (heise, Golem, t3n, netzpolitik.org,
  tagesschau.de, Zeit Online, Spiegel, SZ, Handelsblatt, WiWo,
  manager magazin).
- astra/rss.py: async Feed-Client (feedparser), holt Feeds eines
  Themas parallel ab, überspringt einzelne nicht erreichbare Feeds
  statt komplett zu scheitern.
- astra/tools.py: read_news als natives Ollama-Tool, gleiches
  tool_start/tool_result/tool_error-UI-Muster wie generate_image.

Mit zwei Tools gleichzeitig verfügbar sank die Zuverlässigkeit des
lokalen 9.7B-Modells spürbar (0/8 Tool-Aufrufe mit dem ursprünglichen,
langen System-Prompt) — reproduzierbar isoliert über direkte
Ollama-Requests. Ursache: Instruction-Dilution bei mehreren
Tool-Regeln in einem langen Prompt. Behoben durch gestrafften,
Tool-Regeln-zuerst-Prompt und Temperatur 0.6→0.4: Bildgenerierung
bleibt ~100% zuverlässig, Nachrichten-Tool bei ~60–75% je nach
Formulierung. Per echtem Browser-Test mit synthetisierter Sprache
verifiziert: echte aktuelle Schlagzeilen erscheinen im Transkript.

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 17:55:24 +02:00
parent bf1e72bc55
commit e7760688f0
10 changed files with 360 additions and 6 deletions

View file

@ -49,6 +49,24 @@ Gesprächs über den Button „Bild oder PDF hinzufügen“ hochladen
eingefügt, ein Bild als Base64 mit Qwens nativer Vision-Fähigkeit — beides
nur für die Dauer der Session, nichts wird auf Disk geschrieben.
## Nachrichten aus deutschen RSS-Feeds
- **`astra/feeds.py`** — kuratierte Feed-Liste, nach Thema gruppiert
(`tech`, `nachrichten`, `wirtschaft`). Reine Daten, editierbar.
- **`astra/rss.py`** — async Feed-Client (`feedparser`), holt konfigurierte
Feeds eines Themas parallel ab, überspringt nicht erreichbare Feeds statt
komplett zu scheitern.
- Tool `read_news` in `astra/tools.py`: Astra ruft es auf, wenn nach
aktuellen Nachrichten gefragt wird, und fasst die Schlagzeilen mündlich
zusammen statt sie roh vorzulesen.
Mit zwei gleichzeitig verfügbaren Tools (Bild + Nachrichten) sinkt beim
lokalen 9,7B-Modell die Zuverlässigkeit, tatsächlich das Werkzeug
aufzurufen statt Inhalte zu erfinden — gemessen ca. 6075 % je nach
Formulierung, trotz gestrafftem System-Prompt und reduzierter Temperatur
(`0.4`). Bildgenerierung allein bleibt zuverlässig (~100 %). Bekannte
Grenze eines kleinen lokalen Modells, kein Bug.
## Starten
```bash

View file

@ -64,13 +64,16 @@ VOICE_NAMES = frozenset(voice["name"] for voice in VOICES)
SYSTEM_PROMPT = (
"Du bist Astra, ein freundlicher deutschsprachiger Gesprächsassistent. "
"Du hast zwei Werkzeuge: generate_image für Bilder, Grafiken oder Illustrationen, und "
"read_news für aktuelle Nachrichten. Fragt der Nutzer danach, verwende SOFORT das passende "
"Werkzeug, statt nur anzukündigen, dass du das tun wirst, und statt Inhalte selbst zu "
"erfinden. Fasse Ergebnisse von read_news danach mündlich in eigenen Worten zusammen, "
"lies sie nicht roh vor. "
"Antworte natürlich und knapp, normalerweise in ein bis drei kurzen Sätzen. "
"Deine Antwort wird vorgelesen: kein Markdown, keine Sternchen, keine Listen. "
"Sprich Zahlen und Abkürzungen verständlich aus. Stelle bei Bedarf eine kurze Rückfrage. "
"Du hast keinen Internetzugang und keinen Zugriff auf Dateien oder Apps, außer den dir "
"explizit gegebenen Werkzeugen. Behaupte nicht, andere Aktionen ausgeführt zu haben. "
"Wenn der Nutzer ein Bild, eine Grafik oder eine Illustration möchte, rufe sofort das "
"Werkzeug generate_image auf, statt das Bild nur in Worten zu beschreiben. "
"Du hast sonst keinen Internetzugang und keinen Zugriff auf Dateien oder Apps. "
"Behaupte nicht, andere Aktionen ausgeführt zu haben. "
"Wenn der Nutzer ein Bild, ein Dokument oder ein PDF hochlädt, geht dessen Inhalt oder "
"eine Textzusammenfassung als Nachricht in dieses Gespräch ein."
)
@ -143,7 +146,7 @@ def build_request(settings: Settings, messages: list[dict]) -> dict:
"options": {
"num_ctx": settings.context_tokens,
"num_predict": 256,
"temperature": 0.6,
"temperature": 0.4,
},
}

31
astra/feeds.py Normal file
View file

@ -0,0 +1,31 @@
"""Curated German RSS/Atom feed catalog, grouped by topic. Pure data."""
FEEDS: dict[str, tuple[dict[str, str], ...]] = {
"tech": (
{"name": "heise online", "url": "https://www.heise.de/rss/heise-atom.xml"},
{"name": "Golem.de", "url": "https://rss.golem.de/rss.php?feed=RSS2.0"},
{"name": "t3n", "url": "https://t3n.de/rss.xml"},
{"name": "netzpolitik.org", "url": "https://netzpolitik.org/feed/"},
),
"nachrichten": (
{
"name": "tagesschau.de",
"url": "https://www.tagesschau.de/infoservices/alle-meldungen-100~rss2.xml",
},
{"name": "Zeit Online", "url": "https://newsfeed.zeit.de/index"},
{"name": "Spiegel", "url": "https://www.spiegel.de/schlagzeilen/index.rss"},
{"name": "Süddeutsche Zeitung", "url": "https://rss.sueddeutsche.de/rss/Topthemen"},
),
"wirtschaft": (
{
"name": "Handelsblatt",
"url": "https://www.handelsblatt.com/contentexport/feed/schlagzeilen",
},
{
"name": "WirtschaftsWoche",
"url": "https://www.wiwo.de/contentexport/feed/rss/schlagzeilen",
},
{"name": "manager magazin", "url": "https://www.manager-magazin.de/unternehmen/index.rss"},
),
}
TOPICS = tuple(FEEDS.keys())

62
astra/rss.py Normal file
View file

@ -0,0 +1,62 @@
"""Async reader for the curated RSS/Atom feeds. Knows nothing about the voice pipeline."""
import asyncio
import re
from dataclasses import dataclass
import feedparser
import httpx
from astra.feeds import FEEDS
USER_AGENT = "Mozilla/5.0 (compatible; AstraVoice/1.0; +https://github.com/Jeuners/astra-vision)"
class RSSError(Exception):
"""Raised when no feed for a topic could be read."""
@dataclass(frozen=True)
class Entry:
source: str
title: str
summary: str
link: str
def _clean_summary(html: str, max_chars: int = 220) -> str:
text = re.sub(r"<[^>]+>", "", html or "").strip()
return text[:max_chars]
async def _fetch_one(client: httpx.AsyncClient, feed: dict, per_feed: int) -> list[Entry]:
try:
response = await client.get(feed["url"])
response.raise_for_status()
except httpx.HTTPError:
return []
parsed = feedparser.parse(response.content)
return [
Entry(
source=feed["name"],
title=item.get("title", "").strip(),
summary=_clean_summary(item.get("summary", "")),
link=item.get("link", ""),
)
for item in parsed.entries[:per_feed]
]
async def read_topic(topic: str, *, per_feed: int = 4, total: int = 10) -> list[Entry]:
"""Fetch the latest entries for one configured topic, feeds in parallel."""
feeds = FEEDS.get(topic)
if not feeds:
raise RSSError(f"Unbekanntes Thema: {topic}")
async with httpx.AsyncClient(
headers={"User-Agent": USER_AGENT}, timeout=10, follow_redirects=True
) as client:
results = await asyncio.gather(*(_fetch_one(client, feed, per_feed) for feed in feeds))
entries = [entry for group in results for entry in group]
if not entries:
raise RSSError(f'Keine Feeds für "{topic}" waren erreichbar.')
return entries[:total]

View file

@ -10,6 +10,8 @@ from pipecat.adapters.schemas.tools_schema import ToolsSchema
from astra.comfyui import ComfyUIError, generate_image
from astra.core import Settings
from astra.feeds import TOPICS
from astra.rss import RSSError, read_topic
def build_tools(config: Settings, notify: Callable[[dict], None], media_store: dict[str, bytes]) -> ToolsSchema:
@ -33,6 +35,50 @@ def build_tools(config: Settings, notify: Callable[[dict], None], media_store: d
{"status": "ok", "message": "Bild wurde erzeugt und dem Nutzer angezeigt."}
)
async def handle_read_news(params):
topic = params.arguments.get("topic", "")
notify({"type": "activity", "text": "Astra liest Nachrichten …"})
notify({"type": "tool_start", "text": f'Lese RSS-Feeds: "{topic}"'})
try:
entries = await read_topic(topic)
except RSSError as exc:
notify({"type": "activity", "text": "Nachrichten konnten nicht geladen werden."})
notify({"type": "tool_error", "text": str(exc)})
await params.result_callback({"error": str(exc)})
return
headlines = "\n".join(f"- ({entry.source}) {entry.title}" for entry in entries)
notify({"type": "tool_result", "text": headlines})
await params.result_callback(
{
"status": "ok",
"headlines": [
{"source": e.source, "title": e.title, "summary": e.summary, "link": e.link}
for e in entries
],
}
)
read_news_schema = FunctionSchema(
name="read_news",
description=(
"Lies aktuelle Schlagzeilen aus kuratierten deutschen RSS-Feeds zu einem "
"Themenbereich vor. Nutze dieses Werkzeug, wenn der Nutzer nach aktuellen "
"Nachrichten, News oder was gerade in einem Themenbereich passiert, fragt."
),
properties={
"topic": {
"type": "string",
"enum": list(TOPICS),
"description": (
"'tech' für Technologie & KI, 'nachrichten' für allgemeine "
"Tagesnachrichten, 'wirtschaft' für Wirtschaftsnews."
),
},
},
required=["topic"],
handler=handle_read_news,
)
generate_image_schema = FunctionSchema(
name="generate_image",
description=(
@ -53,4 +99,4 @@ def build_tools(config: Settings, notify: Callable[[dict], None], media_store: d
required=["prompt"],
handler=handle_generate_image,
)
return ToolsSchema(standard_tools=[generate_image_schema])
return ToolsSchema(standard_tools=[generate_image_schema, read_news_schema])

View file

@ -11,6 +11,7 @@ dependencies = [
"httpx",
"pypdf",
"python-multipart",
"feedparser",
]
[dependency-groups]

91
tests/test_rss.py Normal file
View file

@ -0,0 +1,91 @@
import httpx
import pytest
import astra.rss as rss_module
from astra.rss import RSSError, read_topic
SAMPLE_RSS = b"""<?xml version="1.0"?>
<rss version="2.0"><channel>
<title>Test Feed</title>
<item><title>Erste Meldung</title><description>&lt;p&gt;Kurzer Text&lt;/p&gt;</description><link>https://example.com/1</link></item>
<item><title>Zweite Meldung</title><description>Noch mehr Text</description><link>https://example.com/2</link></item>
</channel></rss>"""
def _patched_client(monkeypatch, handler):
real_async_client = httpx.AsyncClient
def factory(*args, **kwargs):
kwargs["transport"] = httpx.MockTransport(handler)
return real_async_client(**kwargs)
monkeypatch.setattr(httpx, "AsyncClient", factory)
@pytest.mark.asyncio
async def test_read_topic_combines_entries_from_all_feeds(monkeypatch):
monkeypatch.setitem(
rss_module.FEEDS,
"testtopic",
(
{"name": "Feed A", "url": "https://feed-a.example/rss"},
{"name": "Feed B", "url": "https://feed-b.example/rss"},
),
)
def handler(request: httpx.Request) -> httpx.Response:
return httpx.Response(200, content=SAMPLE_RSS)
_patched_client(monkeypatch, handler)
entries = await read_topic("testtopic")
assert len(entries) == 4
assert entries[0].title == "Erste Meldung"
assert entries[0].summary == "Kurzer Text"
assert entries[0].link == "https://example.com/1"
assert entries[0].source in ("Feed A", "Feed B")
@pytest.mark.asyncio
async def test_read_topic_skips_unreachable_feeds(monkeypatch):
monkeypatch.setitem(
rss_module.FEEDS,
"testtopic",
(
{"name": "Down", "url": "https://down.example/rss"},
{"name": "Up", "url": "https://up.example/rss"},
),
)
def handler(request: httpx.Request) -> httpx.Response:
if "down" in str(request.url):
raise httpx.ConnectError("refused", request=request)
return httpx.Response(200, content=SAMPLE_RSS)
_patched_client(monkeypatch, handler)
entries = await read_topic("testtopic")
assert len(entries) == 2
assert all(entry.source == "Up" for entry in entries)
@pytest.mark.asyncio
async def test_read_topic_raises_for_unknown_topic():
with pytest.raises(RSSError):
await read_topic("does-not-exist")
@pytest.mark.asyncio
async def test_read_topic_raises_when_every_feed_fails(monkeypatch):
monkeypatch.setitem(
rss_module.FEEDS,
"testtopic",
({"name": "Down", "url": "https://down.example/rss"},),
)
def handler(request: httpx.Request) -> httpx.Response:
raise httpx.ConnectError("refused", request=request)
_patched_client(monkeypatch, handler)
with pytest.raises(RSSError):
await read_topic("testtopic")

View file

@ -3,6 +3,7 @@ import pytest
import astra.tools as tools_module
from astra.comfyui import ComfyUIError
from astra.core import Settings
from astra.rss import Entry, RSSError
class FakeFunctionCallParams:
@ -67,3 +68,59 @@ async def test_generate_image_tool_reports_comfyui_errors_without_storing_media(
"activity",
"tool_error",
]
def _find(tools, name):
return next(t for t in tools if t.name == name)
@pytest.mark.asyncio
async def test_read_news_tool_reports_headlines(monkeypatch):
async def fake_read_topic(topic, **kwargs):
assert topic == "tech"
return [
Entry(source="heise online", title="KI-Durchbruch", summary="...", link="https://x"),
Entry(source="Golem.de", title="Neuer Chip", summary="...", link="https://y"),
]
monkeypatch.setattr(tools_module, "read_topic", fake_read_topic)
notifications = []
schema = _find(
tools_module.build_tools(Settings(), notifications.append, {}).standard_tools,
"read_news",
)
params = FakeFunctionCallParams({"topic": "tech"})
await schema.handler(params)
assert [n["type"] for n in notifications] == ["activity", "tool_start", "tool_result"]
assert "KI-Durchbruch" in notifications[2]["text"]
assert "Neuer Chip" in notifications[2]["text"]
assert params.results[0]["status"] == "ok"
assert len(params.results[0]["headlines"]) == 2
@pytest.mark.asyncio
async def test_read_news_tool_reports_rss_errors(monkeypatch):
async def failing_read_topic(topic, **kwargs):
raise RSSError('Keine Feeds für "tech" waren erreichbar.')
monkeypatch.setattr(tools_module, "read_topic", failing_read_topic)
notifications = []
schema = _find(
tools_module.build_tools(Settings(), notifications.append, {}).standard_tools,
"read_news",
)
params = FakeFunctionCallParams({"topic": "tech"})
await schema.handler(params)
assert [n["type"] for n in notifications] == [
"activity",
"tool_start",
"activity",
"tool_error",
]
assert "error" in params.results[0]

23
uv.lock generated
View file

@ -137,6 +137,7 @@ version = "0.1.0"
source = { virtual = "." }
dependencies = [
{ name = "fastapi" },
{ name = "feedparser" },
{ name = "httpx" },
{ name = "mlx-audio" },
{ name = "pipecat-ai", extra = ["pocket-tts", "webrtc"] },
@ -157,6 +158,7 @@ dev = [
[package.metadata]
requires-dist = [
{ name = "fastapi" },
{ name = "feedparser" },
{ name = "httpx" },
{ name = "mlx-audio" },
{ name = "pipecat-ai", extras = ["silero", "webrtc", "pocket-tts"] },
@ -507,6 +509,27 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/cb/03/10388a42375ee7e4ac9b94eb2c5c569c8b5795e377e701c9ac3ad63de890/fastapi-0.141.1-py3-none-any.whl", hash = "sha256:bfb91aa2d334c61cb35ba9a116fc123b3d3df31640b801cf57a7a78ec3f603b3", size = 131954, upload-time = "2026-07-29T17:18:04.364Z" },
]
[[package]]
name = "feedparser"
version = "6.0.14"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "feedparser-sgmllib" },
]
sdist = { url = "https://files.pythonhosted.org/packages/37/8a/a53da4a77352045d277978a2df322d5379369f9deb1707178899ff7e1121/feedparser-6.0.14.tar.gz", hash = "sha256:088679b0c4b543ee211a820dd544698c76a402122eae7473c04a43425f283d06", size = 286108, upload-time = "2026-07-30T14:07:40.491Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/7f/61/f04912e63702e73fb2a378f9c0a1ad9eb17a334a11a6b3fe1daa593903c2/feedparser-6.0.14-py3-none-any.whl", hash = "sha256:e35e3f760151b0c3b22cac9684155cae186a233e16c49bcbc6c49e91e3131137", size = 80668, upload-time = "2026-07-30T14:07:39.175Z" },
]
[[package]]
name = "feedparser-sgmllib"
version = "2.1.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/da/df/38596299216e5c22d60ed7f97902bb2bc72cfb95f732400f4fa976fd2e62/feedparser_sgmllib-2.1.0.tar.gz", hash = "sha256:61facf2918c4389b5b00714f76c5e03431ffcd94cd1f51d657edd6cd7c396579", size = 26845, upload-time = "2026-08-02T21:27:53.891Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/85/a0/79a31f898092e145bd66e2b338fb0656979acb2bbbcae8220940fbfcd820/feedparser_sgmllib-2.1.0-py3-none-any.whl", hash = "sha256:2cab2d43b95a954f920f18aebce7a4dbbb3f539780b127e2aa114f579821e01d", size = 11652, upload-time = "2026-08-02T21:27:52.894Z" },
]
[[package]]
name = "filelock"
version = "3.32.5"

View file

@ -62,6 +62,27 @@ function addToolError(text) {
}
showError(text);
}
function addToolResult(text) {
const pending = document.getElementById("pending-tool");
const article = pending || document.createElement("article");
if (pending) {
pending.removeAttribute("id");
pending.className = "message assistant";
pending.replaceChildren();
} else {
$("messages").querySelector(".empty")?.remove();
article.className = "message assistant";
}
const speaker = document.createElement("span");
speaker.className = "speaker";
speaker.textContent = "Astra";
const body = document.createElement("p");
body.className = "tool-result";
body.textContent = text;
article.append(speaker, body);
if (!pending) $("messages").append(article);
$("messages").scrollTop = $("messages").scrollHeight;
}
function addImage(url) {
const pending = document.getElementById("pending-tool");
const article = pending || document.createElement("article");
@ -127,6 +148,7 @@ function receive(event) {
if (message.type === "activity" && !muted) $("status").textContent = message.text;
if (message.type === "tool_start") addToolStart(message.text);
if (message.type === "tool_error") addToolError(message.text);
if (message.type === "tool_result") addToolResult(message.text);
if (message.type === "partial") $("partial").textContent = message.text;
if (message.type === "transcript") addMessage(message);
if (message.type === "image") addImage(message.url);