""" Unit tests for changedetectionio/llm/evaluator.py Uses mocked LLM calls — no real API key needed. """ import pytest from unittest.mock import patch, MagicMock def _make_datastore(llm_cfg=None, tags=None): """Build a minimal datastore-like dict for testing.""" ds = MagicMock() app_settings = { 'llm': llm_cfg or {}, 'tags': tags or {}, } ds.data = { 'settings': { 'application': app_settings, } } return ds def _make_watch(llm_intent='', llm_change_summary='', tags=None, uuid='test-uuid-1234'): w = {} w['llm_intent'] = llm_intent w['llm_change_summary'] = llm_change_summary w['tags'] = tags or [] w['uuid'] = uuid w['url'] = 'https://example.com' w['page_title'] = 'Test Page' w['llm_evaluation_cache'] = {} w['llm_prefilter'] = None return w # --------------------------------------------------------------------------- # resolve_intent # --------------------------------------------------------------------------- class TestResolveIntent: def test_watch_intent_takes_priority(self): from changedetectionio.llm.evaluator import resolve_intent tag = {'title': 'mygroup', 'llm_intent': 'group intent'} ds = _make_datastore(tags={'tag-1': tag}) watch = _make_watch(llm_intent='watch intent', tags=['tag-1']) intent, source = resolve_intent(watch, ds) assert intent == 'watch intent' assert source == 'watch' def test_tag_intent_used_when_watch_has_none(self): from changedetectionio.llm.evaluator import resolve_intent tag = {'title': 'pricing-group', 'llm_intent': 'flag price drops'} ds = _make_datastore(tags={'tag-1': tag}) watch = _make_watch(llm_intent='', tags=['tag-1']) intent, source = resolve_intent(watch, ds) assert intent == 'flag price drops' assert source == 'pricing-group' def test_no_intent_anywhere_returns_empty(self): from changedetectionio.llm.evaluator import resolve_intent ds = _make_datastore() watch = _make_watch(llm_intent='') intent, source = resolve_intent(watch, ds) assert intent == '' assert source == '' def test_tag_applied_to_all_watches_in_group(self): """Tag intent propagates to every watch in the tag (no opt-in needed).""" from changedetectionio.llm.evaluator import resolve_intent tag = {'title': 'job-board', 'llm_intent': 'new engineering jobs'} ds = _make_datastore(tags={'tag-1': tag}) # Three different watches, all in the tag, none have their own intent for watch_uuid in ['uuid-A', 'uuid-B', 'uuid-C']: watch = _make_watch(llm_intent='', tags=['tag-1'], uuid=watch_uuid) intent, source = resolve_intent(watch, ds) assert intent == 'new engineering jobs', f"Watch {watch_uuid} should inherit tag intent" assert source == 'job-board' def test_whitespace_only_intent_treated_as_empty(self): from changedetectionio.llm.evaluator import resolve_intent ds = _make_datastore() watch = _make_watch(llm_intent=' ') intent, source = resolve_intent(watch, ds) assert intent == '' def test_missing_tag_in_datastore_skipped(self): from changedetectionio.llm.evaluator import resolve_intent ds = _make_datastore(tags={}) # no tags registered watch = _make_watch(llm_intent='', tags=['nonexistent-tag']) intent, source = resolve_intent(watch, ds) assert intent == '' # --------------------------------------------------------------------------- # get_llm_config # --------------------------------------------------------------------------- class TestGetLlmConfig: def test_returns_none_when_no_model(self): from changedetectionio.llm.evaluator import get_llm_config ds = _make_datastore(llm_cfg={}) assert get_llm_config(ds) is None def test_returns_config_when_model_set(self): from changedetectionio.llm.evaluator import get_llm_config cfg = {'model': 'gpt-4o-mini', 'api_key': 'sk-test'} ds = _make_datastore(llm_cfg=cfg) result = get_llm_config(ds) assert result['model'] == 'gpt-4o-mini' def test_env_var_overrides_datastore(self): """LLM_MODEL env var takes priority over datastore settings.""" from changedetectionio.llm.evaluator import get_llm_config ds = _make_datastore(llm_cfg={'model': 'datastore-model'}) with patch.dict('os.environ', {'LLM_MODEL': 'ollama/llama3.2', 'LLM_API_KEY': '', 'LLM_API_BASE': ''}): result = get_llm_config(ds) assert result['model'] == 'ollama/llama3.2' def test_env_var_api_key_and_base_included(self): """LLM_API_KEY and LLM_API_BASE are picked up alongside LLM_MODEL.""" from changedetectionio.llm.evaluator import get_llm_config ds = _make_datastore() env = {'LLM_MODEL': 'gpt-4o', 'LLM_API_KEY': 'env-key', 'LLM_API_BASE': 'http://localhost:11434'} with patch.dict('os.environ', env): result = get_llm_config(ds) assert result['api_key'] == 'env-key' assert result['api_base'] == 'http://localhost:11434' def test_llm_configured_via_env_true_when_model_set(self): """llm_configured_via_env() returns True when LLM_MODEL is set.""" from changedetectionio.llm.evaluator import llm_configured_via_env with patch.dict('os.environ', {'LLM_MODEL': 'gpt-4o-mini'}): assert llm_configured_via_env() is True def test_llm_configured_via_env_false_when_not_set(self): """llm_configured_via_env() returns False when LLM_MODEL is absent.""" from changedetectionio.llm.evaluator import llm_configured_via_env env = {k: '' for k in ['LLM_MODEL', 'LLM_API_KEY', 'LLM_API_BASE']} with patch.dict('os.environ', env, clear=False): # Ensure LLM_MODEL is truly absent import os os.environ.pop('LLM_MODEL', None) assert llm_configured_via_env() is False # --------------------------------------------------------------------------- # evaluate_change # --------------------------------------------------------------------------- class TestEvaluateChange: def test_returns_none_when_llm_not_configured(self): from changedetectionio.llm.evaluator import evaluate_change ds = _make_datastore(llm_cfg={}) # no model watch = _make_watch(llm_intent='flag price drops') result = evaluate_change(watch, ds, diff='- $500\n+ $400') assert result is None def test_returns_none_when_no_intent(self): from changedetectionio.llm.evaluator import evaluate_change ds = _make_datastore(llm_cfg={'model': 'gpt-4o-mini'}) watch = _make_watch(llm_intent='') result = evaluate_change(watch, ds, diff='some diff') assert result is None def test_returns_not_important_for_empty_diff(self): from changedetectionio.llm.evaluator import evaluate_change ds = _make_datastore(llm_cfg={'model': 'gpt-4o-mini'}) watch = _make_watch(llm_intent='flag price drops') result = evaluate_change(watch, ds, diff='') assert result == {'important': False, 'summary': ''} def test_returns_not_important_for_whitespace_diff(self): from changedetectionio.llm.evaluator import evaluate_change ds = _make_datastore(llm_cfg={'model': 'gpt-4o-mini'}) watch = _make_watch(llm_intent='flag price drops') result = evaluate_change(watch, ds, diff=' \n ') assert result == {'important': False, 'summary': ''} def test_calls_llm_and_returns_result(self): from changedetectionio.llm.evaluator import evaluate_change ds = _make_datastore(llm_cfg={'model': 'gpt-4o-mini', 'api_key': 'sk-test'}) watch = _make_watch(llm_intent='flag price drops') llm_response = '{"important": true, "summary": "Price dropped from $500 to $400"}' with patch('changedetectionio.llm.client.completion', return_value=(llm_response, 150)): result = evaluate_change(watch, ds, diff='- $500\n+ $400') assert result['important'] is True assert 'Price dropped' in result['summary'] def test_cache_hit_skips_llm_call(self): from changedetectionio.llm.evaluator import evaluate_change import hashlib ds = _make_datastore(llm_cfg={'model': 'gpt-4o-mini', 'api_key': 'sk-test'}) watch = _make_watch(llm_intent='flag price drops') diff = '- $500\n+ $400' intent = 'flag price drops' cache_key = hashlib.sha256(f"{intent}||{diff}".encode()).hexdigest() watch['llm_evaluation_cache'] = { cache_key: {'important': True, 'summary': 'cached result'} } with patch('changedetectionio.llm.client.completion') as mock_llm: result = evaluate_change(watch, ds, diff=diff) mock_llm.assert_not_called() assert result['summary'] == 'cached result' def test_llm_failure_returns_important_true(self): """On LLM error, notification should NOT be suppressed (fail open).""" from changedetectionio.llm.evaluator import evaluate_change ds = _make_datastore(llm_cfg={'model': 'gpt-4o-mini', 'api_key': 'sk-test'}) watch = _make_watch(llm_intent='flag price drops') with patch('changedetectionio.llm.client.completion', side_effect=Exception('API timeout')): result = evaluate_change(watch, ds, diff='- $500\n+ $400') assert result['important'] is True assert result['summary'] == '' def test_unimportant_result_from_llm(self): from changedetectionio.llm.evaluator import evaluate_change ds = _make_datastore(llm_cfg={'model': 'gpt-4o-mini'}) watch = _make_watch(llm_intent='only alert on price drops') llm_response = '{"important": false, "summary": "Only a footer copyright year changed"}' with patch('changedetectionio.llm.client.completion', return_value=(llm_response, 45)): result = evaluate_change(watch, ds, diff='- Copyright 2023\n+ Copyright 2024') assert result['important'] is False assert 'footer' in result['summary'].lower() or 'copyright' in result['summary'].lower() def test_last_tokens_used_stored_after_eval(self): """watch['llm_last_tokens_used'] is set to the token count after a successful call.""" from changedetectionio.llm.evaluator import evaluate_change ds = _make_datastore(llm_cfg={'model': 'gpt-4o-mini'}) watch = _make_watch(llm_intent='flag price drops') llm_response = '{"important": true, "summary": "Price fell"}' with patch('changedetectionio.llm.client.completion', return_value=(llm_response, 123)): evaluate_change(watch, ds, diff='- $500\n+ $300') assert watch.get('llm_last_tokens_used') == 123 def test_cumulative_tokens_accumulate_across_evals(self): """Each eval adds its tokens to watch['llm_tokens_used_cumulative'].""" from changedetectionio.llm.evaluator import evaluate_change ds = _make_datastore(llm_cfg={'model': 'gpt-4o-mini'}) watch = _make_watch(llm_intent='flag price drops') resp1 = '{"important": true, "summary": "First"}' resp2 = '{"important": false, "summary": "Second"}' with patch('changedetectionio.llm.client.completion', return_value=(resp1, 80)): evaluate_change(watch, ds, diff='- $500\n+ $400') # Second call needs a different diff to avoid cache hit with patch('changedetectionio.llm.client.completion', return_value=(resp2, 60)): evaluate_change(watch, ds, diff='- $400\n+ $350') assert watch.get('llm_tokens_used_cumulative') == 140 # --------------------------------------------------------------------------- # Token budget enforcement # --------------------------------------------------------------------------- class TestTokenBudget: def test_no_limits_always_returns_true(self): """When no limits configured, budget check always passes.""" from changedetectionio.llm.evaluator import _check_token_budget watch = _make_watch() cfg = {} # no limits assert _check_token_budget(watch, cfg, tokens_this_call=10_000) is True def test_per_period_limit_exceeded_returns_false(self): """Per-period tokens exceeding the cap → False.""" from changedetectionio.llm.evaluator import _check_token_budget, _get_month_key watch = _make_watch() watch['llm_tokens_this_period'] = 900 watch['llm_tokens_period_key'] = _get_month_key() cfg = {'max_tokens_per_count_period': 1000} # This call adds 200 → total 1100 > 1000 result = _check_token_budget(watch, cfg, tokens_this_call=200) assert result is False def test_per_period_limit_not_yet_exceeded_returns_true(self): """Per-period tokens within the cap → True.""" from changedetectionio.llm.evaluator import _check_token_budget, _get_month_key watch = _make_watch() watch['llm_tokens_this_period'] = 500 watch['llm_tokens_period_key'] = _get_month_key() cfg = {'max_tokens_per_count_period': 1000} result = _check_token_budget(watch, cfg, tokens_this_call=100) assert result is True def test_period_rollover_zeroes_counter(self): """Stale period_key triggers rollover: counter resets before this call's tokens are added.""" from changedetectionio.llm.evaluator import _check_token_budget, _get_month_key watch = _make_watch() watch['llm_tokens_this_period'] = 999_999 # last period's giant total watch['llm_tokens_period_key'] = '1970-01' # ancient — guaranteed stale cfg = {'max_tokens_per_count_period': 1000} # This call adds 100 → after rollover should be 100, under the 1000 cap result = _check_token_budget(watch, cfg, tokens_this_call=100) assert result is True assert watch['llm_tokens_this_period'] == 100 assert watch['llm_tokens_period_key'] == _get_month_key() def test_tokens_accumulated_into_both_counters(self): """tokens_this_call increments both the lifetime stat and the per-period counter.""" from changedetectionio.llm.evaluator import _check_token_budget, _get_month_key watch = _make_watch() watch['llm_tokens_used_cumulative'] = 300 watch['llm_tokens_this_period'] = 50 watch['llm_tokens_period_key'] = _get_month_key() cfg = {} _check_token_budget(watch, cfg, tokens_this_call=75) assert watch['llm_tokens_used_cumulative'] == 375 assert watch['llm_tokens_this_period'] == 125 def test_zero_tokens_call_does_not_change_counters(self): """Calling with tokens_this_call=0 (pre-flight check) doesn't modify counters.""" from changedetectionio.llm.evaluator import _check_token_budget, _get_month_key watch = _make_watch() watch['llm_tokens_used_cumulative'] = 200 watch['llm_tokens_this_period'] = 80 watch['llm_tokens_period_key'] = _get_month_key() cfg = {} _check_token_budget(watch, cfg, tokens_this_call=0) assert watch['llm_tokens_used_cumulative'] == 200 assert watch['llm_tokens_this_period'] == 80 def test_evaluate_change_skips_call_when_per_period_over_budget(self): """Pre-flight check: if already over the period cap, skip the LLM call and fail open.""" from changedetectionio.llm.evaluator import evaluate_change, _get_month_key ds = _make_datastore(llm_cfg={'model': 'gpt-4o-mini', 'max_tokens_per_count_period': 100}) watch = _make_watch(llm_intent='flag price drops') watch['llm_tokens_this_period'] = 500 # already far over watch['llm_tokens_period_key'] = _get_month_key() with patch('changedetectionio.llm.client.completion') as mock_llm: result = evaluate_change(watch, ds, diff='- $500\n+ $400') mock_llm.assert_not_called() # Fail open: important=True so the notification is NOT suppressed assert result == {'important': True, 'summary': ''} # --------------------------------------------------------------------------- # resolve_llm_field (generic cascade) # --------------------------------------------------------------------------- class TestResolveLlmField: def test_watch_value_takes_priority(self): from changedetectionio.llm.evaluator import resolve_llm_field tag = {'title': 'mygroup', 'llm_change_summary': 'tag summary prompt'} ds = _make_datastore(tags={'tag-1': tag}) watch = _make_watch(llm_change_summary='watch summary prompt', tags=['tag-1']) value, source = resolve_llm_field(watch, ds, 'llm_change_summary') assert value == 'watch summary prompt' assert source == 'watch' def test_tag_value_used_when_watch_empty(self): from changedetectionio.llm.evaluator import resolve_llm_field tag = {'title': 'events-group', 'llm_change_summary': 'list new events'} ds = _make_datastore(tags={'tag-1': tag}) watch = _make_watch(llm_change_summary='', tags=['tag-1']) value, source = resolve_llm_field(watch, ds, 'llm_change_summary') assert value == 'list new events' assert source == 'events-group' def test_returns_empty_when_not_set_anywhere(self): from changedetectionio.llm.evaluator import resolve_llm_field ds = _make_datastore() watch = _make_watch() value, source = resolve_llm_field(watch, ds, 'llm_change_summary') assert value == '' assert source == '' def test_works_for_llm_intent_field_too(self): """resolve_llm_field is generic — works for llm_intent same as llm_change_summary.""" from changedetectionio.llm.evaluator import resolve_llm_field tag = {'title': 'grp', 'llm_intent': 'flag price drops'} ds = _make_datastore(tags={'t1': tag}) watch = _make_watch(llm_intent='', tags=['t1']) value, source = resolve_llm_field(watch, ds, 'llm_intent') assert value == 'flag price drops' # --------------------------------------------------------------------------- # summarise_change # --------------------------------------------------------------------------- class TestSummariseChange: def test_returns_empty_when_llm_not_configured(self): from changedetectionio.llm.evaluator import summarise_change ds = _make_datastore(llm_cfg={}) watch = _make_watch(llm_change_summary='List what changed') result = summarise_change(watch, ds, diff='- old\n+ new') assert result == '' def test_uses_default_prompt_when_no_summary_prompt(self): """When llm_change_summary is empty, falls back to DEFAULT_CHANGE_SUMMARY_PROMPT.""" from changedetectionio.llm.evaluator import summarise_change, DEFAULT_CHANGE_SUMMARY_PROMPT ds = _make_datastore(llm_cfg={'model': 'gpt-4o-mini', 'api_key': 'sk-test'}) watch = _make_watch(llm_change_summary='') with patch('changedetectionio.llm.client.completion', return_value=('A new item was added.', 40)) as mock_llm: result = summarise_change(watch, ds, diff='- old\n+ new') mock_llm.assert_called_once() # Default prompt must appear in the user message call_messages = mock_llm.call_args.kwargs['messages'] user_msg = next(m['content'] for m in call_messages if m['role'] == 'user') assert DEFAULT_CHANGE_SUMMARY_PROMPT in user_msg assert result == 'A new item was added.' def test_returns_empty_when_diff_empty(self): from changedetectionio.llm.evaluator import summarise_change ds = _make_datastore(llm_cfg={'model': 'gpt-4o-mini'}) watch = _make_watch(llm_change_summary='List what changed') with patch('changedetectionio.llm.client.completion') as mock_llm: result = summarise_change(watch, ds, diff='') mock_llm.assert_not_called() assert result == '' def test_calls_llm_and_returns_plain_text(self): from changedetectionio.llm.evaluator import summarise_change ds = _make_datastore(llm_cfg={'model': 'gpt-4o-mini', 'api_key': 'sk-test'}) watch = _make_watch(llm_change_summary='List new events in English') with patch('changedetectionio.llm.client.completion', return_value=('3 new events added: Jazz Night, Art Show, Comedy Gig', 80)): result = summarise_change(watch, ds, diff='+ Jazz Night\n+ Art Show\n+ Comedy Gig') assert 'Jazz Night' in result assert 'Art Show' in result def test_cascades_from_tag(self): """llm_change_summary on a tag propagates to watches in that tag.""" from changedetectionio.llm.evaluator import summarise_change tag = {'title': 'events', 'llm_change_summary': 'Translate events to English'} ds = _make_datastore(llm_cfg={'model': 'gpt-4o-mini'}, tags={'tag-1': tag}) watch = _make_watch(llm_change_summary='', tags=['tag-1']) with patch('changedetectionio.llm.client.completion', return_value=('New concert added on Friday', 60)): result = summarise_change(watch, ds, diff='+ Konzert am Freitag') assert result == 'New concert added on Friday' def test_llm_failure_raises(self): """On LLM error, summarise_change re-raises so callers can surface the error.""" from changedetectionio.llm.evaluator import summarise_change ds = _make_datastore(llm_cfg={'model': 'gpt-4o-mini'}) watch = _make_watch(llm_change_summary='Describe the change') with patch('changedetectionio.llm.client.completion', side_effect=Exception('timeout')): with pytest.raises(Exception, match='timeout'): summarise_change(watch, ds, diff='- old\n+ new') def test_uses_higher_token_limit_than_eval(self): """summarise_change passes a dynamic max_tokens larger than the eval default (200).""" from changedetectionio.llm.evaluator import summarise_change, _summary_max_tokens ds = _make_datastore(llm_cfg={'model': 'gpt-4o-mini'}) watch = _make_watch(llm_change_summary='Describe changes') diff = '- old\n+ new' with patch('changedetectionio.llm.client.completion', return_value=('Some summary', 100)) as mock_llm: summarise_change(watch, ds, diff=diff) call_kwargs = mock_llm.call_args passed_max_tokens = call_kwargs.kwargs.get('max_tokens') assert passed_max_tokens == _summary_max_tokens(diff) assert passed_max_tokens >= 400 # always more generous than eval cap of 200 def test_dynamic_token_cap_scales_with_diff_size(self): """Larger diffs produce a higher max_tokens cap, bounded at 3000.""" from changedetectionio.llm.evaluator import _summary_max_tokens assert _summary_max_tokens('x' * 100) == 400 # floor assert _summary_max_tokens('x' * 4000) == 1000 assert _summary_max_tokens('x' * 12000) == 3000 # ceiling assert _summary_max_tokens('x' * 99999) == 3000 # never exceeds ceiling # --------------------------------------------------------------------------- # compute_summary_cache_key / get_effective_summary_prompt # --------------------------------------------------------------------------- class TestSummaryCacheKey: def test_same_inputs_produce_same_key(self): from changedetectionio.llm.evaluator import compute_summary_cache_key key1 = compute_summary_cache_key('+ new line', 'describe changes') key2 = compute_summary_cache_key('+ new line', 'describe changes') assert key1 == key2 def test_different_diff_produces_different_key(self): from changedetectionio.llm.evaluator import compute_summary_cache_key key1 = compute_summary_cache_key('+ line A', 'prompt') key2 = compute_summary_cache_key('+ line B', 'prompt') assert key1 != key2 def test_different_prompt_produces_different_key(self): from changedetectionio.llm.evaluator import compute_summary_cache_key key1 = compute_summary_cache_key('diff', 'list changes') key2 = compute_summary_cache_key('diff', 'translate to English') assert key1 != key2 def test_key_is_16_hex_chars(self): from changedetectionio.llm.evaluator import compute_summary_cache_key key = compute_summary_cache_key('diff', 'prompt') assert len(key) == 16 assert all(c in '0123456789abcdef' for c in key) def test_get_effective_prompt_returns_custom_when_set(self): from changedetectionio.llm.evaluator import get_effective_summary_prompt ds = _make_datastore() watch = _make_watch(llm_change_summary='My custom prompt') assert get_effective_summary_prompt(watch, ds) == 'My custom prompt' def test_get_effective_prompt_returns_default_when_empty(self): from changedetectionio.llm.evaluator import get_effective_summary_prompt, DEFAULT_CHANGE_SUMMARY_PROMPT ds = _make_datastore() watch = _make_watch(llm_change_summary='') assert get_effective_summary_prompt(watch, ds) == DEFAULT_CHANGE_SUMMARY_PROMPT def test_get_effective_prompt_cascades_from_tag(self): from changedetectionio.llm.evaluator import get_effective_summary_prompt tag = {'title': 'grp', 'llm_change_summary': 'tag-level prompt'} ds = _make_datastore(tags={'t1': tag}) watch = _make_watch(llm_change_summary='', tags=['t1']) assert get_effective_summary_prompt(watch, ds) == 'tag-level prompt'