From be09f73c3af1bbc698d19a532ef6d3c9987e6370 Mon Sep 17 00:00:00 2001 From: dgtlmoon Date: Mon, 19 Oct 2026 12:32:32 +0200 Subject: [PATCH] Restock: let a plugin do LLM price/stock extraction without litellm (HTML + text hook, reusable helpers) (#4545) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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 * 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 * 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 * 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 * 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 --------- Co-authored-by: Claude Opus 5.5 --- changedetectionio/llm/evaluator.py | 6 +- changedetectionio/pluggy_interface.py | 46 +++-- .../restock_diff/plugins/llm_restock.py | 181 ++++++++++++------ .../tests/llm/test_llm_restock_plugin.py | 174 +++++++++++++++++ .../tests/unit/test_llm_restock_content.py | 6 +- 5 files changed, 335 insertions(+), 78 deletions(-) diff --git a/changedetectionio/llm/evaluator.py b/changedetectionio/llm/evaluator.py index 88bef9321..7fc8e198a 100644 --- a/changedetectionio/llm/evaluator.py +++ b/changedetectionio/llm/evaluator.py @@ -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) diff --git a/changedetectionio/pluggy_interface.py b/changedetectionio/pluggy_interface.py index 1f86e9dc7..314d36e0f 100644 --- a/changedetectionio/pluggy_interface.py +++ b/changedetectionio/pluggy_interface.py @@ -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 diff --git a/changedetectionio/processors/restock_diff/plugins/llm_restock.py b/changedetectionio/processors/restock_diff/plugins/llm_restock.py index 74b617d38..0ebea63b3 100644 --- a/changedetectionio/processors/restock_diff/plugins/llm_restock.py +++ b/changedetectionio/processors/restock_diff/plugins/llm_restock.py @@ -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 = + 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 diff --git a/changedetectionio/tests/llm/test_llm_restock_plugin.py b/changedetectionio/tests/llm/test_llm_restock_plugin.py index 46b3eb0d9..a90523382 100644 --- a/changedetectionio/tests/llm/test_llm_restock_plugin.py +++ b/changedetectionio/tests/llm/test_llm_restock_plugin.py @@ -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 = '

' + 'x' * (_MAX_CONTENT_CHARS * 2) + '

' 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( + '

Widget

$19.99 Add to cart', + '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(' ', '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('

$1

', 'https://example.com/a') + b = llm_restock.build_restock_messages('

$2

', '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('

Widget $5 Add to cart

', '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 = '

Widget

Price: $12.50

' + + @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 '

' 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'] diff --git a/changedetectionio/tests/unit/test_llm_restock_content.py b/changedetectionio/tests/unit/test_llm_restock_content.py index e6f331859..8aaa84644 100644 --- a/changedetectionio/tests/unit/test_llm_restock_content.py +++ b/changedetectionio/tests/unit/test_llm_restock_content.py @@ -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()