From a7a3891d1c8abc406d19d855db211c0ea751dddd Mon Sep 17 00:00:00 2001 From: Jared Vititoe Date: Sun, 26 Apr 2026 19:57:47 -0400 Subject: [PATCH] =?UTF-8?q?fix:=2020q=20answer=20dedup=20cache=20=E2=80=94?= =?UTF-8?q?=20prevent=20repeated=20answers?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a rolling cache of the last 30 answers (persisted to twentyq_cache.json) and pass the recent list to the model as an explicit avoid clause. Also prompt the model to vary categories each round. If the model still returns a cached answer it is rejected and one retry is attempted automatically. Co-Authored-By: Claude Sonnet 4.6 --- matrixbot/commands.py | 36 +++++++++++++++++++++++++++++++++++- 1 file changed, 35 insertions(+), 1 deletion(-) diff --git a/matrixbot/commands.py b/matrixbot/commands.py index 2086a6a..14fd5e3 100644 --- a/matrixbot/commands.py +++ b/matrixbot/commands.py @@ -2730,13 +2730,39 @@ async def cmd_ac(client: AsyncClient, room_id: str, sender: str, args: str): # =========================================================================== _TWENTYQ_GAMES: dict[str, dict] = {} +_TWENTYQ_RECENT_MAX = 30 +_TWENTYQ_CACHE_FILE = Path("twentyq_cache.json") + + +def _load_20q_cache() -> list[str]: + try: + data = json.loads(_TWENTYQ_CACHE_FILE.read_text()) + return data.get("things", []) + except Exception: + return [] + + +def _save_20q_cache(things: list[str]) -> None: + try: + _TWENTYQ_CACHE_FILE.write_text(json.dumps({"things": things}, indent=2)) + except Exception as e: + logger.warning("Failed to save 20q cache: %s", e) + + +_20q_recent: list[str] = _load_20q_cache() async def _generate_20q_thing() -> dict | None: + avoid_clause = ( + f" Do NOT use any of these recently used answers: {', '.join(_20q_recent[-20:])}." + if _20q_recent else "" + ) system_msg = ( "You are generating a subject for a game of 20 questions. " "Pick a specific, well-known, concrete thing. Avoid overly obscure topics. " "Good categories: animal, famous person, place, everyday object, food, movie/show, fictional character. " + "Choose a DIFFERENT category each time — vary between animals, people, objects, places, food, etc." + + avoid_clause + " " "Respond with ONLY a JSON object — no markdown, no explanation. " '{"thing": "elephant", "category": "animal", "hint": "it\'s a living creature"}' ) @@ -2763,6 +2789,14 @@ async def _generate_20q_thing() -> dict | None: category = parsed.get("category", "thing").strip() hint = parsed.get("hint", f"it's a {category}").strip() if thing and len(thing) > 1: + # Check it's not a repeat (case-insensitive) + if thing.lower() in [r.lower() for r in _20q_recent]: + logger.warning("20q generated a cached answer '%s', regenerating", thing) + return None + _20q_recent.append(thing) + if len(_20q_recent) > _TWENTYQ_RECENT_MAX: + _20q_recent.pop(0) + _save_20q_cache(_20q_recent) return {"thing": thing, "category": category, "hint": hint} except Exception as e: logger.error("20q generation error: %s", e, exc_info=True) @@ -2807,7 +2841,7 @@ async def cmd_20q(client: AsyncClient, room_id: str, sender: str, args: str): return await send_text(client, room_id, "🤔 I'm thinking of something...") - thing_data = await _generate_20q_thing() + thing_data = await _generate_20q_thing() or await _generate_20q_thing() if not thing_data: await send_text(client, room_id, "Failed to think of something. Try again!") return