mirror of
https://github.com/dgtlmoon/changedetection.io.git
synced 2026-10-29 10:05:45 +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
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>
520 lines
23 KiB
Python
520 lines
23 KiB
Python
"""
|
|
Tests for the LLM restock fallback plugin.
|
|
|
|
All LLM calls are mocked — no real API key required.
|
|
"""
|
|
import json
|
|
import pytest
|
|
from unittest.mock import patch, MagicMock
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def _clear_llm_result_cache():
|
|
"""The plugin keeps a module-level result cache (keyed by model+prompt) so it won't
|
|
re-bill the LLM when the meaningful page content is unchanged. That cache persists
|
|
across tests in the same process, which makes token-accounting assertions order
|
|
dependent (a cache hit returns zeroed tokens). Clear it before every test so each
|
|
one exercises a fresh extraction."""
|
|
from changedetectionio.processors.restock_diff.plugins import llm_restock
|
|
llm_restock._LLM_RESULT_CACHE.clear()
|
|
yield
|
|
llm_restock._LLM_RESULT_CACHE.clear()
|
|
|
|
|
|
def _make_datastore(llm_model='gpt-4o-mini', enabled=True):
|
|
"""Minimal datastore mock with the fields the plugin reads."""
|
|
ds = MagicMock()
|
|
ds.data = {
|
|
'settings': {
|
|
'application': {
|
|
'llm': {
|
|
'enabled': True,
|
|
'restock_use_fallback_extract': enabled,
|
|
'model': llm_model,
|
|
'api_key': 'test-key',
|
|
'api_base': '',
|
|
'tokens_total_cumulative': 0,
|
|
'tokens_this_month': 0,
|
|
'tokens_month_key': '2099-01',
|
|
'cost_usd_total_cumulative': 0.0,
|
|
'cost_usd_this_month': 0.0,
|
|
},
|
|
}
|
|
}
|
|
}
|
|
return ds
|
|
|
|
|
|
def _call_plugin(content, url='https://example.com/product',
|
|
llm_json=None, datastore=None, enabled=True, llm_intent=None):
|
|
"""Helper: import plugin, inject datastore, call the hook, return result."""
|
|
from changedetectionio.processors.restock_diff.plugins import llm_restock
|
|
|
|
if datastore is None:
|
|
datastore = _make_datastore(enabled=enabled)
|
|
llm_restock.datastore = datastore
|
|
|
|
if llm_json is not None:
|
|
with patch('changedetectionio.llm.client.completion',
|
|
return_value=(llm_json, 50, 40, 10)):
|
|
return llm_restock.get_itemprop_availability_override(
|
|
content=content,
|
|
fetcher_name='html_requests',
|
|
fetcher_instance=None,
|
|
url=url,
|
|
llm_intent=llm_intent,
|
|
)
|
|
else:
|
|
return llm_restock.get_itemprop_availability_override(
|
|
content=content,
|
|
fetcher_name='html_requests',
|
|
fetcher_instance=None,
|
|
url=url,
|
|
llm_intent=llm_intent,
|
|
)
|
|
|
|
|
|
class TestLLMRestockPluginDisabled:
|
|
def test_returns_none_when_no_datastore(self):
|
|
from changedetectionio.processors.restock_diff.plugins import llm_restock
|
|
llm_restock.datastore = None
|
|
result = llm_restock.get_itemprop_availability_override(
|
|
content='<html><body>Price: $49.99 In Stock</body></html>',
|
|
fetcher_name='html_requests',
|
|
fetcher_instance=None,
|
|
url='https://example.com/product',
|
|
llm_intent=None,
|
|
)
|
|
assert result is None
|
|
|
|
def test_returns_none_when_setting_disabled(self):
|
|
result = _call_plugin(
|
|
'<html><body>Price: $49.99 In Stock</body></html>',
|
|
enabled=False,
|
|
)
|
|
assert result is None
|
|
|
|
def test_returns_none_when_no_llm_configured(self):
|
|
ds = MagicMock()
|
|
ds.data = {
|
|
'settings': {
|
|
'application': {
|
|
# No 'llm' key → get_llm_config returns None;
|
|
# restock_use_fallback_extract still defaults to True via LLMSettings
|
|
}
|
|
}
|
|
}
|
|
result = _call_plugin(
|
|
'<html><body>Price: $49.99 In Stock</body></html>',
|
|
datastore=ds,
|
|
)
|
|
assert result is None
|
|
|
|
def test_returns_none_for_empty_content(self):
|
|
result = _call_plugin('', llm_json='{"price": 9.99, "currency": "USD", "availability": "instock"}')
|
|
assert result is None
|
|
|
|
|
|
class TestLLMRestockPluginExtraction:
|
|
def test_extracts_price_and_in_stock(self):
|
|
llm_json = '{"price": 49.99, "currency": "USD", "availability": "instock"}'
|
|
result = _call_plugin(
|
|
'<html><body><span class="price">$49.99</span> <span>In Stock</span></body></html>',
|
|
llm_json=llm_json,
|
|
)
|
|
assert result is not None
|
|
assert result['price'] == 49.99
|
|
assert result['currency'] == 'USD'
|
|
assert result['availability'] == 'instock'
|
|
|
|
def test_extracts_out_of_stock(self):
|
|
llm_json = '{"price": 129.00, "currency": "EUR", "availability": "outofstock"}'
|
|
result = _call_plugin(
|
|
'<html><body>129,00 € — Sold out</body></html>',
|
|
llm_json=llm_json,
|
|
)
|
|
assert result is not None
|
|
assert result['price'] == 129.0
|
|
assert result['currency'] == 'EUR'
|
|
assert result['availability'] == 'outofstock'
|
|
|
|
def test_returns_availability_only_when_no_price(self):
|
|
llm_json = '{"price": null, "currency": null, "availability": "instock"}'
|
|
result = _call_plugin(
|
|
'<html><body>Item available</body></html>',
|
|
llm_json=llm_json,
|
|
)
|
|
assert result is not None
|
|
assert result['price'] is None
|
|
assert result['availability'] == 'instock'
|
|
|
|
def test_returns_price_only_when_no_availability(self):
|
|
llm_json = '{"price": 19.95, "currency": "GBP", "availability": null}'
|
|
result = _call_plugin(
|
|
'<html><body>£19.95</body></html>',
|
|
llm_json=llm_json,
|
|
)
|
|
assert result is not None
|
|
assert result['price'] == 19.95
|
|
assert result['availability'] is None
|
|
|
|
def test_returns_none_when_both_null(self):
|
|
llm_json = '{"price": null, "currency": null, "availability": null}'
|
|
result = _call_plugin(
|
|
'<html><body>No pricing info here</body></html>',
|
|
llm_json=llm_json,
|
|
)
|
|
assert result is None
|
|
|
|
def test_strips_markdown_fences(self):
|
|
llm_json = '```json\n{"price": 9.99, "currency": "USD", "availability": "instock"}\n```'
|
|
result = _call_plugin(
|
|
'<html><body>$9.99 In Stock</body></html>',
|
|
llm_json=llm_json,
|
|
)
|
|
assert result is not None
|
|
assert result['price'] == 9.99
|
|
|
|
def test_handles_integer_price(self):
|
|
llm_json = '{"price": 100, "currency": "USD", "availability": "instock"}'
|
|
result = _call_plugin(
|
|
'<html><body>$100 In Stock</body></html>',
|
|
llm_json=llm_json,
|
|
)
|
|
assert result is not None
|
|
assert result['price'] == 100.0
|
|
|
|
def test_handles_string_price(self):
|
|
"""Model might return price as a string despite the prompt."""
|
|
llm_json = '{"price": "49.99", "currency": "USD", "availability": "instock"}'
|
|
result = _call_plugin(
|
|
'<html><body>$49.99</body></html>',
|
|
llm_json=llm_json,
|
|
)
|
|
assert result is not None
|
|
assert result['price'] == 49.99
|
|
|
|
@pytest.mark.parametrize('price_str, expected', [
|
|
('1.299,95', 1299.95),
|
|
('1.299,95 €', 1299.95),
|
|
('1,299.95', 1299.95),
|
|
('10 000 Kč', 10000.0),
|
|
('10 000,50', 10000.5),
|
|
('$127.99', 127.99),
|
|
])
|
|
def test_handles_locale_formatted_string_price(self, price_str, expected):
|
|
"""A string price in page format must not be mangled by stripping its separators (same as #4493)."""
|
|
llm_json = json.dumps({'price': price_str, 'currency': None, 'availability': 'instock'})
|
|
result = _call_plugin(
|
|
f'<html><body>{price_str}</body></html>',
|
|
llm_json=llm_json,
|
|
)
|
|
assert result is not None
|
|
assert result['price'] == expected
|
|
|
|
|
|
class TestLLMRestockPluginTokenAccounting:
|
|
def test_result_includes_token_metadata(self):
|
|
"""Plugin result must carry _tokens/_input_tokens/_output_tokens/_model."""
|
|
llm_json = '{"price": 49.99, "currency": "USD", "availability": "instock"}'
|
|
result = _call_plugin(
|
|
'<html><body>$49.99 In Stock</body></html>',
|
|
llm_json=llm_json,
|
|
)
|
|
assert result is not None
|
|
assert result['_tokens'] == 50
|
|
assert result['_input_tokens'] == 40
|
|
assert result['_output_tokens'] == 10
|
|
assert result['_model'] == 'gpt-4o-mini'
|
|
|
|
def test_token_keys_not_in_none_result(self):
|
|
"""When LLM returns nothing useful, result is None — no token metadata leaked."""
|
|
llm_json = '{"price": null, "currency": null, "availability": null}'
|
|
result = _call_plugin(
|
|
'<html><body>No pricing info</body></html>',
|
|
llm_json=llm_json,
|
|
)
|
|
assert result is None
|
|
|
|
|
|
class TestLLMRestockPluginIntent:
|
|
def test_llm_intent_appended_to_user_prompt(self):
|
|
"""llm_intent should appear in the prompt sent to the LLM."""
|
|
from changedetectionio.processors.restock_diff.plugins import llm_restock
|
|
ds = _make_datastore()
|
|
llm_restock.datastore = ds
|
|
|
|
captured = {}
|
|
def fake_completion(model, messages, api_key, api_base, max_tokens, extra_body=None, timeout=None):
|
|
captured['messages'] = messages
|
|
return ('{"price": 299.0, "currency": "USD", "availability": "instock"}', 50, 40, 10)
|
|
|
|
with patch('changedetectionio.llm.client.completion', side_effect=fake_completion):
|
|
result = llm_restock.get_itemprop_availability_override(
|
|
content='<html><body>$299 In Stock</body></html>',
|
|
fetcher_name='html_requests',
|
|
fetcher_instance=None,
|
|
url='https://example.com',
|
|
llm_intent='Alert me when price drops below $300',
|
|
)
|
|
|
|
assert result is not None
|
|
user_msg = next(m for m in captured['messages'] if m['role'] == 'user')
|
|
assert 'Alert me when price drops below $300' in user_msg['content']
|
|
|
|
def test_no_intent_prompt_unchanged(self):
|
|
"""Without llm_intent the user prompt should not include the intent line."""
|
|
from changedetectionio.processors.restock_diff.plugins import llm_restock
|
|
ds = _make_datastore()
|
|
llm_restock.datastore = ds
|
|
|
|
captured = {}
|
|
def fake_completion(model, messages, api_key, api_base, max_tokens, extra_body=None, timeout=None):
|
|
captured['messages'] = messages
|
|
return ('{"price": 9.99, "currency": "USD", "availability": "instock"}', 20, 15, 5)
|
|
|
|
with patch('changedetectionio.llm.client.completion', side_effect=fake_completion):
|
|
llm_restock.get_itemprop_availability_override(
|
|
content='<html><body>$9.99 In Stock</body></html>',
|
|
fetcher_name='html_requests',
|
|
fetcher_instance=None,
|
|
url='https://example.com',
|
|
llm_intent=None,
|
|
)
|
|
|
|
user_msg = next(m for m in captured['messages'] if m['role'] == 'user')
|
|
assert 'notification intent' not in user_msg['content']
|
|
|
|
|
|
class TestLLMRestockPluginErrorHandling:
|
|
def test_returns_none_on_bad_json(self):
|
|
from changedetectionio.processors.restock_diff.plugins import llm_restock
|
|
ds = _make_datastore()
|
|
llm_restock.datastore = ds
|
|
|
|
with patch('changedetectionio.llm.client.completion',
|
|
return_value=('not valid json at all', 10, 8, 2)):
|
|
result = llm_restock.get_itemprop_availability_override(
|
|
content='<html><body>$49.99 In Stock</body></html>',
|
|
fetcher_name='html_requests',
|
|
fetcher_instance=None,
|
|
url='https://example.com',
|
|
llm_intent=None,
|
|
)
|
|
assert result is None
|
|
|
|
def test_returns_none_on_llm_exception(self):
|
|
from changedetectionio.processors.restock_diff.plugins import llm_restock
|
|
ds = _make_datastore()
|
|
llm_restock.datastore = ds
|
|
|
|
with patch('changedetectionio.llm.client.completion',
|
|
side_effect=Exception("LLM timeout")):
|
|
result = llm_restock.get_itemprop_availability_override(
|
|
content='<html><body>$49.99 In Stock</body></html>',
|
|
fetcher_name='html_requests',
|
|
fetcher_instance=None,
|
|
url='https://example.com',
|
|
llm_intent=None,
|
|
)
|
|
assert result is None
|
|
|
|
|
|
class TestLLMRestockPluginHTMLStripping:
|
|
def test_strip_html_removes_tags(self):
|
|
from changedetectionio.processors.restock_diff.plugins.llm_restock import _strip_html
|
|
result = _strip_html('<html><body><p>Price: $10</p></body></html>')
|
|
assert '<' not in result
|
|
assert 'Price: $10' in result
|
|
|
|
def test_strip_html_removes_scripts(self):
|
|
from changedetectionio.processors.restock_diff.plugins.llm_restock import _strip_html
|
|
html = '<html><head><script>var x = 1;</script></head><body>In Stock</body></html>'
|
|
result = _strip_html(html)
|
|
assert 'var x' not in result
|
|
assert 'In Stock' in result
|
|
|
|
def test_strip_html_decodes_entities(self):
|
|
from changedetectionio.processors.restock_diff.plugins.llm_restock import _strip_html
|
|
result = _strip_html('Price: 49 & in stock')
|
|
assert '&' not in result
|
|
assert ' ' not in result
|
|
assert 'in stock' in result
|
|
|
|
def test_strip_html_truncates_long_content(self):
|
|
from changedetectionio.processors.restock_diff.plugins.llm_restock import _strip_html, _MAX_CONTENT_CHARS
|
|
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']
|