Skip to content

Commit 9f3211d

Browse files
committed
feature: allow a watch or group to append to the inherited AI Change Summary prompt
1 parent aac6fcf commit 9f3211d

9 files changed

Lines changed: 328 additions & 8 deletions

File tree

changedetectionio/blueprint/tags/form.py

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
from wtforms import (
22
Form,
3+
RadioField,
34
StringField,
45
SubmitField,
56
TextAreaField,
@@ -10,7 +11,11 @@
1011

1112
from changedetectionio.processors.restock_diff.forms import processor_settings_form as restock_settings_form
1213
from changedetectionio.llm.ui_strings import LLM_INTENT_TAG_PLACEHOLDER
13-
from changedetectionio.llm.evaluator import DEFAULT_CHANGE_SUMMARY_PROMPT
14+
from changedetectionio.llm.evaluator import (
15+
DEFAULT_CHANGE_SUMMARY_PROMPT,
16+
LLM_PROMPT_MODE_APPEND,
17+
LLM_PROMPT_MODE_REPLACE,
18+
)
1419

1520
class group_restock_settings_form(restock_settings_form):
1621
overrides_watch = BooleanField(_l('Activate for individual watches in this tag/group?'), default=False)
@@ -26,6 +31,15 @@ class group_restock_settings_form(restock_settings_form):
2631
render_kw={"rows": "5", "placeholder": DEFAULT_CHANGE_SUMMARY_PROMPT},
2732
default='')
2833

34+
llm_change_summary_mode = RadioField(
35+
_l('How this prompt combines with the inherited one'),
36+
choices=[
37+
(LLM_PROMPT_MODE_REPLACE, _l('Replace the inherited prompt')),
38+
(LLM_PROMPT_MODE_APPEND, _l('Append to the inherited prompt')),
39+
],
40+
default=LLM_PROMPT_MODE_REPLACE,
41+
)
42+
2943
class SingleTag(Form):
3044

3145
name = StringField(_l('Tag name'), [validators.InputRequired()], render_kw={"placeholder": _l("Name")})

changedetectionio/forms.py

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,13 @@
66

77
from changedetectionio.blueprint.rss import RSS_FORMAT_TYPES, RSS_TEMPLATE_TYPE_OPTIONS, RSS_TEMPLATE_HTML_DEFAULT
88
from changedetectionio.llm.ui_strings import LLM_INTENT_WATCH_PLACEHOLDER
9-
from changedetectionio.llm.evaluator import DEFAULT_CHANGE_SUMMARY_PROMPT, LLM_DEFAULT_MAX_SUMMARY_TOKENS, LLM_DEFAULT_THINKING_BUDGET
9+
from changedetectionio.llm.evaluator import (
10+
DEFAULT_CHANGE_SUMMARY_PROMPT,
11+
LLM_DEFAULT_MAX_SUMMARY_TOKENS,
12+
LLM_DEFAULT_THINKING_BUDGET,
13+
LLM_PROMPT_MODE_APPEND,
14+
LLM_PROMPT_MODE_REPLACE,
15+
)
1016
from changedetectionio.conditions.form import ConditionFormRow
1117
from changedetectionio.notification_service import NotificationContextData
1218
from changedetectionio.strtobool import strtobool
@@ -886,6 +892,15 @@ class processor_text_json_diff_form(commonSettingsForm):
886892
render_kw={"rows": "5", "placeholder": DEFAULT_CHANGE_SUMMARY_PROMPT},
887893
default='')
888894

895+
llm_change_summary_mode = RadioField(
896+
_l('How this prompt combines with the inherited one'),
897+
choices=[
898+
(LLM_PROMPT_MODE_REPLACE, _l('Replace the inherited prompt')),
899+
(LLM_PROMPT_MODE_APPEND, _l('Append to the inherited prompt')),
900+
],
901+
default=LLM_PROMPT_MODE_REPLACE,
902+
)
903+
889904
include_filters = StringListField(_l('CSS/JSONPath/JQ/XPath Filters'), [ValidateCSSJSONXPATHInput()], default='')
890905

891906
subtractive_selectors = StringListField(_l('Remove elements'), [ValidateCSSJSONXPATHInput(allow_json=False)])

changedetectionio/llm/evaluator.py

Lines changed: 51 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -149,6 +149,13 @@ def _cached_system(text: str, model: str = '') -> dict:
149149
"Do not give partial listings such as 'Examples include:', always be thorough."
150150
)
151151

152+
# How a watch's or tag's llm_change_summary combines with the prompt it inherits.
153+
# 'replace' is the default and the historical behaviour; 'append' lets a watch add a line
154+
# or two to the inherited prompt instead of holding a full private copy of it, so later
155+
# edits to the global prompt still reach that watch. Re #4251.
156+
LLM_PROMPT_MODE_REPLACE = 'replace'
157+
LLM_PROMPT_MODE_APPEND = 'append'
158+
152159

153160
def _summary_max_tokens(diff: str, max_cap: int = LLM_DEFAULT_MAX_SUMMARY_TOKENS) -> int:
154161
"""Scale completion tokens to diff size: floor 400, ~1 token per 4 chars, ceiling max_cap."""
@@ -539,16 +546,54 @@ def run_setup(watch, datastore, snapshot_text: str) -> None:
539546
# AI Change Summary — human-readable description of what changed
540547
# ---------------------------------------------------------------------------
541548

549+
def _first_tag_with_field(watch, datastore, field: str):
550+
"""Return (value, tag) for the first linked tag with a non-empty `field`, else ('', None).
551+
552+
Same first-match-wins order as resolve_llm_field(); this variant also hands back the
553+
tag itself so the caller can read sibling keys such as the prompt mode.
554+
"""
555+
for tag_uuid in watch.get('tags', []):
556+
tag = datastore.data['settings']['application'].get('tags', {}).get(tag_uuid)
557+
if tag:
558+
value = (tag.get(field) or '').strip()
559+
if value:
560+
return value, tag
561+
return '', None
562+
563+
564+
def _apply_prompt_layer(inherited: str, value: str, mode: str) -> str:
565+
"""Fold one cascade level's prompt onto what it inherited.
566+
567+
'append' keeps the inherited prompt and adds `value` after it, so a watch can add a
568+
sentence or two without pinning a private copy of the prompt above it (see #4251).
569+
Anything else replaces, which is the historical behaviour and stays the default.
570+
"""
571+
if not value:
572+
return inherited
573+
if mode == LLM_PROMPT_MODE_APPEND and inherited:
574+
return f"{inherited}\n\n{value}"
575+
return value
576+
577+
542578
def get_effective_summary_prompt(watch, datastore) -> str:
543579
"""Return the prompt that summarise_change will use.
544580
545-
Cascade: watch → tag → global settings default → hardcoded fallback.
581+
Cascade: hardcoded fallback → global settings default → tag → watch. Each level with a
582+
value either replaces what it inherited or appends to it, per its own
583+
`llm_change_summary_mode`. With every level left on the default 'replace' this reduces
584+
to the original watch → tag → global → hardcoded first-non-empty-wins behaviour.
546585
"""
547-
prompt, _ = resolve_llm_field(watch, datastore, 'llm_change_summary')
548-
if prompt:
549-
return prompt
550-
global_default = get_llm_settings(datastore).change_summary_default.strip()
551-
return global_default or DEFAULT_CHANGE_SUMMARY_PROMPT
586+
prompt = get_llm_settings(datastore).change_summary_default.strip() or DEFAULT_CHANGE_SUMMARY_PROMPT
587+
588+
tag_value, tag = _first_tag_with_field(watch, datastore, 'llm_change_summary')
589+
if tag_value:
590+
prompt = _apply_prompt_layer(prompt, tag_value, tag.get('llm_change_summary_mode'))
591+
592+
watch_value = (watch.get('llm_change_summary') or '').strip()
593+
if watch_value:
594+
prompt = _apply_prompt_layer(prompt, watch_value, watch.get('llm_change_summary_mode'))
595+
596+
return prompt
552597

553598

554599
def compute_summary_cache_key(diff_text: str, prompt: str) -> str:

changedetectionio/model/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -191,6 +191,7 @@ def __init__(self, *arg, **kw):
191191
# LLM intent-based evaluation
192192
'llm_intent': '', # Plain-English description of what the user cares about (change filter)
193193
'llm_change_summary': '', # Prompt for AI Change Summary — replaces {{ diff }} in notifications
194+
'llm_change_summary_mode': 'replace', # 'replace' the inherited prompt, or 'append' to it
194195
'llm_prefilter': None, # CSS selector derived at setup time (semantic only, e.g. "footer")
195196
'llm_evaluation_cache': {}, # {sha256(intent+diff): {important, summary}} - evaluated once, cached
196197
'fetch_backend': 'system', # plaintext, playwright etc

changedetectionio/templates/edit/include_llm_intent.html

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -97,6 +97,23 @@ <h4 style="margin: 0 0 0.3em 0;">{{ _('AI Change Summary') }}</h4>
9797
<div style="margin-top: 0.3em;">
9898
<a href="#" class="pure-button button-xsmall" onclick="var t=document.getElementById('llm_change_summary'); if(!t.value&amp;&amp;t.placeholder) t.value=t.placeholder; return false;">{{ _('Modify default prompt') }}</a>
9999
</div>
100+
<div class="pure-control-group" style="margin-top: 0.6em;">
101+
<label>{{ form.llm_change_summary_mode.label.text }}</label>
102+
<div>
103+
{% for subfield in form.llm_change_summary_mode %}
104+
<label class="pure-radio" style="display:block; font-weight:normal; margin-bottom:0.3em;">
105+
{{ subfield() }} {{ subfield.label.text }}
106+
</label>
107+
{% endfor %}
108+
</div>
109+
<span class="pure-form-message-inline">
110+
{% if watch is defined and watch %}
111+
{{ _('Appending keeps the prompt inherited from the group or from global settings and adds your text after it, so later edits to that prompt still reach this watch.') }}
112+
{% else %}
113+
{{ _('Appending keeps the prompt inherited from global settings and adds your text after it, so later edits to that prompt still reach this group.') }}
114+
{% endif %}
115+
</span>
116+
</div>
100117
{% if watch is defined and watch %}
101118
<div class="pure-form-message-inline">
102119
<strong>{{ _('Examples:') }}</strong>

changedetectionio/tests/llm/test_evaluator.py

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -556,3 +556,87 @@ def test_get_effective_prompt_cascades_from_tag(self):
556556
ds = _make_datastore(tags={'t1': tag})
557557
watch = _make_watch(llm_change_summary='', tags=['t1'])
558558
assert get_effective_summary_prompt(watch, ds) == 'tag-level prompt'
559+
560+
561+
# ---------------------------------------------------------------------------
562+
# llm_change_summary_mode — append vs replace (#4251)
563+
# ---------------------------------------------------------------------------
564+
565+
class TestSummaryPromptAppendMode:
566+
"""A watch/tag may add to the prompt it inherits instead of holding a private copy.
567+
568+
Everything here must leave the legacy 'replace' path byte-identical — that is what
569+
the TestSummaryCacheKey cases above pin.
570+
"""
571+
572+
def test_global_default_used_as_base_when_nothing_else_set(self):
573+
from changedetectionio.llm.evaluator import get_effective_summary_prompt
574+
ds = _make_datastore(llm_cfg={'change_summary_default': 'GLOBAL'})
575+
assert get_effective_summary_prompt(_make_watch(), ds) == 'GLOBAL'
576+
577+
def test_watch_appends_to_global_default(self):
578+
from changedetectionio.llm.evaluator import get_effective_summary_prompt
579+
ds = _make_datastore(llm_cfg={'change_summary_default': 'GLOBAL'})
580+
watch = _make_watch(llm_change_summary='Also mention the SKU.')
581+
watch['llm_change_summary_mode'] = 'append'
582+
assert get_effective_summary_prompt(watch, ds) == 'GLOBAL\n\nAlso mention the SKU.'
583+
584+
def test_watch_appends_to_hardcoded_default_when_no_global_set(self):
585+
from changedetectionio.llm.evaluator import get_effective_summary_prompt, DEFAULT_CHANGE_SUMMARY_PROMPT
586+
ds = _make_datastore()
587+
watch = _make_watch(llm_change_summary='Also mention the SKU.')
588+
watch['llm_change_summary_mode'] = 'append'
589+
result = get_effective_summary_prompt(watch, ds)
590+
assert result == f'{DEFAULT_CHANGE_SUMMARY_PROMPT}\n\nAlso mention the SKU.'
591+
592+
def test_watch_append_targets_the_tag_prompt_when_a_tag_supplies_one(self):
593+
"""The watch appends to what it would otherwise have inherited — here the tag."""
594+
from changedetectionio.llm.evaluator import get_effective_summary_prompt
595+
tag = {'title': 'grp', 'llm_change_summary': 'TAG'}
596+
ds = _make_datastore(llm_cfg={'change_summary_default': 'GLOBAL'}, tags={'t1': tag})
597+
watch = _make_watch(llm_change_summary='WATCH', tags=['t1'])
598+
watch['llm_change_summary_mode'] = 'append'
599+
assert get_effective_summary_prompt(watch, ds) == 'TAG\n\nWATCH'
600+
601+
def test_tag_and_watch_can_both_append_forming_a_chain(self):
602+
from changedetectionio.llm.evaluator import get_effective_summary_prompt
603+
tag = {'title': 'grp', 'llm_change_summary': 'TAG', 'llm_change_summary_mode': 'append'}
604+
ds = _make_datastore(llm_cfg={'change_summary_default': 'GLOBAL'}, tags={'t1': tag})
605+
watch = _make_watch(llm_change_summary='WATCH', tags=['t1'])
606+
watch['llm_change_summary_mode'] = 'append'
607+
assert get_effective_summary_prompt(watch, ds) == 'GLOBAL\n\nTAG\n\nWATCH'
608+
609+
def test_tag_appends_while_watch_replaces(self):
610+
from changedetectionio.llm.evaluator import get_effective_summary_prompt
611+
tag = {'title': 'grp', 'llm_change_summary': 'TAG', 'llm_change_summary_mode': 'append'}
612+
ds = _make_datastore(llm_cfg={'change_summary_default': 'GLOBAL'}, tags={'t1': tag})
613+
watch = _make_watch(llm_change_summary='WATCH', tags=['t1'])
614+
assert get_effective_summary_prompt(watch, ds) == 'WATCH'
615+
616+
def test_append_mode_with_empty_text_changes_nothing(self):
617+
from changedetectionio.llm.evaluator import get_effective_summary_prompt
618+
ds = _make_datastore(llm_cfg={'change_summary_default': 'GLOBAL'})
619+
watch = _make_watch(llm_change_summary='')
620+
watch['llm_change_summary_mode'] = 'append'
621+
assert get_effective_summary_prompt(watch, ds) == 'GLOBAL'
622+
623+
def test_missing_mode_key_behaves_as_replace(self):
624+
"""Watches stored before this feature have no mode key at all."""
625+
from changedetectionio.llm.evaluator import get_effective_summary_prompt
626+
ds = _make_datastore(llm_cfg={'change_summary_default': 'GLOBAL'})
627+
watch = _make_watch(llm_change_summary='WATCH')
628+
assert 'llm_change_summary_mode' not in watch
629+
assert get_effective_summary_prompt(watch, ds) == 'WATCH'
630+
631+
def test_append_changes_the_cache_key(self):
632+
"""Toggling the mode must invalidate cached summaries, not silently reuse them."""
633+
from changedetectionio.llm.evaluator import get_effective_summary_prompt, compute_summary_cache_key
634+
ds = _make_datastore(llm_cfg={'change_summary_default': 'GLOBAL'})
635+
636+
replacing = _make_watch(llm_change_summary='WATCH')
637+
appending = _make_watch(llm_change_summary='WATCH')
638+
appending['llm_change_summary_mode'] = 'append'
639+
640+
key_replace = compute_summary_cache_key('diff', get_effective_summary_prompt(replacing, ds))
641+
key_append = compute_summary_cache_key('diff', get_effective_summary_prompt(appending, ds))
642+
assert key_replace != key_append

changedetectionio/tests/test_llm_change_summary.py

Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -319,6 +319,99 @@ def test_watch_prompt_overrides_tag_and_global(
319319
delete_all_watches(client)
320320

321321

322+
def test_append_mode_saved_via_edit_form_and_applied(
323+
client, live_server, measure_memory_usage, datastore_path):
324+
"""
325+
Choosing "add to the end of the inherited prompt" in the watch edit form persists,
326+
and the watch's text is then appended to the global default rather than replacing it.
327+
Re #4251.
328+
"""
329+
from changedetectionio.llm.evaluator import get_effective_summary_prompt
330+
331+
_set_response(datastore_path, HTML_V1)
332+
_configure_llm(client)
333+
ds = client.application.config.get('DATASTORE')
334+
_set_global_default(ds, 'Global: summarise as one sentence.')
335+
336+
test_url = url_for('test_endpoint', _external=True)
337+
uuid = ds.add_watch(url=test_url)
338+
339+
res = client.post(
340+
url_for("ui.ui_edit.edit_page", uuid=uuid),
341+
data={
342+
"url": test_url,
343+
"fetch_backend": "html_requests",
344+
"time_between_check_use_default": "y",
345+
"llm_change_summary": "Also flag anything mentioning a recall.",
346+
"llm_change_summary_mode": "append",
347+
},
348+
follow_redirects=True,
349+
)
350+
assert b"Updated watch." in res.data
351+
352+
watch = ds.data['watching'][uuid]
353+
assert watch.get('llm_change_summary_mode') == 'append'
354+
assert get_effective_summary_prompt(watch, ds) == (
355+
'Global: summarise as one sentence.\n\nAlso flag anything mentioning a recall.'
356+
)
357+
358+
delete_all_watches(client)
359+
360+
361+
def test_edit_form_defaults_to_replace_preserving_old_behaviour(
362+
client, live_server, measure_memory_usage, datastore_path):
363+
"""
364+
A form submitted without the mode field (the pre-#4251 shape) must still replace,
365+
so upgrading does not silently change what existing watches send to the LLM.
366+
"""
367+
from changedetectionio.llm.evaluator import get_effective_summary_prompt
368+
369+
_set_response(datastore_path, HTML_V1)
370+
_configure_llm(client)
371+
ds = client.application.config.get('DATASTORE')
372+
_set_global_default(ds, 'Global: summarise as one sentence.')
373+
374+
test_url = url_for('test_endpoint', _external=True)
375+
uuid = ds.add_watch(url=test_url)
376+
377+
res = client.post(
378+
url_for("ui.ui_edit.edit_page", uuid=uuid),
379+
data={
380+
"url": test_url,
381+
"fetch_backend": "html_requests",
382+
"time_between_check_use_default": "y",
383+
"llm_change_summary": "Only tell me the new price.",
384+
},
385+
follow_redirects=True,
386+
)
387+
assert b"Updated watch." in res.data
388+
389+
watch = ds.data['watching'][uuid]
390+
assert get_effective_summary_prompt(watch, ds) == 'Only tell me the new price.'
391+
392+
delete_all_watches(client)
393+
394+
395+
def test_edit_page_renders_the_prompt_mode_radio(
396+
client, live_server, measure_memory_usage, datastore_path):
397+
"""Both radio options must be present on the watch edit page."""
398+
_set_response(datastore_path, HTML_V1)
399+
_configure_llm(client)
400+
ds = client.application.config.get('DATASTORE')
401+
402+
test_url = url_for('test_endpoint', _external=True)
403+
uuid = ds.add_watch(url=test_url)
404+
405+
res = client.get(url_for("ui.ui_edit.edit_page", uuid=uuid))
406+
body = res.data.decode('utf-8', errors='replace')
407+
408+
assert 'name="llm_change_summary_mode"' in body
409+
assert 'value="replace"' in body
410+
assert 'value="append"' in body
411+
412+
delete_all_watches(client)
413+
414+
322415
def test_hardcoded_fallback_when_nothing_set(
323416
client, live_server, measure_memory_usage, datastore_path):
324417
"""

0 commit comments

Comments
 (0)