Compare commits

...

2 Commits

Author SHA1 Message Date
dgtlmoon aaa0edec75 UI - LLM - Flag LLM_FEATURES_DISABLED to disable all LLM from the UI/system 2026-05-21 10:48:36 +02:00
dgtlmoon 43bb196aa4 UI - Preview problem fix for extract_text/ignore_text #4138 (#4169)
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 App Test / lint-code (push) Has been cancelled
ChangeDetection.io App Test / lint-translations (push) Has been cancelled
ChangeDetection.io App Test / lint-template-i18n (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
2026-05-20 13:57:17 +02:00
13 changed files with 239 additions and 10 deletions
@@ -14,8 +14,10 @@ from changedetectionio.auth_decorator import login_optionally_required
def construct_blueprint(datastore: ChangeDetectionStore):
from changedetectionio.blueprint.settings.llm import construct_llm_blueprint
from changedetectionio.llm.evaluator import is_llm_features_disabled
settings_blueprint = Blueprint('settings', __name__, template_folder="templates")
settings_blueprint.register_blueprint(construct_llm_blueprint(datastore), url_prefix='/llm')
if not is_llm_features_disabled():
settings_blueprint.register_blueprint(construct_llm_blueprint(datastore), url_prefix='/llm')
@settings_blueprint.route("", methods=['GET', "POST"])
@login_optionally_required
@@ -34,7 +34,9 @@
<li class="tab"><a href="#plugin-{{ tab.plugin_id }}">{{ tab.tab_label }}</a></li>
{% endfor %}
{% endif %}
{% if not llm_features_disabled %}
<li class="tab"><a href="#ai">{{ _('AI / LLM') }}</a></li>
{% endif %}
<li class="tab"><a href="#info">{{ _('Info') }}</a></li>
</ul>
</div>
@@ -394,7 +396,9 @@ nav
</div>
{% endfor %}
{% endif %}
{% if not llm_features_disabled %}
{% include 'settings_llm_tab.html' %}
{% endif %}
<div class="tab-pane-inner" id="info">
<p><strong>{{ _('Uptime:') }}</strong> {{ uptime_seconds|format_duration }}</p>
<p><strong>{{ _('Python version:') }}</strong> {{ python_version }}</p>
@@ -57,7 +57,9 @@
{% if capabilities.supports_visual_selector %}
<li class="tab"><a id="visualselector-tab" href="#visualselector">{{ _('Visual Filter Selector') }}</a></li>
{% endif %}
{% if not llm_features_disabled %}
<li class="tab"><a href="#ai-llm">{{ _('AI / LLM') }}</a></li>
{% endif %}
{% if capabilities.supports_text_filters_and_triggers %}
<li class="tab" id="filters-and-triggers-tab"><a href="#filters-and-triggers">{{ _('Filters & Triggers') }}</a></li>
<li class="tab" id="conditions-tab"><a href="#conditions">{{ _('Conditions') }}</a></li>
@@ -321,9 +323,11 @@ Math: {{ 1 + 1 }}") }}
</div>
</div>
</div>
{% if not llm_features_disabled %}
<div class="tab-pane-inner" id="ai-llm">
{% include "edit/include_llm_intent.html" %}
</div>
{% endif %}
<div class="tab-pane-inner" id="filters-and-triggers">
<span id="activate-text-preview" class="pure-button pure-button-primary button-xsmall">{{ _('Activate preview') }}</span>
@@ -503,7 +507,7 @@ Math: {{ 1 + 1 }}") }}
<td>{{ _('Server type reply') }}</td>
<td>{{ watch.get('remote_server_reply') }}</td>
</tr>
{% if settings_application.get('llm', {}).get('model') %}
{% if not llm_features_disabled and settings_application.get('llm', {}).get('model') %}
<tr>
<td>{{ _('AI tokens (last check)') }}</td>
<td>{{ "{:,}".format(watch.get('llm_last_tokens_used') or 0) }}</td>
+5
View File
@@ -522,6 +522,11 @@ def changedetection_app(config=None, datastore_o=None):
available_languages=available_languages
)
@app.context_processor
def inject_llm_features_disabled():
from changedetectionio.llm.evaluator import is_llm_features_disabled
return dict(llm_features_disabled=is_llm_features_disabled())
# Set up a request hook to check authentication for all routes
@app.before_request
def check_authentication():
+11
View File
@@ -20,6 +20,8 @@ from dataclasses import dataclass
from datetime import datetime, timezone
from loguru import logger
from changedetectionio.strtobool import strtobool
from . import client as llm_client
from .prompt_builder import (
build_change_summary_prompt, build_change_summary_system_prompt,
@@ -31,6 +33,11 @@ from .response_parser import parse_eval_response, parse_preview_response, parse_
_DEFAULT_MAX_INPUT_CHARS = 100_000
def is_llm_features_disabled() -> bool:
"""True when the LLM_FEATURES_DISABLED env var is set to a truthy value."""
return bool(strtobool(os.getenv('LLM_FEATURES_DISABLED', '')))
def _get_max_input_chars(datastore) -> int:
"""Max input characters to send to the LLM. Resolution: env var → datastore → 100,000.
Always returns at least 1 unlimited is not permitted.
@@ -207,6 +214,8 @@ def get_llm_config(datastore) -> dict | None:
1. Environment variables: LLM_MODEL, LLM_API_KEY, LLM_API_BASE
2. Datastore settings (set via UI)
"""
if is_llm_features_disabled():
return None
# 1. Environment variable override
env_model = os.getenv('LLM_MODEL', '').strip()
if env_model:
@@ -225,6 +234,8 @@ def get_llm_config(datastore) -> dict | None:
def llm_configured_via_env() -> bool:
"""True when LLM config comes from environment variables, not the UI."""
if is_llm_features_disabled():
return False
return bool(os.getenv('LLM_MODEL', '').strip())
@@ -35,6 +35,50 @@ def _task(watch, update_handler):
return text_after_filter
def _compute_ignore_line_numbers_for_preview(text_pre_extract, ignore_patterns, extract_patterns):
"""1-indexed output line numbers in the post-extract display that correspond
to input lines matching ignore_text patterns.
Needed because extract_text (#4138) transforms line content — e.g. "0.54.10"
becomes ".54.10" so a substring match for "0.54.10" against the post-extract
text fails and the preview UI can no longer mark the line as ignored. We find
the ignored line numbers in the pre-extract text and replay extract_by_regex
line-by-line to map them forward.
"""
from changedetectionio import html_tools
from changedetectionio.processors.text_json_diff.processor import ContentTransformer
if not text_pre_extract or not ignore_patterns:
return []
ignored_input_lines = set(
html_tools.strip_ignore_text(
content=text_pre_extract,
wordlist=ignore_patterns,
mode='line numbers'
)
)
if not ignored_input_lines:
return []
if not extract_patterns:
return sorted(ignored_input_lines)
# Replay extract_by_regex per-line. Each emitted match ends with exactly one
# '\n', so counting newlines tells us how many output lines this input produced.
output_line_counter = 0
result = []
for input_idx, line in enumerate(text_pre_extract.splitlines()):
is_ignored = (input_idx + 1) in ignored_input_lines
matches_in_line = ContentTransformer.extract_by_regex(line, extract_patterns).count('\n')
for _ in range(matches_in_line):
output_line_counter += 1
if is_ignored:
result.append(output_line_counter)
return result
def prepare_filter_prevew(datastore, watch_uuid, form_data):
'''Used by @app.route("/edit/<uuid_str:uuid>/preview-rendered", methods=['POST'])'''
from changedetectionio import forms, html_tools
@@ -50,6 +94,7 @@ def prepare_filter_prevew(datastore, watch_uuid, form_data):
text_after_filter = ''
text_before_filter = ''
text_pre_extract = ''
trigger_line_numbers = []
ignore_line_numbers = []
blocked_line_numbers = []
@@ -89,15 +134,22 @@ def prepare_filter_prevew(datastore, watch_uuid, form_data):
update_handler.fetcher.content = str(decompressed_data) # str() because playwright/puppeteer/requests return string
update_handler.fetcher.headers['content-type'] = tmp_watch.get('content-type')
# Process our watch with filters and the HTML from disk, and also a blank watch with no filters but also with the same HTML from disk
# Process our watch with filters and the HTML from disk, and also a blank watch with no filters but also with the same HTML from disk.
# The third task runs with extract_text cleared so we can compute ignore_line_numbers
# against the pre-extract text (extract_text transforms lines so post-extract substring
# matching for ignore patterns would otherwise fail — see #4138 follow-up).
# Do this as parallel threads (not processes) to avoid pickle issues with Lock objects
tmp_watch_no_extract = deepcopy(tmp_watch)
tmp_watch_no_extract['extract_text'] = []
try:
with ThreadPoolExecutor(max_workers=2) as executor:
with ThreadPoolExecutor(max_workers=3) as executor:
future1 = executor.submit(_task, tmp_watch, update_handler)
future2 = executor.submit(_task, blank_watch_no_filters, update_handler)
future3 = executor.submit(_task, tmp_watch_no_extract, update_handler)
text_after_filter = future1.result()
text_before_filter = future2.result()
text_pre_extract = future3.result()
except Exception as e:
x=1
@@ -111,10 +163,11 @@ def prepare_filter_prevew(datastore, watch_uuid, form_data):
try:
text_to_ignore = tmp_watch.get('ignore_text', []) + datastore.data['settings']['application'].get('global_ignore_text', [])
ignore_line_numbers = html_tools.strip_ignore_text(content=text_after_filter,
wordlist=text_to_ignore,
mode='line numbers'
)
ignore_line_numbers = _compute_ignore_line_numbers_for_preview(
text_pre_extract=text_pre_extract,
ignore_patterns=text_to_ignore,
extract_patterns=tmp_watch.get('extract_text', [])
)
except Exception as e:
text_before_filter = f"Error: {str(e)}"
@@ -9,6 +9,10 @@ function request_textpreview_update() {
$('textarea:visible, input:visible').each(function () {
const $element = $(this); // Cache the jQuery object for the current element
const name = $element.attr('name'); // Get the name attribute of the element
// Radios share a name across multiple inputs; .val() returns the value
// attribute regardless of checked state, so iterating would let the last
// unchecked radio overwrite the user's actual selection. Skip unchecked.
if ($element.is(':radio') && !$element.is(':checked')) return;
data[name] = $element.is(':checkbox') ? ($element.is(':checked') ? $element.val() : false) : $element.val();
});
@@ -112,7 +112,7 @@
<td><code>{{ '{{triggered_text}}' }}</code></td>
<td>{{ _('Text that tripped the trigger from filters') }}</td>
</tr>
{% if settings_application and settings_application.get('llm', {}).get('model') %}
{% if not llm_features_disabled and settings_application and settings_application.get('llm', {}).get('model') %}
<tr>
<td><code>{{ '{{diff}}' }}</code> <small style="opacity:0.6">{{ _('(upgraded)') }}</small></td>
<td>{{ _('When AI Change Summary is configured, contains the AI-generated description instead of the raw diff. Falls back to raw diff when not configured.') }}</td>
+2
View File
@@ -281,6 +281,7 @@
</div>
</dialog>
{% if not llm_features_disabled %}
<!-- LLM Not Configured Modal -->
<dialog id="llm-not-configured-modal" class="modal-dialog" aria-labelledby="llm-not-configured-modal-title">
<div class="modal-header">
@@ -294,6 +295,7 @@
<button type="button" class="pure-button" id="close-llm-not-configured-modal">{{ _('Close') }}</button>
</div>
</dialog>
{% endif %}
<!-- Search Modal -->
{% if current_user.is_authenticated or not has_password %}
+2
View File
@@ -37,10 +37,12 @@
</li>
{% endif %}
<li class="pure-menu-item menu-collapsible" id="inline-menu-extras-group">
{% if not llm_features_disabled %}
<button class="toggle-button toggle-ai-mode" type="button" title="{{ _('Toggle AI Mode') }}" data-llm-configured="{{ 'true' if llm_configured else 'false' }}" data-llm-settings-url="{{ url_for('settings.settings_page') }}#ai">
<span class="visually-hidden">{{ _('Toggle AI mode') }}</span>
{% include "svgs/ai-mode-icon.svg" %}<span class="ai-mode-label">LLM</span>
</button>
{% endif %}
<button class="toggle-button toggle-light-mode " type="button" title="{{ _('Toggle Light/Dark Mode') }}">
<span class="visually-hidden">{{ _('Toggle light/dark mode') }}</span>
<span class="icon-light">
@@ -0,0 +1,62 @@
"""
Smoke test for the LLM_FEATURES_DISABLED env var.
The env var is intended to hide every LLM/AI surface (settings tab, edit tab,
base-template AI toggle/modal) for hosted deployments. This test renders the
three primary pages with the env var set and verifies that none of the
LLM-related markers leak through.
"""
from flask import url_for
def _llm_markers_absent(body: bytes, where: str = ''):
"""All of these strings appear in LLM UI surfaces — none should render."""
for marker in (b'AI / LLM', b'toggle-ai-mode', b'llm-not-configured-modal',
b'id="ai-llm"', b'#ai-llm', b'href="#ai"'):
if marker in body:
idx = body.find(marker)
context = body[max(0, idx - 80):idx + len(marker) + 80].decode('utf-8', 'replace')
raise AssertionError(f"[{where}] {marker!r} found in body, context: ...{context}...")
def test_llm_features_disabled_hides_ui(client, live_server, monkeypatch):
monkeypatch.setenv('LLM_FEATURES_DISABLED', 'true')
# Sanity: helper reports the env var is in effect
from changedetectionio.llm.evaluator import is_llm_features_disabled, get_llm_config
assert is_llm_features_disabled() is True
# get_llm_config() must return None so every `if llm_configured` template hides
datastore = client.application.config.get('DATASTORE')
assert get_llm_config(datastore) is None
# 1. Watch list (base.html + menu.html surface)
res = client.get(url_for('watchlist.index'))
assert res.status_code == 200
_llm_markers_absent(res.data, where='watchlist')
# 2. Settings page (should not have an AI / LLM tab or the LLM tab body)
res = client.get(url_for('settings.settings_page'))
assert res.status_code == 200
_llm_markers_absent(res.data, where='settings')
# 3. Edit page for a watch (should not have an AI / LLM tab or include_llm_intent body)
uuid = datastore.add_watch(url='http://example.com', extras={'title': 'Disabled LLM watch'})
res = client.get(url_for('ui.ui_edit.edit_page', uuid=uuid))
assert res.status_code == 200
_llm_markers_absent(res.data, where='edit')
# The watch-edit-only intent textarea should also be absent
assert b'name="llm_intent"' not in res.data
assert b'name="llm_change_summary"' not in res.data
def test_llm_features_enabled_by_default(client, live_server, monkeypatch):
"""When LLM_FEATURES_DISABLED is unset, the AI / LLM surfaces are still rendered."""
monkeypatch.delenv('LLM_FEATURES_DISABLED', raising=False)
from changedetectionio.llm.evaluator import is_llm_features_disabled
assert is_llm_features_disabled() is False
res = client.get(url_for('settings.settings_page'))
assert res.status_code == 200
# The AI / LLM settings tab anchor should be present when not disabled
assert b'href="#ai"' in res.data
@@ -77,3 +77,82 @@ def test_content_filter_live_preview(client, live_server, measure_memory_usage,
assert reply.get('trigger_line_numbers') == [1] # Triggers "Awesome" in line 1
delete_all_watches(client)
def _setup_version_list_preview(datastore_path, client):
"""Shared HTML fixture for #4138 preview regressions (version tag list)."""
import time
data = """<html><body>
0.55.5<br>
0.55.4<br>
0.55.3<br>
0.54.10<br>
0.54.9<br>
</body></html>"""
with open(os.path.join(datastore_path, "endpoint-content.txt"), "w") as f:
f.write(data)
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 test_url, uuid
def test_preview_ignore_highlight_with_extract_text(client, live_server, measure_memory_usage, datastore_path):
"""Regression for #4138 follow-up: when extract_text rewrites a line (e.g. "0.54.10"".54.10"),
the preview must still highlight that row as 'ignored' even though substring matching against the
post-extract text fails."""
import json
test_url, uuid = _setup_version_list_preview(datastore_path, client)
res = client.post(
url_for("ui.ui_edit.watch_get_preview_rendered", uuid=uuid),
data={
"include_filters": "",
"fetch_backend": 'html_requests',
"ignore_text": "0.54.10",
"extract_text": r"/(.\d+\.\d+)/",
"url": test_url,
},
)
reply = json.loads(res.data.decode('utf-8'))
# The regex strips the leading "0", so the post-extract line for the ignored input is ".54.10".
# The preview should still mark its position (line 4) as ignored.
assert reply.get('ignore_line_numbers') == [4], \
f"Expected line 4 to be highlighted as ignored, got {reply.get('ignore_line_numbers')!r}"
delete_all_watches(client)
def test_preview_strip_ignored_lines_with_extract_text(client, live_server, measure_memory_usage, datastore_path):
"""Regression for #4138 follow-up: with strip_ignored_lines enabled, an ignored line must be
removed from the preview output even when extract_text would otherwise rewrite it (0.54.10 .54.10)."""
import json
test_url, uuid = _setup_version_list_preview(datastore_path, client)
res = client.post(
url_for("ui.ui_edit.watch_get_preview_rendered", uuid=uuid),
data={
"include_filters": "",
"fetch_backend": 'html_requests',
"ignore_text": "0.54.10",
"extract_text": r"/(.\d+\.\d+)/",
"strip_ignored_lines": "true",
"url": test_url,
},
)
reply = json.loads(res.data.decode('utf-8'))
after_filter = reply.get('after_filter', '')
assert '.54.10' not in after_filter, \
f"Stripped ignored line should not appear in preview output, got:\n{after_filter!r}"
assert '0.54.10' not in after_filter
assert reply.get('ignore_line_numbers') == [], \
f"Stripped lines need no highlight, got {reply.get('ignore_line_numbers')!r}"
delete_all_watches(client)
+2 -1
View File
@@ -436,7 +436,8 @@ async def async_update_worker(worker_id, q, notification_q, app, datastore, exec
# Also gated on llm_enabled — a disabled LLM can't be spending tokens,
# so the budget enforcement shouldn't suppress changes when the user
# has explicitly switched LLM off.
_llm_master_enabled = bool(datastore.data['settings']['application'].get('llm_enabled', True))
from changedetectionio.llm.evaluator import is_llm_features_disabled as _is_llm_features_disabled
_llm_master_enabled = bool(datastore.data['settings']['application'].get('llm_enabled', True)) and not _is_llm_features_disabled()
_llm_budget_action = datastore.data['settings']['application'].get('llm_budget_action', 'skip_llm')
if _llm_master_enabled and _llm_budget_action == 'skip_check':
from changedetectionio.llm.evaluator import is_global_token_budget_exceeded