mirror of
https://github.com/dgtlmoon/changedetection.io.git
synced 2026-10-29 10:05:45 +00:00
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
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:
1 parent
804876fdd1
commit
be09f73c3a
5 files changed
+335
-78
No files matched your search
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
|
||||
Reference in new issue
Block a user