From 5d01400638f28345790d442c5ba76409486a6299 Mon Sep 17 00:00:00 2001 From: Oleksandr Kozachuk Date: Tue, 14 Jul 2026 11:34:23 +0200 Subject: [PATCH] news memory + get_news tool (news-07..12): feed summaries, deduped rolling store (schema v6), on-demand filtered retrieval --- DECISIONS.md | 12 +++ fjerkroa_bot/news.py | 120 ++++++++++++++++++++++--- fjerkroa_bot/openai_responder.py | 10 +++ fjerkroa_bot/persistence.py | 67 +++++++++++++- specs/SPEC-013-news.md | 46 ++++++++++ tests/test_spec_news.py | 150 ++++++++++++++++++++++++++++++- 6 files changed, 390 insertions(+), 15 deletions(-) diff --git a/DECISIONS.md b/DECISIONS.md index 3fa4f2f..4846399 100644 --- a/DECISIONS.md +++ b/DECISIONS.md @@ -60,6 +60,18 @@ Decisions inside the set architecture. D-NNN, never renumbered. broken classifier must never mute the bot; the budget gate already bounds spend. Its verdict gates BEFORE the main call, the envelope's answer_needed still gates after — two independent nets. +- **D-019** — News memory + on-demand tool (SPEC-013 NEWS-07..12): + the news pipeline now carries item summaries (feed descriptions, + HTML-stripped) and persists every fetched item into a deduped `news` + table (schema v6), pruned to a rolling window (`news-keep`). Both the + kroa digest run and the ggg posting run write to it, so the store is + a single searchable source across both models. A `get_news` tool + reads that store (topic/source-filtered, metered, sanitized) rather + than re-fetching feeds live: the ambient `{news}` digest stays a + small always-on snapshot, while the tool gives unbounded on-demand + reach without a fresh network round-trip per call. The store is the + same `bot.db` (WAL) the bot uses; the cron process opens it + independently — concurrent reader/writer is what WAL is for. - **D-018** — Codex Mechanicus search (FDB-019, SPEC-014): Luma's lore is grounded in the priest's real archive at binaric.tech via a `codex_search` tool over the site's public `search-index.json`, not diff --git a/fjerkroa_bot/news.py b/fjerkroa_bot/news.py index 74a5ae2..636aef2 100644 --- a/fjerkroa_bot/news.py +++ b/fjerkroa_bot/news.py @@ -11,8 +11,10 @@ CLI: python -m fjerkroa_bot.news --config kroa.toml import argparse import logging +import re import sys import time +from html import unescape from typing import Any, Dict, List, Optional, Tuple import defusedxml.ElementTree as ElementTree # hardened XML: feeds are untrusted (XXE/billion-laughs) @@ -21,8 +23,17 @@ from .ai_responder import sanitize_external_text DEFAULT_PER_FEED = 3 DEFAULT_MAX_ITEMS = 15 +DEFAULT_SUMMARY_CHARS = 200 +DEFAULT_NEWS_KEEP = 400 FETCH_TIMEOUT_S = 15 _ATOM = "{http://www.w3.org/2005/Atom}" +_TAG_RE = re.compile(r"<[^>]+>") + + +def _clean_summary(raw: str, max_len: int = 300) -> str: + """Strip HTML, unescape entities, collapse whitespace (feed descriptions are often HTML).""" + text = unescape(_TAG_RE.sub(" ", raw or "")) + return re.sub(r"\s+", " ", text).strip()[:max_len] def parse_feed(data: bytes, source: str = "") -> List[Dict[str, str]]: @@ -34,31 +45,39 @@ def parse_feed(data: bytes, source: str = "") -> List[Dict[str, str]]: logging.warning(f"news: unparseable/unsafe feed {source!r}: {err!r}") return [] items: List[Dict[str, str]] = [] - # RSS: <link/> + # RSS: <rss><channel><item><title/><link/><description/> for item in root.iter("item"): title = (item.findtext("title") or "").strip() link = (item.findtext("link") or "").strip() + summary = _clean_summary(item.findtext("description") or "") if title: - items.append({"title": title, "link": link, "source": source}) - # Atom: <feed><entry><title/><link href=/> + items.append({"title": title, "link": link, "source": source, "summary": summary}) + # Atom: <feed><entry><title/><link href=/><summary|content/> for entry in root.iter(f"{_ATOM}entry"): title = (entry.findtext(f"{_ATOM}title") or "").strip() link_el = entry.find(f"{_ATOM}link") link = link_el.get("href", "") if link_el is not None else "" + summary = _clean_summary(entry.findtext(f"{_ATOM}summary") or entry.findtext(f"{_ATOM}content") or "") if title: - items.append({"title": title, "link": link, "source": source}) + items.append({"title": title, "link": link, "source": source, "summary": summary}) return items -def render_digest(items: List[Dict[str, str]], max_items: int = DEFAULT_MAX_ITEMS) -> str: - """Compact sanitized digest for the {news} prompt slot.""" +def render_digest(items: List[Dict[str, str]], max_items: int = DEFAULT_MAX_ITEMS, summary_chars: int = DEFAULT_SUMMARY_CHARS) -> str: + """Compact sanitized digest for the {news} prompt slot (title + short summary + link).""" lines = [] for item in items[:max_items]: title = sanitize_external_text(item["title"], 200) source = item.get("source", "") link = item.get("link", "") + summary = sanitize_external_text(item.get("summary", ""), summary_chars) if summary_chars else "" prefix = f"[{source}] " if source else "" - lines.append(f"- {prefix}{title}" + (f" ({link})" if link else "")) + line = f"- {prefix}{title}" + if summary: + line += f" — {summary}" + if link: + line += f" ({link})" + lines.append(line) return "\n".join(lines) @@ -102,10 +121,11 @@ def item_key(item: Dict[str, str]) -> str: class NewsPoster: """Post NEW feed items to Discord channel webhooks (ggg model, SPEC-013 NEWS-04..06).""" - def __init__(self, guard, fetch_bytes, post_webhook) -> None: + def __init__(self, guard, fetch_bytes, post_webhook, store: Any = None) -> None: self._guard = guard self._fetch_bytes = fetch_bytes self._post_webhook = post_webhook + self._store = store async def run_post( self, @@ -115,9 +135,11 @@ class NewsPoster: per_feed: int, max_per_run: int, seed_only: bool, + keep: int = DEFAULT_NEWS_KEEP, ) -> Tuple[int, set]: """Returns (posted_count, updated_seen). seed_only marks new items seen without posting.""" posted = 0 + harvested: List[Dict[str, str]] = [] for url, label, channel in feeds: reason = self._guard(url) if reason: @@ -129,6 +151,7 @@ class NewsPoster: logging.warning(f"news-post: fetch failed for {label}: {repr(err)}") continue for item in parse_feed(data, label)[:per_feed]: + harvested.append(item) # NEWS-09: everything parsed feeds the searchable store key = item_key(item) if not key or key in seen: continue @@ -136,6 +159,9 @@ class NewsPoster: may_post = not seed_only and posted < max_per_run if may_post and await self._deliver(item, label, channel, webhooks): posted += 1 + if self._store is not None and harvested: + self._store.add_news_items(harvested) + self._store.prune_news(keep) return posted, seen async def _deliver(self, item: Dict[str, str], label: str, channel: str, webhooks: Dict[str, str]) -> bool: @@ -154,6 +180,76 @@ class NewsPoster: return False +# --- news memory + on-demand retrieval tool (SPEC-013 NEWS-09..12) --- + +GET_NEWS_TOOL = { + "name": "get_news", + "description": "Fetch recent real-world news the bot has collected from its RSS feeds (local, national, world, sport, " + "culture). Use when someone asks what is new or what is happening, optionally about a topic or from a particular " + "source. Returns headlines with a short summary and a link to read more.", + "parameters": { + "type": "object", + "properties": { + "topic": {"type": "string", "description": "Optional keywords to filter by, e.g. 'Nordland', 'football', 'weather'."}, + "source": {"type": "string", "description": "Optional source label, e.g. 'NRK', 'Aftenposten', 'Verden', 'Sport'."}, + "limit": {"type": "integer", "description": "How many items to return (default 10, max 30)."}, + }, + "required": [], + }, +} + + +def _news_terms(topic: Optional[str]) -> List[str]: + return [t for t in re.split(r"\W+", (topic or "").lower()) if len(t) > 1][:5] + + +def query_news( + store: Any, topic: Optional[str] = None, source: Optional[str] = None, limit: int = 10, summary_chars: int = DEFAULT_SUMMARY_CHARS +) -> Dict[str, Any]: + """Retrieve stored news for the get_news tool: term/source-filtered, sanitized (NEWS-11).""" + if store is None: + return {"error": "news store unavailable"} + limit = max(1, min(int(limit or 10), 30)) + src = (str(source).strip() or None) if source else None + terms = _news_terms(topic) + try: + rows = store.search_news(terms, limit, src) if terms else store.recent_news(limit, src) + except Exception as err: + logging.warning(f"news: query failed: {err!r}") + return {"error": "news lookup failed"} + results = [ + { + "source": row.get("source", ""), + "title": sanitize_external_text(row.get("title", ""), 200), + "summary": sanitize_external_text(row.get("summary", ""), summary_chars), + "link": row.get("link", ""), + } + for row in rows + ] + return {"topic": topic or "", "source": src or "", "results": results} + + +def _open_store(config: Dict[str, Any]) -> Any: + directory = config.get("history-directory") + if not directory: + return None + from pathlib import Path + + from .persistence import PersistentStore + + return PersistentStore(Path(str(directory)).expanduser() / "bot.db") + + +def persist_news(config: Dict[str, Any], items: List[Dict[str, str]]) -> int: + """Upsert fetched items into the news store, prune to the rolling window (NEWS-09).""" + store = _open_store(config) + if store is None or not items: + return 0 + added = store.add_news_items(items) + store.prune_news(int(config.get("news-keep", DEFAULT_NEWS_KEEP))) + return added + + def load_seen(path: str) -> Tuple[set, bool]: """(seen-set, existed). Missing/broken state -> empty set, existed=False (seed run).""" import json @@ -231,7 +327,7 @@ async def run_post(config: Dict[str, Any]) -> int: logging.error("news-post: need news-post-webhooks and news-post-feeds") return 0 seen, existed = load_seen(state_path) - poster = NewsPoster(guard_url, _aiohttp_fetch, _aiohttp_post) + poster = NewsPoster(guard_url, _aiohttp_fetch, _aiohttp_post, store=_open_store(config)) posted, seen = await poster.run_post( feeds, webhooks, @@ -239,6 +335,7 @@ async def run_post(config: Dict[str, Any]) -> int: int(config.get("news-post-per-feed", DEFAULT_POST_PER_FEED)), int(config.get("news-post-max-per-run", DEFAULT_POST_MAX_PER_RUN)), seed_only=not existed, # first run seeds without flooding the channels + keep=int(config.get("news-keep", DEFAULT_NEWS_KEEP)), ) save_seen(state_path, seen, int(config.get("news-post-seen-cap", DEFAULT_SEEN_CAP))) logging.info(f"news-post: posted {posted} item(s)" + (" (seed run — nothing posted)" if not existed else "")) @@ -258,7 +355,10 @@ async def run(config: Dict[str, Any]) -> Optional[str]: return None fetcher = NewsFetcher(guard_url, _aiohttp_fetch) items = await fetcher.collect(feeds, int(config.get("news-per-feed", DEFAULT_PER_FEED))) - digest = render_digest(items, int(config.get("news-max-items", DEFAULT_MAX_ITEMS))) + persist_news(config, items) # NEWS-09: feed the searchable rolling store for get_news + digest = render_digest( + items, int(config.get("news-max-items", DEFAULT_MAX_ITEMS)), int(config.get("news-summary-chars", DEFAULT_SUMMARY_CHARS)) + ) header = f"News as of {time.strftime('%Y-%m-%d %H:%M UTC', time.gmtime())}:\n" with open(out_path, "w", encoding="utf-8") as fd: fd.write(header + digest + "\n") diff --git a/fjerkroa_bot/openai_responder.py b/fjerkroa_bot/openai_responder.py index 808ca31..4d5ddc9 100644 --- a/fjerkroa_bot/openai_responder.py +++ b/fjerkroa_bot/openai_responder.py @@ -14,6 +14,7 @@ from .codex import DEFAULT_LIMIT as CODEX_DEFAULT_LIMIT from .codex import CodexSearch from .igdblib import IGDBQuery from .leonardo_draw import LeonardoAIDrawMixIn +from .news import GET_NEWS_TOOL, query_news from .quota import QuotaLedger from .url_reader import FETCH_URL_TOOL, URLReader @@ -180,6 +181,8 @@ class OpenAIResponder(AIResponder, LeonardoAIDrawMixIn): functions.append(FETCH_URL_TOOL) if self.codex.enabled(): # CDX-01 functions.append(CODEX_SEARCH_TOOL) + if self.config.get("enable-news-tool", False) and self.store is not None: # NEWS-10 + functions.append(GET_NEWS_TOOL) return functions async def _dispatch_tool(self, name: str, args: Dict[str, Any], author: str) -> Any: @@ -197,6 +200,13 @@ class OpenAIResponder(AIResponder, LeonardoAIDrawMixIn): self.ledger._add(f"codex:{author}", 1) limit = int(self.config.get("codex-limit", CODEX_DEFAULT_LIMIT)) return await self.codex.search(str(args.get("query", "")), str(args.get("lang", "en")), limit) + if name == "get_news": + per_user_cap = int(self.config.get("news-daily-per-user", 30)) + if self.ledger._get(f"news:{author}") >= per_user_cap: # NEWS-12 + return {"error": "daily news lookup limit reached"} + self.ledger._add(f"news:{author}", 1) + summary_chars = int(self.config.get("news-summary-chars", 200)) + return query_news(self.store, args.get("topic"), args.get("source"), args.get("limit", 10), summary_chars) return await self._execute_igdb_function(name, args) async def draw_openai(self, description: str, count: int = 1) -> List[BytesIO]: diff --git a/fjerkroa_bot/persistence.py b/fjerkroa_bot/persistence.py index e14401e..4780402 100644 --- a/fjerkroa_bot/persistence.py +++ b/fjerkroa_bot/persistence.py @@ -13,7 +13,7 @@ from contextlib import closing from pathlib import Path from typing import Any, Dict, List, Optional -SCHEMA_VERSION = 5 +SCHEMA_VERSION = 6 class PersistentStore: @@ -72,6 +72,13 @@ class PersistentStore: " due_at TEXT NOT NULL, payload TEXT NOT NULL, state TEXT NOT NULL DEFAULT 'queued'," " created_at TEXT NOT NULL DEFAULT (datetime('now')), executed_at TEXT)" ) + if version < 6: + # News memory (SPEC-013 NEWS-09): deduped rolling store of fetched items + conn.execute( + "CREATE TABLE IF NOT EXISTS news (id INTEGER PRIMARY KEY, dedup_key TEXT UNIQUE NOT NULL," + " source TEXT NOT NULL DEFAULT '', title TEXT NOT NULL, link TEXT NOT NULL DEFAULT ''," + " summary TEXT NOT NULL DEFAULT '', first_seen TEXT NOT NULL DEFAULT (datetime('now')))" + ) if version < SCHEMA_VERSION: conn.execute(f"PRAGMA user_version = {SCHEMA_VERSION}") os.chmod(self.db_path, 0o600) # conversation data (PER-04) @@ -115,6 +122,64 @@ class PersistentStore: row = conn.execute("SELECT value FROM usage WHERE day = ? AND key = ?", (day, key)).fetchone() return float(row[0]) if row else 0.0 + # --- news memory (SPEC-013 NEWS-09..12) --- + + def add_news_items(self, items: List[Dict[str, Any]]) -> int: + """Insert deduped news rows (by link or title); returns how many were new (NEWS-09).""" + added = 0 + with closing(self._connect()) as conn, conn: + for item in items: + title = str(item.get("title") or "").strip() + key = (str(item.get("link") or "").strip()) or title + if not title or not key: + continue + cursor = conn.execute( + "INSERT OR IGNORE INTO news (dedup_key, source, title, link, summary) VALUES (?, ?, ?, ?, ?)", + (key, str(item.get("source") or ""), title, str(item.get("link") or ""), str(item.get("summary") or "")), + ) + added += cursor.rowcount + return added + + def recent_news(self, limit: int = 20, source: Optional[str] = None) -> List[Dict[str, Any]]: + sql = "SELECT source, title, link, summary FROM news" + params: List[Any] = [] + if source: + sql += " WHERE source = ?" + params.append(source) + sql += " ORDER BY id DESC LIMIT ?" + params.append(int(limit)) + with closing(self._connect()) as conn: + rows = conn.execute(sql, params).fetchall() + return [{"source": r[0], "title": r[1], "link": r[2], "summary": r[3]} for r in rows] + + def search_news(self, terms: List[str], limit: int = 20, source: Optional[str] = None) -> List[Dict[str, Any]]: + """Rows where every term appears in title or summary; optional source filter (NEWS-11).""" + params: List[Any] = [] + clauses = [] + for term in terms: + clauses.append("(title LIKE ? OR summary LIKE ? OR source LIKE ?)") + like = f"%{term}%" + params += [like, like, like] + where = " AND ".join(clauses) if clauses else "1=1" + if source: + where = f"({where}) AND source = ?" + params.append(source) + params.append(int(limit)) + sql = f"SELECT source, title, link, summary FROM news WHERE {where} ORDER BY id DESC LIMIT ?" # nosec B608 - fixed templates; values parameterised + with closing(self._connect()) as conn: + rows = conn.execute(sql, params).fetchall() + return [{"source": r[0], "title": r[1], "link": r[2], "summary": r[3]} for r in rows] + + def prune_news(self, keep: int) -> int: + """Keep the newest `keep` rows, delete the rest (rolling window, NEWS-09).""" + with closing(self._connect()) as conn, conn: + cursor = conn.execute("DELETE FROM news WHERE id NOT IN (SELECT id FROM news ORDER BY id DESC LIMIT ?)", (int(keep),)) + return cursor.rowcount + + def news_count(self) -> int: + with closing(self._connect()) as conn: + return int(conn.execute("SELECT COUNT(*) FROM news").fetchone()[0]) + def delete_history_of_user(self, user: str) -> int: """Remove persisted rows carrying this user's messages (SAF-08).""" with closing(self._connect()) as conn, conn: diff --git a/specs/SPEC-013-news.md b/specs/SPEC-013-news.md index c692325..cdf5694 100644 --- a/specs/SPEC-013-news.md +++ b/specs/SPEC-013-news.md @@ -52,3 +52,49 @@ whose channel has no configured webhook, and a webhook POST that raises are each logged and skipped — one failure never sinks the run, and the seen-set still advances for successfully-processed items. + +### NEWS-07 — Item summaries are extracted (coverage: test) + +`parse_feed` also captures each item's short description — RSS +`<description>`, Atom `<summary>` or `<content>` — with HTML stripped, +entities unescaped, and whitespace collapsed, so an item carries what +it is about, not only a headline. Missing descriptions yield an empty +summary, never an error. + +### NEWS-08 — The digest carries summaries (coverage: test) + +`render_digest` appends the sanitized, length-capped +(`news-summary-chars`, default 200) summary after each headline, so +the bot's ambient `{news}` context knows the gist of each story, not +just its title. A zero cap restores the title-only digest. + +### NEWS-09 — Fetched news is stored, deduped, and rolled over (coverage: test) + +Both the digest run (kroa) and the posting run (ggg) upsert every +fetched item into a `news` table keyed by link (or title), so the same +story is stored once. After each run the store is pruned to the newest +`news-keep` rows (default 400), a rolling window that bounds growth +while keeping recent history searchable. + +### NEWS-10 — get_news is offered as a tool (coverage: test) + +When `enable-news-tool` is true and a store is configured, the chat +call's `tools` list includes a `get_news` function (optional `topic`, +`source`, `limit`) next to the other tools. Without a store or the +flag it is absent. + +### NEWS-11 — get_news retrieves filtered, sanitized items (coverage: test) + +`get_news` returns recent stored items, newest first, optionally +narrowed by `topic` (every keyword must appear in the title, summary, +or source label — so `topic: "Nordland"` finds items from that source) +and/or an exact `source`; `limit` is clamped to 1..30. Each result's title and +summary are passed through `sanitize_external_text`. The bot can then +`fetch_url` a returned link for the full article. + +### NEWS-12 — get_news is metered per user (coverage: test) + +Each `get_news` call increments a per-user daily counter; over +`news-daily-per-user` (default 30) the tool refuses with an error +result without touching the store. The budget gate (SAF-04) still +applies to the surrounding model calls. diff --git a/tests/test_spec_news.py b/tests/test_spec_news.py index d802705..d51faac 100644 --- a/tests/test_spec_news.py +++ b/tests/test_spec_news.py @@ -1,9 +1,24 @@ -"""Unit coverage for SPEC-013 news digest (NEWS-01..03).""" +"""Unit coverage for SPEC-013 news digest + memory + tool (NEWS-01..12).""" +import tempfile import unittest +from pathlib import Path from unittest.mock import AsyncMock -from fjerkroa_bot.news import NewsFetcher, NewsPoster, load_seen, parse_feed, render_digest, save_seen +from fjerkroa_bot.news import ( + GET_NEWS_TOOL, + NewsFetcher, + NewsPoster, + load_seen, + parse_feed, + query_news, + render_digest, + save_seen, +) +from fjerkroa_bot.openai_responder import OpenAIResponder +from fjerkroa_bot.persistence import PersistentStore + +CONFIG = {"openai-token": "t", "model": "m", "system": "s", "history-limit": 5} RSS = b"""<?xml version="1.0"?><rss><channel> <item><title>Game X releasedhttps://ex.com/x @@ -166,10 +181,137 @@ class TestSeenState(unittest.TestCase): def test_cap_bounds_state(self): """NEWS-05: save keeps at most `cap` keys.""" import json - import tempfile - from pathlib import Path with tempfile.TemporaryDirectory() as tmp: path = str(Path(tmp) / "state.json") save_seen(path, {f"k{i}" for i in range(100)}, cap=10) self.assertEqual(len(json.load(open(path))), 10) + + +RSS_DESC = b""" +Storm hits coasthttps://ex.com/s +<p>Heavy <b>wind</b> expected</p> +""" + +ATOM_SUM = b""" +Atom TShort gist here +""" + + +class TestSummaries(unittest.TestCase): + def test_rss_description_stripped(self): + """NEWS-07: RSS description parsed, HTML stripped, entities unescaped, whitespace collapsed.""" + items = parse_feed(RSS_DESC, "S") + self.assertEqual(items[0]["summary"], "Heavy wind expected") + + def test_atom_summary(self): + """NEWS-07: Atom summary collapsed to clean text.""" + items = parse_feed(ATOM_SUM, "A") + self.assertEqual(items[0]["summary"], "Short gist here") + + def test_missing_description_is_empty(self): + """NEWS-07: no description -> empty summary, never an error.""" + self.assertEqual(parse_feed(RSS, "S")[0]["summary"], "") + + def test_digest_carries_summary(self): + """NEWS-08: digest appends the sanitized capped summary; zero cap = title only.""" + items = [{"title": "T", "link": "https://ex.com/x", "source": "NRK", "summary": "the gist of it"}] + digest = render_digest(items, 10, 100) + self.assertIn("[NRK]", digest) + self.assertIn("the gist of it", digest) + self.assertNotIn("the gist", render_digest(items, 10, 0)) # zero cap -> title only + + +class NewsStoreBase(unittest.TestCase): + def setUp(self): + self.tmp = tempfile.TemporaryDirectory() + self.addCleanup(self.tmp.cleanup) + self.store = PersistentStore(Path(self.tmp.name) / "bot.db") + + +class TestNewsStore(NewsStoreBase): + def test_dedup_and_rolling_window(self): + """NEWS-09: items deduped by link; prune keeps the newest N.""" + first = [ + {"title": "A", "link": "L1", "source": "S", "summary": "sa"}, + {"title": "B", "link": "L2", "source": "S", "summary": "sb"}, + ] + self.assertEqual(self.store.add_news_items(first), 2) + self.assertEqual(self.store.add_news_items([dict(first[0])]), 0) # dup link ignored + self.assertEqual(self.store.news_count(), 2) + self.store.prune_news(1) + self.assertEqual(self.store.news_count(), 1) + self.assertEqual(self.store.recent_news(5)[0]["title"], "B") # newest survives + + def test_dedup_by_title_when_no_link(self): + """NEWS-09: linkless items dedup on title.""" + self.store.add_news_items([{"title": "Same", "link": "", "source": "S", "summary": ""}]) + self.store.add_news_items([{"title": "Same", "link": "", "source": "S", "summary": ""}]) + self.assertEqual(self.store.news_count(), 1) + + +class TestQueryNews(NewsStoreBase): + def seed(self): + self.store.add_news_items( + [ + {"title": "Nordland storm", "link": "L1", "source": "Nordland", "summary": "strong wind on the coast"}, + {"title": "Oslo budget", "link": "L2", "source": "NRK", "summary": "@everyone spending plan"}, + {"title": "Sport result", "link": "L3", "source": "Sport", "summary": "the match ended"}, + ] + ) + + def test_topic_filter(self): + """NEWS-11: topic keywords must appear in title or summary.""" + self.seed() + res = query_news(self.store, topic="storm") + self.assertEqual([r["title"] for r in res["results"]], ["Nordland storm"]) + + def test_topic_matches_source_label(self): + """NEWS-11: topic also matches the source label, so 'Nordland' finds regional items.""" + self.store.add_news_items([{"title": "Ferry delayed", "link": "LX", "source": "Nordland", "summary": "boat late"}]) + res = query_news(self.store, topic="Nordland") + self.assertTrue(any(r["link"] == "LX" for r in res["results"])) # matched via source, not title/summary + + def test_source_filter_and_sanitize(self): + """NEWS-11: source narrows results; title/summary are sanitized.""" + self.seed() + res = query_news(self.store, source="NRK") + self.assertTrue(res["results"] and all(r["source"] == "NRK" for r in res["results"])) + self.assertNotIn("@everyone", res["results"][0]["summary"]) + + def test_limit_clamped_and_no_store(self): + """NEWS-11: limit clamps to 1..30; a missing store returns an error.""" + self.seed() + self.assertLessEqual(len(query_news(self.store, limit=999)["results"]), 30) + self.assertGreaterEqual(len(query_news(self.store, limit=0)["results"]), 1) + self.assertIn("error", query_news(None)) + + +class TestNewsTool(unittest.IsolatedAsyncioTestCase): + def setUp(self): + self.tmp = tempfile.TemporaryDirectory() + self.addCleanup(self.tmp.cleanup) + + def _responder(self, **extra): + cfg = dict(CONFIG, **{"history-directory": self.tmp.name}, **extra) + return OpenAIResponder(cfg, "chat") + + def test_tool_offered_needs_flag_and_store(self): + """NEWS-10: get_news offered only with enable-news-tool AND a store.""" + no_store = OpenAIResponder(dict(CONFIG, **{"enable-news-tool": True}), "chat") + self.assertIsNone(no_store.store) + self.assertNotIn("get_news", [f["name"] for f in no_store._available_tools()]) + flag_off = self._responder() + self.assertNotIn("get_news", [f["name"] for f in flag_off._available_tools()]) + on = self._responder(**{"enable-news-tool": True}) + self.assertIn("get_news", [f["name"] for f in on._available_tools()]) + self.assertEqual(GET_NEWS_TOOL["name"], "get_news") + + async def test_dispatch_caps_news(self): + """NEWS-12: over news-daily-per-user, get_news refuses without querying.""" + responder = self._responder(**{"enable-news-tool": True, "news-daily-per-user": 2}) + responder.store.add_news_items([{"title": "x", "link": "l", "source": "s", "summary": "y"}]) + for _ in range(2): + self.assertIn("results", await responder._dispatch_tool("get_news", {}, "alice")) + blocked = await responder._dispatch_tool("get_news", {}, "alice") + self.assertIn("error", blocked)