Files
dgtlmoon e9e8c8d218
Build and push containers / metadata (push) Has been cancelled
Build and push containers / build-push-containers (push) Has been cancelled
Publish Python 🐍distribution 📦 to PyPI and TestPyPI / Build distribution 📦 (push) Has been cancelled
ChangeDetection.io Container Build Test / Build linux/amd64 (alpine) (push) Has been cancelled
ChangeDetection.io Container Build Test / Build linux/arm64 (alpine) (push) Has been cancelled
ChangeDetection.io Container Build Test / Build linux/amd64 (main) (push) Has been cancelled
ChangeDetection.io Container Build Test / Build linux/arm/v7 (main) (push) Has been cancelled
ChangeDetection.io Container Build Test / Build linux/arm/v8 (main) (push) Has been cancelled
ChangeDetection.io Container Build Test / Build linux/arm64 (main) (push) Has been cancelled
ChangeDetection.io App Test / lint-code (push) Has been cancelled
ChangeDetection.io App Test / lint-translations (push) Has been cancelled
Publish Python 🐍distribution 📦 to PyPI and TestPyPI / Test the built package works basically. (push) Has been cancelled
Publish Python 🐍distribution 📦 to PyPI and TestPyPI / Publish Python 🐍 distribution 📦 to PyPI (push) Has been cancelled
ChangeDetection.io App Test / test-application-3-10 (push) Has been cancelled
ChangeDetection.io App Test / test-application-3-11 (push) Has been cancelled
ChangeDetection.io App Test / test-application-3-12 (push) Has been cancelled
ChangeDetection.io App Test / test-application-3-13 (push) Has been cancelled
ChangeDetection.io App Test / test-application-3-14 (push) Has been cancelled
LLM / AI Change detection rules and Summaries
2026-04-24 15:21:09 +02:00

233 lines
8.4 KiB
Python

#!/usr/bin/env python3
"""
Integration tests: /edit/<uuid>/preview-rendered returns llm_evaluation when
llm_intent is submitted alongside the filter form data.
These tests verify the full backend path:
JS POSTs llm_intent → prepare_filter_prevew() applies it to tmp_watch
→ preview_extract() is called → llm_evaluation appears in JSON response
The response uses {'found': bool, 'answer': str} — NOT the diff-evaluation
{'important', 'summary'} shape, because preview asks the LLM to extract from
the current content directly (e.g. "30 articles listed") rather than compare
a diff.
"""
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_WITH_ARTICLES = """<html><body>
<ul id="articles">
<li>Article One</li>
<li>Article Two</li>
<li>Article Three</li>
</ul>
</body></html>"""
HTML_WITH_PRICE = """<html><body>
<p class="price">Original price: $199.00</p>
<p class="discount">Now: $149.00 — 25% off!</p>
</body></html>"""
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 _add_and_fetch(client, live_server, datastore_path, html):
"""Add a watch, fetch it once so a snapshot exists, return uuid."""
_set_response(datastore_path, html)
test_url = url_for('test_endpoint', _external=True)
uuid = client.application.config.get('DATASTORE').add_watch(url=test_url)
client.get(url_for("ui.form_watch_checknow"), follow_redirects=True)
time.sleep(0.5)
wait_for_all_checks(client)
return uuid
def _configure_llm(client):
"""Put a fake LLM config into the datastore."""
datastore = client.application.config.get('DATASTORE')
datastore.data['settings']['application']['llm'] = {
'model': 'gpt-4o-mini',
'api_key': 'sk-test-fake',
}
# ---------------------------------------------------------------------------
# llm_intent submitted → llm_evaluation returned with found/answer shape
# ---------------------------------------------------------------------------
def test_preview_returns_llm_answer_for_article_intent(
client, live_server, measure_memory_usage, datastore_path):
"""
With llm_intent='Tell me the number of articles in the list',
the preview endpoint returns llm_evaluation with found=True and an answer
that directly addresses the intent (e.g. "3 articles listed").
"""
uuid = _add_and_fetch(client, live_server, datastore_path, HTML_WITH_ARTICLES)
_configure_llm(client)
llm_json = '{"found": true, "answer": "3 articles are listed in the content"}'
with patch('changedetectionio.llm.client.completion', return_value=(llm_json, 50)):
res = client.post(
url_for("ui.ui_edit.watch_get_preview_rendered", uuid=uuid),
data={
'llm_intent': 'Tell me the number of articles in the list',
'fetch_backend': 'html_requests',
},
)
assert res.status_code == 200
data = json.loads(res.data.decode('utf-8'))
# Filtered text must still be present
assert data.get('after_filter'), "after_filter must be present"
# LLM evaluation must be returned with the new shape
ev = data.get('llm_evaluation')
assert ev is not None, "llm_evaluation must be in response"
assert ev['found'] is True
assert '3' in ev['answer']
delete_all_watches(client)
def test_preview_returns_llm_answer_for_price_intent(
client, live_server, measure_memory_usage, datastore_path):
"""
With a price-change intent, the LLM answer should reflect the discount
extracted directly from the current page (not a diff comparison).
"""
uuid = _add_and_fetch(client, live_server, datastore_path, HTML_WITH_PRICE)
_configure_llm(client)
llm_json = '{"found": true, "answer": "Price $149, 25% off (was $199)"}'
with patch('changedetectionio.llm.client.completion', return_value=(llm_json, 60)):
res = client.post(
url_for("ui.ui_edit.watch_get_preview_rendered", uuid=uuid),
data={
'llm_intent': 'Flag any price change, including discount percentages',
'fetch_backend': 'html_requests',
},
)
assert res.status_code == 200
data = json.loads(res.data.decode('utf-8'))
ev = data.get('llm_evaluation')
assert ev is not None
assert ev['found'] is True
assert '25' in ev['answer'] or '149' in ev['answer']
delete_all_watches(client)
def test_preview_found_false_when_content_not_relevant(
client, live_server, measure_memory_usage, datastore_path):
"""found=False when the LLM determines page content doesn't match intent."""
uuid = _add_and_fetch(client, live_server, datastore_path, HTML_WITH_ARTICLES)
_configure_llm(client)
llm_json = '{"found": false, "answer": "No price information found on this page"}'
with patch('changedetectionio.llm.client.completion', return_value=(llm_json, 45)):
res = client.post(
url_for("ui.ui_edit.watch_get_preview_rendered", uuid=uuid),
data={
'llm_intent': 'Show me any product prices',
'fetch_backend': 'html_requests',
},
)
assert res.status_code == 200
data = json.loads(res.data.decode('utf-8'))
ev = data.get('llm_evaluation')
assert ev is not None
assert ev['found'] is False
assert ev['answer']
delete_all_watches(client)
# ---------------------------------------------------------------------------
# No intent / no LLM → llm_evaluation is None
# ---------------------------------------------------------------------------
def test_preview_no_llm_evaluation_without_intent(
client, live_server, measure_memory_usage, datastore_path):
"""When llm_intent is absent, the LLM client must not be called."""
uuid = _add_and_fetch(client, live_server, datastore_path, HTML_WITH_ARTICLES)
_configure_llm(client)
with patch('changedetectionio.llm.client.completion') as mock_llm:
res = client.post(
url_for("ui.ui_edit.watch_get_preview_rendered", uuid=uuid),
data={'fetch_backend': 'html_requests'},
)
mock_llm.assert_not_called()
assert res.status_code == 200
data = json.loads(res.data.decode('utf-8'))
assert data.get('llm_evaluation') is None
delete_all_watches(client)
def test_preview_no_llm_evaluation_when_llm_not_configured(
client, live_server, measure_memory_usage, datastore_path):
"""When LLM model is not set, llm_evaluation must be None even with an intent."""
uuid = _add_and_fetch(client, live_server, datastore_path, HTML_WITH_ARTICLES)
# Intentionally do NOT configure LLM
with patch('changedetectionio.llm.client.completion') as mock_llm:
res = client.post(
url_for("ui.ui_edit.watch_get_preview_rendered", uuid=uuid),
data={
'llm_intent': 'Tell me the number of articles',
'fetch_backend': 'html_requests',
},
)
mock_llm.assert_not_called()
assert res.status_code == 200
data = json.loads(res.data.decode('utf-8'))
assert data.get('llm_evaluation') is None
delete_all_watches(client)
# ---------------------------------------------------------------------------
# LLM failure → llm_evaluation is None, preview still works
# ---------------------------------------------------------------------------
def test_preview_llm_failure_does_not_break_preview(
client, live_server, measure_memory_usage, datastore_path):
"""If the LLM call raises, preview_extract returns None — preview still works."""
uuid = _add_and_fetch(client, live_server, datastore_path, HTML_WITH_ARTICLES)
_configure_llm(client)
with patch('changedetectionio.llm.client.completion', side_effect=Exception('API timeout')):
res = client.post(
url_for("ui.ui_edit.watch_get_preview_rendered", uuid=uuid),
data={
'llm_intent': 'Tell me the number of articles',
'fetch_backend': 'html_requests',
},
)
assert res.status_code == 200
data = json.loads(res.data.decode('utf-8'))
# Filter content must still be returned
assert data.get('after_filter')
# preview_extract returns None on error (doesn't fail-open like evaluate_change)
assert data.get('llm_evaluation') is None
delete_all_watches(client)