mirror of
https://github.com/dgtlmoon/changedetection.io.git
synced 2026-11-08 23:18:55 +00:00
remove duplicates
This commit is contained in:
1 parent
7e84eabed8
commit
60de170f06
9 files changed
+117
-56
No files matched your search
@@ -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):
|
||||
|
||||
@@ -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 %}
|
||||
|
||||
@@ -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='')
|
||||
|
||||
@@ -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()
|
||||
@@ -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)
|
||||
|
||||
Reference in new issue
Block a user