From 60de170f0622e0bcc7faf5e8cdadf5fc09ce54e4 Mon Sep 17 00:00:00 2001 From: dgtlmoon Date: Fri, 17 Apr 2026 05:42:52 +0200 Subject: [PATCH] remove duplicates --- changedetectionio/blueprint/tags/form.py | 7 +-- changedetectionio/blueprint/ui/diff.py | 13 ++-- .../blueprint/watchlist/__init__.py | 2 + .../watchlist/templates/watch-overview.html | 2 +- changedetectionio/forms.py | 5 +- changedetectionio/model/Watch.py | 37 +++++------ .../processors/text_json_diff/difference.py | 20 +++--- .../tests/unit/test_watch_model.py | 62 +++++++++++++++++++ changedetectionio/worker.py | 25 +++++--- 9 files changed, 117 insertions(+), 56 deletions(-) diff --git a/changedetectionio/blueprint/tags/form.py b/changedetectionio/blueprint/tags/form.py index b33124661..f292b7cbd 100644 --- a/changedetectionio/blueprint/tags/form.py +++ b/changedetectionio/blueprint/tags/form.py @@ -8,6 +8,7 @@ from wtforms import ( from wtforms.fields.simple import BooleanField from changedetectionio.processors.restock_diff.forms import processor_settings_form as restock_settings_form +from changedetectionio.llm.ui_strings import LLM_INTENT_TAG_PLACEHOLDER, LLM_CHANGE_SUMMARY_PLACEHOLDER class group_restock_settings_form(restock_settings_form): overrides_watch = BooleanField('Activate for individual watches in this tag/group?', default=False) @@ -16,13 +17,11 @@ class group_restock_settings_form(restock_settings_form): tag_colour = StringField('Tag colour', default='') llm_intent = TextAreaField('AI Change Intent', validators=[validators.Optional(), validators.Length(max=2000)], - render_kw={"rows": "3", - "placeholder": "e.g. Flag price changes or new product launches across all watches in this group"}) + render_kw={"rows": "3", "placeholder": LLM_INTENT_TAG_PLACEHOLDER}) llm_change_summary = TextAreaField('AI Change Summary', validators=[validators.Optional(), validators.Length(max=2000)], - render_kw={"rows": "3", - "placeholder": "e.g. List what was added or removed. Translate to English."}, + render_kw={"rows": "3", "placeholder": LLM_CHANGE_SUMMARY_PLACEHOLDER}, default='') class SingleTag(Form): diff --git a/changedetectionio/blueprint/ui/diff.py b/changedetectionio/blueprint/ui/diff.py index 54914f2d8..c2d9096c2 100644 --- a/changedetectionio/blueprint/ui/diff.py +++ b/changedetectionio/blueprint/ui/diff.py @@ -174,14 +174,12 @@ def construct_blueprint(datastore: ChangeDetectionStore): if not diff_text.strip(): return jsonify({'summary': None, 'error': 'No differences found'}) - from changedetectionio.llm.evaluator import ( - summarise_change, get_effective_summary_prompt, compute_summary_cache_key, - ) + from changedetectionio.llm.evaluator import summarise_change, get_effective_summary_prompt - # Check cache — skip LLM if this exact (diff, prompt) pair was already summarised effective_prompt = get_effective_summary_prompt(watch, datastore) - cache_key = compute_summary_cache_key(diff_text, effective_prompt) - cached = watch.get_last_llm_diff_summary(cache_key=cache_key) + + # Check cache — keyed by version pair + prompt hash (invalidates if prompt changes) + cached = watch.get_llm_diff_summary(from_version, to_version, prompt=effective_prompt) if cached: return jsonify({'summary': cached, 'error': None, 'cached': True}) @@ -194,9 +192,8 @@ def construct_blueprint(datastore: ChangeDetectionStore): if not summary: return jsonify({'summary': None, 'error': 'LLM returned empty summary'}) - # Persist with cache key so subsequent requests (and next page load) are instant try: - watch.save_llm_diff_summary(summary, cache_key=cache_key) + watch.save_llm_diff_summary(summary, from_version, to_version, prompt=effective_prompt) except Exception as e: logger.warning(f"Could not cache llm summary for {uuid}: {e}") diff --git a/changedetectionio/blueprint/watchlist/__init__.py b/changedetectionio/blueprint/watchlist/__init__.py index 5d209b07e..793734528 100644 --- a/changedetectionio/blueprint/watchlist/__init__.py +++ b/changedetectionio/blueprint/watchlist/__init__.py @@ -84,6 +84,7 @@ def construct_blueprint(datastore: ChangeDetectionStore, update_q, queuedWatchMe proxy_list = datastore.proxy_list from changedetectionio.llm.evaluator import get_llm_config as _get_llm_config + from changedetectionio.llm.ui_strings import LLM_INTENT_WATCH_PLACEHOLDER llm_configured = bool(_get_llm_config(datastore)) output = render_template( @@ -115,6 +116,7 @@ def construct_blueprint(datastore: ChangeDetectionStore, update_q, queuedWatchMe unread_changes_count=datastore.unread_changes_count, watches=sorted_watches, llm_configured=llm_configured, + llm_intent_watch_placeholder=LLM_INTENT_WATCH_PLACEHOLDER, ) # Return freed template-building memory to the OS immediately. diff --git a/changedetectionio/blueprint/watchlist/templates/watch-overview.html b/changedetectionio/blueprint/watchlist/templates/watch-overview.html index ca6198e82..82e122364 100644 --- a/changedetectionio/blueprint/watchlist/templates/watch-overview.html +++ b/changedetectionio/blueprint/watchlist/templates/watch-overview.html @@ -119,7 +119,7 @@ html[data-darkmode="true"] .watch-tag-list.tag-{{ class_name }} { id="quick_watch_llm_intent" rows="2" class="pure-input-1" - placeholder="{{ _('AI — Notify when… (e.g. Alert me when the price drops below $300, or a new product is launched)') }}" + placeholder="{{ _('AI — Notify when…') }} {{ llm_intent_watch_placeholder }}" > {% endif %} diff --git a/changedetectionio/forms.py b/changedetectionio/forms.py index 098edbc1b..a057b2bd3 100644 --- a/changedetectionio/forms.py +++ b/changedetectionio/forms.py @@ -5,6 +5,7 @@ from wtforms.widgets.core import TimeInput from flask_babel import lazy_gettext as _l, gettext from changedetectionio.blueprint.rss import RSS_FORMAT_TYPES, RSS_TEMPLATE_TYPE_OPTIONS, RSS_TEMPLATE_HTML_DEFAULT +from changedetectionio.llm.ui_strings import LLM_INTENT_WATCH_PLACEHOLDER, LLM_CHANGE_SUMMARY_PLACEHOLDER from changedetectionio.conditions.form import ConditionFormRow from changedetectionio.notification_service import NotificationContextData from changedetectionio.strtobool import strtobool @@ -795,10 +796,10 @@ class processor_text_json_diff_form(commonSettingsForm): time_between_check_use_default = BooleanField(_l('Use global settings for time between check and scheduler.'), default=False) llm_intent = TextAreaField(_l('AI Change Intent'), validators=[validators.Optional(), validators.Length(max=2000)], - render_kw={"rows": "3", "placeholder": "e.g. Alert me when the price drops below $300"}) + render_kw={"rows": "3", "placeholder": LLM_INTENT_WATCH_PLACEHOLDER}) llm_change_summary = TextAreaField(_l('AI Change Summary'), validators=[validators.Optional(), validators.Length(max=2000)], - render_kw={"rows": "3", "placeholder": "e.g. List what was added or removed. Translate to English."}, + render_kw={"rows": "3", "placeholder": LLM_CHANGE_SUMMARY_PLACEHOLDER}, default='') include_filters = StringListField(_l('CSS/JSONPath/JQ/XPath Filters'), [ValidateCSSJSONXPATHInput()], default='') diff --git a/changedetectionio/model/Watch.py b/changedetectionio/model/Watch.py index 98669cb9f..ae7c6be15 100644 --- a/changedetectionio/model/Watch.py +++ b/changedetectionio/model/Watch.py @@ -1001,37 +1001,32 @@ class model(EntityPersistenceMixin, watch_base): return False - def get_last_llm_diff_summary(self, cache_key: str = None) -> str: - """Return the cached AI Change Summary, or '' if absent or stale. + @staticmethod + def _llm_summary_prompt_hash(prompt: str) -> str: + """8-char hex hash of the prompt — used to detect when the prompt changes.""" + import hashlib + return hashlib.md5(prompt.encode('utf-8', errors='replace')).hexdigest()[:8] - If *cache_key* is provided the stored key is compared first; - a mismatch (different diff or prompt) returns '' so the caller - knows it must regenerate rather than serving a stale summary. + def get_llm_diff_summary(self, from_version, to_version, prompt: str = '') -> str: + """Return the cached AI Change Summary for this from→to + prompt combination, or ''. + + The prompt hash is embedded in the filename so that a changed prompt + automatically produces a cache miss and triggers regeneration. """ - fname = os.path.join(self.data_dir, 'last-llm-diff-summary.txt') + prompt_hash = self._llm_summary_prompt_hash(prompt) + fname = os.path.join(self.data_dir, f'change-summary-{from_version}-to-{to_version}-{prompt_hash}.txt') if not os.path.isfile(fname): return '' - if cache_key is not None: - key_fname = os.path.join(self.data_dir, 'last-llm-diff-summary.key') - stored_key = '' - if os.path.isfile(key_fname): - with open(key_fname, 'r', encoding='utf-8') as f: - stored_key = f.read().strip() - if stored_key != cache_key: - return '' with open(fname, 'r', encoding='utf-8') as f: return f.read().strip() - def save_llm_diff_summary(self, summary: str, cache_key: str = None): - """Persist the AI Change Summary and its cache key so stale entries are detectable.""" + def save_llm_diff_summary(self, summary: str, from_version, to_version, prompt: str = ''): + """Persist the AI Change Summary keyed by version pair + prompt hash.""" self.ensure_data_dir_exists() - fname = os.path.join(self.data_dir, 'last-llm-diff-summary.txt') + prompt_hash = self._llm_summary_prompt_hash(prompt) + fname = os.path.join(self.data_dir, f'change-summary-{from_version}-to-{to_version}-{prompt_hash}.txt') with open(fname, 'w', encoding='utf-8') as f: f.write(summary) - if cache_key is not None: - key_fname = os.path.join(self.data_dir, 'last-llm-diff-summary.key') - with open(key_fname, 'w', encoding='utf-8') as f: - f.write(cache_key) def pause(self): self['paused'] = True diff --git a/changedetectionio/processors/text_json_diff/difference.py b/changedetectionio/processors/text_json_diff/difference.py index c8776ff27..690a787a9 100644 --- a/changedetectionio/processors/text_json_diff/difference.py +++ b/changedetectionio/processors/text_json_diff/difference.py @@ -202,22 +202,16 @@ def render(watch, datastore, request, url_for, render_template, flash, redirect, datastore.data.get('settings', {}).get('application', {}).get('llm', {}).get('model') ) - # Load cached AI diff summary (only shown when viewing the latest snapshot) - llm_diff_summary = '' + # Load cached AI diff summary for this exact from→to + prompt combination viewing_latest = str(to_version) == str(dates[-1]) - if viewing_latest and llm_configured: + llm_diff_summary = '' + if llm_configured: try: - import difflib as _difflib - from changedetectionio.llm.evaluator import get_effective_summary_prompt, compute_summary_cache_key - _plain_diff = '\n'.join(list(_difflib.unified_diff( - from_version_file_contents.splitlines(), - to_version_file_contents.splitlines(), - lineterm='', n=3, - ))[2:]) - _cache_key = compute_summary_cache_key(_plain_diff, get_effective_summary_prompt(watch, datastore)) - llm_diff_summary = watch.get_last_llm_diff_summary(cache_key=_cache_key) + from changedetectionio.llm.evaluator import get_effective_summary_prompt + _prompt = get_effective_summary_prompt(watch, datastore) + llm_diff_summary = watch.get_llm_diff_summary(from_version, to_version, prompt=_prompt) except Exception as e: - logger.warning(f"Could not validate llm-diff-summary cache for {uuid}: {e}") + logger.warning(f"Could not load llm-diff-summary for {uuid}: {e}") output = render_template("diff.html", #initial_scroll_line_number=100, diff --git a/changedetectionio/tests/unit/test_watch_model.py b/changedetectionio/tests/unit/test_watch_model.py index d15a0a57b..4a8145bab 100644 --- a/changedetectionio/tests/unit/test_watch_model.py +++ b/changedetectionio/tests/unit/test_watch_model.py @@ -249,5 +249,67 @@ class TestDiffBuilder(unittest.TestCase): self.assertLess(elapsed, 0.5, f"Deepcopy too slow ({elapsed:.3f}s for 10 copies) - might be copying datastore") +class TestLLMDiffSummaryCache(unittest.TestCase): + """Tests for get_llm_diff_summary / save_llm_diff_summary — version-pair + prompt-hash caching.""" + + PROMPT = 'List what changed as bullet points' + + def _make_watch(self): + mock_datastore = {'settings': {'application': {}}, 'watching': {}} + watch = Watch.model(datastore_path='/tmp', __datastore=mock_datastore, default={}) + watch.ensure_data_dir_exists() + return watch + + def test_returns_empty_when_no_file_exists(self): + watch = self._make_watch() + assert watch.get_llm_diff_summary('1000', '2000', prompt=self.PROMPT) == '' + + def test_save_and_retrieve(self): + watch = self._make_watch() + watch.save_llm_diff_summary('Price dropped to $199', '1000', '2000', prompt=self.PROMPT) + assert watch.get_llm_diff_summary('1000', '2000', prompt=self.PROMPT) == 'Price dropped to $199' + + def test_different_version_pairs_are_independent(self): + watch = self._make_watch() + watch.save_llm_diff_summary('Summary A', '1000', '2000', prompt=self.PROMPT) + watch.save_llm_diff_summary('Summary B', '2000', '3000', prompt=self.PROMPT) + assert watch.get_llm_diff_summary('1000', '2000', prompt=self.PROMPT) == 'Summary A' + assert watch.get_llm_diff_summary('2000', '3000', prompt=self.PROMPT) == 'Summary B' + + def test_unknown_pair_returns_empty(self): + watch = self._make_watch() + watch.save_llm_diff_summary('Summary A', '1000', '2000', prompt=self.PROMPT) + assert watch.get_llm_diff_summary('9999', '8888', prompt=self.PROMPT) == '' + + def test_changed_prompt_is_a_cache_miss(self): + """Changing the prompt must invalidate the cached summary for the same version pair.""" + watch = self._make_watch() + watch.save_llm_diff_summary('Old summary', '1000', '2000', prompt='original prompt') + # Different prompt → different hash → different filename → miss + assert watch.get_llm_diff_summary('1000', '2000', prompt='new different prompt') == '' + + def test_file_named_by_versions_and_prompt_hash(self): + """Cache file must be named change-summary-{from}-to-{to}-{hash}.txt.""" + import hashlib + watch = self._make_watch() + prompt = 'my summary prompt' + watch.save_llm_diff_summary('Test summary', '1776000000', '1776001000', prompt=prompt) + prompt_hash = hashlib.md5(prompt.encode()).hexdigest()[:8] + expected_path = os.path.join( + watch.data_dir, + f'change-summary-1776000000-to-1776001000-{prompt_hash}.txt' + ) + assert os.path.isfile(expected_path), \ + f"Expected cache file not found: {expected_path}" + with open(expected_path, 'r') as f: + assert f.read().strip() == 'Test summary' + + def test_overwrite_same_pair_and_prompt(self): + watch = self._make_watch() + watch.save_llm_diff_summary('First summary', '1000', '2000', prompt=self.PROMPT) + watch.save_llm_diff_summary('Updated summary', '1000', '2000', prompt=self.PROMPT) + assert watch.get_llm_diff_summary('1000', '2000', prompt=self.PROMPT) == 'Updated summary' + + if __name__ == '__main__': unittest.main() diff --git a/changedetectionio/worker.py b/changedetectionio/worker.py index 767b6b14e..1e95d2ea6 100644 --- a/changedetectionio/worker.py +++ b/changedetectionio/worker.py @@ -415,12 +415,13 @@ async def async_update_worker(worker_id, q, notification_q, app, datastore, exec from changedetectionio.llm.evaluator import ( evaluate_change, resolve_intent, resolve_llm_field, summarise_change, get_llm_config, - get_effective_summary_prompt, compute_summary_cache_key, ) _llm_cfg = get_llm_config(datastore) if _llm_cfg: # Compute unified diff once — used by both intent and summary _watch_dates = list(watch.history.keys()) + # Capture from_version before new snapshot is added + _llm_from_version = _watch_dates[-1] if _watch_dates else None if _watch_dates: _prev_text = watch.get_history_snapshot(timestamp=_watch_dates[-1]) or '' from difflib import unified_diff as _unified_diff @@ -456,8 +457,6 @@ async def async_update_worker(worker_id, q, notification_q, app, datastore, exec # Step 2: AI Change Summary — runs for any LLM-configured watch with a change if changed_detected: - _effective_prompt = get_effective_summary_prompt(watch, datastore) - _summary_cache_key = compute_summary_cache_key(_diff_text, _effective_prompt) _change_summary = await loop.run_in_executor( executor, lambda diff=_diff_text, snap=contents: summarise_change( @@ -466,15 +465,27 @@ async def async_update_worker(worker_id, q, notification_q, app, datastore, exec ) if _change_summary: update_obj['_llm_change_summary'] = _change_summary - try: - watch.save_llm_diff_summary(_change_summary, cache_key=_summary_cache_key) - except Exception as _fe: - logger.warning(f"Could not write last-llm-diff-summary.txt for {uuid}: {_fe}") except Exception as e: logger.warning(f"LLM evaluation error for {uuid}: {e}") datastore.update_watch(uuid=uuid, update_obj=update_obj) + # Save AI summary file now that the new snapshot has been committed + # and its version timestamp is the last key in history + if update_obj.get('_llm_change_summary') and _llm_from_version: + try: + from changedetectionio.llm.evaluator import get_effective_summary_prompt + _llm_to_version = list(watch.history.keys())[-1] + _llm_prompt = get_effective_summary_prompt(watch, datastore) + watch.save_llm_diff_summary( + update_obj['_llm_change_summary'], + _llm_from_version, + _llm_to_version, + prompt=_llm_prompt, + ) + except Exception as _fe: + logger.warning(f"Could not write change-summary file for {uuid}: {_fe}") + if changed_detected or not watch.history_n: if update_handler.screenshot: watch.save_screenshot(screenshot=update_handler.screenshot)