LLM - Per watch/group settings and UI tidyup (#4354)

* UI - LLM section tidyup

* Rebuild translatiosn

* UI - Fixing language for prompt adjustement to be more clear

* UI - clarify field action

* UI - clarify layout for sections

* WIP

* test tweaks
This commit is contained in:
dgtlmoon
2026-09-02 10:07:34 +02:00
committed by GitHub
parent 10521dd5e1
commit 97317398ac
39 changed files with 1782 additions and 278 deletions
@@ -189,6 +189,9 @@ def construct_blueprint(datastore: ChangeDetectionStore):
'watch': default,
'extra_notification_token_placeholder_info': datastore.get_unique_notification_token_placeholders_available(),
'llm_configured': bool(_get_llm_config(datastore)),
# Tells the shared AI include it is rendering a group, not a watch — `watch` above
# is this tag, so it cannot be used to tell the two apart.
'llm_group_edit': True,
}
included_content = {}
+18 -2
View File
@@ -10,6 +10,7 @@ from wtforms.fields.simple import BooleanField
from flask_babel import lazy_gettext as _l
from changedetectionio.blueprint.tags.colour import CSS_HEX_COLOUR_REGEX
from changedetectionio.widgets.ternary_boolean import TernaryNoneBooleanField
from changedetectionio.processors.restock_diff.forms import processor_settings_form as restock_settings_form
from changedetectionio.llm.ui_strings import LLM_INTENT_TAG_PLACEHOLDER
from changedetectionio.llm.evaluator import (
@@ -28,7 +29,22 @@ class group_restock_settings_form(restock_settings_form):
validators=[validators.Optional(),
validators.Regexp(CSS_HEX_COLOUR_REGEX,
message=_l('Must be a hex colour, for example #4f8ef7'))])
llm_intent = TextAreaField('AI Change Intent',
# The group's one and only AI control. Same key as the per-watch switch (see forms.py) but
# ternary, because a group has a third useful answer: "no opinion, leave it to each watch"
# (the default). See tag_llm_decision() for the semantics of each state — #4204.
# @NOTE! In the near future this stops being a ternary bool and becomes a *profile*
# selector — pick one of the configured LLM profiles, or 'off', or inherit. The
# field name is already the future one; only the widget and the True/False/None
# value space need to change, so keep reads going through tag_llm_decision().
llm_backend_profile = TernaryNoneBooleanField(
_l('AI for watches in this group'),
default=None,
yes_text=_l('On, use the settings below'),
no_text=_l('Off for every watch'),
none_text=_l('Leave it to each watch'),
)
llm_intent = TextAreaField('AI Change Intent - Notify me when..',
validators=[validators.Optional(), validators.Length(max=2000)],
render_kw={"rows": "5", "placeholder": LLM_INTENT_TAG_PLACEHOLDER})
@@ -38,7 +54,7 @@ class group_restock_settings_form(restock_settings_form):
default='')
llm_change_summary_mode = RadioField(
_l('How this prompt combines with the inherited one'),
_l('Change Summary prompt - Append or Replace the default?'),
choices=[
(LLM_PROMPT_MODE_REPLACE, _l('Replace the inherited prompt')),
(LLM_PROMPT_MODE_APPEND, _l('Append to the inherited prompt')),
@@ -27,9 +27,9 @@
<div class="tabs collapsable">
<ul>
<li class="tab" id=""><a href="#general">{{ _('General') }}</a></li>
{% if llm_configured %}
{# Always shown, like the watch edit page: with no provider configured the pane
explains how to set one up (and carries the AI switches through on save). #}
<li class="tab"><a href="#ai-llm">{{ _('AI / LLM') }}</a></li>
{% endif %}
<li class="tab"><a href="#filters-and-triggers">{{ _('Filters & Triggers') }}</a></li>
{% if extra_tab_content %}
<li class="tab"><a href="#extras_tab">{{ extra_tab_content }}</a></li>
@@ -92,11 +92,9 @@
</fieldset>
</div>
{% if llm_configured %}
<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">
{# TRANSLATORS: CJK fonts lack native italics; allow substitution with conventional local styling. dennis-ignore: W303 #}
+44 -7
View File
@@ -15,23 +15,50 @@ from changedetectionio.llm.evaluator import get_llm_config as _get_llm_config
def construct_blueprint(datastore: ChangeDetectionStore, update_q, queuedWatchMetaData):
edit_blueprint = Blueprint('ui_edit', __name__, template_folder="../ui/templates")
def _watch_tags(watch):
"""(uuid, tag) for this watch's tags, in its own tag order, skipping UUIDs we don't know."""
tags = datastore.data['settings']['application'].get('tags', {})
return [(tag_uuid, tags[tag_uuid]) for tag_uuid in watch.get('tags', []) if tag_uuid in tags]
def _resolve_llm_group_overrides(watch, datastore) -> dict:
"""
For each LLM field (llm_intent, llm_change_summary): if the watch has no own
value but a linked tag does, return {'value': ..., 'group_name': ...} so the
edit template can render the textarea as readonly with a group-sourced placeholder.
Returns None for each field when the watch has its own value (editable).
value but a linked group does, return {'value': ..., 'group_name': ..., 'group_uuid': ...}
so the edit template can show the inherited value as the textarea placeholder and link
back to the group that supplied it.
Returns None for each field when the watch has its own value (nothing inherited).
Only groups whose AI setting is "On" lend their prompts — the same gate the evaluator
applies via resolve_llm_field(), so the placeholder always reflects what will actually
run. See llm/evaluator.py:tag_llm_decision().
"""
result = {'llm_intent': None, 'llm_change_summary': None}
from changedetectionio.llm.evaluator import tag_llm_applies_to_watches, tag_llm_decision
result = {'llm_intent': None, 'llm_change_summary': None, 'llm_backend_profile': None}
# AI on/off is not a "fill in the blank" field: a group that has taken the decision
# (On or Off, i.e. not "leave it to each watch") decides for every watch in it (#4204),
# so report it and let the template show the watch's own checkbox as overridden.
for tag_uuid, tag in _watch_tags(watch):
if tag_llm_decision(tag) is not None:
result['llm_backend_profile'] = {
'value': tag_llm_decision(tag),
'group_name': tag.get('title', 'tag'),
'group_uuid': tag_uuid,
}
break
for field in ('llm_intent', 'llm_change_summary'):
if (watch.get(field) or '').strip():
continue # watch has its own value — editable, no group override
for tag_uuid in watch.get('tags', []):
tag = datastore.data['settings']['application'].get('tags', {}).get(tag_uuid)
if tag and (tag.get(field) or '').strip():
for tag_uuid, tag in _watch_tags(watch):
if not tag_llm_applies_to_watches(tag):
continue
if (tag.get(field) or '').strip():
result[field] = {
'value': tag.get(field).strip(),
'group_name': tag.get('title', 'tag'),
'group_uuid': tag_uuid,
}
break
return result
@@ -211,6 +238,16 @@ def construct_blueprint(datastore: ChangeDetectionStore, update_q, queuedWatchMe
extra_update_obj['filter_text_replaced'] = True
extra_update_obj['filter_text_removed'] = True
# A group that has taken the AI on/off decision owns that control, so the edit page
# renders it disabled (see include_llm_intent.html). A disabled checkbox isn't
# submitted at all, and for a checkbox "not submitted" is indistinguishable from
# "unticked" — so don't take this field from the form while a group decides. The
# watch keeps its own preference untouched, ready for when the group stops deciding.
# Resolved against the watch's *stored* tags — i.e. what the page was rendered from,
# so attaching or detaching a group in this same save is still honoured correctly.
if _resolve_llm_group_overrides(datastore.data['watching'][uuid], datastore).get('llm_backend_profile'):
extra_update_obj['llm_backend_profile'] = datastore.data['watching'][uuid].get('llm_backend_profile', True)
# Because wtforms doesn't support accessing other data in process_ , but we convert the CSV list of tags back to a list of UUIDs
tag_uuids = []
if form.data.get('tags'):
+5 -2
View File
@@ -953,7 +953,7 @@ class processor_text_json_diff_form(commonSettingsForm):
time_between_check_use_default = BooleanField(_l('Use global settings for time between check and scheduler.'), default=False)
llm_intent = TextAreaField(_l('AI Change Intent'), validators=[validators.Optional(), validators.Length(max=2000)],
llm_intent = TextAreaField(_l('AI Change Intent - Notify me when..'), validators=[validators.Optional(), validators.Length(max=2000)],
render_kw={"rows": "5", "placeholder": LLM_INTENT_WATCH_PLACEHOLDER})
llm_change_summary = TextAreaField(_l('AI Change Summary'), validators=[validators.Optional(), validators.Length(max=2000)],
@@ -961,13 +961,16 @@ class processor_text_json_diff_form(commonSettingsForm):
default='')
llm_change_summary_mode = RadioField(
_l('How this prompt combines with the inherited one'),
_l('Change Summary prompt - Append or Replace the default?'),
choices=[
(LLM_PROMPT_MODE_REPLACE, _l('Replace the inherited prompt')),
(LLM_PROMPT_MODE_APPEND, _l('Append to the inherited prompt')),
],
default=LLM_PROMPT_MODE_REPLACE,
)
# @NOTE! In the near future you should be able to select which LLM profile *OR* "off"/None for this watch/group
# For now we use the 'future' field naming but keep the functionality simple.
llm_backend_profile = BooleanField(_l('AI enabled for this watch?'), default=True)
include_filters = StringListField(_l('CSS/JSONPath/JQ/XPath Filters'), [ValidateCSSJSONXPATHInput()], default='')
+81 -21
View File
@@ -5,7 +5,8 @@ Two public entry points:
- run_setup(watch, datastore) — one-time: decide if pre-filter needed
- evaluate_change(watch, datastore, diff, current_snapshot) — per-change evaluation
Intent resolution: watch.llm_intent → first tag with llm_intent → None (no evaluation)
Intent resolution: watch.llm_intent → first tag with llm_intent whose AI switch is "on"
(see tag_llm_applies_to_watches) → None (no evaluation)
Cache: each (intent, diff) pair is evaluated exactly once, result stored in watch.
Environment variable overrides (take priority over datastore settings):
@@ -244,6 +245,70 @@ def resolve_llm_timeout(llm_cfg: dict) -> int:
# Intent resolution
# ---------------------------------------------------------------------------
# A group/tag has exactly one AI control (`llm_backend_profile`), and it is ternary:
#
# True — AI on for every watch in the group, using the group's AI settings
# (llm_intent / llm_change_summary cascade down to the watches)
# False — AI off for every watch in the group; its prompts are stored but never used
# None — the group has no opinion: each watch's own AI switch and prompts apply
#
# On a *watch* the same key is a plain bool (on/off, default on). One control per level, so
# there is nothing to reconcile between an "override?" flag and an "enabled?" flag.
def tag_llm_decision(tag):
"""This group's AI decision: True (on, use its settings), False (off), or None (no opinion)."""
if not tag:
return None
value = tag.get('llm_backend_profile')
return None if value is None else bool(value)
def tag_llm_applies_to_watches(tag) -> bool:
"""True when this group hands its AI settings down to its watches.
Only the "on" state does that: a group set to "off" suppresses AI for its watches rather
than lending them prompts, and a group with no opinion leaves them alone entirely. This is
the single gate behind both the evaluator cascade and the watch edit page's
"From group ..." placeholder.
"""
return tag_llm_decision(tag) is True
def _watch_tags(watch, datastore):
"""Yield this watch's tag dicts, in the watch's own tag order, skipping unknown UUIDs."""
for tag_uuid in watch.get('tags', []):
tag = datastore.data['settings']['application'].get('tags', {}).get(tag_uuid)
if tag:
yield tag
def _tags_applying_llm(watch, datastore):
"""Yield this watch's groups, in order, that hand their AI settings to their watches."""
for tag in _watch_tags(watch, datastore):
if tag_llm_applies_to_watches(tag):
yield tag
def llm_enabled_for_watch(watch, datastore) -> tuple[bool, str]:
"""Is automatic AI evaluation switched on for this watch? Returns (enabled, source).
See #4204 — users with hundreds of watches want AI on only a select few.
A group with an opinion decides for all of its watches ("the group setting overrides any
watch on/off"), so the first such group wins over the watch's own switch; groups set to
"leave it to each watch" are skipped. With no group deciding, the watch decides — and a
missing key means on, so watches predating this switch keep working.
Only gates *automatic* spend (the worker's intent/summary passes and the restock AI
plugin). Explicit user actions — the diff page "Summary" button, the intent preview —
stay available, since those cost tokens only when someone deliberately clicks.
"""
for tag in _watch_tags(watch, datastore):
decision = tag_llm_decision(tag)
if decision is not None:
return decision, tag.get('title', 'tag')
return bool(watch.get('llm_backend_profile', True)), 'watch'
def resolve_llm_field(watch, datastore, field: str) -> tuple[str, str]:
"""
Generic cascade resolver for any LLM per-watch field.
@@ -254,12 +319,10 @@ def resolve_llm_field(watch, datastore, field: str) -> tuple[str, str]:
if value:
return value, 'watch'
for tag_uuid in watch.get('tags', []):
tag = datastore.data['settings']['application'].get('tags', {}).get(tag_uuid)
if tag:
tag_value = (tag.get(field) or '').strip()
if tag_value:
return tag_value, tag.get('title', 'tag')
for tag in _tags_applying_llm(watch, datastore):
tag_value = (tag.get(field) or '').strip()
if tag_value:
return tag_value, tag.get('title', 'tag')
return '', ''
@@ -273,12 +336,10 @@ def resolve_intent(watch, datastore) -> tuple[str, str]:
if intent:
return intent, 'watch'
for tag_uuid in watch.get('tags', []):
tag = datastore.data['settings']['application'].get('tags', {}).get(tag_uuid)
if tag:
tag_intent = (tag.get('llm_intent') or '').strip()
if tag_intent:
return tag_intent, tag.get('title', 'tag')
for tag in _tags_applying_llm(watch, datastore):
tag_intent = (tag.get('llm_intent') or '').strip()
if tag_intent:
return tag_intent, tag.get('title', 'tag')
return '', ''
@@ -549,15 +610,14 @@ def run_setup(watch, datastore, snapshot_text: str) -> None:
def _first_tag_with_field(watch, datastore, field: str):
"""Return (value, tag) for the first linked tag with a non-empty `field`, else ('', None).
Same first-match-wins order as resolve_llm_field(); this variant also hands back the
tag itself so the caller can read sibling keys such as the prompt mode.
Same first-match-wins order as resolve_llm_field() (so only groups opted in via
tag_llm_applies_to_watches() count); this variant also hands back the tag itself so
the caller can read sibling keys such as the prompt mode.
"""
for tag_uuid in watch.get('tags', []):
tag = datastore.data['settings']['application'].get('tags', {}).get(tag_uuid)
if tag:
value = (tag.get(field) or '').strip()
if value:
return value, tag
for tag in _tags_applying_llm(watch, datastore):
value = (tag.get(field) or '').strip()
if value:
return value, tag
return '', None
+4
View File
@@ -46,6 +46,10 @@ class model(EntityPersistenceMixin, watch_base):
super(model, self).__init__(*arg, **kw)
self['overrides_watch'] = kw.get('default', {}).get('overrides_watch')
# Ternary on a tag, unlike the plain bool on a watch: None ("leave it to each watch")
# is the default, so a group never touches its watches' AI until explicitly set.
# See llm/evaluator.py:tag_llm_decision().
self['llm_backend_profile'] = kw.get('default', {}).get('llm_backend_profile', None)
self['url_match_pattern'] = kw.get('default', {}).get('url_match_pattern', '')
if kw.get('default'):
+9 -8
View File
@@ -184,16 +184,11 @@ class watch_base(dict):
'check_count': 0,
'check_unique_lines': False, # On change-detected, compare against all history if its something new
'consecutive_filter_failures': 0, # Every time the CSS/xPath filter cannot be located, reset when all is fine.
# LLM intent-based evaluation
'content-type': None,
'date_created': None,
'extract_lines_containing': [], # Keep only lines containing these substrings (plain text, case-insensitive)
'extract_text': [], # Extract text by regex after filters
# LLM intent-based evaluation
'llm_intent': '', # Plain-English description of what the user cares about (change filter)
'llm_change_summary': '', # Prompt for AI Change Summary — replaces {{ diff }} in notifications
'llm_change_summary_mode': 'replace', # 'replace' the inherited prompt, or 'append' to it
'llm_prefilter': None, # CSS selector derived at setup time (semantic only, e.g. "footer")
'llm_evaluation_cache': {}, # {sha256(intent+diff): {important, summary}} - evaluated once, cached
'fetch_backend': 'system', # plaintext, playwright etc
'fetch_time': 0.0,
'filter_failure_notification_send': strtobool(os.getenv('FILTER_FAILURE_NOTIFICATION_SEND_DEFAULT', 'True')),
@@ -202,16 +197,22 @@ class watch_base(dict):
'filter_text_replaced': True,
'follow_price_changes': True,
'has_ldjson_price_data': None,
'history_snapshot_max_length': None,
'headers': {}, # Extra headers to send
'ignore_text': [], # List of text to ignore when calculating the comparison checksum
'history_snapshot_max_length': None,
'ignore_status_codes': None,
'ignore_text': [], # List of text to ignore when calculating the comparison checksum
'in_stock_only': True, # Only trigger change on going to instock from out-of-stock
'include_filters': [],
'last_checked': 0,
'last_error': False,
'last_notification_error': None,
'last_viewed': 0, # history key value of the last viewed via the [diff] link
'llm_backend_profile': True, # @note - now its just a bool but in the near future we can select a LLM profile or 'off'/false
'llm_change_summary': '', # Prompt for AI Change Summary — replaces {{ diff }} in notifications
'llm_change_summary_mode': 'replace', # 'replace' the inherited prompt, or 'append' to it
'llm_evaluation_cache': {}, # {sha256(intent+diff): {important, summary}} - evaluated once, cached
'llm_intent': '', # Plain-English description of what the user cares about (change filter)
'llm_prefilter': None, # CSS selector derived at setup time (semantic only, e.g. "footer")
'method': 'GET',
'notification_alert_count': 0,
'notification_body': None,
@@ -506,8 +506,11 @@ class perform_site_check(difference_detection_processor):
# Try plugin override - plugins can decide if they support this fetcher
if fetcher_name:
logger.debug(f"Calling extra plugins for getting item price/availability (fetcher: {fetcher_name})")
from changedetectionio.llm.evaluator import resolve_intent
_llm_intent, _ = resolve_intent(watch, self.datastore)
from changedetectionio.llm.evaluator import llm_enabled_for_watch, resolve_intent
# AI off for this watch (or for its group) means no intent is handed to the
# LLM restock plugin, so it doesn't spend tokens here either — #4204.
_llm_on, _ = llm_enabled_for_watch(watch, self.datastore)
_llm_intent, _ = resolve_intent(watch, self.datastore) if _llm_on else ('', '')
plugin_availability = get_itemprop_availability_from_plugin(self.fetcher.content, fetcher_name, self.fetcher, watch.link, llm_intent=_llm_intent or None)
if plugin_availability:
+19
View File
@@ -179,6 +179,25 @@ function toggleOpacity(checkboxSelector, fieldSelector, inverted) {
checkbox.addEventListener('change', updateOpacity);
}
// Radio-group counterpart of toggleOpacity: fields are full opacity only while the named
// radio group sits on activeValue, otherwise greyed out. Used by the tag AI/LLM tab, where a
// ternary (On / Off / Leave it to each watch) decides whether the prompts below apply.
function toggleOpacityByRadioValue(radioName, activeValue, fieldSelector) {
const radios = document.querySelectorAll(`input[type="radio"][name="${radioName}"]`);
const fields = document.querySelectorAll(fieldSelector);
function updateOpacity() {
const active = Array.from(radios).some(radio => radio.checked && radio.value === activeValue);
fields.forEach(field => {
field.style.opacity = active ? 1 : 0.6;
});
}
// Initial setup
updateOpacity();
radios.forEach(radio => radio.addEventListener('change', updateOpacity));
}
function toggleVisibility(checkboxSelector, fieldSelector, inverted) {
const checkbox = document.querySelector(checkboxSelector);
const fields = document.querySelectorAll(fieldSelector);
@@ -1152,4 +1152,8 @@ header {
cursor: pointer;
width: 1.4rem; /*it's slightly more wider than square so default auto will trim it slightly */
}
}
textarea::placeholder {
white-space: pre-wrap;
}
File diff suppressed because one or more lines are too long
@@ -5,59 +5,130 @@
llm_configured — bool: LLM provider is configured in settings
form — the WTForms form (must have .llm_intent and .llm_change_summary fields)
Optional (watch edit only):
Group/tag edit only:
llm_group_edit — bool: True when rendering the tag/group edit page. This is the ONLY
way to tell the two contexts apart — the tags blueprint also passes
the tag dict as `watch`, so `watch` is truthy in both.
form.llm_backend_profile is ternary here (On / Off / Leave it to each
watch) and is the group's only AI control; on a watch it is a checkbox.
Watch edit only:
watch — the Watch object (for processor check and prefilter display)
llm_group_overrides — dict returned by _resolve_llm_group_overrides():
{'llm_intent': {'value': str, 'group_name': str} | None,
'llm_change_summary': {'value': str, 'group_name': str} | None}
Present only in watch edit context; absent in tag edit context.
'llm_change_summary': {'value': str, 'group_name': str} | None,
'llm_backend_profile': {'value': bool, 'group_name': str} | None}
The two prompt entries are non-None only when the watch has no own
value AND a linked group set to "On" has one — that is what puts
"From group '<name>': <value>" in the placeholder.
llm_backend_profile is non-None when a linked group has taken the
on/off decision (On or Off, not "leave it to each watch") — #4204.
Usage in watch edit (edit.html):
{% include "edit/include_llm_intent.html" %}
Usage in tag edit (edit-tag.html):
{% include "edit/include_llm_intent.html" %}
(watch is not set → tag mode: no processor check, no examples, different description)
Usage (both): {% include "edit/include_llm_intent.html" %}
#}
{% from '_helpers.html' import render_field %}
{% from '_helpers.html' import render_field, render_checkbox_field, render_ternary_field %}
{# Processor check only applies in watch-edit context (llm_group_overrides present). #}
{# In tag/group edit context the AI section is always visible. #}
{% set llm_group_mode = llm_group_edit|default(false) %}
{# Processor check only applies in watch-edit context. #}
{# In tag/group edit context the AI section is always visible. #}
{# Processors whose edit form carries the AI Intent / Change Summary fields (i.e. those whose
form inherits processor_text_json_diff_form). text_json_diff + restock_diff today. #}
{% if llm_group_overrides is defined %}
{% set show_ai_section = not watch.get('processor') or watch.get('processor') in ['text_json_diff', 'restock_diff'] %}
{% else %}
{% if llm_group_mode %}
{% set show_ai_section = true %}
{% else %}
{% set show_ai_section = not watch.get('processor') or watch.get('processor') in ['text_json_diff', 'restock_diff'] %}
{% endif %}
{# An unrendered switch is absent from the POST, and WTForms reads that as off. So whenever we
do NOT render the AI switch (no LLM provider configured, or a processor whose form has no AI
fields) we carry its saved state through in a hidden input — otherwise merely saving the page
would silently switch AI off. #}
{% if not (show_ai_section and llm_configured) %}
{% if llm_group_mode %}
{# Group: ternary, so preserve whichever of the three states it is in #}
{% if form.llm_backend_profile.data is not none %}<input type="hidden" name="llm_backend_profile" value="{{ 'true' if form.llm_backend_profile.data else 'false' }}">{% endif %}
{% elif form.llm_backend_profile.data %}
<input type="hidden" name="llm_backend_profile" value="y">
{% endif %}
{% endif %}
{% if show_ai_section %}
{# ── Configured: show the intent + summary fields ────────────────── #}
{% if llm_configured %}
<div class="border-fieldset" id="llm-intent-section">
<h3>&#x2728; {{ _('AI') }}</h3>
<div id="llm-intent-section">
{# — AI Change Intent — #}
<h4 style="margin: 0 0 0.3em 0;">{{ _('AI — Notify when…') }}</h4>
{% if llm_group_mode %}
{# The group's single AI control. "On" is also what makes the prompts below cascade to the
watches (tag_llm_applies_to_watches), so there is no second "does this override?" flag. #}
<div class="pure-control-group inline-radio" id="llm-ai-enabled-row">
{{ render_ternary_field(form.llm_backend_profile) }}
<span class="pure-form-message-inline">
{{ _('<strong>On</strong> &ndash; every watch in this group uses the AI settings below, unless it fills in its own. <strong>Off</strong> &ndash; no AI for any watch in this group. <strong>Leave it to each watch</strong> &ndash; this group has no say; each watch uses its own AI settings.')|safe }}
</span>
</div>
{# The prompts below only mean something in the "On" state, so grey them out otherwise — the
radio counterpart of the toggleOpacity cue used by #overrides_watch on the restock tab. #}
<script id="llm-group-opacity-toggle">
$(document).ready(function () {
toggleOpacityByRadioValue('llm_backend_profile', 'true', '#change-intent-notify-me-when, #change-summary');
});
</script>
{% else %}
{# Per-watch AI on/off (#4204). Resolution is global settings → group → watch, so a group that
has taken the decision (its AI setting is On or Off rather than "leave it to each watch")
owns this control: we disable it, show the state the group decided, and name the group.
The watch's own preference is not lost — because the field isn't user-writable in this
state, edit.py ignores whatever the POST says for it and keeps the stored value. #}
{% set profile_group = llm_group_overrides.llm_backend_profile if llm_group_overrides is defined else none %}
<div class="pure-control-group" id="llm-ai-enabled-row">
{# Only the checkbox is dimmed — the explanation of *why* has to stay readable. #}
<div{% if profile_group %} style="opacity: 0.6;"{% endif %}>
{% if profile_group %}
{# Show what the group decided, not this watch's now-inert own value. #}
{% set dummy = form.llm_backend_profile.__setattr__('checked', profile_group.value) %}
{{ render_checkbox_field(form.llm_backend_profile, disabled=True) }}
{% else %}
{{ render_checkbox_field(form.llm_backend_profile) }}
{% endif %}
</div>
{% if profile_group %}
{# The group name links to its edit page so the decision can be changed where it lives.
Built as escaped markup and passed into the sentence, so translators keep one string. #}
{%- set group_link -%}
<a href="{{ url_for('tags.form_tag_edit', uuid=profile_group.group_uuid) }}#ai-llm">{{ profile_group.group_name }}</a>
{%- endset -%}
<span class="pure-form-message-inline">
{% if profile_group.value %}
{{ _("Group %(name)s decides this: AI is ON for every watch in that group.", name=group_link) | safe }}
{% else %}
{{ _("Group %(name)s decides this: AI is OFF for every watch in that group.", name=group_link) | safe }}
{% endif %}
</span>
{% endif %}
</div>
{% endif %}
<div class="border-fieldset" id="change-intent-notify-me-when">
<p class="pure-form-message-inline" style="margin-top:0">
{% if watch is defined and watch %}
{% if not llm_group_mode %}
{{ _('Describe what you care about. The AI evaluates every detected change against this and only notifies you when it matches.') }}
{% else %}
{{ _('Set a change intent for all watches in this tag/group. Each watch can override with its own.') }}
{% endif %}
</p>
<div class="pure-control-group">
{% if watch is defined and watch and llm_group_overrides is defined and llm_group_overrides.llm_intent %}
{% if not llm_group_mode and llm_group_overrides is defined and llm_group_overrides.llm_intent %}
{% set intent_placeholder = _("From group '%(name)s': %(value)s", name=llm_group_overrides.llm_intent.group_name, value=llm_group_overrides.llm_intent.value) %}
{% elif watch is defined and watch %}
{% elif not llm_group_mode %}
{% set intent_placeholder = _('e.g. Alert me when the price drops below $300, or a new product is launched. Ignore footer and navigation changes.') %}
{% else %}
{% set intent_placeholder = _('e.g. Flag price changes or new product launches across all watches in this group') %}
{% endif %}
{{ render_field(form.llm_intent, placeholder=intent_placeholder, rows=5, class="pure-input-1") }}
</div>
{% if watch is defined and watch %}
{% if not llm_group_mode %}
<div class="pure-form-message-inline">
<strong>{{ _('Examples:') }}</strong>
<ul style="margin: 0.3em 0 0 1.2em; padding: 0;">
@@ -67,19 +138,19 @@
<li><em>{{ _('Only important if package versions change or a CVE is mentioned') }}</em></li>
</ul>
</div>
{% if watch.get('llm_prefilter') %}
{% if watch is defined and watch.get('llm_prefilter') %}
<div class="pure-form-message-inline" style="margin-top: 0.5em;">
<small>{{ _('AI pre-filter active: <code>%(filter)s</code> — narrows content scope before evaluation', filter=watch.get('llm_prefilter')|e) | safe }}</small>
</div>
{% endif %}
{% endif %}
<hr style="margin: 1.2em 0; border: none; border-top: 1px solid var(--color-border, #ddd);">
</div>
<div class="border-fieldset" id="change-summary">
{# — AI Change Summary — #}
<h4 style="margin: 0 0 0.3em 0;">{{ _('AI Change Summary') }}</h4>
<p class="pure-form-message-inline" style="margin-top:0">
{% if watch is defined and watch %}
{% if not llm_group_mode %}
{{ _('When a change is detected, the AI describes it according to your instructions and replaces <code>%(diff)s</code> in your notification. Use <code>%(raw_diff)s</code> if you still want the original diff.',
diff='{{diff}}', raw_diff='{{raw_diff}}') | safe }}
{% else %}
@@ -87,34 +158,30 @@
{% endif %}
</p>
<div class="pure-control-group">
{% if watch is defined and watch and llm_group_overrides is defined and llm_group_overrides.llm_change_summary %}
{% if not llm_group_mode and llm_group_overrides is defined and llm_group_overrides.llm_change_summary %}
{% set summary_placeholder = _("From group '%(name)s': %(value)s", name=llm_group_overrides.llm_change_summary.group_name, value=llm_group_overrides.llm_change_summary.value) %}
{% else %}
{% set summary_placeholder = form.llm_change_summary.render_kw['placeholder'] %}
{% endif %}
{{ render_field(form.llm_change_summary, placeholder=summary_placeholder, rows=5, class="pure-input-1") }}
</div>
<div style="margin-top: 0.3em;">
<a href="#" class="pure-button button-xsmall" onclick="var t=document.getElementById('llm_change_summary'); if(!t.value&amp;&amp;t.placeholder) t.value=t.placeholder; return false;">{{ _('Modify default prompt') }}</a>
</div>
<div class="pure-control-group" style="margin-top: 0.6em;">
<label>{{ form.llm_change_summary_mode.label.text }}</label>
<div>
{% for subfield in form.llm_change_summary_mode %}
<label class="pure-radio" style="display:block; font-weight:normal; margin-bottom:0.3em;">
{{ subfield() }} {{ subfield.label.text }}
</label>
{% endfor %}
<br>
<div class="inline-radio">
{{ render_field(form.llm_change_summary_mode) }}
<span class="pure-form-message-inline">
{% if not llm_group_mode %}
{{ _('Appending keeps the prompt inherited from the group or from global settings and adds your text after it, so later edits to that prompt still reach this watch.') }}
{% else %}
{{ _('Appending keeps the prompt inherited from global settings and adds your text after it, so later edits to that prompt still reach this group.') }}
{% endif %}
</span>
</div>
<span class="pure-form-message-inline">
{% if watch is defined and watch %}
{{ _('Appending keeps the prompt inherited from the group or from global settings and adds your text after it, so later edits to that prompt still reach this watch.') }}
{% else %}
{{ _('Appending keeps the prompt inherited from global settings and adds your text after it, so later edits to that prompt still reach this group.') }}
{% endif %}
</span>
</div>
{% if watch is defined and watch %}
{% if not llm_group_mode %}
<div class="pure-form-message-inline">
<strong>{{ _('Examples:') }}</strong>
<ul style="margin: 0.3em 0 0 1.2em; padding: 0;">
@@ -125,13 +192,14 @@
</div>
{% endif %}
</div>
</div>
{# ── Not configured: greyed-out prompt to configure ──────────────── #}
{% else %}
<div class="border-fieldset" id="llm-intent-section-disabled" style="opacity: 0.5;">
<h3>&#x2728; {{ _('AI') }}</h3>
<p>
{% if watch is defined and watch %}
{% if not llm_group_mode %}
{{ _('Configure an AI / LLM provider in <a href="%(url)s">Settings → AI / LLM</a> to enable AI Change Intent and AI Change Summary.',
url=url_for('settings.settings_page') + '#ai') | safe }}
{% else %}
+133 -12
View File
@@ -22,6 +22,19 @@ def _make_datastore(llm_cfg=None, tags=None):
return ds
def _make_tag(ai=True, **fields):
"""Build a tag dict.
`ai` is the group's single AI control (llm_backend_profile): True = on, and its AI
settings apply to its watches; False = off for every watch in the group; None = the
group has no say. Defaults to True because most cases here are about what a group set
to "On" does.
"""
tag = {'title': 'grp', 'llm_backend_profile': ai}
tag.update(fields)
return tag
def _make_watch(llm_intent='', llm_change_summary='', tags=None, uuid='test-uuid-1234'):
w = {}
w['llm_intent'] = llm_intent
@@ -43,7 +56,7 @@ class TestResolveIntent:
def test_watch_intent_takes_priority(self):
from changedetectionio.llm.evaluator import resolve_intent
tag = {'title': 'mygroup', 'llm_intent': 'group intent'}
tag = _make_tag(title='mygroup', llm_intent='group intent')
ds = _make_datastore(tags={'tag-1': tag})
watch = _make_watch(llm_intent='watch intent', tags=['tag-1'])
@@ -54,7 +67,7 @@ class TestResolveIntent:
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'}
tag = _make_tag(title='pricing-group', llm_intent='flag price drops')
ds = _make_datastore(tags={'tag-1': tag})
watch = _make_watch(llm_intent='', tags=['tag-1'])
@@ -73,10 +86,10 @@ class TestResolveIntent:
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)."""
"""An opted-in tag's intent propagates to every watch in the tag."""
from changedetectionio.llm.evaluator import resolve_intent
tag = {'title': 'job-board', 'llm_intent': 'new engineering jobs'}
tag = _make_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
@@ -103,6 +116,114 @@ class TestResolveIntent:
assert intent == ''
# ---------------------------------------------------------------------------
# The group's AI setting gates the whole cascade
# ---------------------------------------------------------------------------
class TestGroupAiSettingGatesTheCascade:
def test_only_the_on_state_hands_settings_down(self):
from changedetectionio.llm.evaluator import tag_llm_applies_to_watches
assert tag_llm_applies_to_watches(_make_tag(ai=True)) is True
# "Off" suppresses AI rather than lending prompts; "leave it to each watch" and tags
# predating the setting (and missing tags) hand nothing down either
assert tag_llm_applies_to_watches(_make_tag(ai=False)) is False
assert tag_llm_applies_to_watches(_make_tag(ai=None)) is False
assert tag_llm_applies_to_watches({'title': 'legacy'}) is False
assert tag_llm_applies_to_watches(None) is False
def test_intent_not_inherited_when_group_is_off(self):
from changedetectionio.llm.evaluator import resolve_intent
tag = _make_tag(ai=False, title='pricing-group', llm_intent='flag price drops')
ds = _make_datastore(tags={'tag-1': tag})
watch = _make_watch(llm_intent='', tags=['tag-1'])
assert resolve_intent(watch, ds) == ('', '')
def test_field_not_inherited_when_group_is_off(self):
from changedetectionio.llm.evaluator import resolve_llm_field
tag = _make_tag(ai=False, llm_change_summary='list new events')
ds = _make_datastore(tags={'t1': tag})
watch = _make_watch(llm_change_summary='', tags=['t1'])
assert resolve_llm_field(watch, ds, 'llm_change_summary') == ('', '')
def test_summary_prompt_not_inherited_when_group_is_off(self):
from changedetectionio.llm.evaluator import get_effective_summary_prompt
tag = _make_tag(ai=False, llm_change_summary='TAG')
ds = _make_datastore(llm_cfg={'change_summary_default': 'GLOBAL'}, tags={'t1': tag})
watch = _make_watch(llm_change_summary='', tags=['t1'])
assert get_effective_summary_prompt(watch, ds) == 'GLOBAL'
def test_first_group_set_to_on_wins_over_an_earlier_one_that_is_off(self):
"""A group that isn't "On" is skipped entirely, not treated as "found, but empty"."""
from changedetectionio.llm.evaluator import resolve_intent
ds = _make_datastore(tags={
'ignored': _make_tag(ai=False, title='ignored-group', llm_intent='IGNORED'),
'used': _make_tag(ai=True, title='used-group', llm_intent='USED'),
})
watch = _make_watch(llm_intent='', tags=['ignored', 'used'])
assert resolve_intent(watch, ds) == ('USED', 'used-group')
# ---------------------------------------------------------------------------
# llm_enabled_for_watch — per-watch / per-group AI on-off switch (#4204)
# ---------------------------------------------------------------------------
class TestLlmEnabledForWatch:
def test_enabled_by_default(self):
"""Watches predating the switch (no key at all) keep AI on."""
from changedetectionio.llm.evaluator import llm_enabled_for_watch
ds = _make_datastore()
assert llm_enabled_for_watch(_make_watch(), ds) == (True, 'watch')
def test_watch_switch_off(self):
from changedetectionio.llm.evaluator import llm_enabled_for_watch
ds = _make_datastore()
watch = _make_watch()
watch['llm_backend_profile'] = False
assert llm_enabled_for_watch(watch, ds) == (False, 'watch')
def test_group_switch_overrides_watch_off(self):
""""The group setting overrides any watch on/off" — group ON beats watch OFF."""
from changedetectionio.llm.evaluator import llm_enabled_for_watch
tag = _make_tag(ai=True, title='ai-group')
ds = _make_datastore(tags={'t1': tag})
watch = _make_watch(tags=['t1'])
watch['llm_backend_profile'] = False
assert llm_enabled_for_watch(watch, ds) == (True, 'ai-group')
def test_group_switch_overrides_watch_on(self):
from changedetectionio.llm.evaluator import llm_enabled_for_watch
tag = _make_tag(ai=False, title='no-ai-group')
ds = _make_datastore(tags={'t1': tag})
watch = _make_watch(tags=['t1'])
watch['llm_backend_profile'] = True
assert llm_enabled_for_watch(watch, ds) == (False, 'no-ai-group')
def test_group_leaving_it_to_each_watch_does_not_decide(self):
from changedetectionio.llm.evaluator import llm_enabled_for_watch
ds = _make_datastore(tags={'t1': _make_tag(ai=None, title='undecided-group')})
watch = _make_watch(tags=['t1'])
watch['llm_backend_profile'] = False
assert llm_enabled_for_watch(watch, ds) == (False, 'watch')
def test_group_without_the_key_leaves_it_to_the_watch(self):
"""Tags predating the setting behave like "leave it to each watch"."""
from changedetectionio.llm.evaluator import llm_enabled_for_watch
ds = _make_datastore(tags={'t1': {'title': 'legacy'}})
watch = _make_watch(tags=['t1'])
watch['llm_backend_profile'] = False
assert llm_enabled_for_watch(watch, ds) == (False, 'watch')
def test_first_deciding_group_wins(self):
"""An undecided group is skipped; the next group with an opinion decides."""
from changedetectionio.llm.evaluator import llm_enabled_for_watch
ds = _make_datastore(tags={
'a': _make_tag(ai=None, title='undecided-group'),
'b': _make_tag(ai=False, title='no-ai-group'),
})
watch = _make_watch(tags=['a', 'b'])
assert llm_enabled_for_watch(watch, ds) == (False, 'no-ai-group')
# ---------------------------------------------------------------------------
# get_llm_config
# ---------------------------------------------------------------------------
@@ -386,7 +507,7 @@ class TestTokenBudget:
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'}
tag = _make_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')
@@ -395,7 +516,7 @@ class TestResolveLlmField:
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'}
tag = _make_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')
@@ -413,7 +534,7 @@ class TestResolveLlmField:
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'}
tag = _make_tag(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')
@@ -469,7 +590,7 @@ class TestSummariseChange:
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'}
tag = _make_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',
@@ -552,7 +673,7 @@ class TestSummaryCacheKey:
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'}
tag = _make_tag(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'
@@ -592,7 +713,7 @@ class TestSummaryPromptAppendMode:
def test_watch_append_targets_the_tag_prompt_when_a_tag_supplies_one(self):
"""The watch appends to what it would otherwise have inherited — here the tag."""
from changedetectionio.llm.evaluator import get_effective_summary_prompt
tag = {'title': 'grp', 'llm_change_summary': 'TAG'}
tag = _make_tag(llm_change_summary='TAG')
ds = _make_datastore(llm_cfg={'change_summary_default': 'GLOBAL'}, tags={'t1': tag})
watch = _make_watch(llm_change_summary='WATCH', tags=['t1'])
watch['llm_change_summary_mode'] = 'append'
@@ -600,7 +721,7 @@ class TestSummaryPromptAppendMode:
def test_tag_and_watch_can_both_append_forming_a_chain(self):
from changedetectionio.llm.evaluator import get_effective_summary_prompt
tag = {'title': 'grp', 'llm_change_summary': 'TAG', 'llm_change_summary_mode': 'append'}
tag = _make_tag(llm_change_summary='TAG', llm_change_summary_mode='append')
ds = _make_datastore(llm_cfg={'change_summary_default': 'GLOBAL'}, tags={'t1': tag})
watch = _make_watch(llm_change_summary='WATCH', tags=['t1'])
watch['llm_change_summary_mode'] = 'append'
@@ -608,7 +729,7 @@ class TestSummaryPromptAppendMode:
def test_tag_appends_while_watch_replaces(self):
from changedetectionio.llm.evaluator import get_effective_summary_prompt
tag = {'title': 'grp', 'llm_change_summary': 'TAG', 'llm_change_summary_mode': 'append'}
tag = _make_tag(llm_change_summary='TAG', llm_change_summary_mode='append')
ds = _make_datastore(llm_cfg={'change_summary_default': 'GLOBAL'}, tags={'t1': tag})
watch = _make_watch(llm_change_summary='WATCH', tags=['t1'])
assert get_effective_summary_prompt(watch, ds) == 'WATCH'
@@ -75,9 +75,10 @@ def test_llm_change_summary_cascades_from_tag(
_set_response(datastore_path, HTML_V1)
test_url = url_for('test_endpoint', _external=True)
# Create a tag with llm_change_summary
# Create a tag with llm_change_summary, AI set to On so its watches inherit it
tag_uuid = ds.add_tag('events-group')
ds.data['settings']['application']['tags'][tag_uuid]['llm_change_summary'] = 'Summarise new events'
ds.data['settings']['application']['tags'][tag_uuid]['llm_backend_profile'] = True
# Watch in that tag, no own summary prompt
uuid = ds.add_watch(url=test_url)
@@ -281,6 +282,7 @@ def test_tag_prompt_overrides_global_default(
tag_uuid = ds.add_tag('my-group')
ds.data['settings']['application']['tags'][tag_uuid]['llm_change_summary'] = 'Tag: bullet points.'
ds.data['settings']['application']['tags'][tag_uuid]['llm_backend_profile'] = True
uuid = ds.add_watch(url='https://example.com')
watch = ds.data['watching'][uuid]
@@ -306,6 +308,7 @@ def test_watch_prompt_overrides_tag_and_global(
tag_uuid = ds.add_tag('my-group')
ds.data['settings']['application']['tags'][tag_uuid]['llm_change_summary'] = 'Tag prompt.'
ds.data['settings']['application']['tags'][tag_uuid]['llm_backend_profile'] = True
uuid = ds.add_watch(url='https://example.com')
watch = ds.data['watching'][uuid]
@@ -1,18 +1,27 @@
#!/usr/bin/env python3
"""
Tests for group/tag LLM field overrides on the watch edit page.
Tests for the AI/LLM settings a group hands to its watches.
When a watch's first linked tag has llm_intent or llm_change_summary set
and the watch itself has no own value, the watch edit form should render
the relevant textarea as readonly with a "From group '<name>': <value>"
placeholder.
A group has exactly ONE AI control on its edit page — the ternary llm_backend_profile
("AI for watches in this group"):
When the watch has its own value, the textarea is editable as normal.
On the group's llm_intent / llm_change_summary apply to every watch in
it (unless the watch fills in its own), and AI is on for all of them
Off AI off for every watch in the group; its prompts are never used
Leave it to each the group has no say; each watch's own AI settings apply
The evaluator cascade (resolve_llm_field) is already tested in the
evaluator unit tests; these tests focus on the UI and form behaviour.
Only "On" makes anything cascade, so it decides both:
* whether the evaluator inherits the group's prompts (resolve_llm_field /
get_effective_summary_prompt), and
* whether the watch edit page shows the inherited value as a
"From group '<name>': <value>" placeholder.
So every UI assertion here is paired: group On → "From group ..." visible, group Off or
undecided → not a trace of it. On a watch the same field is a plain on/off checkbox (#4204).
"""
import html
import json
from flask import url_for
@@ -20,6 +29,42 @@ from flask import url_for
from changedetectionio.tests.util import live_server_setup, delete_all_watches
# The exact rendered string under test, from templates/edit/include_llm_intent.html:
# {% set intent_placeholder = _("From group '%(name)s': %(value)s", ...) %}
def _from_group_text(name, value):
return f"From group '{name}': {value}"
def _page_text(res):
"""Response body with HTML entities resolved, so we can match the placeholder as written."""
return html.unescape(res.data.decode('utf-8', errors='replace'))
def _input_tags(body, name):
"""Every whole <input ...> tag carrying name="<name>", in document order."""
tags = []
pos = body.find(f'name="{name}"')
while pos != -1:
start = body.rfind('<input', 0, pos)
end = body.find('>', pos)
tags.append(body[start:end + 1])
pos = body.find(f'name="{name}"', end)
return tags
def _input_tag(body, name):
"""Return the first <input ...> tag carrying name="<name>", or '' if there isn't one."""
tags = _input_tags(body, name)
return tags[0] if tags else ''
def _checkbox_is_checked(body, name):
"""True when that checkbox renders as checked (attribute order is not guaranteed)."""
tag = _input_tag(body, name)
assert tag, f'no <input name="{name}"> in the page'
return 'checked' in tag
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
@@ -51,14 +96,20 @@ def _api_token(client):
# Tag setup
# ---------------------------------------------------------------------------
def _add_tag_with_llm(datastore, title, llm_intent='', llm_change_summary=''):
"""Create a tag with LLM fields set directly in the datastore."""
def _add_tag_with_llm(datastore, title, llm_intent='', llm_change_summary='', ai=True):
"""Create a tag with LLM fields set directly in the datastore.
`ai` is the group's single AI control (llm_backend_profile): True = On, False = Off,
None = leave it to each watch. Defaults to True because most cases here are about what
a group set to "On" hands down.
"""
tag_uuid = datastore.add_tag(title)
tag = datastore.data['settings']['application']['tags'][tag_uuid]
if llm_intent:
tag['llm_intent'] = llm_intent
if llm_change_summary:
tag['llm_change_summary'] = llm_change_summary
tag['llm_backend_profile'] = ai
return tag_uuid
@@ -72,16 +123,123 @@ def _link_watch_to_tag(datastore, watch_uuid, tag_uuid):
# ---------------------------------------------------------------------------
# Watch edit page — llm_intent group override
# The group's one AI control — ternary on a group, checkbox on a watch
# ---------------------------------------------------------------------------
def test_watch_edit_shows_llm_intent_placeholder_from_group(
def test_group_edit_page_has_the_ternary_ai_control(
client, live_server, measure_memory_usage, datastore_path):
"""
When a watch has no own llm_intent but its first tag does,
the edit page must show "From group" + group name + group value in the
placeholder so the user sees the inherited value but can still type to override.
The field must NOT be readonly.
The group edit page must offer all three states — without them there is no way to turn
group-wide AI settings on, or to switch AI off for a whole group (#4204).
"""
ds = client.application.config.get('DATASTORE')
_configure_llm(ds)
tag_uuid = ds.add_tag('Ternary Group')
res = client.get(url_for('tags.form_tag_edit', uuid=tag_uuid))
assert res.status_code == 200
body = res.data.decode('utf-8', errors='replace')
text = _page_text(res)
assert 'name="llm_backend_profile"' in body, \
"group edit page is missing the 'AI for watches in this group' control"
for value in ('true', 'false', 'none'):
assert f'name="llm_backend_profile" value="{value}"' in body, \
f"group AI control is missing its '{value}' option"
assert 'AI for watches in this group' in text
assert 'Leave it to each watch' in text
# New groups start undecided, so they never touch their watches
assert 'id="llm_backend_profile_none" checked' in body, \
"a new group must default to 'Leave it to each watch'"
delete_all_watches(client)
def test_watch_edit_page_has_a_plain_ai_checkbox(
client, live_server, measure_memory_usage, datastore_path):
"""A watch gets a simple on/off, not the group's three-way choice."""
ds = client.application.config.get('DATASTORE')
_configure_llm(ds)
api_token = _api_token(client)
watch_uuid = _create_watch(client, url_for('test_endpoint', _external=True), api_token)
res = client.get(url_for('ui.ui_edit.edit_page', uuid=watch_uuid))
assert res.status_code == 200
body = res.data.decode('utf-8', errors='replace')
assert 'name="llm_intent"' in body # AI section is rendered...
assert 'type="checkbox"' in _input_tag(body, 'llm_backend_profile')
assert 'Leave it to each watch' not in _page_text(res), \
"the group's three-way AI choice must not appear on a watch"
delete_all_watches(client)
def test_group_edit_page_never_shows_the_from_group_placeholder(
client, live_server, measure_memory_usage, datastore_path):
"""
"From group ..." describes something inherited by a watch; the group edit page is
where the value is authored, so it must show group-flavoured copy instead.
"""
ds = client.application.config.get('DATASTORE')
_configure_llm(ds)
tag_uuid = _add_tag_with_llm(ds, 'Authoring Group', llm_intent='Group intent value')
res = client.get(url_for('tags.form_tag_edit', uuid=tag_uuid))
assert res.status_code == 200
text = _page_text(res)
assert 'From group' not in text
# Group copy, not the per-watch copy (both live in the same shared include)
assert 'Set a change intent for all watches in this tag/group' in text
assert 'Describe what you care about' not in text
delete_all_watches(client)
def test_group_edit_form_saves_and_reloads_each_ai_state(
client, live_server, measure_memory_usage, datastore_path):
"""All three states round-trip through the real form, and the prompt is kept regardless."""
res = client.post(url_for('tags.form_tag_add'), data={'name': 'Saved Group'}, follow_redirects=True)
assert b'Tag added' in res.data
ds = client.application.config.get('DATASTORE')
_configure_llm(ds)
tag_uuid = list(ds.data['settings']['application']['tags'].keys())[0]
for posted, expected in (('true', True), ('false', False), ('none', None)):
res = client.post(
url_for('tags.form_tag_edit_submit', uuid=tag_uuid),
data={'title': 'Saved Group',
'llm_intent': 'Only notify me about price drops',
'llm_backend_profile': posted},
follow_redirects=True,
)
assert b'Updated' in res.data
tag = ds.data['settings']['application']['tags'][tag_uuid]
assert tag.get('llm_backend_profile') is expected, f"posting {posted!r} should store {expected!r}"
# The prompt is always kept — the AI state only decides whether it is used
assert tag.get('llm_intent') == 'Only notify me about price drops'
# ..and the reloaded page comes back on the same option
body = client.get(url_for('tags.form_tag_edit', uuid=tag_uuid)).data.decode('utf-8', errors='replace')
assert f'id="llm_backend_profile_{posted}" checked' in body, \
f"saved state {posted!r} must render as the selected option"
delete_all_watches(client)
# ---------------------------------------------------------------------------
# Watch edit page — llm_intent group override, gated on the checkbox
# ---------------------------------------------------------------------------
def test_watch_edit_shows_llm_intent_placeholder_when_group_overrides_enabled(
client, live_server, measure_memory_usage, datastore_path):
"""
Group override ON + watch has no own llm_intent → the edit page shows
"From group '<name>': <value>" as the placeholder, and the field stays editable.
"""
ds = client.application.config.get('DATASTORE')
_configure_llm(ds)
@@ -89,34 +247,64 @@ def test_watch_edit_shows_llm_intent_placeholder_from_group(
test_url = url_for('test_endpoint', _external=True)
watch_uuid = _create_watch(client, test_url, api_token)
tag_uuid = _add_tag_with_llm(ds, 'Price Watchers', llm_intent='Notify only when price drops')
tag_uuid = _add_tag_with_llm(ds, 'Price Watchers',
llm_intent='Notify only when price drops',
ai=True)
_link_watch_to_tag(ds, watch_uuid, tag_uuid)
res = client.get(url_for('ui.ui_edit.edit_page', uuid=watch_uuid))
assert res.status_code == 200
body = res.data.decode('utf-8', errors='replace')
text = _page_text(res)
assert 'name="llm_intent"' in body
# Placeholder must contain "From group", the tag name, and the value
assert 'From group' in body
assert 'Price Watchers' in body
assert 'Notify only when price drops' in body
assert 'name="llm_intent"' in text
assert _from_group_text('Price Watchers', 'Notify only when price drops') in text, \
"watch edit must show the inherited group intent as a 'From group ...' placeholder"
# Field must be editable — no readonly attribute
intent_pos = body.find('name="llm_intent"')
snippet = body[max(0, intent_pos - 50): intent_pos + 300]
intent_pos = text.find('name="llm_intent"')
snippet = text[max(0, intent_pos - 50): intent_pos + 300]
assert 'readonly' not in snippet, \
f"llm_intent must be editable when group sets it; snippet: {snippet!r}"
delete_all_watches(client)
def test_watch_edit_hides_llm_intent_placeholder_when_group_overrides_disabled(
client, live_server, measure_memory_usage, datastore_path):
"""
Same group, same intent, checkbox OFF → no "From group ..." anywhere, and the
generic example placeholder is used instead. This is the pairing that makes the
checkbox meaningful.
"""
ds = client.application.config.get('DATASTORE')
_configure_llm(ds)
api_token = _api_token(client)
test_url = url_for('test_endpoint', _external=True)
watch_uuid = _create_watch(client, test_url, api_token)
tag_uuid = _add_tag_with_llm(ds, 'Price Watchers',
llm_intent='Notify only when price drops',
ai=False)
_link_watch_to_tag(ds, watch_uuid, tag_uuid)
res = client.get(url_for('ui.ui_edit.edit_page', uuid=watch_uuid))
assert res.status_code == 200
text = _page_text(res)
assert 'From group' not in text, \
"group AI settings must not leak into the watch unless the group is set to On"
assert 'Notify only when price drops' not in text
# Falls back to the normal per-watch example placeholder
assert 'e.g. Alert me when the price drops below $300' in text
delete_all_watches(client)
def test_watch_edit_llm_intent_shows_own_value_not_group_placeholder(
client, live_server, measure_memory_usage, datastore_path):
"""
When a watch has its own llm_intent, the textarea body shows the watch's value
and the placeholder does NOT say "From group" (the group value is irrelevant).
When the watch has its own llm_intent, the textarea body shows the watch's value
and the placeholder does NOT say "From group" — even with the group opted in.
"""
ds = client.application.config.get('DATASTORE')
_configure_llm(ds)
@@ -124,33 +312,32 @@ def test_watch_edit_llm_intent_shows_own_value_not_group_placeholder(
test_url = url_for('test_endpoint', _external=True)
watch_uuid = _create_watch(client, test_url, api_token)
tag_uuid = _add_tag_with_llm(ds, 'Deals Group', llm_intent='Tag intent: notify on any deal')
tag_uuid = _add_tag_with_llm(ds, 'Deals Group',
llm_intent='Tag intent: notify on any deal',
ai=True)
_link_watch_to_tag(ds, watch_uuid, tag_uuid)
ds.data['watching'][watch_uuid]['llm_intent'] = 'My own watch intent'
res = client.get(url_for('ui.ui_edit.edit_page', uuid=watch_uuid))
assert res.status_code == 200
body = res.data.decode('utf-8', errors='replace')
text = _page_text(res)
# Watch's own value in the textarea body
assert 'My own watch intent' in body
assert 'My own watch intent' in text
# No group placeholder — the watch has its own value
assert 'From group' not in body
assert 'From group' not in text
delete_all_watches(client)
# ---------------------------------------------------------------------------
# Watch edit page — llm_change_summary group override
# Watch edit page — llm_change_summary group override, gated on the checkbox
# ---------------------------------------------------------------------------
def test_watch_edit_shows_llm_change_summary_placeholder_from_group(
def test_watch_edit_shows_llm_change_summary_placeholder_when_group_overrides_enabled(
client, live_server, measure_memory_usage, datastore_path):
"""
When a watch has no own llm_change_summary but its first tag does,
the edit page shows the group value as placeholder (editable, not readonly).
"""
"""Group override ON → the group summary prompt shows as placeholder (editable)."""
ds = client.application.config.get('DATASTORE')
_configure_llm(ds)
api_token = _api_token(client)
@@ -159,31 +346,58 @@ def test_watch_edit_shows_llm_change_summary_placeholder_from_group(
watch_uuid = _create_watch(client, test_url, api_token)
tag_uuid = _add_tag_with_llm(
ds, 'Summary Group',
llm_change_summary='List new items as bullet points. Translate to English.'
llm_change_summary='List new items as bullet points. Translate to English.',
ai=True,
)
_link_watch_to_tag(ds, watch_uuid, tag_uuid)
res = client.get(url_for('ui.ui_edit.edit_page', uuid=watch_uuid))
assert res.status_code == 200
body = res.data.decode('utf-8', errors='replace')
text = _page_text(res)
assert 'Summary Group' in body
assert 'List new items as bullet points' in body
assert _from_group_text('Summary Group',
'List new items as bullet points. Translate to English.') in text
# Field must be editable
summary_pos = body.find('name="llm_change_summary"')
summary_pos = text.find('name="llm_change_summary"')
assert summary_pos != -1
snippet = body[max(0, summary_pos - 50): summary_pos + 300]
snippet = text[max(0, summary_pos - 50): summary_pos + 300]
assert 'readonly' not in snippet, \
f"llm_change_summary must be editable; snippet: {snippet!r}"
delete_all_watches(client)
def test_watch_edit_hides_llm_change_summary_placeholder_when_group_overrides_disabled(
client, live_server, measure_memory_usage, datastore_path):
"""Group override OFF → no "From group ..." for the summary prompt either."""
ds = client.application.config.get('DATASTORE')
_configure_llm(ds)
api_token = _api_token(client)
test_url = url_for('test_endpoint', _external=True)
watch_uuid = _create_watch(client, test_url, api_token)
tag_uuid = _add_tag_with_llm(
ds, 'Summary Group',
llm_change_summary='List new items as bullet points. Translate to English.',
ai=False,
)
_link_watch_to_tag(ds, watch_uuid, tag_uuid)
res = client.get(url_for('ui.ui_edit.edit_page', uuid=watch_uuid))
assert res.status_code == 200
text = _page_text(res)
assert 'From group' not in text
assert 'List new items as bullet points' not in text
delete_all_watches(client)
def test_watch_edit_llm_change_summary_shows_own_value_not_group_placeholder(
client, live_server, measure_memory_usage, datastore_path):
"""
When a watch has its own llm_change_summary, the textarea body shows the watch's
When the watch has its own llm_change_summary, the textarea body shows the watch's
value and no group placeholder appears.
"""
ds = client.application.config.get('DATASTORE')
@@ -192,17 +406,18 @@ def test_watch_edit_llm_change_summary_shows_own_value_not_group_placeholder(
test_url = url_for('test_endpoint', _external=True)
watch_uuid = _create_watch(client, test_url, api_token)
tag_uuid = _add_tag_with_llm(ds, 'Summary Group', llm_change_summary='Tag summary prompt')
tag_uuid = _add_tag_with_llm(ds, 'Summary Group', llm_change_summary='Tag summary prompt',
ai=True)
_link_watch_to_tag(ds, watch_uuid, tag_uuid)
ds.data['watching'][watch_uuid]['llm_change_summary'] = 'My own summary prompt'
res = client.get(url_for('ui.ui_edit.edit_page', uuid=watch_uuid))
assert res.status_code == 200
body = res.data.decode('utf-8', errors='replace')
text = _page_text(res)
assert 'My own summary prompt' in body
assert 'From group' not in body
assert 'My own summary prompt' in text
assert 'From group' not in text
delete_all_watches(client)
@@ -230,8 +445,7 @@ def test_watch_edit_no_tag_fields_are_editable(
# Neither textarea should be readonly
for field in ('llm_intent', 'llm_change_summary'):
pos = body.find(f'name="{field}"')
if pos == -1:
continue # field might not render if LLM section hidden for some reason
assert pos != -1, f"{field} textarea missing from watch edit page"
snippet = body[max(0, pos - 50): pos + 300]
assert 'readonly' not in snippet, \
f"{field} textarea must not be readonly with no tags; snippet: {snippet!r}"
@@ -242,14 +456,14 @@ def test_watch_edit_no_tag_fields_are_editable(
# ---------------------------------------------------------------------------
# Evaluator cascade — group value used when watch has none
# Evaluator cascade — gated on the same checkbox as the UI
# ---------------------------------------------------------------------------
def test_resolve_llm_field_uses_tag_value_when_watch_has_none(
def test_resolve_llm_field_uses_tag_value_when_group_overrides_enabled(
client, live_server, measure_memory_usage, datastore_path):
"""
resolve_llm_field returns the tag's value (and tag name as source) when
the watch has no own value.
resolve_llm_field returns the tag's value (and tag name as source) when the watch
has no own value and the group is opted in.
"""
from changedetectionio.llm.evaluator import resolve_llm_field
@@ -258,7 +472,8 @@ def test_resolve_llm_field_uses_tag_value_when_watch_has_none(
test_url = url_for('test_endpoint', _external=True)
watch_uuid = _create_watch(client, test_url, api_token)
tag_uuid = _add_tag_with_llm(ds, 'Cascade Group', llm_intent='Group-level intent')
tag_uuid = _add_tag_with_llm(ds, 'Cascade Group', llm_intent='Group-level intent',
ai=True)
_link_watch_to_tag(ds, watch_uuid, tag_uuid)
watch = ds.data['watching'][watch_uuid]
@@ -270,10 +485,33 @@ def test_resolve_llm_field_uses_tag_value_when_watch_has_none(
delete_all_watches(client)
def test_resolve_llm_field_ignores_tag_value_when_group_overrides_disabled(
client, live_server, measure_memory_usage, datastore_path):
"""The UI hint and the evaluator agree: no opt-in, no inheritance."""
from changedetectionio.llm.evaluator import resolve_llm_field
ds = client.application.config.get('DATASTORE')
api_token = _api_token(client)
test_url = url_for('test_endpoint', _external=True)
watch_uuid = _create_watch(client, test_url, api_token)
tag_uuid = _add_tag_with_llm(ds, 'Cascade Group', llm_intent='Group-level intent',
ai=False)
_link_watch_to_tag(ds, watch_uuid, tag_uuid)
watch = ds.data['watching'][watch_uuid]
value, source = resolve_llm_field(watch, ds, 'llm_intent')
assert value == ''
assert source == ''
delete_all_watches(client)
def test_resolve_llm_field_uses_watch_value_over_tag(
client, live_server, measure_memory_usage, datastore_path):
"""
resolve_llm_field prefers the watch's own value over the tag's.
resolve_llm_field prefers the watch's own value over the tag's, opted in or not.
"""
from changedetectionio.llm.evaluator import resolve_llm_field
@@ -282,7 +520,8 @@ def test_resolve_llm_field_uses_watch_value_over_tag(
test_url = url_for('test_endpoint', _external=True)
watch_uuid = _create_watch(client, test_url, api_token)
tag_uuid = _add_tag_with_llm(ds, 'Override Group', llm_intent='Tag intent')
tag_uuid = _add_tag_with_llm(ds, 'Override Group', llm_intent='Tag intent',
ai=True)
_link_watch_to_tag(ds, watch_uuid, tag_uuid)
ds.data['watching'][watch_uuid]['llm_intent'] = 'Watch-level intent'
@@ -303,8 +542,8 @@ def test_resolve_llm_field_uses_watch_value_over_tag(
def test_watch_edit_independent_field_overrides(
client, live_server, measure_memory_usage, datastore_path):
"""
llm_intent can come from a group (readonly) while llm_change_summary
is editable (watch has its own), and vice versa.
llm_intent can be inherited from an opted-in group while llm_change_summary
is the watch's own value.
"""
ds = client.application.config.get('DATASTORE')
_configure_llm(ds)
@@ -316,6 +555,7 @@ def test_watch_edit_independent_field_overrides(
ds, 'Mixed Group',
llm_intent='Group intent here',
llm_change_summary='Group summary here',
ai=True,
)
_link_watch_to_tag(ds, watch_uuid, tag_uuid)
@@ -324,21 +564,22 @@ def test_watch_edit_independent_field_overrides(
res = client.get(url_for('ui.ui_edit.edit_page', uuid=watch_uuid))
assert res.status_code == 200
body = res.data.decode('utf-8', errors='replace')
text = _page_text(res)
# llm_intent: group placeholder visible (watch has no own value)
assert 'Group intent here' in body
intent_pos = body.find('name="llm_intent"')
assert _from_group_text('Mixed Group', 'Group intent here') in text
intent_pos = text.find('name="llm_intent"')
assert intent_pos != -1
intent_snippet = body[max(0, intent_pos - 50): intent_pos + 300]
intent_snippet = text[max(0, intent_pos - 50): intent_pos + 300]
assert 'readonly' not in intent_snippet, \
f"llm_intent must be editable even when group sets it; snippet: {intent_snippet!r}"
# llm_change_summary: watch own value shown in body, no group placeholder
assert 'My own summary' in body
summary_pos = body.find('name="llm_change_summary"')
# llm_change_summary: watch own value shown in body, no group placeholder for it
assert 'My own summary' in text
assert _from_group_text('Mixed Group', 'Group summary here') not in text
summary_pos = text.find('name="llm_change_summary"')
assert summary_pos != -1
summary_snippet = body[max(0, summary_pos - 50): summary_pos + 300]
summary_snippet = text[max(0, summary_pos - 50): summary_pos + 300]
assert 'readonly' not in summary_snippet, \
f"llm_change_summary should be editable; snippet: {summary_snippet!r}"
@@ -409,7 +650,7 @@ def test_tag_edit_page_shows_prompt_mode_radio(
def test_tag_append_mode_persists_and_applies(
client, live_server, measure_memory_usage, datastore_path):
"""A group set to append adds its text to the global prompt for its watches."""
"""An opted-in group set to append adds its text to the global prompt for its watches."""
from changedetectionio.llm.evaluator import get_effective_summary_prompt
ds = client.application.config.get('DATASTORE')
@@ -420,7 +661,8 @@ def test_tag_append_mode_persists_and_applies(
test_url = url_for('test_endpoint', _external=True)
watch_uuid = _create_watch(client, test_url, api_token)
tag_uuid = _add_tag_with_llm(ds, 'Append Group', llm_change_summary='Group extra line.')
tag_uuid = _add_tag_with_llm(ds, 'Append Group', llm_change_summary='Group extra line.',
ai=True)
ds.data['settings']['application']['tags'][tag_uuid]['llm_change_summary_mode'] = 'append'
_link_watch_to_tag(ds, watch_uuid, tag_uuid)
@@ -428,3 +670,348 @@ def test_tag_append_mode_persists_and_applies(
assert get_effective_summary_prompt(watch, ds) == 'GLOBAL RULES\n\nGroup extra line.'
delete_all_watches(client)
def test_tag_append_mode_ignored_when_group_overrides_disabled(
client, live_server, measure_memory_usage, datastore_path):
"""Without the opt-in the group's appended text never reaches the effective prompt."""
from changedetectionio.llm.evaluator import get_effective_summary_prompt
ds = client.application.config.get('DATASTORE')
_configure_llm(ds)
ds.data['settings']['application']['llm']['change_summary_default'] = 'GLOBAL RULES'
api_token = _api_token(client)
test_url = url_for('test_endpoint', _external=True)
watch_uuid = _create_watch(client, test_url, api_token)
tag_uuid = _add_tag_with_llm(ds, 'Append Group', llm_change_summary='Group extra line.',
ai=False)
ds.data['settings']['application']['tags'][tag_uuid]['llm_change_summary_mode'] = 'append'
_link_watch_to_tag(ds, watch_uuid, tag_uuid)
watch = ds.data['watching'][watch_uuid]
assert get_effective_summary_prompt(watch, ds) == 'GLOBAL RULES'
delete_all_watches(client)
# ---------------------------------------------------------------------------
# AI on/off per watch, and per group when the group overrides — #4204
# ---------------------------------------------------------------------------
def test_watch_edit_has_ai_enabled_checkbox(
client, live_server, measure_memory_usage, datastore_path):
"""Every watch gets its own AI on/off switch, on by default."""
ds = client.application.config.get('DATASTORE')
_configure_llm(ds)
api_token = _api_token(client)
watch_uuid = _create_watch(client, url_for('test_endpoint', _external=True), api_token)
res = client.get(url_for('ui.ui_edit.edit_page', uuid=watch_uuid))
body = res.data.decode('utf-8', errors='replace')
assert 'name="llm_backend_profile"' in body, \
"watch edit page is missing the AI on/off checkbox (#4204)"
assert _checkbox_is_checked(body, 'llm_backend_profile'), \
"AI should default to on for a new watch"
# No group involved, so no override note
assert 'overrides this' not in _page_text(res)
delete_all_watches(client)
def test_group_edit_can_switch_ai_off_for_the_whole_group(
client, live_server, measure_memory_usage, datastore_path):
"""The group's control has an explicit "Off for every watch" state — the #4204 ask."""
ds = client.application.config.get('DATASTORE')
_configure_llm(ds)
tag_uuid = ds.add_tag('AI Toggle Group')
res = client.get(url_for('tags.form_tag_edit', uuid=tag_uuid))
assert 'Off for every watch' in _page_text(res), \
"group edit page cannot switch AI off for all of its watches (#4204)"
delete_all_watches(client)
def test_group_edit_greys_out_the_prompts_unless_ai_is_on(
client, live_server, measure_memory_usage, datastore_path):
"""
Same cue as the restock group override (#overrides_watch + toggleOpacity): the prompts
only mean something in the "On" state, so they are greyed out otherwise. The state
changes without a reload, so this pins the JS wiring rather than the opacity value.
"""
ds = client.application.config.get('DATASTORE')
_configure_llm(ds)
tag_uuid = ds.add_tag('Dimmed Group')
res = client.get(url_for('tags.form_tag_edit', uuid=tag_uuid))
body = res.data.decode('utf-8', errors='replace')
assert "toggleOpacityByRadioValue('llm_backend_profile', 'true'" in body, \
"group edit page lost the wiring that greys out the AI prompts"
# ..and the elements it drives are all present
for element_id in ('llm_backend_profile_true', 'change-intent-notify-me-when', 'change-summary'):
assert f'id="{element_id}"' in body, f"#{element_id} missing — opacity toggle would be a no-op"
delete_all_watches(client)
def test_watch_edit_shows_which_group_decided_the_ai_switch(
client, live_server, measure_memory_usage, datastore_path):
"""
A group that has taken the decision decides for its watches, so the watch edit page says
which group is in charge and what it decided — and that explanation must stay readable
(only the checkbox it describes is dimmed).
"""
ds = client.application.config.get('DATASTORE')
_configure_llm(ds)
api_token = _api_token(client)
watch_uuid = _create_watch(client, url_for('test_endpoint', _external=True), api_token)
tag_uuid = _add_tag_with_llm(ds, 'Tech news', ai=False)
_link_watch_to_tag(ds, watch_uuid, tag_uuid)
res = client.get(url_for('ui.ui_edit.edit_page', uuid=watch_uuid))
text = _page_text(res)
assert 'decides this: AI is OFF for every watch in that group.' in text
# The group name links to that group's edit page, straight to its AI tab
tag_edit_url = url_for('tags.form_tag_edit', uuid=tag_uuid)
assert f'<a href="{tag_edit_url}#ai-llm">Tech news</a>' in text, \
"the group name in the note must link to the group's edit page"
# The note itself is not inside the dimmed wrapper
note_pos = text.find('decides this: AI is OFF')
dimmed_pos = text.find('style="opacity: 0.6;"', text.find('id="llm-ai-enabled-row"'))
assert dimmed_pos != -1, "the overridden checkbox should be dimmed"
assert text.find('</div>', dimmed_pos) < note_pos, \
"the 'Group X decides this' note must not be greyed out with the checkbox"
# Group switched to On → the note reflects that, still linked
ds.data['settings']['application']['tags'][tag_uuid]['llm_backend_profile'] = True
text = _page_text(client.get(url_for('ui.ui_edit.edit_page', uuid=watch_uuid)))
assert 'decides this: AI is ON for every watch in that group.' in text
assert f'<a href="{tag_edit_url}#ai-llm">Tech news</a>' in text
# ..and the watch's own checkbox is disabled, showing what the group decided
body = client.get(url_for('ui.ui_edit.edit_page', uuid=watch_uuid)).data.decode('utf-8', errors='replace')
checkbox = _input_tags(body, 'llm_backend_profile')[0]
assert 'disabled' in checkbox, "the group decides, so the watch's own checkbox must be disabled"
assert 'checked' in checkbox, "disabled checkbox must show the state the group decided (ON)"
ds.data['settings']['application']['tags'][tag_uuid]['llm_backend_profile'] = False
body = client.get(url_for('ui.ui_edit.edit_page', uuid=watch_uuid)).data.decode('utf-8', errors='replace')
checkbox = _input_tags(body, 'llm_backend_profile')[0]
assert 'disabled' in checkbox and 'checked' not in checkbox, \
"disabled checkbox must show the state the group decided (OFF)"
# ..and with the group leaving it to each watch, the watch is on its own again
ds.data['settings']['application']['tags'][tag_uuid]['llm_backend_profile'] = None
res = client.get(url_for('ui.ui_edit.edit_page', uuid=watch_uuid))
assert 'decides this' not in _page_text(res)
delete_all_watches(client)
def test_watch_own_ai_switch_survives_being_overridden_by_a_group(
client, live_server, measure_memory_usage, datastore_path):
"""
While a group decides, the watch's checkbox is disabled — and a disabled checkbox is not
POSTed, which for a checkbox reads as "off". Saving the watch must therefore NOT quietly
rewrite its own preference: it has to come back unchanged once the group stops deciding.
"""
from changedetectionio.llm.evaluator import llm_enabled_for_watch
ds = client.application.config.get('DATASTORE')
_configure_llm(ds)
api_token = _api_token(client)
test_url = url_for('test_endpoint', _external=True)
watch_uuid = _create_watch(client, test_url, api_token)
# Watch says AI on (the default); the group overrules it with "off"
tag_uuid = _add_tag_with_llm(ds, 'Deciding Group', ai=False)
_link_watch_to_tag(ds, watch_uuid, tag_uuid)
assert ds.data['watching'][watch_uuid].get('llm_backend_profile') is True
assert llm_enabled_for_watch(ds.data['watching'][watch_uuid], ds) == (False, 'Deciding Group')
# Save the page exactly as the browser would: the disabled checkbox sends nothing at all
res = client.post(
url_for('ui.ui_edit.edit_page', uuid=watch_uuid),
data={'url': test_url, 'fetch_backend': 'html_requests',
'time_between_check_use_default': 'y'},
follow_redirects=True,
)
assert b'Updated watch' in res.data
watch = ds.data['watching'][watch_uuid]
assert watch.get('llm_backend_profile') is True, \
"saving while a group decides must not overwrite the watch's own AI preference"
# Not even a hand-crafted POST can write it while it isn't user-editable
res = client.post(
url_for('ui.ui_edit.edit_page', uuid=watch_uuid),
data={'url': test_url, 'fetch_backend': 'html_requests',
'time_between_check_use_default': 'y', 'llm_backend_profile': ''},
follow_redirects=True,
)
assert b'Updated watch' in res.data
watch = ds.data['watching'][watch_uuid]
assert watch.get('llm_backend_profile') is True
# The group still wins for now...
assert llm_enabled_for_watch(watch, ds) == (False, 'Deciding Group')
# ..and when the group stops deciding, the watch's untouched preference applies again
ds.data['settings']['application']['tags'][tag_uuid]['llm_backend_profile'] = None
assert llm_enabled_for_watch(watch, ds) == (True, 'watch')
delete_all_watches(client)
def test_watch_ai_switch_saves_via_edit_form(
client, live_server, measure_memory_usage, datastore_path):
"""Turning AI off on a watch persists, and turning it back on works."""
ds = client.application.config.get('DATASTORE')
_configure_llm(ds)
api_token = _api_token(client)
test_url = url_for('test_endpoint', _external=True)
watch_uuid = _create_watch(client, test_url, api_token)
from changedetectionio.llm.evaluator import llm_enabled_for_watch
# Unchecked checkbox is simply absent from the POST
res = client.post(
url_for('ui.ui_edit.edit_page', uuid=watch_uuid),
data={'url': test_url, 'fetch_backend': 'html_requests',
'time_between_check_use_default': 'y'},
follow_redirects=True,
)
assert b'Updated watch' in res.data
watch = ds.data['watching'][watch_uuid]
assert watch.get('llm_backend_profile') is False
assert llm_enabled_for_watch(watch, ds) == (False, 'watch')
res = client.post(
url_for('ui.ui_edit.edit_page', uuid=watch_uuid),
data={'url': test_url, 'fetch_backend': 'html_requests',
'time_between_check_use_default': 'y', 'llm_backend_profile': 'y'},
follow_redirects=True,
)
assert b'Updated watch' in res.data
watch = ds.data['watching'][watch_uuid]
assert watch.get('llm_backend_profile') is True
assert llm_enabled_for_watch(watch, ds) == (True, 'watch')
delete_all_watches(client)
def test_group_ai_switch_saves_and_decides_for_its_watches(
client, live_server, measure_memory_usage, datastore_path):
"""
Group form set to "Off for every watch" → every watch in the group is off, whatever the
watch itself says. This is the #4204 "turn AI off for a whole group" flow.
"""
from changedetectionio.llm.evaluator import llm_enabled_for_watch
ds = client.application.config.get('DATASTORE')
_configure_llm(ds)
api_token = _api_token(client)
watch_uuid = _create_watch(client, url_for('test_endpoint', _external=True), api_token)
res = client.post(url_for('tags.form_tag_add'), data={'name': 'Budget Group'}, follow_redirects=True)
assert b'Tag added' in res.data
tag_uuid = [u for u, t in ds.data['settings']['application']['tags'].items()
if t.get('title') == 'Budget Group'][0]
res = client.post(
url_for('tags.form_tag_edit_submit', uuid=tag_uuid),
data={'title': 'Budget Group', 'llm_backend_profile': 'false'},
follow_redirects=True,
)
assert b'Updated' in res.data
tag = ds.data['settings']['application']['tags'][tag_uuid]
assert tag.get('llm_backend_profile') is False
_link_watch_to_tag(ds, watch_uuid, tag_uuid)
watch = ds.data['watching'][watch_uuid]
assert watch.get('llm_backend_profile') is True # the watch itself still says "on"
assert llm_enabled_for_watch(watch, ds) == (False, 'Budget Group'), \
"a group set to Off must win over the watch's own AI switch"
delete_all_watches(client)
def test_group_ai_state_survives_a_save_with_no_llm_configured(
client, live_server, measure_memory_usage, datastore_path):
"""
With no provider configured the AI control isn't rendered, and an unrendered control is
indistinguishable from "off" in a POST — so the page must carry the saved state in a
hidden input, otherwise merely saving the group would switch AI off.
"""
ds = client.application.config.get('DATASTORE')
# deliberately NOT calling _configure_llm
res = client.post(url_for('tags.form_tag_add'), data={'name': 'Unconfigured Group'}, follow_redirects=True)
assert b'Tag added' in res.data
tag_uuid = [u for u, t in ds.data['settings']['application']['tags'].items()
if t.get('title') == 'Unconfigured Group'][0]
ds.data['settings']['application']['tags'][tag_uuid]['llm_backend_profile'] = True
res = client.get(url_for('tags.form_tag_edit', uuid=tag_uuid))
body = res.data.decode('utf-8', errors='replace')
assert 'name="llm_intent"' not in body, "AI fields should not render without a provider"
hidden = _input_tag(body, 'llm_backend_profile')
assert 'type="hidden"' in hidden and 'value="true"' in hidden, \
"the group AI state must be preserved in a hidden input when the AI section is not rendered"
# Submit exactly what that page would send
res = client.post(
url_for('tags.form_tag_edit_submit', uuid=tag_uuid),
data={'title': 'Unconfigured Group', 'llm_backend_profile': 'true'},
follow_redirects=True,
)
assert b'Updated' in res.data
tag = ds.data['settings']['application']['tags'][tag_uuid]
assert tag.get('llm_backend_profile') is True, \
"saving with the AI section hidden must not switch AI off"
delete_all_watches(client)
# ---------------------------------------------------------------------------
# End-to-end through the real forms — the path the user actually clicks
# ---------------------------------------------------------------------------
def test_group_override_round_trip_through_both_forms(
client, live_server, measure_memory_usage, datastore_path):
"""
Set the group to On with an intent via the group form, tag a watch with it, and the watch
edit page shows the inherited value as its placeholder. This is the flow that was broken:
the group had no way to switch group-wide AI settings on at all.
"""
ds = client.application.config.get('DATASTORE')
_configure_llm(ds)
api_token = _api_token(client)
test_url = url_for('test_endpoint', _external=True)
watch_uuid = _create_watch(client, test_url, api_token)
res = client.post(url_for('tags.form_tag_add'), data={'name': 'E2E Group'}, follow_redirects=True)
assert b'Tag added' in res.data
tag_uuid = [u for u, t in ds.data['settings']['application']['tags'].items()
if t.get('title') == 'E2E Group'][0]
res = client.post(
url_for('tags.form_tag_edit_submit', uuid=tag_uuid),
data={'title': 'E2E Group',
'llm_intent': 'Only tell me about stock changes',
'llm_backend_profile': 'true'},
follow_redirects=True,
)
assert b'Updated' in res.data
_link_watch_to_tag(ds, watch_uuid, tag_uuid)
res = client.get(url_for('ui.ui_edit.edit_page', uuid=watch_uuid))
assert _from_group_text('E2E Group', 'Only tell me about stock changes') in _page_text(res)
delete_all_watches(client)
@@ -1332,6 +1332,22 @@ msgstr ""
msgid "Must be a hex colour, for example #4f8ef7"
msgstr "Musí být hexadecimální barva, například #4f8ef7"
#: changedetectionio/blueprint/tags/form.py
msgid "AI for watches in this group"
msgstr ""
#: changedetectionio/blueprint/tags/form.py
msgid "On, use the settings below"
msgstr ""
#: changedetectionio/blueprint/tags/form.py
msgid "Off for every watch"
msgstr ""
#: changedetectionio/blueprint/tags/form.py
msgid "Leave it to each watch"
msgstr ""
#: changedetectionio/blueprint/tags/form.py
msgid "Tag name"
msgstr ""
@@ -3181,16 +3197,16 @@ msgid "Use global settings for time between check and scheduler."
msgstr "Použít globální nastavení pro čas mezi kontrolou a plánovačem."
#: changedetectionio/forms.py
msgid "AI Change Intent"
msgstr "AI záměr změny"
msgid "AI Change Intent - Notify me when.."
msgstr ""
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html changedetectionio/blueprint/ui/templates/diff.html
#: changedetectionio/forms.py changedetectionio/templates/edit/include_llm_intent.html
#: changedetectionio/forms.py
msgid "AI Change Summary"
msgstr "AI souhrn změny"
#: changedetectionio/blueprint/tags/form.py changedetectionio/forms.py
msgid "How this prompt combines with the inherited one"
msgid "Change Summary prompt - Append or Replace the default?"
msgstr ""
#: changedetectionio/blueprint/tags/form.py changedetectionio/forms.py
@@ -3201,6 +3217,10 @@ msgstr ""
msgid "Append to the inherited prompt"
msgstr ""
#: changedetectionio/forms.py
msgid "AI enabled for this watch?"
msgstr ""
#: changedetectionio/forms.py
msgid "CSS/JSONPath/JQ/XPath Filters"
msgstr "CSS/JSONPath/JQ/xPath filtry"
@@ -4528,11 +4548,20 @@ msgid "Enter search term..."
msgstr ""
#: changedetectionio/templates/edit/include_llm_intent.html
msgid "AI"
msgstr "AI"
msgid ""
"<strong>On</strong> &ndash; every watch in this group uses the AI settings below, unless it fills in its own. "
"<strong>Off</strong> &ndash; no AI for any watch in this group. <strong>Leave it to each watch</strong> &ndash; this "
"group has no say; each watch uses its own AI settings."
msgstr ""
#: changedetectionio/templates/edit/include_llm_intent.html
msgid "AI — Notify when…"
#, python-format
msgid "Group %(name)s decides this: AI is ON for every watch in that group."
msgstr ""
#: changedetectionio/templates/edit/include_llm_intent.html
#, python-format
msgid "Group %(name)s decides this: AI is OFF for every watch in that group."
msgstr ""
#: changedetectionio/templates/edit/include_llm_intent.html
@@ -4618,6 +4647,10 @@ msgstr ""
msgid "Describe the price change: old price, new price, percentage difference."
msgstr ""
#: changedetectionio/templates/edit/include_llm_intent.html
msgid "AI"
msgstr "AI"
#: changedetectionio/templates/edit/include_llm_intent.html
#, python-format
msgid ""
@@ -1348,6 +1348,22 @@ msgstr ""
msgid "Must be a hex colour, for example #4f8ef7"
msgstr "Muss eine Hex-Farbe sein, zum Beispiel #4f8ef7"
#: changedetectionio/blueprint/tags/form.py
msgid "AI for watches in this group"
msgstr ""
#: changedetectionio/blueprint/tags/form.py
msgid "On, use the settings below"
msgstr ""
#: changedetectionio/blueprint/tags/form.py
msgid "Off for every watch"
msgstr ""
#: changedetectionio/blueprint/tags/form.py
msgid "Leave it to each watch"
msgstr ""
#: changedetectionio/blueprint/tags/form.py
msgid "Tag name"
msgstr ""
@@ -3230,16 +3246,16 @@ msgid "Use global settings for time between check and scheduler."
msgstr "Verwenden Sie globale Einstellungen für die Zeit zwischen Prüfung und Planer."
#: changedetectionio/forms.py
msgid "AI Change Intent"
msgid "AI Change Intent - Notify me when.."
msgstr ""
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html changedetectionio/blueprint/ui/templates/diff.html
#: changedetectionio/forms.py changedetectionio/templates/edit/include_llm_intent.html
#: changedetectionio/forms.py
msgid "AI Change Summary"
msgstr ""
#: changedetectionio/blueprint/tags/form.py changedetectionio/forms.py
msgid "How this prompt combines with the inherited one"
msgid "Change Summary prompt - Append or Replace the default?"
msgstr ""
#: changedetectionio/blueprint/tags/form.py changedetectionio/forms.py
@@ -3250,6 +3266,10 @@ msgstr ""
msgid "Append to the inherited prompt"
msgstr ""
#: changedetectionio/forms.py
msgid "AI enabled for this watch?"
msgstr ""
#: changedetectionio/forms.py
msgid "CSS/JSONPath/JQ/XPath Filters"
msgstr "CSS/xPath-Filter"
@@ -4582,11 +4602,20 @@ msgid "Enter search term..."
msgstr "Suchbegriff eingeben..."
#: changedetectionio/templates/edit/include_llm_intent.html
msgid "AI"
msgid ""
"<strong>On</strong> &ndash; every watch in this group uses the AI settings below, unless it fills in its own. "
"<strong>Off</strong> &ndash; no AI for any watch in this group. <strong>Leave it to each watch</strong> &ndash; this "
"group has no say; each watch uses its own AI settings."
msgstr ""
#: changedetectionio/templates/edit/include_llm_intent.html
msgid "AI — Notify when…"
#, python-format
msgid "Group %(name)s decides this: AI is ON for every watch in that group."
msgstr ""
#: changedetectionio/templates/edit/include_llm_intent.html
#, python-format
msgid "Group %(name)s decides this: AI is OFF for every watch in that group."
msgstr ""
#: changedetectionio/templates/edit/include_llm_intent.html
@@ -4672,6 +4701,10 @@ msgstr ""
msgid "Describe the price change: old price, new price, percentage difference."
msgstr ""
#: changedetectionio/templates/edit/include_llm_intent.html
msgid "AI"
msgstr ""
#: changedetectionio/templates/edit/include_llm_intent.html
#, python-format
msgid ""
@@ -1330,6 +1330,22 @@ msgstr ""
msgid "Must be a hex colour, for example #4f8ef7"
msgstr ""
#: changedetectionio/blueprint/tags/form.py
msgid "AI for watches in this group"
msgstr ""
#: changedetectionio/blueprint/tags/form.py
msgid "On, use the settings below"
msgstr ""
#: changedetectionio/blueprint/tags/form.py
msgid "Off for every watch"
msgstr ""
#: changedetectionio/blueprint/tags/form.py
msgid "Leave it to each watch"
msgstr ""
#: changedetectionio/blueprint/tags/form.py
msgid "Tag name"
msgstr ""
@@ -3173,16 +3189,16 @@ msgid "Use global settings for time between check and scheduler."
msgstr ""
#: changedetectionio/forms.py
msgid "AI Change Intent"
msgid "AI Change Intent - Notify me when.."
msgstr ""
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html changedetectionio/blueprint/ui/templates/diff.html
#: changedetectionio/forms.py changedetectionio/templates/edit/include_llm_intent.html
#: changedetectionio/forms.py
msgid "AI Change Summary"
msgstr ""
#: changedetectionio/blueprint/tags/form.py changedetectionio/forms.py
msgid "How this prompt combines with the inherited one"
msgid "Change Summary prompt - Append or Replace the default?"
msgstr ""
#: changedetectionio/blueprint/tags/form.py changedetectionio/forms.py
@@ -3193,6 +3209,10 @@ msgstr ""
msgid "Append to the inherited prompt"
msgstr ""
#: changedetectionio/forms.py
msgid "AI enabled for this watch?"
msgstr ""
#: changedetectionio/forms.py
msgid "CSS/JSONPath/JQ/XPath Filters"
msgstr ""
@@ -4520,11 +4540,20 @@ msgid "Enter search term..."
msgstr ""
#: changedetectionio/templates/edit/include_llm_intent.html
msgid "AI"
msgid ""
"<strong>On</strong> &ndash; every watch in this group uses the AI settings below, unless it fills in its own. "
"<strong>Off</strong> &ndash; no AI for any watch in this group. <strong>Leave it to each watch</strong> &ndash; this "
"group has no say; each watch uses its own AI settings."
msgstr ""
#: changedetectionio/templates/edit/include_llm_intent.html
msgid "AI — Notify when…"
#, python-format
msgid "Group %(name)s decides this: AI is ON for every watch in that group."
msgstr ""
#: changedetectionio/templates/edit/include_llm_intent.html
#, python-format
msgid "Group %(name)s decides this: AI is OFF for every watch in that group."
msgstr ""
#: changedetectionio/templates/edit/include_llm_intent.html
@@ -4610,6 +4639,10 @@ msgstr ""
msgid "Describe the price change: old price, new price, percentage difference."
msgstr ""
#: changedetectionio/templates/edit/include_llm_intent.html
msgid "AI"
msgstr ""
#: changedetectionio/templates/edit/include_llm_intent.html
#, python-format
msgid ""
@@ -1330,6 +1330,22 @@ msgstr ""
msgid "Must be a hex colour, for example #4f8ef7"
msgstr ""
#: changedetectionio/blueprint/tags/form.py
msgid "AI for watches in this group"
msgstr ""
#: changedetectionio/blueprint/tags/form.py
msgid "On, use the settings below"
msgstr ""
#: changedetectionio/blueprint/tags/form.py
msgid "Off for every watch"
msgstr ""
#: changedetectionio/blueprint/tags/form.py
msgid "Leave it to each watch"
msgstr ""
#: changedetectionio/blueprint/tags/form.py
msgid "Tag name"
msgstr ""
@@ -3173,16 +3189,16 @@ msgid "Use global settings for time between check and scheduler."
msgstr ""
#: changedetectionio/forms.py
msgid "AI Change Intent"
msgid "AI Change Intent - Notify me when.."
msgstr ""
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html changedetectionio/blueprint/ui/templates/diff.html
#: changedetectionio/forms.py changedetectionio/templates/edit/include_llm_intent.html
#: changedetectionio/forms.py
msgid "AI Change Summary"
msgstr ""
#: changedetectionio/blueprint/tags/form.py changedetectionio/forms.py
msgid "How this prompt combines with the inherited one"
msgid "Change Summary prompt - Append or Replace the default?"
msgstr ""
#: changedetectionio/blueprint/tags/form.py changedetectionio/forms.py
@@ -3193,6 +3209,10 @@ msgstr ""
msgid "Append to the inherited prompt"
msgstr ""
#: changedetectionio/forms.py
msgid "AI enabled for this watch?"
msgstr ""
#: changedetectionio/forms.py
msgid "CSS/JSONPath/JQ/XPath Filters"
msgstr ""
@@ -4520,11 +4540,20 @@ msgid "Enter search term..."
msgstr ""
#: changedetectionio/templates/edit/include_llm_intent.html
msgid "AI"
msgid ""
"<strong>On</strong> &ndash; every watch in this group uses the AI settings below, unless it fills in its own. "
"<strong>Off</strong> &ndash; no AI for any watch in this group. <strong>Leave it to each watch</strong> &ndash; this "
"group has no say; each watch uses its own AI settings."
msgstr ""
#: changedetectionio/templates/edit/include_llm_intent.html
msgid "AI — Notify when…"
#, python-format
msgid "Group %(name)s decides this: AI is ON for every watch in that group."
msgstr ""
#: changedetectionio/templates/edit/include_llm_intent.html
#, python-format
msgid "Group %(name)s decides this: AI is OFF for every watch in that group."
msgstr ""
#: changedetectionio/templates/edit/include_llm_intent.html
@@ -4610,6 +4639,10 @@ msgstr ""
msgid "Describe the price change: old price, new price, percentage difference."
msgstr ""
#: changedetectionio/templates/edit/include_llm_intent.html
msgid "AI"
msgstr ""
#: changedetectionio/templates/edit/include_llm_intent.html
#, python-format
msgid ""
@@ -1368,6 +1368,22 @@ msgstr ""
msgid "Must be a hex colour, for example #4f8ef7"
msgstr "Debe ser un color hexadecimal, por ejemplo #4f8ef7"
#: changedetectionio/blueprint/tags/form.py
msgid "AI for watches in this group"
msgstr ""
#: changedetectionio/blueprint/tags/form.py
msgid "On, use the settings below"
msgstr ""
#: changedetectionio/blueprint/tags/form.py
msgid "Off for every watch"
msgstr ""
#: changedetectionio/blueprint/tags/form.py
msgid "Leave it to each watch"
msgstr ""
#: changedetectionio/blueprint/tags/form.py
msgid "Tag name"
msgstr ""
@@ -3246,16 +3262,16 @@ msgid "Use global settings for time between check and scheduler."
msgstr "Utilice la configuración global para el tiempo entre la verificación y el programador."
#: changedetectionio/forms.py
msgid "AI Change Intent"
msgid "AI Change Intent - Notify me when.."
msgstr ""
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html changedetectionio/blueprint/ui/templates/diff.html
#: changedetectionio/forms.py changedetectionio/templates/edit/include_llm_intent.html
#: changedetectionio/forms.py
msgid "AI Change Summary"
msgstr ""
#: changedetectionio/blueprint/tags/form.py changedetectionio/forms.py
msgid "How this prompt combines with the inherited one"
msgid "Change Summary prompt - Append or Replace the default?"
msgstr ""
#: changedetectionio/blueprint/tags/form.py changedetectionio/forms.py
@@ -3266,6 +3282,10 @@ msgstr ""
msgid "Append to the inherited prompt"
msgstr ""
#: changedetectionio/forms.py
msgid "AI enabled for this watch?"
msgstr ""
#: changedetectionio/forms.py
msgid "CSS/JSONPath/JQ/XPath Filters"
msgstr "Filtros CSS/JSONPath/JQ/XPath"
@@ -4597,11 +4617,20 @@ msgid "Enter search term..."
msgstr "Introduzca el término de búsqueda..."
#: changedetectionio/templates/edit/include_llm_intent.html
msgid "AI"
msgid ""
"<strong>On</strong> &ndash; every watch in this group uses the AI settings below, unless it fills in its own. "
"<strong>Off</strong> &ndash; no AI for any watch in this group. <strong>Leave it to each watch</strong> &ndash; this "
"group has no say; each watch uses its own AI settings."
msgstr ""
#: changedetectionio/templates/edit/include_llm_intent.html
msgid "AI — Notify when…"
#, python-format
msgid "Group %(name)s decides this: AI is ON for every watch in that group."
msgstr ""
#: changedetectionio/templates/edit/include_llm_intent.html
#, python-format
msgid "Group %(name)s decides this: AI is OFF for every watch in that group."
msgstr ""
#: changedetectionio/templates/edit/include_llm_intent.html
@@ -4687,6 +4716,10 @@ msgstr ""
msgid "Describe the price change: old price, new price, percentage difference."
msgstr ""
#: changedetectionio/templates/edit/include_llm_intent.html
msgid "AI"
msgstr ""
#: changedetectionio/templates/edit/include_llm_intent.html
#, python-format
msgid ""
@@ -1336,6 +1336,22 @@ msgstr ""
msgid "Must be a hex colour, for example #4f8ef7"
msgstr "Doit être une couleur hexadécimale, par exemple #4f8ef7"
#: changedetectionio/blueprint/tags/form.py
msgid "AI for watches in this group"
msgstr ""
#: changedetectionio/blueprint/tags/form.py
msgid "On, use the settings below"
msgstr ""
#: changedetectionio/blueprint/tags/form.py
msgid "Off for every watch"
msgstr ""
#: changedetectionio/blueprint/tags/form.py
msgid "Leave it to each watch"
msgstr ""
#: changedetectionio/blueprint/tags/form.py
msgid "Tag name"
msgstr ""
@@ -3186,16 +3202,16 @@ msgid "Use global settings for time between check and scheduler."
msgstr "Utilisez les paramètres globaux pour le temps entre la vérification et le programmateur."
#: changedetectionio/forms.py
msgid "AI Change Intent"
msgid "AI Change Intent - Notify me when.."
msgstr ""
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html changedetectionio/blueprint/ui/templates/diff.html
#: changedetectionio/forms.py changedetectionio/templates/edit/include_llm_intent.html
#: changedetectionio/forms.py
msgid "AI Change Summary"
msgstr ""
#: changedetectionio/blueprint/tags/form.py changedetectionio/forms.py
msgid "How this prompt combines with the inherited one"
msgid "Change Summary prompt - Append or Replace the default?"
msgstr ""
#: changedetectionio/blueprint/tags/form.py changedetectionio/forms.py
@@ -3206,6 +3222,10 @@ msgstr ""
msgid "Append to the inherited prompt"
msgstr ""
#: changedetectionio/forms.py
msgid "AI enabled for this watch?"
msgstr ""
#: changedetectionio/forms.py
msgid "CSS/JSONPath/JQ/XPath Filters"
msgstr "Filtre CSS/JSONPath/JQ/XPath"
@@ -4535,11 +4555,20 @@ msgid "Enter search term..."
msgstr ""
#: changedetectionio/templates/edit/include_llm_intent.html
msgid "AI"
msgid ""
"<strong>On</strong> &ndash; every watch in this group uses the AI settings below, unless it fills in its own. "
"<strong>Off</strong> &ndash; no AI for any watch in this group. <strong>Leave it to each watch</strong> &ndash; this "
"group has no say; each watch uses its own AI settings."
msgstr ""
#: changedetectionio/templates/edit/include_llm_intent.html
msgid "AI — Notify when…"
#, python-format
msgid "Group %(name)s decides this: AI is ON for every watch in that group."
msgstr ""
#: changedetectionio/templates/edit/include_llm_intent.html
#, python-format
msgid "Group %(name)s decides this: AI is OFF for every watch in that group."
msgstr ""
#: changedetectionio/templates/edit/include_llm_intent.html
@@ -4625,6 +4654,10 @@ msgstr ""
msgid "Describe the price change: old price, new price, percentage difference."
msgstr ""
#: changedetectionio/templates/edit/include_llm_intent.html
msgid "AI"
msgstr ""
#: changedetectionio/templates/edit/include_llm_intent.html
#, python-format
msgid ""
@@ -1332,6 +1332,22 @@ msgstr ""
msgid "Must be a hex colour, for example #4f8ef7"
msgstr "Deve essere un colore esadecimale, ad esempio #4f8ef7"
#: changedetectionio/blueprint/tags/form.py
msgid "AI for watches in this group"
msgstr ""
#: changedetectionio/blueprint/tags/form.py
msgid "On, use the settings below"
msgstr ""
#: changedetectionio/blueprint/tags/form.py
msgid "Off for every watch"
msgstr ""
#: changedetectionio/blueprint/tags/form.py
msgid "Leave it to each watch"
msgstr ""
#: changedetectionio/blueprint/tags/form.py
msgid "Tag name"
msgstr ""
@@ -3175,16 +3191,16 @@ msgid "Use global settings for time between check and scheduler."
msgstr "Usa impostazioni globali per intervallo controlli e pianificazione."
#: changedetectionio/forms.py
msgid "AI Change Intent"
msgid "AI Change Intent - Notify me when.."
msgstr ""
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html changedetectionio/blueprint/ui/templates/diff.html
#: changedetectionio/forms.py changedetectionio/templates/edit/include_llm_intent.html
#: changedetectionio/forms.py
msgid "AI Change Summary"
msgstr ""
#: changedetectionio/blueprint/tags/form.py changedetectionio/forms.py
msgid "How this prompt combines with the inherited one"
msgid "Change Summary prompt - Append or Replace the default?"
msgstr ""
#: changedetectionio/blueprint/tags/form.py changedetectionio/forms.py
@@ -3195,6 +3211,10 @@ msgstr ""
msgid "Append to the inherited prompt"
msgstr ""
#: changedetectionio/forms.py
msgid "AI enabled for this watch?"
msgstr ""
#: changedetectionio/forms.py
msgid "CSS/JSONPath/JQ/XPath Filters"
msgstr "Filtri CSS/JSONPath/JQ/XPath"
@@ -4522,11 +4542,20 @@ msgid "Enter search term..."
msgstr ""
#: changedetectionio/templates/edit/include_llm_intent.html
msgid "AI"
msgid ""
"<strong>On</strong> &ndash; every watch in this group uses the AI settings below, unless it fills in its own. "
"<strong>Off</strong> &ndash; no AI for any watch in this group. <strong>Leave it to each watch</strong> &ndash; this "
"group has no say; each watch uses its own AI settings."
msgstr ""
#: changedetectionio/templates/edit/include_llm_intent.html
msgid "AI — Notify when…"
#, python-format
msgid "Group %(name)s decides this: AI is ON for every watch in that group."
msgstr ""
#: changedetectionio/templates/edit/include_llm_intent.html
#, python-format
msgid "Group %(name)s decides this: AI is OFF for every watch in that group."
msgstr ""
#: changedetectionio/templates/edit/include_llm_intent.html
@@ -4612,6 +4641,10 @@ msgstr ""
msgid "Describe the price change: old price, new price, percentage difference."
msgstr ""
#: changedetectionio/templates/edit/include_llm_intent.html
msgid "AI"
msgstr ""
#: changedetectionio/templates/edit/include_llm_intent.html
#, python-format
msgid ""
@@ -1337,6 +1337,22 @@ msgstr "タグの色"
msgid "Must be a hex colour, for example #4f8ef7"
msgstr "16進数のカラーコードを指定してください(例: #4f8ef7)"
#: changedetectionio/blueprint/tags/form.py
msgid "AI for watches in this group"
msgstr ""
#: changedetectionio/blueprint/tags/form.py
msgid "On, use the settings below"
msgstr ""
#: changedetectionio/blueprint/tags/form.py
msgid "Off for every watch"
msgstr ""
#: changedetectionio/blueprint/tags/form.py
msgid "Leave it to each watch"
msgstr ""
#: changedetectionio/blueprint/tags/form.py
msgid "Tag name"
msgstr "タグ名"
@@ -3192,16 +3208,16 @@ msgid "Use global settings for time between check and scheduler."
msgstr "チェック間隔とスケジューラーにはグローバル設定を使用する。"
#: changedetectionio/forms.py
msgid "AI Change Intent"
msgid "AI Change Intent - Notify me when.."
msgstr ""
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html changedetectionio/blueprint/ui/templates/diff.html
#: changedetectionio/forms.py changedetectionio/templates/edit/include_llm_intent.html
#: changedetectionio/forms.py
msgid "AI Change Summary"
msgstr ""
#: changedetectionio/blueprint/tags/form.py changedetectionio/forms.py
msgid "How this prompt combines with the inherited one"
msgid "Change Summary prompt - Append or Replace the default?"
msgstr ""
#: changedetectionio/blueprint/tags/form.py changedetectionio/forms.py
@@ -3212,6 +3228,10 @@ msgstr ""
msgid "Append to the inherited prompt"
msgstr ""
#: changedetectionio/forms.py
msgid "AI enabled for this watch?"
msgstr ""
#: changedetectionio/forms.py
msgid "CSS/JSONPath/JQ/XPath Filters"
msgstr "CSS/JSONPath/JQ/XPath フィルタ"
@@ -4553,11 +4573,20 @@ msgid "Enter search term..."
msgstr "検索語を入力..."
#: changedetectionio/templates/edit/include_llm_intent.html
msgid "AI"
msgid ""
"<strong>On</strong> &ndash; every watch in this group uses the AI settings below, unless it fills in its own. "
"<strong>Off</strong> &ndash; no AI for any watch in this group. <strong>Leave it to each watch</strong> &ndash; this "
"group has no say; each watch uses its own AI settings."
msgstr ""
#: changedetectionio/templates/edit/include_llm_intent.html
msgid "AI — Notify when…"
#, python-format
msgid "Group %(name)s decides this: AI is ON for every watch in that group."
msgstr ""
#: changedetectionio/templates/edit/include_llm_intent.html
#, python-format
msgid "Group %(name)s decides this: AI is OFF for every watch in that group."
msgstr ""
#: changedetectionio/templates/edit/include_llm_intent.html
@@ -4643,6 +4672,10 @@ msgstr ""
msgid "Describe the price change: old price, new price, percentage difference."
msgstr ""
#: changedetectionio/templates/edit/include_llm_intent.html
msgid "AI"
msgstr ""
#: changedetectionio/templates/edit/include_llm_intent.html
#, python-format
msgid ""
@@ -1338,6 +1338,22 @@ msgstr "태그 색상"
msgid "Must be a hex colour, for example #4f8ef7"
msgstr "16진수 색상이어야 합니다. 예: #4f8ef7"
#: changedetectionio/blueprint/tags/form.py
msgid "AI for watches in this group"
msgstr ""
#: changedetectionio/blueprint/tags/form.py
msgid "On, use the settings below"
msgstr ""
#: changedetectionio/blueprint/tags/form.py
msgid "Off for every watch"
msgstr ""
#: changedetectionio/blueprint/tags/form.py
msgid "Leave it to each watch"
msgstr ""
#: changedetectionio/blueprint/tags/form.py
msgid "Tag name"
msgstr "태그 이름"
@@ -3183,16 +3199,16 @@ msgid "Use global settings for time between check and scheduler."
msgstr "확인 간격 및 예약 실행에 전역 설정 사용"
#: changedetectionio/forms.py
msgid "AI Change Intent"
msgstr "AI 판단 기준"
msgid "AI Change Intent - Notify me when.."
msgstr ""
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html changedetectionio/blueprint/ui/templates/diff.html
#: changedetectionio/forms.py changedetectionio/templates/edit/include_llm_intent.html
#: changedetectionio/forms.py
msgid "AI Change Summary"
msgstr "AI 변경 요약"
#: changedetectionio/blueprint/tags/form.py changedetectionio/forms.py
msgid "How this prompt combines with the inherited one"
msgid "Change Summary prompt - Append or Replace the default?"
msgstr ""
#: changedetectionio/blueprint/tags/form.py changedetectionio/forms.py
@@ -3203,6 +3219,10 @@ msgstr ""
msgid "Append to the inherited prompt"
msgstr ""
#: changedetectionio/forms.py
msgid "AI enabled for this watch?"
msgstr ""
#: changedetectionio/forms.py
msgid "CSS/JSONPath/JQ/XPath Filters"
msgstr "CSS/JSONPath/JQ/XPath 필터"
@@ -4530,12 +4550,21 @@ msgid "Enter search term..."
msgstr "검색어를 입력하세요..."
#: changedetectionio/templates/edit/include_llm_intent.html
msgid "AI"
msgstr "AI"
msgid ""
"<strong>On</strong> &ndash; every watch in this group uses the AI settings below, unless it fills in its own. "
"<strong>Off</strong> &ndash; no AI for any watch in this group. <strong>Leave it to each watch</strong> &ndash; this "
"group has no say; each watch uses its own AI settings."
msgstr ""
#: changedetectionio/templates/edit/include_llm_intent.html
msgid "AI — Notify when…"
msgstr "AI - 다음 경우 알림"
#, python-format
msgid "Group %(name)s decides this: AI is ON for every watch in that group."
msgstr ""
#: changedetectionio/templates/edit/include_llm_intent.html
#, python-format
msgid "Group %(name)s decides this: AI is OFF for every watch in that group."
msgstr ""
#: changedetectionio/templates/edit/include_llm_intent.html
msgid ""
@@ -4620,6 +4649,10 @@ msgstr "추가되거나 취소된 이벤트를 요약하세요. 최대 두 문
msgid "Describe the price change: old price, new price, percentage difference."
msgstr "가격 변경을 설명하세요: 이전 가격, 새 가격, 비율 차이."
#: changedetectionio/templates/edit/include_llm_intent.html
msgid "AI"
msgstr "AI"
#: changedetectionio/templates/edit/include_llm_intent.html
#, python-format
msgid ""
+39 -6
View File
@@ -8,7 +8,7 @@ msgid ""
msgstr ""
"Project-Id-Version: changedetection.io 0.55.8\n"
"Report-Msgid-Bugs-To: EMAIL@ADDRESS\n"
"POT-Creation-Date: 2026-08-26 11:14+0200\n"
"POT-Creation-Date: 2026-09-01 19:10+0200\n"
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
"Language-Team: LANGUAGE <LL@li.org>\n"
@@ -1329,6 +1329,22 @@ msgstr ""
msgid "Must be a hex colour, for example #4f8ef7"
msgstr ""
#: changedetectionio/blueprint/tags/form.py
msgid "AI for watches in this group"
msgstr ""
#: changedetectionio/blueprint/tags/form.py
msgid "On, use the settings below"
msgstr ""
#: changedetectionio/blueprint/tags/form.py
msgid "Off for every watch"
msgstr ""
#: changedetectionio/blueprint/tags/form.py
msgid "Leave it to each watch"
msgstr ""
#: changedetectionio/blueprint/tags/form.py
msgid "Tag name"
msgstr ""
@@ -3172,16 +3188,16 @@ msgid "Use global settings for time between check and scheduler."
msgstr ""
#: changedetectionio/forms.py
msgid "AI Change Intent"
msgid "AI Change Intent - Notify me when.."
msgstr ""
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html changedetectionio/blueprint/ui/templates/diff.html
#: changedetectionio/forms.py changedetectionio/templates/edit/include_llm_intent.html
#: changedetectionio/forms.py
msgid "AI Change Summary"
msgstr ""
#: changedetectionio/blueprint/tags/form.py changedetectionio/forms.py
msgid "How this prompt combines with the inherited one"
msgid "Change Summary prompt - Append or Replace the default?"
msgstr ""
#: changedetectionio/blueprint/tags/form.py changedetectionio/forms.py
@@ -3192,6 +3208,10 @@ msgstr ""
msgid "Append to the inherited prompt"
msgstr ""
#: changedetectionio/forms.py
msgid "AI enabled for this watch?"
msgstr ""
#: changedetectionio/forms.py
msgid "CSS/JSONPath/JQ/XPath Filters"
msgstr ""
@@ -4519,11 +4539,20 @@ msgid "Enter search term..."
msgstr ""
#: changedetectionio/templates/edit/include_llm_intent.html
msgid "AI"
msgid ""
"<strong>On</strong> &ndash; every watch in this group uses the AI settings below, unless it fills in its own. "
"<strong>Off</strong> &ndash; no AI for any watch in this group. <strong>Leave it to each watch</strong> &ndash; this "
"group has no say; each watch uses its own AI settings."
msgstr ""
#: changedetectionio/templates/edit/include_llm_intent.html
msgid "AI — Notify when…"
#, python-format
msgid "Group %(name)s decides this: AI is ON for every watch in that group."
msgstr ""
#: changedetectionio/templates/edit/include_llm_intent.html
#, python-format
msgid "Group %(name)s decides this: AI is OFF for every watch in that group."
msgstr ""
#: changedetectionio/templates/edit/include_llm_intent.html
@@ -4609,6 +4638,10 @@ msgstr ""
msgid "Describe the price change: old price, new price, percentage difference."
msgstr ""
#: changedetectionio/templates/edit/include_llm_intent.html
msgid "AI"
msgstr ""
#: changedetectionio/templates/edit/include_llm_intent.html
#, python-format
msgid ""
@@ -1451,6 +1451,22 @@ msgstr "Kolor etykiety"
msgid "Must be a hex colour, for example #4f8ef7"
msgstr "Musi być kolorem szesnastkowym, na przykład #4f8ef7"
#: changedetectionio/blueprint/tags/form.py
msgid "AI for watches in this group"
msgstr ""
#: changedetectionio/blueprint/tags/form.py
msgid "On, use the settings below"
msgstr ""
#: changedetectionio/blueprint/tags/form.py
msgid "Off for every watch"
msgstr ""
#: changedetectionio/blueprint/tags/form.py
msgid "Leave it to each watch"
msgstr ""
#: changedetectionio/blueprint/tags/form.py
msgid "Tag name"
msgstr "Nazwa tagu"
@@ -3332,16 +3348,16 @@ msgid "Use global settings for time between check and scheduler."
msgstr "W przypadku funkcji sprawdzania i harmonogramu należy stosować ustawienia globalne dotyczące czasu."
#: changedetectionio/forms.py
msgid "AI Change Intent"
msgstr "Intencja zmian AI"
msgid "AI Change Intent - Notify me when.."
msgstr ""
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html changedetectionio/blueprint/ui/templates/diff.html
#: changedetectionio/forms.py changedetectionio/templates/edit/include_llm_intent.html
#: changedetectionio/forms.py
msgid "AI Change Summary"
msgstr "Podsumowanie zmian AI"
#: changedetectionio/blueprint/tags/form.py changedetectionio/forms.py
msgid "How this prompt combines with the inherited one"
msgid "Change Summary prompt - Append or Replace the default?"
msgstr "Jak ten prompt łączy się z odziedziczonym"
#: changedetectionio/blueprint/tags/form.py changedetectionio/forms.py
@@ -3352,6 +3368,10 @@ msgstr "Zastąp odziedziczony prompt"
msgid "Append to the inherited prompt"
msgstr "Dołącz do odziedziczonego promptu"
#: changedetectionio/forms.py
msgid "AI enabled for this watch?"
msgstr ""
#: changedetectionio/forms.py
msgid "CSS/JSONPath/JQ/XPath Filters"
msgstr "Filtry CSS/JSONPath/JQ/XPath"
@@ -4702,12 +4722,21 @@ msgid "Enter search term..."
msgstr "Wpisz szukane hasło..."
#: changedetectionio/templates/edit/include_llm_intent.html
msgid "AI"
msgstr "Sztuczna inteligencja"
msgid ""
"<strong>On</strong> &ndash; every watch in this group uses the AI settings below, unless it fills in its own. "
"<strong>Off</strong> &ndash; no AI for any watch in this group. <strong>Leave it to each watch</strong> &ndash; this "
"group has no say; each watch uses its own AI settings."
msgstr ""
#: changedetectionio/templates/edit/include_llm_intent.html
msgid "AI — Notify when…"
msgstr "Sztuczna inteligencja — Powiadom, gdy…"
#, python-format
msgid "Group %(name)s decides this: AI is ON for every watch in that group."
msgstr ""
#: changedetectionio/templates/edit/include_llm_intent.html
#, python-format
msgid "Group %(name)s decides this: AI is OFF for every watch in that group."
msgstr ""
#: changedetectionio/templates/edit/include_llm_intent.html
msgid ""
@@ -4804,6 +4833,10 @@ msgstr "Podsumuj, jakie wydarzenia zostały dodane lub odwołane. Maksymalnie dw
msgid "Describe the price change: old price, new price, percentage difference."
msgstr "Opisz zmianę ceny: stara cena, nowa cena, różnica procentowa."
#: changedetectionio/templates/edit/include_llm_intent.html
msgid "AI"
msgstr "Sztuczna inteligencja"
#: changedetectionio/templates/edit/include_llm_intent.html
#, python-format
msgid ""
@@ -1355,6 +1355,22 @@ msgstr ""
msgid "Must be a hex colour, for example #4f8ef7"
msgstr "Deve ser uma cor hexadecimal, por exemplo #4f8ef7"
#: changedetectionio/blueprint/tags/form.py
msgid "AI for watches in this group"
msgstr ""
#: changedetectionio/blueprint/tags/form.py
msgid "On, use the settings below"
msgstr ""
#: changedetectionio/blueprint/tags/form.py
msgid "Off for every watch"
msgstr ""
#: changedetectionio/blueprint/tags/form.py
msgid "Leave it to each watch"
msgstr ""
#: changedetectionio/blueprint/tags/form.py
msgid "Tag name"
msgstr ""
@@ -3223,16 +3239,16 @@ msgid "Use global settings for time between check and scheduler."
msgstr "Usar configurações globais para o tempo entre verificações e agendador."
#: changedetectionio/forms.py
msgid "AI Change Intent"
msgid "AI Change Intent - Notify me when.."
msgstr ""
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html changedetectionio/blueprint/ui/templates/diff.html
#: changedetectionio/forms.py changedetectionio/templates/edit/include_llm_intent.html
#: changedetectionio/forms.py
msgid "AI Change Summary"
msgstr ""
#: changedetectionio/blueprint/tags/form.py changedetectionio/forms.py
msgid "How this prompt combines with the inherited one"
msgid "Change Summary prompt - Append or Replace the default?"
msgstr ""
#: changedetectionio/blueprint/tags/form.py changedetectionio/forms.py
@@ -3243,6 +3259,10 @@ msgstr ""
msgid "Append to the inherited prompt"
msgstr ""
#: changedetectionio/forms.py
msgid "AI enabled for this watch?"
msgstr ""
#: changedetectionio/forms.py
msgid "CSS/JSONPath/JQ/XPath Filters"
msgstr "Filtros CSS/JSONPath/JQ/XPath"
@@ -4572,11 +4592,20 @@ msgid "Enter search term..."
msgstr "Digite o termo de busca..."
#: changedetectionio/templates/edit/include_llm_intent.html
msgid "AI"
msgid ""
"<strong>On</strong> &ndash; every watch in this group uses the AI settings below, unless it fills in its own. "
"<strong>Off</strong> &ndash; no AI for any watch in this group. <strong>Leave it to each watch</strong> &ndash; this "
"group has no say; each watch uses its own AI settings."
msgstr ""
#: changedetectionio/templates/edit/include_llm_intent.html
msgid "AI — Notify when…"
#, python-format
msgid "Group %(name)s decides this: AI is ON for every watch in that group."
msgstr ""
#: changedetectionio/templates/edit/include_llm_intent.html
#, python-format
msgid "Group %(name)s decides this: AI is OFF for every watch in that group."
msgstr ""
#: changedetectionio/templates/edit/include_llm_intent.html
@@ -4662,6 +4691,10 @@ msgstr ""
msgid "Describe the price change: old price, new price, percentage difference."
msgstr ""
#: changedetectionio/templates/edit/include_llm_intent.html
msgid "AI"
msgstr ""
#: changedetectionio/templates/edit/include_llm_intent.html
#, python-format
msgid ""
@@ -1419,6 +1419,22 @@ msgstr "Цвет тега"
msgid "Must be a hex colour, for example #4f8ef7"
msgstr "Должен быть шестнадцатеричный цвет, например #4f8ef7"
#: changedetectionio/blueprint/tags/form.py
msgid "AI for watches in this group"
msgstr ""
#: changedetectionio/blueprint/tags/form.py
msgid "On, use the settings below"
msgstr ""
#: changedetectionio/blueprint/tags/form.py
msgid "Off for every watch"
msgstr ""
#: changedetectionio/blueprint/tags/form.py
msgid "Leave it to each watch"
msgstr ""
#: changedetectionio/blueprint/tags/form.py
msgid "Tag name"
msgstr "Имя тега"
@@ -3291,16 +3307,16 @@ msgid "Use global settings for time between check and scheduler."
msgstr "Используйте глобальные настройки времени между проверкой и планировщиком."
#: changedetectionio/forms.py
msgid "AI Change Intent"
msgstr "Изменение намерения ИИ"
msgid "AI Change Intent - Notify me when.."
msgstr ""
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html changedetectionio/blueprint/ui/templates/diff.html
#: changedetectionio/forms.py changedetectionio/templates/edit/include_llm_intent.html
#: changedetectionio/forms.py
msgid "AI Change Summary"
msgstr "Обзор изменений ИИ"
#: changedetectionio/blueprint/tags/form.py changedetectionio/forms.py
msgid "How this prompt combines with the inherited one"
msgid "Change Summary prompt - Append or Replace the default?"
msgstr ""
#: changedetectionio/blueprint/tags/form.py changedetectionio/forms.py
@@ -3311,6 +3327,10 @@ msgstr ""
msgid "Append to the inherited prompt"
msgstr ""
#: changedetectionio/forms.py
msgid "AI enabled for this watch?"
msgstr ""
#: changedetectionio/forms.py
msgid "CSS/JSONPath/JQ/XPath Filters"
msgstr "Фильтры CSS/JSONPath/JQ/XPath"
@@ -4650,12 +4670,21 @@ msgid "Enter search term..."
msgstr "Введите поисковый запрос..."
#: changedetectionio/templates/edit/include_llm_intent.html
msgid "AI"
msgstr "ИИ"
msgid ""
"<strong>On</strong> &ndash; every watch in this group uses the AI settings below, unless it fills in its own. "
"<strong>Off</strong> &ndash; no AI for any watch in this group. <strong>Leave it to each watch</strong> &ndash; this "
"group has no say; each watch uses its own AI settings."
msgstr ""
#: changedetectionio/templates/edit/include_llm_intent.html
msgid "AI — Notify when…"
msgstr "AI — Сообщить, когда…"
#, python-format
msgid "Group %(name)s decides this: AI is ON for every watch in that group."
msgstr ""
#: changedetectionio/templates/edit/include_llm_intent.html
#, python-format
msgid "Group %(name)s decides this: AI is OFF for every watch in that group."
msgstr ""
#: changedetectionio/templates/edit/include_llm_intent.html
msgid ""
@@ -4750,6 +4779,10 @@ msgstr "Подведите итог, какие события были доба
msgid "Describe the price change: old price, new price, percentage difference."
msgstr "Опишите изменение цены: старая цена, новая цена, процентная разница."
#: changedetectionio/templates/edit/include_llm_intent.html
msgid "AI"
msgstr "ИИ"
#: changedetectionio/templates/edit/include_llm_intent.html
#, python-format
msgid ""
@@ -1365,6 +1365,22 @@ msgstr ""
msgid "Must be a hex colour, for example #4f8ef7"
msgstr "Onaltılık bir renk olmalıdır, örneğin #4f8ef7"
#: changedetectionio/blueprint/tags/form.py
msgid "AI for watches in this group"
msgstr ""
#: changedetectionio/blueprint/tags/form.py
msgid "On, use the settings below"
msgstr ""
#: changedetectionio/blueprint/tags/form.py
msgid "Off for every watch"
msgstr ""
#: changedetectionio/blueprint/tags/form.py
msgid "Leave it to each watch"
msgstr ""
#: changedetectionio/blueprint/tags/form.py
msgid "Tag name"
msgstr ""
@@ -3226,16 +3242,16 @@ msgid "Use global settings for time between check and scheduler."
msgstr "Kontrol arası süre ve zamanlayıcı için genel ayarları kullanın."
#: changedetectionio/forms.py
msgid "AI Change Intent"
msgid "AI Change Intent - Notify me when.."
msgstr ""
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html changedetectionio/blueprint/ui/templates/diff.html
#: changedetectionio/forms.py changedetectionio/templates/edit/include_llm_intent.html
#: changedetectionio/forms.py
msgid "AI Change Summary"
msgstr ""
#: changedetectionio/blueprint/tags/form.py changedetectionio/forms.py
msgid "How this prompt combines with the inherited one"
msgid "Change Summary prompt - Append or Replace the default?"
msgstr ""
#: changedetectionio/blueprint/tags/form.py changedetectionio/forms.py
@@ -3246,6 +3262,10 @@ msgstr ""
msgid "Append to the inherited prompt"
msgstr ""
#: changedetectionio/forms.py
msgid "AI enabled for this watch?"
msgstr ""
#: changedetectionio/forms.py
msgid "CSS/JSONPath/JQ/XPath Filters"
msgstr "CSS/JSONPath/JQ/XPath Filtreleri"
@@ -4577,11 +4597,20 @@ msgid "Enter search term..."
msgstr "Arama terimini girin..."
#: changedetectionio/templates/edit/include_llm_intent.html
msgid "AI"
msgid ""
"<strong>On</strong> &ndash; every watch in this group uses the AI settings below, unless it fills in its own. "
"<strong>Off</strong> &ndash; no AI for any watch in this group. <strong>Leave it to each watch</strong> &ndash; this "
"group has no say; each watch uses its own AI settings."
msgstr ""
#: changedetectionio/templates/edit/include_llm_intent.html
msgid "AI — Notify when…"
#, python-format
msgid "Group %(name)s decides this: AI is ON for every watch in that group."
msgstr ""
#: changedetectionio/templates/edit/include_llm_intent.html
#, python-format
msgid "Group %(name)s decides this: AI is OFF for every watch in that group."
msgstr ""
#: changedetectionio/templates/edit/include_llm_intent.html
@@ -4667,6 +4696,10 @@ msgstr ""
msgid "Describe the price change: old price, new price, percentage difference."
msgstr ""
#: changedetectionio/templates/edit/include_llm_intent.html
msgid "AI"
msgstr ""
#: changedetectionio/templates/edit/include_llm_intent.html
#, python-format
msgid ""
@@ -1345,6 +1345,22 @@ msgstr ""
msgid "Must be a hex colour, for example #4f8ef7"
msgstr "Має бути шістнадцятковий колір, наприклад #4f8ef7"
#: changedetectionio/blueprint/tags/form.py
msgid "AI for watches in this group"
msgstr ""
#: changedetectionio/blueprint/tags/form.py
msgid "On, use the settings below"
msgstr ""
#: changedetectionio/blueprint/tags/form.py
msgid "Off for every watch"
msgstr ""
#: changedetectionio/blueprint/tags/form.py
msgid "Leave it to each watch"
msgstr ""
#: changedetectionio/blueprint/tags/form.py
msgid "Tag name"
msgstr ""
@@ -3205,16 +3221,16 @@ msgid "Use global settings for time between check and scheduler."
msgstr "Використовувати глобальні налаштування для часу між перевірками та планувальника."
#: changedetectionio/forms.py
msgid "AI Change Intent"
msgid "AI Change Intent - Notify me when.."
msgstr ""
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html changedetectionio/blueprint/ui/templates/diff.html
#: changedetectionio/forms.py changedetectionio/templates/edit/include_llm_intent.html
#: changedetectionio/forms.py
msgid "AI Change Summary"
msgstr ""
#: changedetectionio/blueprint/tags/form.py changedetectionio/forms.py
msgid "How this prompt combines with the inherited one"
msgid "Change Summary prompt - Append or Replace the default?"
msgstr ""
#: changedetectionio/blueprint/tags/form.py changedetectionio/forms.py
@@ -3225,6 +3241,10 @@ msgstr ""
msgid "Append to the inherited prompt"
msgstr ""
#: changedetectionio/forms.py
msgid "AI enabled for this watch?"
msgstr ""
#: changedetectionio/forms.py
msgid "CSS/JSONPath/JQ/XPath Filters"
msgstr "Фільтри CSS/JSONPath/JQ/XPath"
@@ -4554,11 +4574,20 @@ msgid "Enter search term..."
msgstr "Введіть пошуковий запит..."
#: changedetectionio/templates/edit/include_llm_intent.html
msgid "AI"
msgid ""
"<strong>On</strong> &ndash; every watch in this group uses the AI settings below, unless it fills in its own. "
"<strong>Off</strong> &ndash; no AI for any watch in this group. <strong>Leave it to each watch</strong> &ndash; this "
"group has no say; each watch uses its own AI settings."
msgstr ""
#: changedetectionio/templates/edit/include_llm_intent.html
msgid "AI — Notify when…"
#, python-format
msgid "Group %(name)s decides this: AI is ON for every watch in that group."
msgstr ""
#: changedetectionio/templates/edit/include_llm_intent.html
#, python-format
msgid "Group %(name)s decides this: AI is OFF for every watch in that group."
msgstr ""
#: changedetectionio/templates/edit/include_llm_intent.html
@@ -4644,6 +4673,10 @@ msgstr ""
msgid "Describe the price change: old price, new price, percentage difference."
msgstr ""
#: changedetectionio/templates/edit/include_llm_intent.html
msgid "AI"
msgstr ""
#: changedetectionio/templates/edit/include_llm_intent.html
#, python-format
msgid ""
@@ -1335,6 +1335,22 @@ msgstr ""
msgid "Must be a hex colour, for example #4f8ef7"
msgstr "必须是十六进制颜色,例如 #4f8ef7"
#: changedetectionio/blueprint/tags/form.py
msgid "AI for watches in this group"
msgstr ""
#: changedetectionio/blueprint/tags/form.py
msgid "On, use the settings below"
msgstr ""
#: changedetectionio/blueprint/tags/form.py
msgid "Off for every watch"
msgstr ""
#: changedetectionio/blueprint/tags/form.py
msgid "Leave it to each watch"
msgstr ""
#: changedetectionio/blueprint/tags/form.py
msgid "Tag name"
msgstr ""
@@ -3179,16 +3195,16 @@ msgid "Use global settings for time between check and scheduler."
msgstr "检查间隔与调度时间使用全局设置。"
#: changedetectionio/forms.py
msgid "AI Change Intent"
msgid "AI Change Intent - Notify me when.."
msgstr ""
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html changedetectionio/blueprint/ui/templates/diff.html
#: changedetectionio/forms.py changedetectionio/templates/edit/include_llm_intent.html
#: changedetectionio/forms.py
msgid "AI Change Summary"
msgstr ""
#: changedetectionio/blueprint/tags/form.py changedetectionio/forms.py
msgid "How this prompt combines with the inherited one"
msgid "Change Summary prompt - Append or Replace the default?"
msgstr ""
#: changedetectionio/blueprint/tags/form.py changedetectionio/forms.py
@@ -3199,6 +3215,10 @@ msgstr ""
msgid "Append to the inherited prompt"
msgstr ""
#: changedetectionio/forms.py
msgid "AI enabled for this watch?"
msgstr ""
#: changedetectionio/forms.py
msgid "CSS/JSONPath/JQ/XPath Filters"
msgstr "CSS/JSONPath/JQ/XPath 过滤器"
@@ -4528,11 +4548,20 @@ msgid "Enter search term..."
msgstr "输入搜索关键词..."
#: changedetectionio/templates/edit/include_llm_intent.html
msgid "AI"
msgid ""
"<strong>On</strong> &ndash; every watch in this group uses the AI settings below, unless it fills in its own. "
"<strong>Off</strong> &ndash; no AI for any watch in this group. <strong>Leave it to each watch</strong> &ndash; this "
"group has no say; each watch uses its own AI settings."
msgstr ""
#: changedetectionio/templates/edit/include_llm_intent.html
msgid "AI — Notify when…"
#, python-format
msgid "Group %(name)s decides this: AI is ON for every watch in that group."
msgstr ""
#: changedetectionio/templates/edit/include_llm_intent.html
#, python-format
msgid "Group %(name)s decides this: AI is OFF for every watch in that group."
msgstr ""
#: changedetectionio/templates/edit/include_llm_intent.html
@@ -4618,6 +4647,10 @@ msgstr ""
msgid "Describe the price change: old price, new price, percentage difference."
msgstr ""
#: changedetectionio/templates/edit/include_llm_intent.html
msgid "AI"
msgstr ""
#: changedetectionio/templates/edit/include_llm_intent.html
#, python-format
msgid ""
@@ -1334,6 +1334,22 @@ msgstr ""
msgid "Must be a hex colour, for example #4f8ef7"
msgstr "必須是十六進位色碼,例如 #4f8ef7"
#: changedetectionio/blueprint/tags/form.py
msgid "AI for watches in this group"
msgstr ""
#: changedetectionio/blueprint/tags/form.py
msgid "On, use the settings below"
msgstr ""
#: changedetectionio/blueprint/tags/form.py
msgid "Off for every watch"
msgstr ""
#: changedetectionio/blueprint/tags/form.py
msgid "Leave it to each watch"
msgstr ""
#: changedetectionio/blueprint/tags/form.py
msgid "Tag name"
msgstr ""
@@ -3179,16 +3195,16 @@ msgid "Use global settings for time between check and scheduler."
msgstr "檢查與排程時間使用全域設定。"
#: changedetectionio/forms.py
msgid "AI Change Intent"
msgid "AI Change Intent - Notify me when.."
msgstr ""
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html changedetectionio/blueprint/ui/templates/diff.html
#: changedetectionio/forms.py changedetectionio/templates/edit/include_llm_intent.html
#: changedetectionio/forms.py
msgid "AI Change Summary"
msgstr ""
#: changedetectionio/blueprint/tags/form.py changedetectionio/forms.py
msgid "How this prompt combines with the inherited one"
msgid "Change Summary prompt - Append or Replace the default?"
msgstr ""
#: changedetectionio/blueprint/tags/form.py changedetectionio/forms.py
@@ -3199,6 +3215,10 @@ msgstr ""
msgid "Append to the inherited prompt"
msgstr ""
#: changedetectionio/forms.py
msgid "AI enabled for this watch?"
msgstr ""
#: changedetectionio/forms.py
msgid "CSS/JSONPath/JQ/XPath Filters"
msgstr "CSS / JSONPath / JQ / XPath 過濾器"
@@ -4526,11 +4546,20 @@ msgid "Enter search term..."
msgstr "輸入搜尋關鍵字 ..."
#: changedetectionio/templates/edit/include_llm_intent.html
msgid "AI"
msgid ""
"<strong>On</strong> &ndash; every watch in this group uses the AI settings below, unless it fills in its own. "
"<strong>Off</strong> &ndash; no AI for any watch in this group. <strong>Leave it to each watch</strong> &ndash; this "
"group has no say; each watch uses its own AI settings."
msgstr ""
#: changedetectionio/templates/edit/include_llm_intent.html
msgid "AI — Notify when…"
#, python-format
msgid "Group %(name)s decides this: AI is ON for every watch in that group."
msgstr ""
#: changedetectionio/templates/edit/include_llm_intent.html
#, python-format
msgid "Group %(name)s decides this: AI is OFF for every watch in that group."
msgstr ""
#: changedetectionio/templates/edit/include_llm_intent.html
@@ -4616,6 +4645,10 @@ msgstr ""
msgid "Describe the price change: old price, new price, percentage difference."
msgstr ""
#: changedetectionio/templates/edit/include_llm_intent.html
msgid "AI"
msgstr ""
#: changedetectionio/templates/edit/include_llm_intent.html
#, python-format
msgid ""
+8 -3
View File
@@ -455,15 +455,20 @@ async def async_update_worker(worker_id, q, notification_q, app, datastore, exec
if changed_detected and watch.history_n >= 1:
try:
from changedetectionio.llm.evaluator import (
evaluate_change, resolve_intent, resolve_llm_field,
summarise_change, _runtime_llm_config,
evaluate_change, llm_enabled_for_watch, resolve_intent,
resolve_llm_field, summarise_change, _runtime_llm_config,
)
# Per-watch (or group-wide) AI on/off — #4204. Checked before
# any diff work so a switched-off watch costs nothing.
_llm_on, _llm_on_source = llm_enabled_for_watch(watch, datastore)
if not _llm_on:
logger.debug(f"LLM disabled for {uuid} (by {_llm_on_source}) — skipping AI intent/summary")
# _runtime_llm_config returns None (and logs a debug skip
# message) when the master 'llm_enabled' toggle is off, so
# the whole block — diff computation, status minitext, and
# the two executor dispatches — is skipped, not just the
# inner LLM lookups.
_llm_cfg = _runtime_llm_config(datastore)
_llm_cfg = _runtime_llm_config(datastore) if _llm_on else None
if _llm_cfg:
# Compute unified diff once — used by both intent and summary
_watch_dates = list(watch.history.keys())
+11
View File
@@ -606,6 +606,17 @@ components:
description: Logic operator - ALL (match all conditions) or ANY (match any condition)
# AI / LLM
llm_backend_profile:
type: [boolean, 'null']
default: true
description: |
Whether AI/LLM features are enabled. On a watch this is a plain on/off (default true).
On a tag/group it is ternary and is that group's only AI control:
- true: AI on for every watch in the group, and the group's `llm_intent` /
`llm_change_summary` are inherited by its watches (unless a watch sets its own)
- false: AI off for every watch in the group
- null (group default): the group has no say; each watch uses its own AI settings
(Reserved for selecting a named LLM profile in future - currently a plain on/off.)
llm_intent:
type: string
maxLength: 2000