fix: robust JSON extraction with try/except + retry in all AI commands
Lint / Shell (shellcheck) (push) Successful in 10s
Lint / JS (eslint) (push) Successful in 7s
Lint / Python (ruff) (push) Successful in 4s
Lint / Python deps (pip-audit) (push) Successful in 53s
Lint / Secret scan (gitleaks) (push) Successful in 9s

- Added _extract_riddle_answer() with dual fallback: JSON parse first,
  then regex extraction of quoted riddle/answer values directly from text
- _generate_riddle() now retries up to 2 times on parse/network failure
- Hangman, scramble, WYR, and trivia now catch JSONDecodeError and log
  the raw model output instead of letting the exception propagate silently

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-23 19:03:18 -04:00
parent 78c01cf5f2
commit 8fc734643b
+80 -50
View File
@@ -1071,12 +1071,15 @@ async def _generate_trivia_question(category: str) -> dict | None:
) as response: ) as response:
data = await response.json() data = await response.json()
text = data.get("message", {}).get("content", "").strip() text = data.get("message", {}).get("content", "").strip()
# Strip markdown code fences if present if "```" in text:
if text.startswith("```"): text = re.sub(r"```[a-z]*\n?", "", text).strip()
text = text.split("```")[1] m = re.search(r"\{.+\}", text, re.DOTALL)
if text.startswith("json"): candidate = m.group(0) if m else text
text = text[4:] try:
parsed = json.loads(text) parsed = json.loads(candidate)
except json.JSONDecodeError:
logger.warning("trivia: JSON parse failed, raw: %.200s", text)
parsed = {}
# Validate structure # Validate structure
if ( if (
isinstance(parsed.get("q"), str) isinstance(parsed.get("q"), str)
@@ -1398,9 +1401,12 @@ async def _generate_hangman_word() -> dict | None:
if "```" in text: if "```" in text:
text = re.sub(r"```[a-z]*\n?", "", text).strip() text = re.sub(r"```[a-z]*\n?", "", text).strip()
m = re.search(r"\{[^{}]+\}", text, re.DOTALL) m = re.search(r"\{[^{}]+\}", text, re.DOTALL)
if m: candidate = m.group(0) if m else text
text = m.group(0) try:
parsed = json.loads(text) parsed = json.loads(candidate)
except json.JSONDecodeError:
logger.warning("hangman: JSON parse failed, raw: %.200s", text)
parsed = {}
word = parsed.get("word", "").lower().strip() word = parsed.get("word", "").lower().strip()
hint = parsed.get("hint", "").strip() hint = parsed.get("hint", "").strip()
if word.isalpha() and 5 <= len(word) <= 8 and hint: if word.isalpha() and 5 <= len(word) <= 8 and hint:
@@ -1559,9 +1565,12 @@ async def _generate_scramble_word() -> dict | None:
if "```" in text: if "```" in text:
text = re.sub(r"```[a-z]*\n?", "", text).strip() text = re.sub(r"```[a-z]*\n?", "", text).strip()
m = re.search(r"\{[^{}]+\}", text, re.DOTALL) m = re.search(r"\{[^{}]+\}", text, re.DOTALL)
if m: candidate = m.group(0) if m else text
text = m.group(0) try:
parsed = json.loads(text) parsed = json.loads(candidate)
except json.JSONDecodeError:
logger.warning("scramble: JSON parse failed, raw: %.200s", text)
parsed = {}
word = parsed.get("word", "").lower().strip() word = parsed.get("word", "").lower().strip()
if word.isalpha() and 4 <= len(word) <= 8: if word.isalpha() and 4 <= len(word) <= 8:
return {"word": word} return {"word": word}
@@ -1706,9 +1715,12 @@ async def _generate_wyr() -> dict | None:
if "```" in text: if "```" in text:
text = re.sub(r"```[a-z]*\n?", "", text).strip() text = re.sub(r"```[a-z]*\n?", "", text).strip()
m = re.search(r"\{[^{}]+\}", text, re.DOTALL) m = re.search(r"\{[^{}]+\}", text, re.DOTALL)
if m: candidate = m.group(0) if m else text
text = m.group(0) try:
parsed = json.loads(text) parsed = json.loads(candidate)
except json.JSONDecodeError:
logger.warning("WYR: JSON parse failed, raw: %.200s", text)
parsed = {}
a = parsed.get("option_a", "").strip() a = parsed.get("option_a", "").strip()
b = parsed.get("option_b", "").strip() b = parsed.get("option_b", "").strip()
_HANGING = {"but", "and", "or", "with", "for", "in", "on", "at", _HANGING = {"but", "and", "or", "with", "for", "in", "on", "at",
@@ -1824,6 +1836,28 @@ def _save_riddle_cache(riddles: list[str], answers: list[str]) -> None:
_riddle_recent, _riddle_recent_answers = _load_riddle_cache() _riddle_recent, _riddle_recent_answers = _load_riddle_cache()
def _extract_riddle_answer(text: str) -> tuple[str, str] | None:
"""Try JSON parse, then fall back to regex extraction of riddle/answer values."""
if "```" in text:
text = re.sub(r"```[a-z]*\n?", "", text).strip()
m = re.search(r"\{[^{}]+\}", text, re.DOTALL)
candidate = m.group(0) if m else text
try:
parsed = json.loads(candidate)
riddle = parsed.get("riddle", "").strip()
answer = parsed.get("answer", "").strip()
if riddle and answer:
return riddle, answer
except (json.JSONDecodeError, AttributeError):
pass
# Fallback: extract quoted values for "riddle" and "answer" keys
rm = re.search(r'"riddle"\s*[:\s]+["“]([^"”]{10,})["”]', text)
am = re.search(r'"answer"\s*[:\s]+["“]([^"”]{1,50})["”]', text)
if rm and am:
return rm.group(1).strip(), am.group(1).strip()
return None
async def _generate_riddle() -> dict | None: async def _generate_riddle() -> dict | None:
avoid_riddles = ( avoid_riddles = (
" Do NOT reuse any of these recent riddles: " " Do NOT reuse any of these recent riddles: "
@@ -1847,41 +1881,37 @@ async def _generate_riddle() -> dict | None:
"- Avoid 'shadow' as an answer. Prefer concrete things: candle, mirror, clock, river, echo, stamp, key, glove, envelope, etc." "- Avoid 'shadow' as an answer. Prefer concrete things: candle, mirror, clock, river, echo, stamp, key, glove, envelope, etc."
) )
user_msg = f"Generate a clever, original riddle with a clear unambiguous answer.{avoid_answers}{avoid_riddles}" user_msg = f"Generate a clever, original riddle with a clear unambiguous answer.{avoid_answers}{avoid_riddles}"
try: for attempt in range(2):
timeout = aiohttp.ClientTimeout(total=60) try:
async with aiohttp.ClientSession(timeout=timeout) as session: timeout = aiohttp.ClientTimeout(total=60)
async with session.post( async with aiohttp.ClientSession(timeout=timeout) as session:
f"{OLLAMA_URL}/api/chat", async with session.post(
json={ f"{OLLAMA_URL}/api/chat",
"model": CREATIVE_MODEL, json={
"stream": False, "model": CREATIVE_MODEL,
"messages": [ "stream": False,
{"role": "system", "content": system_msg}, "messages": [
{"role": "user", "content": user_msg}, {"role": "system", "content": system_msg},
], {"role": "user", "content": user_msg},
}, ],
) as response: },
data = await response.json() ) as response:
text = data.get("message", {}).get("content", "").strip() data = await response.json()
if "```" in text: text = data.get("message", {}).get("content", "").strip()
text = re.sub(r"```[a-z]*\n?", "", text).strip() result = _extract_riddle_answer(text)
m = re.search(r"\{[^{}]+\}", text, re.DOTALL) if result:
if m: riddle, answer = result
text = m.group(0) _riddle_recent.append(riddle)
parsed = json.loads(text) if len(_riddle_recent) > _RIDDLE_RECENT_MAX:
riddle = parsed.get("riddle", "").strip() _riddle_recent.pop(0)
answer = parsed.get("answer", "").strip() _riddle_recent_answers.append(answer.lower())
if riddle and answer: if len(_riddle_recent_answers) > _RIDDLE_RECENT_MAX:
_riddle_recent.append(riddle) _riddle_recent_answers.pop(0)
if len(_riddle_recent) > _RIDDLE_RECENT_MAX: _save_riddle_cache(_riddle_recent, _riddle_recent_answers)
_riddle_recent.pop(0) return {"riddle": riddle, "answer": answer}
_riddle_recent_answers.append(answer.lower()) logger.warning("riddle attempt %d: could not extract from: %.200s", attempt + 1, text)
if len(_riddle_recent_answers) > _RIDDLE_RECENT_MAX: except Exception as e:
_riddle_recent_answers.pop(0) logger.error(f"riddle generation error (attempt {attempt + 1}): {e}", exc_info=True)
_save_riddle_cache(_riddle_recent, _riddle_recent_answers)
return {"riddle": riddle, "answer": answer}
except Exception as e:
logger.error(f"riddle generation error: {e}", exc_info=True)
return None return None