diff --git a/README.md b/README.md index 42bb401..18dfdca 100644 --- a/README.md +++ b/README.md @@ -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. 60–75 % 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 diff --git a/astra/core.py b/astra/core.py index 59f895b..28de658 100644 --- a/astra/core.py +++ b/astra/core.py @@ -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, }, } diff --git a/astra/feeds.py b/astra/feeds.py new file mode 100644 index 0000000..92b3102 --- /dev/null +++ b/astra/feeds.py @@ -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()) diff --git a/astra/rss.py b/astra/rss.py new file mode 100644 index 0000000..7e92954 --- /dev/null +++ b/astra/rss.py @@ -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] diff --git a/astra/tools.py b/astra/tools.py index 85c9640..335a958 100644 --- a/astra/tools.py +++ b/astra/tools.py @@ -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]) diff --git a/pyproject.toml b/pyproject.toml index c2495fe..4b250eb 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -11,6 +11,7 @@ dependencies = [ "httpx", "pypdf", "python-multipart", + "feedparser", ] [dependency-groups] diff --git a/tests/test_rss.py b/tests/test_rss.py new file mode 100644 index 0000000..b3ffb37 --- /dev/null +++ b/tests/test_rss.py @@ -0,0 +1,91 @@ +import httpx +import pytest + +import astra.rss as rss_module +from astra.rss import RSSError, read_topic + +SAMPLE_RSS = b""" + +Test Feed +Erste Meldung<p>Kurzer Text</p>https://example.com/1 +Zweite MeldungNoch mehr Texthttps://example.com/2 +""" + + +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") diff --git a/tests/test_tools.py b/tests/test_tools.py index afcd925..552bb09 100644 --- a/tests/test_tools.py +++ b/tests/test_tools.py @@ -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] diff --git a/uv.lock b/uv.lock index a235e85..bad9670 100644 --- a/uv.lock +++ b/uv.lock @@ -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" diff --git a/web/app.js b/web/app.js index 36730f0..2e8b4d8 100644 --- a/web/app.js +++ b/web/app.js @@ -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);