diff --git a/fjerkroa_bot/news.py b/fjerkroa_bot/news.py index 1849080..87e9453 100644 --- a/fjerkroa_bot/news.py +++ b/fjerkroa_bot/news.py @@ -198,7 +198,12 @@ GET_NEWS_TOOL = { "parameters": { "type": "object", "properties": { - "topic": {"type": "string", "description": "Optional keywords to filter by, e.g. 'Nordland', 'football', 'weather'."}, + "topic": { + "type": "string", + "description": "Optional filter: one or two keywords, in the language the feeds are written in " + "(e.g. Norwegian for Norwegian news: 'Nordland', 'fotball', 'trafikkulykke'). If nothing matches " + "exactly, related or recent items come back with a `note` saying so.", + }, "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)."}, }, @@ -220,8 +225,15 @@ def query_news( limit = max(1, min(int(limit or 10), 30)) src = (str(source).strip() or None) if source else None terms = _news_terms(topic) + note = None try: rows = store.search_news(terms, limit, src) if terms else store.recent_news(limit, src) + if terms and not rows: # NEWS-13: soft degradation, never empty-handed + rows = store.search_news(terms, limit, src, match_any=True) + note = "no item matches all keywords; showing items matching some of them" + if terms and not rows: + rows = store.recent_news(limit, src) + note = "nothing matches the topic; showing the newest stored items instead" except Exception as err: logging.warning(f"news: query failed: {err!r}") return {"error": "news lookup failed"} @@ -234,7 +246,10 @@ def query_news( } for row in rows ] - return {"topic": topic or "", "source": src or "", "results": results} + payload = {"topic": topic or "", "source": src or "", "results": results} + if note: + payload["note"] = note + return payload def _open_store(config: Dict[str, Any]) -> Any: diff --git a/fjerkroa_bot/persistence.py b/fjerkroa_bot/persistence.py index 4780402..2d11aaf 100644 --- a/fjerkroa_bot/persistence.py +++ b/fjerkroa_bot/persistence.py @@ -152,20 +152,33 @@ class PersistentStore: 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).""" + def search_news(self, terms: List[str], limit: int = 20, source: Optional[str] = None, match_any: bool = False) -> List[Dict[str, Any]]: + """Rows where every term appears in title/summary/source; match_any ranks by how many terms hit (NEWS-11/13).""" 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 + if match_any and clauses: + hits = " + ".join(clauses) + where = "hits > 0" + if source: + where += " AND source = ?" + params.append(source) + params.append(int(limit)) + sql = ( + f"SELECT source, title, link, summary FROM " # nosec B608 - fixed templates; values parameterised + f"(SELECT id, source, title, link, summary, {hits} AS hits FROM news) " + f"WHERE {where} ORDER BY hits DESC, id DESC LIMIT ?" + ) + else: + 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] diff --git a/fjerkroa_bot/tasks.py b/fjerkroa_bot/tasks.py index 6eb07da..c8ecb7c 100644 --- a/fjerkroa_bot/tasks.py +++ b/fjerkroa_bot/tasks.py @@ -20,7 +20,9 @@ DEFAULT_TASKGEN_INTERVAL_HOURS = 6.0 DEFAULT_BORENESS_PROMPT = ( "A thought just occurred to you. Anchor it to something real you know — recent news (use get_news), a game " "releasing soon, the weather, or a regular you remember — not a generic musing. Share it briefly, in your own " - "voice, as an observation, a gentle question, or a joke; never an advertisement. Read the room and stay in character." + "voice, as an observation, a gentle question, or a joke; never an advertisement. Read the room and stay in character. " + "Check your own recent posts in the history first: pick a subject you have not touched lately and a different form " + "than last time, and never open with a fixed label or heading — just start mid-thought." ) ExecuteCallback = Callable[[str, str], Awaitable[None]] diff --git a/specs/SPEC-013-news.md b/specs/SPEC-013-news.md index 5c60cc7..b604281 100644 --- a/specs/SPEC-013-news.md +++ b/specs/SPEC-013-news.md @@ -98,3 +98,14 @@ 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. + +### NEWS-13 — Topic misses degrade softly, never empty-handed (coverage: test) + +A `topic` whose AND-match (NEWS-11) finds nothing falls back to an +any-term match, ranked by how many keywords hit (ties: newest first); +if that too is empty, the newest stored items are returned instead. +Both fallbacks set a `note` field naming the degradation so the model +can answer honestly ("nothing on that exactly, but…"). A model +passing a multi-word or wrong-language topic (the live +`"Nordland road accident"` → `[]` case) thus still gets usable +context. Exact matches return no `note`. diff --git a/tests/test_spec_news.py b/tests/test_spec_news.py index d472bf1..a31a28d 100644 --- a/tests/test_spec_news.py +++ b/tests/test_spec_news.py @@ -303,6 +303,26 @@ class TestQueryNews(NewsStoreBase): self.assertGreaterEqual(len(query_news(self.store, limit=0)["results"]), 1) self.assertIn("error", query_news(None)) + def test_exact_match_has_no_note(self): + """NEWS-13: a direct AND-match returns without a note field.""" + self.seed() + self.assertNotIn("note", query_news(self.store, topic="storm")) + + def test_partial_match_falls_back_ranked(self): + """NEWS-13: AND-miss -> any-term match, most keyword hits first, with a note.""" + self.seed() + res = query_news(self.store, topic="Nordland road accident") + self.assertEqual(res["results"][0]["title"], "Nordland storm") + self.assertIn("note", res) + + def test_no_match_falls_back_to_recent(self): + """NEWS-13: nothing matches any term -> newest items + note, never empty-handed.""" + self.seed() + res = query_news(self.store, topic="quantum blockchain") + self.assertTrue(res["results"]) + self.assertEqual(res["results"][0]["title"], "Sport result") # newest first + self.assertIn("note", res) + class TestNewsTool(unittest.IsolatedAsyncioTestCase): def setUp(self):