#!/usr/bin/env python3 """ Integration tests for AI Change Summary: - llm_change_summary field saved via watch edit form - llm_change_summary cascades from tag to watches - {{ diff }} replaced by AI summary in notifications - {{ raw_diff }} always contains original diff - summarise_change only runs when change is detected """ import json import time from unittest.mock import patch from flask import url_for from changedetectionio.tests.util import wait_for_all_checks, delete_all_watches HTML_V1 = "" HTML_V2 = "" def _set_response(datastore_path, content): import os with open(os.path.join(datastore_path, "endpoint-content.txt"), "w") as f: f.write(content) def _configure_llm(client): ds = client.application.config.get('DATASTORE') ds.data['settings']['application']['llm'] = {'model': 'gpt-4o-mini', 'api_key': 'sk-test'} # --------------------------------------------------------------------------- # Form field persistence # --------------------------------------------------------------------------- def test_llm_change_summary_saved_via_edit_form( client, live_server, measure_memory_usage, datastore_path): """llm_change_summary submitted via watch edit form is persisted.""" _set_response(datastore_path, HTML_V1) _configure_llm(client) test_url = url_for('test_endpoint', _external=True) uuid = client.application.config.get('DATASTORE').add_watch(url=test_url) res = client.post( url_for("ui.ui_edit.edit_page", uuid=uuid), data={ "url": test_url, "fetch_backend": "html_requests", "time_between_check_use_default": "y", "llm_change_summary": "List new items added as bullet points. Translate to English.", }, follow_redirects=True, ) assert b"Updated watch." in res.data watch = client.application.config.get('DATASTORE').data['watching'][uuid] assert watch.get('llm_change_summary') == "List new items added as bullet points. Translate to English." delete_all_watches(client) def test_llm_change_summary_cascades_from_tag( client, live_server, measure_memory_usage, datastore_path): """llm_change_summary set on a tag is resolved for watches in that tag.""" from changedetectionio.llm.evaluator import resolve_llm_field ds = client.application.config.get('DATASTORE') _configure_llm(client) _set_response(datastore_path, HTML_V1) test_url = url_for('test_endpoint', _external=True) # Create a tag with llm_change_summary tag_uuid = ds.add_tag('events-group') ds.data['settings']['application']['tags'][tag_uuid]['llm_change_summary'] = 'Summarise new events' # Watch in that tag, no own summary prompt uuid = ds.add_watch(url=test_url) ds.data['watching'][uuid]['tags'] = [tag_uuid] ds.data['watching'][uuid]['llm_change_summary'] = '' watch = ds.data['watching'][uuid] value, source = resolve_llm_field(watch, ds, 'llm_change_summary') assert value == 'Summarise new events' assert source == 'events-group' delete_all_watches(client) # --------------------------------------------------------------------------- # Notification token behaviour # --------------------------------------------------------------------------- def test_diff_token_replaced_by_ai_summary_in_notification( client, live_server, measure_memory_usage, datastore_path): """ When _llm_change_summary is set on the watch, the notification handler must substitute it into {{ diff }} and preserve {{ raw_diff }}. """ from changedetectionio.notification.handler import process_notification n_object = { 'notification_urls': ['json://localhost/'], 'notification_title': 'Change detected', 'notification_body': 'Summary: {{diff}}\nRaw: {{raw_diff}}', 'notification_format': 'text', 'uuid': 'test-uuid', 'watch_url': 'https://example.com', 'current_snapshot': 'Item A\nItem B\nItem C', 'prev_snapshot': 'Item A\nItem B', 'diff': '', # populated by add_rendered_diff_to_notification_vars 'raw_diff': '', '_llm_change_summary': '1 new item added: Item C', '_llm_result': None, '_llm_intent': '', 'base_url': 'http://localhost:5000/', 'watch_mime_type': 'text/plain', 'triggered_text': '', } # We only need to verify the token substitution logic, not send a real notification # Invoke just enough of the handler to check n_object state after substitution from changedetectionio.notification_service import add_rendered_diff_to_notification_vars diff_vars = add_rendered_diff_to_notification_vars( notification_scan_text=n_object['notification_body'] + n_object['notification_title'], current_snapshot=n_object['current_snapshot'], prev_snapshot=n_object['prev_snapshot'], word_diff=False, ) n_object.update(diff_vars) # Simulate what handler.py does n_object['raw_diff'] = n_object.get('diff', '') llm_summary = (n_object.get('_llm_change_summary') or '').strip() if llm_summary: n_object['diff'] = llm_summary assert n_object['diff'] == '1 new item added: Item C' assert 'Item C' in n_object['raw_diff'] or n_object['raw_diff'] != n_object['diff'] delete_all_watches(client) def test_diff_token_unchanged_when_no_ai_summary( client, live_server, measure_memory_usage, datastore_path): """When no AI Change Summary is configured, {{ diff }} renders the raw diff as normal.""" from changedetectionio.notification_service import add_rendered_diff_to_notification_vars n_object = { 'current_snapshot': 'Item A\nItem B\nItem C', 'prev_snapshot': 'Item A\nItem B', '_llm_change_summary': '', } diff_vars = add_rendered_diff_to_notification_vars( notification_scan_text='{{diff}}', current_snapshot=n_object['current_snapshot'], prev_snapshot=n_object['prev_snapshot'], word_diff=False, ) n_object.update(diff_vars) raw = n_object.get('diff', '') n_object['raw_diff'] = raw if (n_object.get('_llm_change_summary') or '').strip(): n_object['diff'] = n_object['_llm_change_summary'] # diff should still be the raw diff (not replaced) assert n_object['diff'] == n_object['raw_diff'] delete_all_watches(client)