news: get_news degrades softly on topic miss (news-13) — any-term ranked fallback, recent+note, language hint in schema; idle-impulse default varies form

This commit is contained in:
Oleksandr Kozachuk
2026-07-17 16:26:47 +02:00
parent dc7864efe1
commit f8b9bc75ee
5 changed files with 72 additions and 11 deletions
+17 -2
View File
@@ -198,7 +198,12 @@ GET_NEWS_TOOL = {
"parameters": { "parameters": {
"type": "object", "type": "object",
"properties": { "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'."}, "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)."}, "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)) limit = max(1, min(int(limit or 10), 30))
src = (str(source).strip() or None) if source else None src = (str(source).strip() or None) if source else None
terms = _news_terms(topic) terms = _news_terms(topic)
note = None
try: try:
rows = store.search_news(terms, limit, src) if terms else store.recent_news(limit, src) 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: except Exception as err:
logging.warning(f"news: query failed: {err!r}") logging.warning(f"news: query failed: {err!r}")
return {"error": "news lookup failed"} return {"error": "news lookup failed"}
@@ -234,7 +246,10 @@ def query_news(
} }
for row in rows 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: def _open_store(config: Dict[str, Any]) -> Any:
+15 -2
View File
@@ -152,14 +152,27 @@ class PersistentStore:
rows = conn.execute(sql, params).fetchall() rows = conn.execute(sql, params).fetchall()
return [{"source": r[0], "title": r[1], "link": r[2], "summary": r[3]} for r in rows] 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]]: 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 or summary; optional source filter (NEWS-11).""" """Rows where every term appears in title/summary/source; match_any ranks by how many terms hit (NEWS-11/13)."""
params: List[Any] = [] params: List[Any] = []
clauses = [] clauses = []
for term in terms: for term in terms:
clauses.append("(title LIKE ? OR summary LIKE ? OR source LIKE ?)") clauses.append("(title LIKE ? OR summary LIKE ? OR source LIKE ?)")
like = f"%{term}%" like = f"%{term}%"
params += [like, like, like] params += [like, like, like]
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" where = " AND ".join(clauses) if clauses else "1=1"
if source: if source:
where = f"({where}) AND source = ?" where = f"({where}) AND source = ?"
+2
View File
@@ -21,6 +21,8 @@ DEFAULT_BORENESS_PROMPT = (
"A thought just occurred to you. Anchor it to something real you know — recent news (use get_news), a game " "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 " "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]] ExecuteCallback = Callable[[str, str], Awaitable[None]]
+11
View File
@@ -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 `news-daily-per-user` (default 30) the tool refuses with an error
result without touching the store. The budget gate (SAF-04) still result without touching the store. The budget gate (SAF-04) still
applies to the surrounding model calls. 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`.
+20
View File
@@ -303,6 +303,26 @@ class TestQueryNews(NewsStoreBase):
self.assertGreaterEqual(len(query_news(self.store, limit=0)["results"]), 1) self.assertGreaterEqual(len(query_news(self.store, limit=0)["results"]), 1)
self.assertIn("error", query_news(None)) 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): class TestNewsTool(unittest.IsolatedAsyncioTestCase):
def setUp(self): def setUp(self):