Restock: let a plugin do LLM price/stock extraction without litellm (HTML + text hook, reusable helpers) (#4545)
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
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 / 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
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

* LLM restock: parse string prices with price_parser instead of stripping separators

If the model returns the price as page text, e.g. "1.299,95 €", stripping
everything but digits and dots gave 1.29995. price_parser (already a
dependency) handles the locale formats the prompt lists.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* LLM restock: use Restock.parse_currency() like the other price extractors

Same parser c8afea1b (#4494) moved the structured-data extractors to, so
there's one place price text becomes a number. parse_currency() now lists
every caller, and the call sites point back to it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* LLM restock: expose prompt/cache/parsing helpers so other LLM backends can reuse them

Split the hook body into build_restock_messages(), restock_cache_key/get/put()
and parse_restock_reply(), so a plugin talking to a provider without litellm
only has to supply the HTTP call. accumulate_global_tokens() takes an optional
cost_usd, since the default estimate imports litellm's pricing (~200MB that's
never released). The built-in plugin behaves exactly as before.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Test: give the no-litellm subprocess check a PYTHONPATH

CI runs pytest from inside the package dir without installing it, so the
child interpreter couldn't import changedetectionio.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Restock plugins: pass html_to_text() output to the restock hook, let third-party backends go first

get_itemprop_availability_override now also gets `text` (the fetched page through
html_to_text()), only computed when an implementation declares it. The hook is
firstresult and the built-in litellm plugin is trylast, so a plugin that talks to an
LLM with plain requests answers first and the built-in one is never billed as well.

Also fixes the built-in plugin never receiving llm_intent: pluggy silently doesn't
pass hookimpl arguments that have a default value, and it declared llm_intent=None.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
dgtlmoonandClaude Opus 5.5 authored and GitHub committed 2026-10-19 12:32:32 +02:00
1 parent 804876fdd1
commit be09f73c3a
5 files changed
+335 -78

No files matched your search

+4 -2
View File
@@ -480,11 +480,13 @@ def _estimate_cost_usd(model: str, input_tokens: int, output_tokens: int) -> flo
def accumulate_global_tokens(datastore, tokens: int,
input_tokens: int = 0, output_tokens: int = 0,
model: str = '') -> None:
model: str = '', cost_usd: float = None) -> None:
"""
Add *tokens* to both the all-time and this-month global counters.
When input_tokens / output_tokens / model are supplied the estimated
USD cost is accumulated alongside the token counts.
cost_usd, when given, is used as-is instead of the estimate - callers that
don't use litellm pass their own, as the estimate imports litellm's pricing.
Resets monthly counters automatically on month rollover.
These counters live at datastore.data['settings']['application']['llm']
@@ -495,7 +497,7 @@ def accumulate_global_tokens(datastore, tokens: int,
return
current_month = _get_month_key()
cost = _estimate_cost_usd(model, input_tokens, output_tokens)
cost = cost_usd if cost_usd is not None else _estimate_cost_usd(model, input_tokens, output_tokens)
with datastore.lock:
settings = get_llm_settings(datastore)
+34 -12
View File
@@ -60,20 +60,32 @@ class ChangeDetectionSpec:
"""
pass
@hookspec
def get_itemprop_availability_override(self, content, fetcher_name, fetcher_instance, url, llm_intent=None):
@hookspec(firstresult=True)
def get_itemprop_availability_override(self, content, fetcher_name, fetcher_instance, url, llm_intent, text):
"""Provide custom implementation of get_itemprop_availability for a specific fetcher.
This hook allows plugins to provide their own product availability detection
when their fetcher is being used. This is called as a fallback when the built-in
method doesn't find good data.
The first implementation to return a non-None result wins and the rest are skipped,
so return None (not an empty dict) when you have nothing. The built-in LLM restock
plugin (litellm) is registered trylast, so your own plugin, for example one that
POSTs to an LLM with plain `requests`, always gets the first go and the built-in
one is never billed when yours already answered. See plugins/llm_restock.py for
the prompt, cache and reply-parsing helpers you can reuse without importing litellm.
Args:
content: The HTML/text content to parse
content: The HTML content that was just fetched
fetcher_name: The name of the fetcher being used (e.g., 'html_js_zyte')
fetcher_instance: The fetcher instance that generated the content
url: The URL being watched/checked
llm_intent: Optional user-supplied intent string (e.g. "alert when price drops below $300")
text: The same content converted with html_to_text(). Only computed when an
implementation declares this argument, so leave it out if you don't need it.
Declare the arguments you want WITHOUT default values (`text`, not `text=None`),
pluggy silently never passes an argument that has a default.
Returns:
dict or None: Dictionary with availability data:
@@ -383,22 +395,32 @@ def get_itemprop_availability_from_plugin(content, fetcher_name, fetcher_instanc
Returns:
dict or None: Availability data dictionary from first matching plugin, or None
"""
# Get availability data from plugins
results = plugin_manager.hook.get_itemprop_availability_override(
hook = plugin_manager.hook.get_itemprop_availability_override
# html_to_text() isn't free on a large product page, so only run it when some
# implementation actually asks for `text` (pluggy only passes the args an impl declares).
text = None
if content and any('text' in impl.argnames for impl in hook.get_hookimpls()):
try:
from changedetectionio.html_tools import html_to_text
text = html_to_text(content)
except Exception as e:
logger.warning(f"Could not convert {url} to text for restock plugins: {e}")
# firstresult=True, so this is the first non-None answer
result = hook(
content=content,
fetcher_name=fetcher_name,
fetcher_instance=fetcher_instance,
url=url,
llm_intent=llm_intent,
text=text,
)
# Return first non-None result with actual data
if results:
for result in results:
if result and isinstance(result, dict):
# Check if the result has any meaningful data
if result.get('price') is not None or result.get('availability'):
return result
# Only accept it if it carries actual data
if result and isinstance(result, dict):
if result.get('price') is not None or result.get('availability'):
return result
return None
@@ -8,6 +8,32 @@ LLM and asks it to return a structured JSON answer.
The module-level `datastore` variable is injected at startup by
`inject_datastore_into_plugins()` in pluggy_interface.py.
Reusable by other LLM backends: a plugin that talks to a provider some other way
(without litellm) can reuse the prompt, page trimming, cache and reply parsing,
and only supply the HTTP call itself. Register it under the 'changedetectionio' setuptools
entrypoint; it runs before this one (trylast), and this one is skipped once it answers:
from changedetectionio.pluggy_interface import hookimpl
from changedetectionio.processors.restock_diff.plugins.llm_restock import (
build_restock_messages, restock_cache_key, restock_cache_get, restock_cache_put, parse_restock_reply)
@hookimpl
def get_itemprop_availability_override(content, url, llm_intent, text):
# `content` is the fetched HTML, `text` the same page through html_to_text().
# No default values here, pluggy silently never passes an argument that has one.
messages = build_restock_messages(content, url, llm_intent)
key = restock_cache_key(MODEL, messages)
result = restock_cache_get(key)
if result is None:
raw = <POST messages to your provider with requests>
result = parse_restock_reply(raw, url)
if result:
restock_cache_put(key, result)
return result # None lets the next plugin try
None of these import litellm. Avoid evaluator._thinking_extra_body(), it does, and
pass cost_usd= to accumulate_global_tokens() so it doesn't look the price up in litellm.
"""
import json
import os
@@ -108,11 +134,16 @@ _LLM_RESULT_CACHE = OrderedDict()
_LLM_RESULT_CACHE_MAX = 500
def _llm_cache_get(key):
def restock_cache_key(model: str, messages: list) -> str:
"""Cache key for one extraction - the model plus the exact user prompt sent to it."""
return hashlib.md5(((model or '') + '\n' + messages[-1]['content']).encode('utf-8')).hexdigest()
def restock_cache_get(key):
return _LLM_RESULT_CACHE.get(key)
def _llm_cache_put(key, value):
def restock_cache_put(key, value):
_LLM_RESULT_CACHE[key] = value
_LLM_RESULT_CACHE.move_to_end(key)
while len(_LLM_RESULT_CACHE) > _LLM_RESULT_CACHE_MAX:
@@ -236,8 +267,84 @@ def _strip_html(html_content: str, max_chars: int = _MAX_CONTENT_CHARS) -> str:
return text[:max_chars]
@hookimpl
def get_itemprop_availability_override(content, fetcher_name, fetcher_instance, url, llm_intent=None):
def build_restock_messages(content: str, url: str, llm_intent: str = None,
max_chars: int = _MAX_CONTENT_CHARS) -> list | None:
"""Chat messages (system + user) for extracting price/stock from *content* (HTML).
Returns None when there's no text left after stripping the HTML, nothing worth sending.
"""
text_content = _strip_html(content, max_chars=max_chars) if content else ''
logger.debug(f"LLM restock fallback: stripped HTML to {len(text_content)} chars for {url}")
if not text_content.strip():
logger.debug("LLM restock fallback: no text content after stripping HTML")
return None
user_prompt = f'URL: {url or "unknown"}\n\nPage content:\n{text_content}'
if llm_intent:
user_prompt += f'\n\nUser notification intent: {llm_intent}'
return [
{'role': 'system', 'content': SYSTEM_PROMPT},
{'role': 'user', 'content': user_prompt},
]
def parse_restock_reply(raw: str, url: str = None) -> dict | None:
"""{'price', 'currency', 'availability'} from the model's raw reply.
Returns None when the reply isn't JSON or carries neither a price nor an availability.
"""
# Strip optional markdown fences the model might add
raw = (raw or '').strip()
if raw.startswith('```'):
raw = re.sub(r'^```[a-z]*\n?', '', raw)
raw = raw.rstrip('`').strip()
logger.debug(f"LLM restock fallback raw response: {raw!r}")
try:
result = json.loads(raw)
except json.JSONDecodeError as e:
logger.warning(f"LLM restock fallback: JSON parse failed ({e}) - raw response was: {raw!r}")
return None
if not isinstance(result, dict):
logger.warning(f"LLM restock fallback: expected a JSON object - raw response was: {raw!r}")
return None
price = result.get('price')
currency = result.get('currency') or None
availability = result.get('availability') or None
# Normalise price to float
if price is not None:
try:
if isinstance(price, str):
# The model sometimes returns the price as page text ("1.299,95 €"), stripping
# everything but digits and dots would turn that into 1.29995. Same parser as the
# structured-data extractors, see Restock.parse_currency() for every caller.
from changedetectionio.processors.restock_diff import Restock
price = Restock().parse_currency(price)
else:
price = float(price)
except (ValueError, TypeError):
logger.warning(f"LLM restock fallback: could not convert price {price!r} to float, ignoring")
price = None
if price is None and not availability:
logger.info(f"LLM restock fallback: LLM returned no usable price or availability for {url} (raw: {raw!r})")
return None
logger.info(
f"LLM restock fallback result: price={price} currency={currency} "
f"availability={availability!r} url={url}"
)
return {'price': price, 'currency': currency, 'availability': availability}
# trylast: plugins that talk to a provider some other way get the first go, and since the
# hook is firstresult this one (and litellm) is skipped when one of them already answered.
@hookimpl(trylast=True)
def get_itemprop_availability_override(content, fetcher_name, fetcher_instance, url, llm_intent):
"""Use an LLM as a last-resort fallback for price and restock extraction."""
global datastore
@@ -266,36 +373,26 @@ def get_itemprop_availability_override(content, fetcher_name, fetcher_instance,
return None
# Prompt size obeys the app's "Max input characters" setting (env → datastore → 100k)
max_input_chars = _get_max_input_chars(datastore)
text_content = _strip_html(content, max_chars=max_input_chars) if content else ''
logger.debug(f"LLM restock fallback: stripped HTML to {len(text_content)} chars for {url}")
if not text_content.strip():
logger.debug("LLM restock fallback: no text content after stripping HTML")
messages = build_restock_messages(content, url, llm_intent, max_chars=_get_max_input_chars(datastore))
if not messages:
return None
logger.info(f"LLM restock fallback: using LLM ({llm_cfg['model']}) for price/stock extraction - {url}")
user_prompt = f'URL: {url or "unknown"}\n\nPage content:\n{text_content}'
if llm_intent:
user_prompt += f'\n\nUser notification intent: {llm_intent}'
# Skip the (billed) LLM call entirely if we've already extracted this exact input — the page
# content that matters hasn't changed since last time, only noisy raw HTML around it.
cache_key = hashlib.md5(((llm_cfg.get('model') or '') + '\n' + user_prompt).encode('utf-8')).hexdigest()
cached = _llm_cache_get(cache_key)
cache_key = restock_cache_key(llm_cfg['model'], messages)
cached = restock_cache_get(cache_key)
if cached is not None:
logger.info(f"LLM restock fallback: content unchanged since last LLM call, reusing cached result - {url}")
return {**cached, '_tokens': 0, '_input_tokens': 0, '_output_tokens': 0, '_model': llm_cfg['model']}
logger.debug(f"LLM System Prompt: {SYSTEM_PROMPT}")
logger.debug(f"LLM Prompt: {user_prompt}")
logger.debug(f"LLM Prompt: {messages[-1]['content']}")
try:
raw, tokens, input_tokens, output_tokens = llm_client.completion(
model=llm_cfg['model'],
messages=[
{'role': 'system', 'content': SYSTEM_PROMPT},
{'role': 'user', 'content': user_prompt},
],
messages=messages,
api_key=llm_cfg.get('api_key'),
api_base=llm_cfg.get('api_base'),
timeout=resolve_llm_timeout(llm_cfg),
@@ -316,46 +413,11 @@ def get_itemprop_availability_override(content, fetcher_name, fetcher_instance,
model=llm_cfg['model'],
)
# Strip optional markdown fences the model might add
raw = raw.strip()
if raw.startswith('```'):
raw = re.sub(r'^```[a-z]*\n?', '', raw)
raw = raw.rstrip('`').strip()
logger.debug(f"LLM restock fallback raw response: {raw!r}")
result = json.loads(raw)
price = result.get('price')
currency = result.get('currency') or None
availability = result.get('availability') or None
# Normalise price to float
if price is not None:
try:
if isinstance(price, str):
# The model sometimes returns the price as page text ("1.299,95 €"), stripping
# everything but digits and dots would turn that into 1.29995. Same parser as the
# structured-data extractors, see Restock.parse_currency() for every caller.
from changedetectionio.processors.restock_diff import Restock
price = Restock().parse_currency(price)
else:
price = float(price)
except (ValueError, TypeError):
logger.warning(f"LLM restock fallback: could not convert price {price!r} to float, ignoring")
price = None
if price is None and not availability:
logger.info(f"LLM restock fallback: LLM returned no usable price or availability for {url} (raw: {raw!r})")
result_clean = parse_restock_reply(raw, url)
if not result_clean:
return None
logger.info(
f"LLM restock fallback result: price={price} currency={currency} "
f"availability={availability!r} url={url}"
)
result_clean = {'price': price, 'currency': currency, 'availability': availability}
# Remember this result so an identical next check doesn't re-bill the LLM.
_llm_cache_put(cache_key, result_clean)
restock_cache_put(cache_key, result_clean)
return {
**result_clean,
'_tokens': tokens,
@@ -364,9 +426,6 @@ def get_itemprop_availability_override(content, fetcher_name, fetcher_instance,
'_model': llm_cfg['model'],
}
except json.JSONDecodeError as e:
logger.warning(f"LLM restock fallback: JSON parse failed ({e}) - raw response was: {raw!r}")
return None
except Exception as e:
logger.warning(f"LLM restock fallback: extraction failed for {url}: {e}")
return None
@@ -83,6 +83,7 @@ class TestLLMRestockPluginDisabled:
fetcher_name='html_requests',
fetcher_instance=None,
url='https://example.com/product',
llm_intent=None,
)
assert result is None
@@ -298,6 +299,7 @@ class TestLLMRestockPluginErrorHandling:
fetcher_name='html_requests',
fetcher_instance=None,
url='https://example.com',
llm_intent=None,
)
assert result is None
@@ -313,6 +315,7 @@ class TestLLMRestockPluginErrorHandling:
fetcher_name='html_requests',
fetcher_instance=None,
url='https://example.com',
llm_intent=None,
)
assert result is None
@@ -343,3 +346,174 @@ class TestLLMRestockPluginHTMLStripping:
long_html = '<p>' + 'x' * (_MAX_CONTENT_CHARS * 2) + '</p>'
result = _strip_html(long_html)
assert len(result) <= _MAX_CONTENT_CHARS
class TestLLMRestockReusableHelpers:
"""The pieces another LLM backend (a plugin not using litellm) reuses."""
def test_build_restock_messages(self):
from changedetectionio.processors.restock_diff.plugins import llm_restock
messages = llm_restock.build_restock_messages(
'<html><body><h1>Widget</h1>$19.99 Add to cart</body></html>',
'https://example.com/widget', llm_intent='tell me when it is cheaper')
assert [m['role'] for m in messages] == ['system', 'user']
assert messages[0]['content'] == llm_restock.SYSTEM_PROMPT
assert 'URL: https://example.com/widget' in messages[1]['content']
assert '$19.99' in messages[1]['content']
assert 'User notification intent: tell me when it is cheaper' in messages[1]['content']
def test_build_restock_messages_nothing_to_send(self):
from changedetectionio.processors.restock_diff.plugins import llm_restock
assert llm_restock.build_restock_messages('', 'https://example.com') is None
assert llm_restock.build_restock_messages('<html><body> </body></html>', 'https://example.com') is None
def test_cache_key_follows_model_and_prompt(self):
from changedetectionio.processors.restock_diff.plugins import llm_restock
a = llm_restock.build_restock_messages('<p>$1</p>', 'https://example.com/a')
b = llm_restock.build_restock_messages('<p>$2</p>', 'https://example.com/a')
assert llm_restock.restock_cache_key('m1', a) == llm_restock.restock_cache_key('m1', a)
assert llm_restock.restock_cache_key('m1', a) != llm_restock.restock_cache_key('m2', a)
assert llm_restock.restock_cache_key('m1', a) != llm_restock.restock_cache_key('m1', b)
@pytest.mark.parametrize('raw, expected', [
('{"price": 19.99, "currency": "USD", "availability": "instock"}',
{'price': 19.99, 'currency': 'USD', 'availability': 'instock'}),
('```json\n{"price": "1.299,95 €", "currency": "EUR", "availability": null}\n```',
{'price': 1299.95, 'currency': 'EUR', 'availability': None}),
('{"price": null, "currency": null, "availability": "outofstock"}',
{'price': None, 'currency': None, 'availability': 'outofstock'}),
('{"price": null, "currency": null, "availability": null}', None),
('Sure! The price is $19.99', None),
('[19.99]', None),
('', None),
])
def test_parse_restock_reply(self, raw, expected):
from changedetectionio.processors.restock_diff.plugins import llm_restock
assert llm_restock.parse_restock_reply(raw, 'https://example.com') == expected
def test_accumulate_global_tokens_uses_given_cost(self):
from changedetectionio.llm import evaluator
ds = _make_datastore()
with patch.object(evaluator, '_estimate_cost_usd', side_effect=AssertionError('should not estimate')):
evaluator.accumulate_global_tokens(ds, 100, input_tokens=90, output_tokens=10,
model='some-model', cost_usd=0.25)
llm = ds.data['settings']['application']['llm']
assert llm['tokens_total_cumulative'] == 100
assert llm['cost_usd_total_cumulative'] == 0.25
def test_alternative_backend_path_does_not_import_litellm(self):
"""litellm costs ~200MB once imported and is never released, the whole point of reusing
these helpers from another backend is to not pay that. Fresh interpreter, since other
tests in this process may already have imported it."""
import subprocess, sys
code = '''
import sys
from unittest.mock import MagicMock
from changedetectionio.processors.restock_diff.plugins import llm_restock
from changedetectionio.llm.evaluator import accumulate_global_tokens
messages = llm_restock.build_restock_messages('<p>Widget $5 Add to cart</p>', 'https://example.com')
key = llm_restock.restock_cache_key('gemini/gemini-flash-lite', messages)
result = llm_restock.parse_restock_reply('{"price": 5, "currency": "USD", "availability": "instock"}')
llm_restock.restock_cache_put(key, result)
ds = MagicMock()
ds.data = {'settings': {'application': {'llm': {}}}}
accumulate_global_tokens(ds, 10, input_tokens=8, output_tokens=2, model='gemini/gemini-flash-lite', cost_usd=0.0001)
assert result['price'] == 5.0, result
print('litellm' in sys.modules)
'''
# CI runs pytest from inside the package dir without installing it, so the child
# wouldn't find 'changedetectionio' - point it at the directory that contains it.
import os, changedetectionio
repo_root = os.path.dirname(os.path.dirname(os.path.abspath(changedetectionio.__file__)))
env = {**os.environ, 'PYTHONPATH': os.pathsep.join(filter(None, [repo_root, os.environ.get('PYTHONPATH')]))}
out = subprocess.run([sys.executable, '-c', code], capture_output=True, text=True, timeout=120, env=env)
assert out.returncode == 0, out.stderr
assert out.stdout.strip().splitlines()[-1] == 'False', "litellm was imported"
class TestRestockHookDispatch:
"""get_itemprop_availability_from_plugin(): a third-party plugin (e.g. plain `requests` to an
LLM, no litellm) gets the HTML and the html_to_text() version, and goes before the built-in."""
HTML = '<html><body><h1>Widget</h1><p>Price: $12.50</p><button>Add to cart</button></body></html>'
@pytest.fixture
def registered(self):
from changedetectionio.pluggy_interface import plugin_manager, register_builtin_restock_plugins
register_builtin_restock_plugins()
added = []
def register(plugin):
plugin_manager.register(plugin)
added.append(plugin)
yield register
for plugin in added:
plugin_manager.unregister(plugin)
def _dispatch(self):
from changedetectionio.pluggy_interface import get_itemprop_availability_from_plugin
return get_itemprop_availability_from_plugin(self.HTML, 'html_requests', None, 'https://example.com/widget')
def test_custom_plugin_gets_html_and_text_and_wins(self, registered):
from changedetectionio.pluggy_interface import hookimpl
from changedetectionio.processors.restock_diff.plugins import llm_restock
seen = {}
class MyPlugin:
@hookimpl
def get_itemprop_availability_override(self, content, url, text):
seen.update(content=content, text=text, url=url)
return {'price': 12.5, 'currency': 'USD', 'availability': 'instock'}
registered(MyPlugin())
llm_restock.datastore = _make_datastore()
with patch('changedetectionio.llm.client.completion') as litellm_completion:
result = self._dispatch()
assert result == {'price': 12.5, 'currency': 'USD', 'availability': 'instock'}
assert seen['content'] == self.HTML
assert 'Price: $12.50' in seen['text'] and '<p>' not in seen['text']
# The built-in LLM plugin must not have been billed as well
litellm_completion.assert_not_called()
def test_custom_plugin_returning_none_falls_through_to_builtin(self, registered):
from changedetectionio.pluggy_interface import hookimpl
from changedetectionio.processors.restock_diff.plugins import llm_restock
class NotMine:
@hookimpl
def get_itemprop_availability_override(self, content):
return None
registered(NotMine())
llm_restock.datastore = _make_datastore()
with patch('changedetectionio.llm.client.completion',
return_value=('{"price": 12.5, "currency": "USD", "availability": "instock"}', 50, 40, 10)):
result = self._dispatch()
assert result['price'] == 12.5
def test_text_not_computed_when_nobody_asks_for_it(self, registered):
from changedetectionio.pluggy_interface import hookimpl
class HtmlOnly:
@hookimpl
def get_itemprop_availability_override(self, content):
return {'price': 1.0, 'currency': None, 'availability': None}
registered(HtmlOnly())
with patch('changedetectionio.html_tools.html_to_text') as html_to_text:
assert self._dispatch()['price'] == 1.0
html_to_text.assert_not_called()
def test_builtin_receives_llm_intent_through_pluggy(self, registered):
"""The built-in impl used to declare llm_intent=None, and pluggy never passes an argument
that has a default, so the user's intent silently never reached the restock prompt."""
from changedetectionio.pluggy_interface import get_itemprop_availability_from_plugin
from changedetectionio.processors.restock_diff.plugins import llm_restock
llm_restock.datastore = _make_datastore()
with patch('changedetectionio.llm.client.completion',
return_value=('{"price": 12.5, "currency": "USD", "availability": "instock"}', 50, 40, 10)) as completion:
get_itemprop_availability_from_plugin(self.HTML, 'html_requests', None, 'https://example.com/widget',
llm_intent='alert below $10')
assert 'alert below $10' in completion.call_args.kwargs['messages'][-1]['content']
@@ -59,10 +59,10 @@ class TestLLMRestockContent(unittest.TestCase):
def test_result_cache_is_bounded_fifo(self):
m._LLM_RESULT_CACHE.clear()
for i in range(m._LLM_RESULT_CACHE_MAX + 25):
m._llm_cache_put(f'key{i}', {'price': i})
m.restock_cache_put(f'key{i}', {'price': i})
self.assertEqual(len(m._LLM_RESULT_CACHE), m._LLM_RESULT_CACHE_MAX)
self.assertIsNone(m._llm_cache_get('key0')) # oldest evicted
self.assertIsNotNone(m._llm_cache_get(f'key{m._LLM_RESULT_CACHE_MAX + 24}')) # newest kept
self.assertIsNone(m.restock_cache_get('key0')) # oldest evicted
self.assertIsNotNone(m.restock_cache_get(f'key{m._LLM_RESULT_CACHE_MAX + 24}')) # newest kept
m._LLM_RESULT_CACHE.clear()