diff --git a/changedetectionio/blueprint/ui/diff.py b/changedetectionio/blueprint/ui/diff.py index 353ff73fa..855c9ecb4 100644 --- a/changedetectionio/blueprint/ui/diff.py +++ b/changedetectionio/blueprint/ui/diff.py @@ -198,10 +198,12 @@ def construct_blueprint(datastore: ChangeDetectionStore): best_from = watch.get_from_version_based_on_last_viewed from_version = request.args.get('from_version', best_from if best_from else dates[-2]) to_version = request.args.get('to_version', dates[-1]) - all_changes = request.args.get('all_changes', '0') == '1' - ignore_whitespace = request.args.get('ignore_whitespace', '0') == '1' - show_removed = request.args.get('removed', '1') == '1' - show_added = request.args.get('added', '1') == '1' + from changedetectionio.llm.evaluator import DiffPrefs + prefs = DiffPrefs.from_request_args(request.args) + all_changes = prefs.all_changes + ignore_whitespace = prefs.ignore_whitespace + show_removed = prefs.show_removed + show_added = prefs.show_added def _prep(text): """Optionally normalise whitespace on each line before diffing.""" @@ -263,21 +265,17 @@ def construct_blueprint(datastore: ChangeDetectionStore): return jsonify({'summary': None, 'error': 'No differences found'}) from changedetectionio.llm.evaluator import ( - summarise_change, get_effective_summary_prompt, + summarise_change, get_effective_summary_prompt, build_summary_cache_prompt, is_global_token_budget_exceeded, get_global_token_budget_month, LLMInputTooLargeError, ) - effective_prompt = get_effective_summary_prompt(watch, datastore) - from changedetectionio.llm.prompt_builder import build_change_summary_system_prompt - # Diff-pref flags + system prompt are part of the cache key so prompt changes bust the cache + # Diff-pref flags + system prompt are part of the cache key so prompt changes bust the cache. _max_summary_tokens = datastore.data['settings']['application'].get('llm_max_summary_tokens', 3000) - cache_prompt = ( - effective_prompt - + f'\x00prefs:all={int(all_changes)},ws={int(ignore_whitespace)}' - f',rm={int(show_removed)},add={int(show_added)}' - + f'\x00sys:{build_change_summary_system_prompt()}' - + f'\x00max_tokens:{_max_summary_tokens}' + cache_prompt = build_summary_cache_prompt( + effective_prompt=get_effective_summary_prompt(watch, datastore), + max_summary_tokens=_max_summary_tokens, + prefs=prefs, ) # Check cache — keyed by version pair + prompt hash (invalidates if prompt changes) diff --git a/changedetectionio/llm/evaluator.py b/changedetectionio/llm/evaluator.py index e375f63c3..7502dabe7 100644 --- a/changedetectionio/llm/evaluator.py +++ b/changedetectionio/llm/evaluator.py @@ -16,6 +16,7 @@ Environment variable overrides (take priority over datastore settings): import hashlib import os +from dataclasses import dataclass from datetime import datetime, timezone from loguru import logger @@ -438,6 +439,58 @@ def compute_summary_cache_key(diff_text: str, prompt: str) -> str: return h.hexdigest()[:16] +@dataclass(frozen=True) +class DiffPrefs: + """ + User-facing diff display preferences. Part of the LLM summary cache key so + that toggling a preference produces a fresh summary. + + Field defaults are the single source of truth — the UI query-arg defaults in + diff.py's from_request_args() and the worker pre-cache's bare DiffPrefs() + both rely on these. + """ + all_changes: bool = False + ignore_whitespace: bool = False + show_removed: bool = True + show_added: bool = True + + @classmethod + def from_request_args(cls, args) -> 'DiffPrefs': + """Parse from a Flask request.args (or any .get(key, default)-shaped mapping).""" + return cls( + all_changes = args.get('all_changes', '0') == '1', + ignore_whitespace = args.get('ignore_whitespace', '0') == '1', + show_removed = args.get('removed', '1') == '1', + show_added = args.get('added', '1') == '1', + ) + + def cache_key_suffix(self) -> str: + return ( + f'\x00prefs:all={int(self.all_changes)},ws={int(self.ignore_whitespace)}' + f',rm={int(self.show_removed)},add={int(self.show_added)}' + ) + + +def build_summary_cache_prompt(effective_prompt: str, max_summary_tokens: int, + prefs: DiffPrefs = None) -> str: + """ + Compose the full cache-key string passed to save/get_llm_diff_summary. + + Default prefs are DiffPrefs() — must match the UI's query-arg defaults so a + worker-side pre-cache is hit by an unmodified UI request. Same helper must + be used by both the worker pre-cache write and the UI diff route read, + otherwise the prompt hashes diverge and the cache file isn't found. + """ + if prefs is None: + prefs = DiffPrefs() + return ( + effective_prompt + + prefs.cache_key_suffix() + + f'\x00sys:{build_change_summary_system_prompt()}' + + f'\x00max_tokens:{max_summary_tokens}' + ) + + def summarise_change(watch, datastore, diff: str, current_snapshot: str = '') -> str: """ Generate a plain-language summary of the change using the watch's diff --git a/changedetectionio/model/Watch.py b/changedetectionio/model/Watch.py index 3d9f62c58..4153b555f 100644 --- a/changedetectionio/model/Watch.py +++ b/changedetectionio/model/Watch.py @@ -1024,8 +1024,10 @@ class model(EntityPersistenceMixin, watch_base): 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): + logger.debug(f"LLM cached diff summary '{fname}' NOT found") return '' with open(fname, 'r', encoding='utf-8') as f: + logger.debug(f"LLM cached diff summary '{fname}' FOUND") return f.read().strip() def save_llm_diff_summary(self, summary: str, from_version, to_version, prompt: str = ''): diff --git a/changedetectionio/processors/text_json_diff/difference.py b/changedetectionio/processors/text_json_diff/difference.py index b4df8dbba..d712e315d 100644 --- a/changedetectionio/processors/text_json_diff/difference.py +++ b/changedetectionio/processors/text_json_diff/difference.py @@ -210,10 +210,19 @@ def render(watch, datastore, request, url_for, render_template, flash, redirect, llm_summary_prompt = '' if llm_configured: try: - from changedetectionio.llm.evaluator import get_effective_summary_prompt + from changedetectionio.llm.evaluator import ( + get_effective_summary_prompt, build_summary_cache_prompt, + ) _prompt = get_effective_summary_prompt(watch, datastore) llm_summary_prompt = _prompt - llm_diff_summary = watch.get_llm_diff_summary(from_version, to_version, prompt=_prompt) + # Must match the cache_prompt the worker writes and the UI ajax route reads — + # using UI default diff prefs so the initial render finds the worker's pre-cache. + _max_summary_tokens = datastore.data['settings']['application'].get('llm_max_summary_tokens', 3000) + _cache_prompt = build_summary_cache_prompt( + effective_prompt=_prompt, + max_summary_tokens=_max_summary_tokens, + ) + llm_diff_summary = watch.get_llm_diff_summary(from_version, to_version, prompt=_cache_prompt) except Exception as e: logger.warning(f"Could not load llm-diff-summary for {uuid}: {e}") diff --git a/changedetectionio/worker.py b/changedetectionio/worker.py index 15721fba7..d3dd9cb68 100644 --- a/changedetectionio/worker.py +++ b/changedetectionio/worker.py @@ -502,22 +502,6 @@ async def async_update_worker(worker_id, q, notification_q, app, datastore, exec 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) @@ -543,6 +527,31 @@ async def async_update_worker(worker_id, q, notification_q, app, datastore, exec timestamp=int(fetch_start_time), snapshot_id=update_obj.get('previous_md5', 'none')) + # Save AI summary file now that the new snapshot is committed — + # watch.history.keys()[-1] now reflects the just-saved version, + # so the cache filename matches what the UI will later look up. + # Cache key must use build_summary_cache_prompt() with UI defaults so + # the worker write and the UI read hash to the same prompt_hash. + if update_obj.get('_llm_change_summary') and _llm_from_version: + try: + from changedetectionio.llm.evaluator import ( + get_effective_summary_prompt, build_summary_cache_prompt, + ) + _llm_to_version = list(watch.history.keys())[-1] + _llm_max_summary_tokens = datastore.data['settings']['application'].get('llm_max_summary_tokens', 3000) + _llm_cache_prompt = build_summary_cache_prompt( + effective_prompt=get_effective_summary_prompt(watch, datastore), + max_summary_tokens=_llm_max_summary_tokens, + ) + watch.save_llm_diff_summary( + update_obj['_llm_change_summary'], + _llm_from_version, + _llm_to_version, + prompt=_llm_cache_prompt, + ) + except Exception as _fe: + logger.warning(f"Could not write change-summary file for {uuid}: {_fe}") + empty_pages_are_a_change = datastore.data['settings']['application'].get('empty_pages_are_a_change', False) if update_handler.fetcher.content or (not update_handler.fetcher.content and empty_pages_are_a_change): watch.save_last_fetched_html(contents=update_handler.fetcher.content, timestamp=int(fetch_start_time))