remove duplicates

This commit is contained in:
dgtlmoon committed 2026-04-17 05:42:52 +02:00
1 parent 7e84eabed8
commit 60de170f06
9 files changed
+117 -56

No files matched your search

+3 -4
View File
@@ -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):
+5 -8
View File
@@ -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}")
@@ -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.
@@ -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 }}"
></textarea>
</div>
{% endif %}
+3 -2
View File
@@ -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='')
+16 -21
View File
@@ -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
@@ -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,
@@ -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()
+18 -7
View File
@@ -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)