Files
changedetection.io/changedetectionio/tests/unit/test_llm_restock_content.py
dgtlmoonandClaude Opus 5.5 be09f73c3a
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
Restock: let a plugin do LLM price/stock extraction without litellm (HTML + text hook, reusable helpers) (#4545)
* 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>
2026-10-19 12:32:32 +02:00

71 lines
3.0 KiB
Python

#!/usr/bin/env python3
"""
Unit tests for the LLM restock fallback's prompt-content building (no LLM call needed):
* _extract_jsonld() keeps only JSON-LD blocks carrying price/stock info, dropping noise
(breadcrumbs, site metadata) that otherwise crowds out the useful data.
* _strip_html() sends the page's visible text IN ADDITION to that structured metadata —
the real/visible price is often not in JSON-LD (placeholder "0").
* the result cache evicts oldest-first and stays bounded.
Run from the tests/ directory:
python -m unittest unit/test_llm_restock_content.py
"""
import unittest
from changedetectionio.processors.restock_diff.plugins import llm_restock as m
PRODUCT_LD = '{"@type":"Product","offers":{"price":"319.01","priceCurrency":"CZK","availability":"http://schema.org/InStock"}}'
BREADCRUMB_LD = '{"@type":"BreadcrumbList","itemListElement":[{"name":"a"},{"name":"b"}]}'
HTML = f"""
<html><head>
<script type="application/ld+json">{PRODUCT_LD}</script>
<script type="application/ld+json">{BREADCRUMB_LD}</script>
</head><body>
<nav>menu junk links</nav>
<h1>Olejovy filtr</h1>
<div class="price">319,01 Kc</div>
<button>Add to cart</button>
</body></html>
"""
class TestLLMRestockContent(unittest.TestCase):
def test_jsonld_keeps_price_block_drops_breadcrumb(self):
jsonld = m._extract_jsonld(HTML)
self.assertIn('319.01', jsonld) # product/price block kept
self.assertIn('availability', jsonld)
self.assertNotIn('BreadcrumbList', jsonld) # noise dropped
self.assertNotIn('itemListElement', jsonld)
def test_strip_html_includes_both_metadata_and_visible_text(self):
out = m._strip_html(HTML, max_chars=500)
# structured metadata for context...
self.assertIn('priceCurrency', out)
# ...AND the visible page text (where the real price often actually lives)
self.assertIn('319,01', out)
self.assertIn('Add to cart', out)
def test_strip_html_metadata_never_eats_whole_budget(self):
# Even with a tiny budget, visible text must still get a share (metadata capped to half).
big_meta_html = '<script type="application/ld+json">' + '{"price":"1",' + 'x' * 5000 + '}' + \
'</script><body><p>VISIBLE_TEXT_MARKER add to cart</p></body>'
out = m._strip_html(big_meta_html, max_chars=400)
self.assertLessEqual(len(out), 400)
self.assertIn('VISIBLE_TEXT_MARKER', out)
def test_result_cache_is_bounded_fifo(self):
m._LLM_RESULT_CACHE.clear()
for i in range(m._LLM_RESULT_CACHE_MAX + 25):
m.restock_cache_put(f'key{i}', {'price': i})
self.assertEqual(len(m._LLM_RESULT_CACHE), m._LLM_RESULT_CACHE_MAX)
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()
if __name__ == '__main__':
unittest.main()