mirror of
https://github.com/dgtlmoon/changedetection.io.git
synced 2026-09-25 06:46:11 +00:00
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:
@@ -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 = {}
|
||||
|
||||
@@ -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 #}
|
||||
|
||||
@@ -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'):
|
||||
|
||||
@@ -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='')
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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'):
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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>✨ {{ _('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> – every watch in this group uses the AI settings below, unless it fills in its own. <strong>Off</strong> – no AI for any watch in this group. <strong>Leave it to each watch</strong> – 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&&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>✨ {{ _('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 %}
|
||||
|
||||
@@ -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)
|
||||
|
||||
Binary file not shown.
@@ -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> – every watch in this group uses the AI settings below, unless it fills in its own. "
|
||||
"<strong>Off</strong> – no AI for any watch in this group. <strong>Leave it to each watch</strong> – 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> – every watch in this group uses the AI settings below, unless it fills in its own. "
|
||||
"<strong>Off</strong> – no AI for any watch in this group. <strong>Leave it to each watch</strong> – 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> – every watch in this group uses the AI settings below, unless it fills in its own. "
|
||||
"<strong>Off</strong> – no AI for any watch in this group. <strong>Leave it to each watch</strong> – 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> – every watch in this group uses the AI settings below, unless it fills in its own. "
|
||||
"<strong>Off</strong> – no AI for any watch in this group. <strong>Leave it to each watch</strong> – 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> – every watch in this group uses the AI settings below, unless it fills in its own. "
|
||||
"<strong>Off</strong> – no AI for any watch in this group. <strong>Leave it to each watch</strong> – 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> – every watch in this group uses the AI settings below, unless it fills in its own. "
|
||||
"<strong>Off</strong> – no AI for any watch in this group. <strong>Leave it to each watch</strong> – 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> – every watch in this group uses the AI settings below, unless it fills in its own. "
|
||||
"<strong>Off</strong> – no AI for any watch in this group. <strong>Leave it to each watch</strong> – 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> – every watch in this group uses the AI settings below, unless it fills in its own. "
|
||||
"<strong>Off</strong> – no AI for any watch in this group. <strong>Leave it to each watch</strong> – 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 ""
|
||||
|
||||
Binary file not shown.
@@ -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> – every watch in this group uses the AI settings below, unless it fills in its own. "
|
||||
"<strong>Off</strong> – no AI for any watch in this group. <strong>Leave it to each watch</strong> – 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 ""
|
||||
|
||||
@@ -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> – every watch in this group uses the AI settings below, unless it fills in its own. "
|
||||
"<strong>Off</strong> – no AI for any watch in this group. <strong>Leave it to each watch</strong> – 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 ""
|
||||
|
||||
Binary file not shown.
@@ -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> – every watch in this group uses the AI settings below, unless it fills in its own. "
|
||||
"<strong>Off</strong> – no AI for any watch in this group. <strong>Leave it to each watch</strong> – 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> – every watch in this group uses the AI settings below, unless it fills in its own. "
|
||||
"<strong>Off</strong> – no AI for any watch in this group. <strong>Leave it to each watch</strong> – 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 ""
|
||||
|
||||
Binary file not shown.
@@ -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> – every watch in this group uses the AI settings below, unless it fills in its own. "
|
||||
"<strong>Off</strong> – no AI for any watch in this group. <strong>Leave it to each watch</strong> – 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> – every watch in this group uses the AI settings below, unless it fills in its own. "
|
||||
"<strong>Off</strong> – no AI for any watch in this group. <strong>Leave it to each watch</strong> – 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> – every watch in this group uses the AI settings below, unless it fills in its own. "
|
||||
"<strong>Off</strong> – no AI for any watch in this group. <strong>Leave it to each watch</strong> – 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> – every watch in this group uses the AI settings below, unless it fills in its own. "
|
||||
"<strong>Off</strong> – no AI for any watch in this group. <strong>Leave it to each watch</strong> – 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> – every watch in this group uses the AI settings below, unless it fills in its own. "
|
||||
"<strong>Off</strong> – no AI for any watch in this group. <strong>Leave it to each watch</strong> – 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 ""
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user