From 7ec79fc231e3a504eaf20ed3942a6cda1710b794 Mon Sep 17 00:00:00 2001 From: dgtlmoon Date: Tue, 13 Oct 2026 11:21:06 +0200 Subject: [PATCH] LLM restock: fix locale-formatted string prices (1.299,95 read as 1.29995) (#4544) 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 --------- Co-authored-by: Claude Opus 5.5 --- .../processors/restock_diff/__init__.py | 5 +++++ .../restock_diff/plugins/llm_restock.py | 6 +++++- .../restock_diff/pure_python_extractor.py | 3 ++- .../tests/llm/test_llm_restock_plugin.py | 18 ++++++++++++++++++ 4 files changed, 30 insertions(+), 2 deletions(-) diff --git a/changedetectionio/processors/restock_diff/__init__.py b/changedetectionio/processors/restock_diff/__init__.py index a2402e99c..6fcf19a32 100644 --- a/changedetectionio/processors/restock_diff/__init__.py +++ b/changedetectionio/processors/restock_diff/__init__.py @@ -16,6 +16,11 @@ _price_re = re.compile(r"Price:\s*(\d+(?:\.\d+)?)", re.IGNORECASE) class Restock(dict): + # The one place price text becomes a number - keep it that way, don't strip to [^\d.] and float() + # at the call site, that turns "39,99" into 3999 (#4493). Used by: + # processor.py _deduplicate_prices() (itemprop/JSON-LD prices) + # pure_python_extractor.py MicrodataExtractor, query_price_availability() + # plugins/llm_restock.py get_itemprop_availability_override() (price the LLM returns as a string) def parse_currency(self, raw_value: str) -> Union[float, None]: # Clean and standardize the value (ie 1,400.00 should be 1400.00), even better would be store the whole thing as an integer. standardized_value = raw_value diff --git a/changedetectionio/processors/restock_diff/plugins/llm_restock.py b/changedetectionio/processors/restock_diff/plugins/llm_restock.py index b36f8cc1f..74b617d38 100644 --- a/changedetectionio/processors/restock_diff/plugins/llm_restock.py +++ b/changedetectionio/processors/restock_diff/plugins/llm_restock.py @@ -334,7 +334,11 @@ def get_itemprop_availability_override(content, fetcher_name, fetcher_instance, if price is not None: try: if isinstance(price, str): - price = float(re.sub(r'[^\d.]', '', price)) + # 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): diff --git a/changedetectionio/processors/restock_diff/pure_python_extractor.py b/changedetectionio/processors/restock_diff/pure_python_extractor.py index d7d7cf6b1..a54587270 100644 --- a/changedetectionio/processors/restock_diff/pure_python_extractor.py +++ b/changedetectionio/processors/restock_diff/pure_python_extractor.py @@ -136,6 +136,7 @@ class MicrodataExtractor(HTMLParser): if self.current_itemprop == 'price': # Try to extract numeric price from text try: + # Not [^\d.] + float(), that breaks "39,99" (#4493), see Restock.parse_currency() for every caller price = Restock().parse_currency(data.strip()) if price is not None: self.microdata['price'] = price @@ -240,7 +241,7 @@ def query_price_availability(extracted_data): if isinstance(price_val, (int, float)): result['price'] = float(price_val) elif isinstance(price_val, str): - # Extract numeric value from string + # Extract numeric value from string, see Restock.parse_currency() for every caller try: result['price'] = Restock().parse_currency(price_val) except ValueError: diff --git a/changedetectionio/tests/llm/test_llm_restock_plugin.py b/changedetectionio/tests/llm/test_llm_restock_plugin.py index 6009e7086..46b3eb0d9 100644 --- a/changedetectionio/tests/llm/test_llm_restock_plugin.py +++ b/changedetectionio/tests/llm/test_llm_restock_plugin.py @@ -193,6 +193,24 @@ class TestLLMRestockPluginExtraction: 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'{price_str}', + llm_json=llm_json, + ) + assert result is not None + assert result['price'] == expected + class TestLLMRestockPluginTokenAccounting: def test_result_includes_token_metadata(self):