Compare commits

...

1 Commits

Author SHA1 Message Date
dgtlmoon c28d639019 LLM - Fixing summary cache miss-hit 2026-05-12 16:37:35 +02:00
5 changed files with 103 additions and 32 deletions
+12 -14
View File
@@ -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)
+53
View File
@@ -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
@@ -416,6 +417,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
+2
View File
@@ -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 = ''):
@@ -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}")
+25 -16
View File
@@ -478,22 +478,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)
@@ -519,6 +503,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))