@@ -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 \n Also 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 \n Also 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 \n WATCH'
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 \n TAG\n \n WATCH'
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
0 commit comments