mirror of
https://github.com/dgtlmoon/changedetection.io.git
synced 2026-09-18 19:36:04 +00:00
Build and push containers / metadata (push) Canceled after 0s
Build and push containers / build-push-containers (push) Canceled after 0s
Publish Python 🐍distribution 📦 to PyPI and TestPyPI / Build distribution 📦 (push) Canceled after 0s
ChangeDetection.io App Test / lint-code (push) Canceled after 0s
ChangeDetection.io App Test / lint-translations (push) Canceled after 0s
ChangeDetection.io App Test / lint-template-i18n (push) Canceled after 0s
Publish Python 🐍distribution 📦 to PyPI and TestPyPI / Test the built package works basically. (push) Canceled after 0s
Publish Python 🐍distribution 📦 to PyPI and TestPyPI / Publish Python 🐍 distribution 📦 to PyPI (push) Canceled after 0s
ChangeDetection.io App Test / test-application-3-11 (push) Canceled after 0s
ChangeDetection.io App Test / test-application-3-12 (push) Canceled after 0s
ChangeDetection.io App Test / test-application-3-13 (push) Canceled after 0s
ChangeDetection.io App Test / test-application-3-14 (push) Canceled after 0s
Follow-up to #4340. The <think> stripper added there only matches a closed pair, but a reasoning scratchpad routinely contains JSON of its own ("initially I thought {"important": false}, but..."), so any leftover scratchpad lets _extract_json lock onto a discarded intermediate answer. Three shapes slipped through, all of which inverted the verdict to important=false and therefore silently suppressed the notification: - opener stripped by the provider/chat template, only </think> comes back over the wire - <thinking> spelled out in full - unterminated block, i.e. the response was cut off mid-thought by max_tokens The first two are now stripped. The third raises ValueError, because a truncated response holds no answer at all - only the abandoned guess. Raising routes it to the existing handler in evaluator.py, which passes the change through as important rather than dropping it; parse_eval_response deliberately does not catch ValueError, since its own fallback (important=False) would suppress the notification instead. Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
125 lines
4.8 KiB
Python
125 lines
4.8 KiB
Python
"""
|
|
Parse and validate LLM JSON responses.
|
|
Pure functions — no side effects, fully testable.
|
|
|
|
LLMs occasionally return JSON wrapped in markdown fences or with trailing
|
|
text. This module handles those cases gracefully.
|
|
"""
|
|
|
|
import json
|
|
import re
|
|
|
|
from changedetectionio.strtobool import strtobool
|
|
|
|
# Positional selectors are fragile — reject them even if the LLM generates them
|
|
_POSITIONAL_SELECTOR_RE = re.compile(
|
|
r'nth-child|nth-of-type|:eq\(|\[\d+\]|\/\/\*\[\d', re.IGNORECASE
|
|
)
|
|
|
|
# Reasoning models (DeepSeek-R1, Qwen reasoning, etc.) wrap their scratchpad in <think> tags.
|
|
# Three shapes have to be handled, because the scratchpad routinely contains JSON of its own
|
|
# ("initially I thought {"important": false}, but..."), so leaving any of it in place lets
|
|
# _extract_json lock onto a discarded intermediate answer instead of the real one.
|
|
_THINK_BLOCK_RE = re.compile(r'<think(?:ing)?>.*?</think(?:ing)?>', re.DOTALL | re.IGNORECASE)
|
|
_THINK_TAIL_RE = re.compile(r'^.*</think(?:ing)?>', re.DOTALL | re.IGNORECASE)
|
|
_THINK_OPEN_RE = re.compile(r'<think(?:ing)?>', re.IGNORECASE)
|
|
|
|
|
|
def _to_bool(value, default: bool = False) -> bool:
|
|
"""Safely coerce boolean values from LLM responses.
|
|
|
|
Handles native booleans, truthy/falsy integers (1/0), and string booleans
|
|
("true", "false", "yes", "no", "1", "0") using strtobool.
|
|
Avoids Python's bool("false") -> True bug on stringified JSON booleans.
|
|
"""
|
|
if value is None:
|
|
return default
|
|
try:
|
|
return strtobool(value)
|
|
except (ValueError, AttributeError):
|
|
return default
|
|
|
|
|
|
def _extract_json(raw: str) -> str:
|
|
"""Strip reasoning blocks, markdown fences, and extract the first JSON object.
|
|
|
|
Raises:
|
|
ValueError: the response opens a reasoning block it never closes, i.e. it was cut
|
|
off mid-thought (usually by max_tokens) and contains no answer at all. Callers
|
|
in evaluator.py catch this and fall back safely - for diff evaluation that
|
|
means passing the change through as important rather than silently dropping it.
|
|
"""
|
|
raw = raw.strip()
|
|
# Well-formed scratchpads.
|
|
raw = _THINK_BLOCK_RE.sub('', raw).strip()
|
|
# Some providers/chat templates emit the opening tag themselves and only the closer comes
|
|
# back over the wire, so anything up to the last closer is still scratchpad.
|
|
raw = _THINK_TAIL_RE.sub('', raw).strip()
|
|
# An opener with no closer means the response was truncated part-way through reasoning.
|
|
# There is no answer to find; the only JSON present would be a discarded intermediate one.
|
|
if _THINK_OPEN_RE.search(raw):
|
|
raise ValueError('LLM response contains an unterminated reasoning block (truncated?)')
|
|
# Remove ```json ... ``` or ``` ... ``` fences
|
|
raw = re.sub(r'^```(?:json)?\s*', '', raw, flags=re.MULTILINE)
|
|
raw = re.sub(r'\s*```$', '', raw, flags=re.MULTILINE)
|
|
# Find the first { ... } block
|
|
match = re.search(r'\{.*\}', raw, re.DOTALL)
|
|
return match.group(0) if match else raw
|
|
|
|
|
|
def parse_eval_response(raw: str) -> dict:
|
|
"""
|
|
Parse a diff evaluation response.
|
|
Returns {'important': bool, 'summary': str}.
|
|
Falls back to important=False on any parse error.
|
|
"""
|
|
try:
|
|
data = json.loads(_extract_json(raw))
|
|
return {
|
|
'important': _to_bool(data.get('important'), default=False),
|
|
'summary': str(data.get('summary', '')).strip(),
|
|
}
|
|
except (json.JSONDecodeError, AttributeError):
|
|
return {'important': False, 'summary': ''}
|
|
|
|
|
|
def parse_preview_response(raw: str) -> dict:
|
|
"""
|
|
Parse a live-preview extraction response.
|
|
Returns {'found': bool, 'answer': str}.
|
|
Falls back to found=False on any parse error.
|
|
"""
|
|
try:
|
|
data = json.loads(_extract_json(raw))
|
|
return {
|
|
'found': _to_bool(data.get('found'), default=False),
|
|
'answer': str(data.get('answer', '')).strip(),
|
|
}
|
|
except (json.JSONDecodeError, AttributeError):
|
|
return {'found': False, 'answer': ''}
|
|
|
|
|
|
def parse_setup_response(raw: str) -> dict:
|
|
"""
|
|
Parse a setup/pre-filter decision response.
|
|
Returns {'needs_prefilter': bool, 'selector': str|None, 'reason': str}.
|
|
Rejects positional selectors even if the LLM generates them.
|
|
"""
|
|
try:
|
|
data = json.loads(_extract_json(raw))
|
|
needs = _to_bool(data.get('needs_prefilter'), default=False)
|
|
selector = data.get('selector') or None
|
|
|
|
# Sanitise: reject positional selectors
|
|
if selector and _POSITIONAL_SELECTOR_RE.search(selector):
|
|
selector = None
|
|
needs = False
|
|
|
|
return {
|
|
'needs_prefilter': needs,
|
|
'selector': selector if needs else None,
|
|
'reason': str(data.get('reason', '')).strip(),
|
|
}
|
|
except (json.JSONDecodeError, AttributeError):
|
|
return {'needs_prefilter': False, 'selector': None, 'reason': ''}
|