mirror of
https://github.com/dgtlmoon/changedetection.io.git
synced 2026-10-28 17:47:53 +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>
71 lines
3.0 KiB
Python
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()
|