Files
2026-06-01 18:43:22 +02:00

805 lines
32 KiB
Python

"""
LLM evaluation orchestration.
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)
Cache: each (intent, diff) pair is evaluated exactly once, result stored in watch.
Environment variable overrides (take priority over datastore settings):
LLM_MODEL — model string (e.g. "gpt-4o-mini", "ollama/llama3.2")
LLM_API_KEY — API key for cloud providers
LLM_API_BASE — base URL for local/custom endpoints (e.g. http://localhost:11434)
"""
import hashlib
import os
from dataclasses import dataclass
from datetime import datetime, timezone
from loguru import logger
from changedetectionio.strtobool import strtobool
from . import client as llm_client
from .prompt_builder import (
build_change_summary_prompt, build_change_summary_system_prompt,
build_eval_prompt, build_eval_system_prompt,
build_preview_prompt, build_preview_system_prompt,
build_setup_prompt, build_setup_system_prompt,
)
from .response_parser import parse_eval_response, parse_preview_response, parse_setup_response
from changedetectionio.model.LLMSettings import (
LLMSettings,
LLM_DEFAULT_MAX_INPUT_CHARS as _DEFAULT_MAX_INPUT_CHARS,
LLM_DEFAULT_MAX_SUMMARY_TOKENS,
LLM_DEFAULT_THINKING_BUDGET,
)
def is_llm_features_disabled() -> bool:
"""True when the LLM_FEATURES_DISABLED env var is set to a truthy value."""
return bool(strtobool(os.getenv('LLM_FEATURES_DISABLED', '')))
def get_llm_settings(datastore) -> LLMSettings:
"""Hydrate the LLM config dict at settings.application.llm into a validated model.
Returns a default-constructed LLMSettings when the dict is missing or empty —
callers never have to None-check the result. The storage layer remains a plain
dict; this is only the validation/typing layer for reads.
"""
cfg = datastore.data.get('settings', {}).get('application', {}).get('llm') or {}
return LLMSettings.model_validate(cfg)
def _get_max_input_chars(datastore) -> int:
"""Max input characters to send to the LLM. Resolution: env var → datastore → 100,000.
Always returns at least 1 — unlimited is not permitted.
"""
env_val = os.getenv('LLM_MAX_INPUT_CHARS', '').strip()
if env_val.isdigit() and int(env_val) > 0:
return int(env_val)
stored = get_llm_settings(datastore).max_input_chars
if stored and stored > 0:
return stored
return _DEFAULT_MAX_INPUT_CHARS
class LLMInputTooLargeError(Exception):
pass
def _check_input_size(text: str, max_chars: int) -> None:
"""Raise LLMInputTooLargeError if text exceeds max_chars."""
if len(text) > max_chars:
raise LLMInputTooLargeError(
f"Change too large for AI summary ({len(text):,} chars, limit {max_chars:,})"
)
def _thinking_extra_body(model: str, budget: int) -> dict | None:
"""Return litellm extra_body to control thinking for models that support it.
The `thinkingConfig.thinkingBudget` payload is Gemini-specific (Anthropic and
OpenAI reasoning models use different parameters), so we gate on the gemini/
provider prefix first, then defer to litellm's model registry for the actual
"does this model think?" decision. That picks up new Gemini variants and
rolling aliases (`gemini-flash-latest`, etc.) as litellm's registry tracks
them, without us hardcoding model names here.
"""
if not model.startswith('gemini/'):
return None
try:
import litellm
if not litellm.get_model_info(model).get('supports_reasoning'):
return None
except Exception:
# Unknown model or registry lookup failed — skip the thinking config
# rather than guess. Worst case: thinking stays at the provider default.
return None
return {'generationConfig': {'thinkingConfig': {'thinkingBudget': budget}}}
def _cached_system(text: str, model: str = '') -> dict:
"""Wrap a system prompt, adding Anthropic prompt-caching headers only for Anthropic models.
Gemini and other providers have their own caching APIs that break when they receive
cache_control, so we only apply it where it's supported.
"""
is_anthropic = model.startswith('claude') or model.startswith('anthropic/')
if is_anthropic:
return {'role': 'system', 'content': [{'type': 'text', 'text': text, 'cache_control': {'type': 'ephemeral'}}]}
return {'role': 'system', 'content': text}
# Output-token cap for the JSON-returning calls (intent eval, preview, setup/prefilter).
# Mirrors client.py's _MAX_COMPLETION_TOKENS so the multiplier helper has a base value
# to scale; cloud-LLM users hit this default unmodified, preserving prior cost defaults.
JSON_RESPONSE_MAX_TOKENS = 400
# Default prompt used when the user hasn't configured llm_change_summary.
# This owns the OUTPUT FORMAT (structure, sections, style, language). The system prompt
# in prompt_builder.build_change_summary_system_prompt() only covers how to READ the diff.
# Users can replace this entirely (e.g. "Just tell me the new timestamp.") without
# fighting hard-coded structure rules from the system prompt.
DEFAULT_CHANGE_SUMMARY_PROMPT = (
"Describe what changed in plain English using these sections, in this fixed order — "
"omit a section entirely if there is nothing to report for it:\n"
" Added: ...\n"
" Changed: ...\n"
" Removed: ...\n"
"The Removed section MUST always be last. Never place removals before additions or changes.\n\n"
"List items as bullet points with key details for each one. Be considerate of the style "
"of content you are summarising and adjust your report accordingly.\n"
"Do not list standalone timestamps like '3 hours ago', 'Yesterday', '2 minutes ago' as added "
"or removed items — they are not meaningful content changes.\n"
"For content-heavy pages (news, listings, feeds): quote or paraphrase the specific new "
"headlines, items, or entries that were added — do not collapse them into vague phrases "
"like 'new articles were added' or 'section was expanded'.\n"
"For large blocks of new text (full articles, documents, long paragraphs): briefly summarise "
"the substance in 1-2 sentences capturing the key point — do not just repeat the title.\n\n"
"Do not quote non-English text verbatim; translate and summarise all content into English. "
"Do not give partial listings such as 'Examples include:', always be thorough."
)
def _summary_max_tokens(diff: str, max_cap: int = LLM_DEFAULT_MAX_SUMMARY_TOKENS) -> int:
"""Scale completion tokens to diff size: floor 400, ~1 token per 4 chars, ceiling max_cap."""
return max(400, min(len(diff) // 4, max_cap))
def apply_local_token_multiplier(base_max_tokens: int, llm_cfg: dict) -> int:
"""
Scale max_tokens for endpoints that commonly serve reasoning models
(Ollama — self-hosted or ollama.com cloud — and OpenAI-compatible servers like
vLLM, LM Studio, llama.cpp).
Reasoning models (Qwen3, DeepSeek-R1, Gemma 3, etc.) emit chain-of-thought into
`message.reasoning_content` BEFORE the final answer lands in `message.content`.
Without enough headroom the request truncates mid-thought (`finish_reason='length'`
or `'stop'` with empty content) and the answer never lands — callers see an empty
string and silently fall through to safe defaults, hiding the problem.
Cloud providers with stable, non-reasoning defaults (OpenAI, Anthropic, Gemini,
OpenRouter) keep their original tight caps so existing users see no behavior or
cost change. Ollama / OpenAI-compatible users can dial the multiplier down to 1x
in Settings → AI → Provider if they want to keep costs tight on a paid endpoint.
Activated when `llm_cfg['provider_kind']` is `'ollama'` or `'openai_compatible'`.
Multiplier defaults to 5x and is user-configurable in Settings → AI → Provider.
"""
if (llm_cfg or {}).get('provider_kind') not in ('ollama', 'openai_compatible'):
return base_max_tokens
try:
multiplier = int(llm_cfg.get('local_token_multiplier') or 5)
except (TypeError, ValueError):
multiplier = 5
# Clamp to the same 1-20 range the form enforces. Defense-in-depth against
# corrupted datastore values that bypassed form validation (manual JSON edits,
# future migrations, plugins): a runaway multiplier could otherwise produce
# absurdly large max_tokens caps and exhaust local-endpoint memory.
multiplier = max(1, min(multiplier, 20))
return base_max_tokens * multiplier
# ---------------------------------------------------------------------------
# Intent resolution
# ---------------------------------------------------------------------------
def resolve_llm_field(watch, datastore, field: str) -> tuple[str, str]:
"""
Generic cascade resolver for any LLM per-watch field.
Returns (value, source) where source is 'watch' or tag title.
Returns ('', '') if not set anywhere.
"""
value = (watch.get(field) or '').strip()
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')
return '', ''
def resolve_intent(watch, datastore) -> tuple[str, str]:
"""
Return (intent, source) where source is 'watch' or tag title.
Returns ('', '') if no intent is configured anywhere.
"""
intent = (watch.get('llm_intent') or '').strip()
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')
return '', ''
# ---------------------------------------------------------------------------
# LLM config helper
# ---------------------------------------------------------------------------
def get_llm_config(datastore) -> dict | None:
"""
Return LLM config dict or None if not configured.
Resolution order (first non-empty model wins):
1. Environment variables: LLM_MODEL, LLM_API_KEY, LLM_API_BASE
2. Datastore settings (set via UI)
"""
if is_llm_features_disabled():
return None
# 1. Environment variable override
env_model = os.getenv('LLM_MODEL', '').strip()
if env_model:
return {
'model': env_model,
'api_key': os.getenv('LLM_API_KEY', '').strip(),
'api_base': os.getenv('LLM_API_BASE', '').strip(),
}
# 2. Datastore settings
cfg = datastore.data['settings']['application'].get('llm') or {}
if not cfg.get('model'):
return None
return cfg
def llm_configured_via_env() -> bool:
"""True when LLM config comes from environment variables, not the UI."""
if is_llm_features_disabled():
return False
return bool(os.getenv('LLM_MODEL', '').strip())
def _runtime_llm_config(datastore) -> dict | None:
"""
Runtime gate used by every LLM entry point in this module (and the restock
fallback). Returns the resolved config dict only when both:
- the master 'llm_enabled' toggle is on (default True)
- a provider+model is actually configured
When the toggle is off but a config exists, logs a debug message and returns
None so callers fall through their existing "not configured" early-return path.
The settings UI deliberately still calls get_llm_config() directly so the
"AI / LLM configured: ..." badge keeps showing the saved provider even while
the toggle is off.
"""
cfg = get_llm_config(datastore)
if not get_llm_settings(datastore).enabled:
if cfg:
logger.debug("LLM features disabled via settings (enabled=False) — skipping LLM lookup")
return None
return cfg
# ---------------------------------------------------------------------------
# Global monthly token budget
# ---------------------------------------------------------------------------
def _get_month_key() -> str:
"""Returns 'YYYY-MM' for the current UTC month."""
return datetime.now(timezone.utc).strftime("%Y-%m")
def get_global_token_budget_month(datastore=None) -> int:
"""
Monthly token budget ceiling. Resolution order:
1. LLM_TOKEN_BUDGET_MONTH env var (takes priority, makes field read-only in UI)
2. datastore settings (set via UI)
Returns 0 (no limit) if not set anywhere.
"""
try:
env_val = int(os.getenv('LLM_TOKEN_BUDGET_MONTH', '0'))
if env_val > 0:
return env_val
except (ValueError, TypeError):
pass
if datastore is not None:
try:
stored = datastore.data['settings']['application'].get('llm') or {}
val = int(stored.get('token_budget_month') or 0)
return max(0, val)
except (ValueError, TypeError):
pass
return 0
def _estimate_cost_usd(model: str, input_tokens: int, output_tokens: int) -> float:
"""
Return estimated cost in USD using litellm's pricing database.
Returns 0.0 for unknown models (local/Ollama/custom endpoints).
Never raises — cost estimation is best-effort.
"""
if not model or (not input_tokens and not output_tokens):
return 0.0
try:
from litellm.cost_calculator import cost_per_token
prompt_cost, completion_cost = cost_per_token(
model=model,
prompt_tokens=input_tokens,
completion_tokens=output_tokens,
)
return float(prompt_cost + completion_cost)
except Exception:
return 0.0
def accumulate_global_tokens(datastore, tokens: int,
input_tokens: int = 0, output_tokens: int = 0,
model: str = '') -> None:
"""
Add *tokens* to both the all-time and this-month global counters.
When input_tokens / output_tokens / model are supplied the estimated
USD cost is accumulated alongside the token counts.
Resets monthly counters automatically on month rollover.
These counters live at datastore.data['settings']['application']['llm']
and are intentionally read-only from the API/form side — they are only
ever written here, in a controlled way.
"""
if tokens <= 0:
return
current_month = _get_month_key()
cost = _estimate_cost_usd(model, input_tokens, output_tokens)
settings = get_llm_settings(datastore)
# Month rollover: reset monthly counters
if settings.tokens_month_key != current_month:
settings.tokens_this_month = 0
settings.cost_usd_this_month = 0.0
settings.tokens_month_key = current_month
settings.tokens_total_cumulative += tokens
settings.tokens_this_month += tokens
settings.cost_usd_total_cumulative += cost
settings.cost_usd_this_month += cost
# Round-trip through model_dump so storage stays a plain dict and the schema
# contract (extra='forbid', type coercion) is re-enforced on every write.
datastore.data['settings']['application']['llm'] = settings.model_dump()
datastore.commit()
def is_global_token_budget_exceeded(datastore) -> bool:
"""
Returns True when a monthly token budget is configured (via
LLM_TOKEN_BUDGET_MONTH) and the current month's usage has reached
or exceeded that budget.
"""
budget = get_global_token_budget_month(datastore)
if not budget:
return False
llm_cfg = datastore.data['settings']['application'].get('llm') or {}
if llm_cfg.get('tokens_month_key') != _get_month_key():
# Counter hasn't been updated yet this month → zero usage
return False
return (llm_cfg.get('tokens_this_month') or 0) >= budget
# ---------------------------------------------------------------------------
# One-time setup: derive pre-filter
# ---------------------------------------------------------------------------
def _check_token_budget(watch, cfg, tokens_this_call: int = 0) -> bool:
"""
Per-watch per-period token cap.
Period is currently month (matches the global counter rollover); the field
name `max_tokens_per_count_period` is period-agnostic so a configurable
day/week/month can land later without renaming storage.
On non-zero tokens_this_call:
- rolls over watch['llm_tokens_this_period'] if a new period started
- increments the per-period counter
- also increments the existing lifetime counter (UI stat, unchanged)
Returns False once the per-period counter exceeds max_tokens_per_count_period
so subsequent evaluate_change calls bail out for this watch until rollover.
Note: only evaluate_change actually gates on the return value (the other
callers invoke this for the side-effect of accumulating tokens).
"""
if tokens_this_call > 0:
current_period = _get_month_key()
# Rollover: new period zeroes the per-period counter
if watch.get('llm_tokens_period_key') != current_period:
watch['llm_tokens_this_period'] = 0
watch['llm_tokens_period_key'] = current_period
watch['llm_tokens_this_period'] = (watch.get('llm_tokens_this_period') or 0) + tokens_this_call
# Informational lifetime counter (UI shows this; not used for the cap)
watch['llm_tokens_used_cumulative'] = (watch.get('llm_tokens_used_cumulative') or 0) + tokens_this_call
max_per_period = int(cfg.get('max_tokens_per_count_period') or 0)
if max_per_period:
# Pre-flight (tokens_this_call=0) and post-call paths both read the
# same counter — but a stale period key means "no usage yet this period".
if watch.get('llm_tokens_period_key') == _get_month_key():
total = watch.get('llm_tokens_this_period') or 0
if total > max_per_period:
logger.warning(
f"LLM per-period token budget exceeded for {watch.get('uuid')}: "
f"{total} tokens > limit {max_per_period}"
)
return False
return True
def run_setup(watch, datastore, snapshot_text: str) -> None:
"""
Ask the LLM whether a CSS pre-filter would improve precision for this intent.
Stores result in watch['llm_prefilter'] (str selector or None).
Called once when intent is first set, and again if pre-filter returns zero matches.
"""
cfg = _runtime_llm_config(datastore)
if not cfg:
return
intent, _ = resolve_intent(watch, datastore)
if not intent:
return
url = watch.get('url', '')
system_prompt = build_setup_system_prompt()
user_prompt = build_setup_prompt(intent, snapshot_text, url=url)
settings = get_llm_settings(datastore)
try:
raw, tokens, *_ = llm_client.completion(
model=cfg['model'],
messages=[
_cached_system(system_prompt, model=cfg['model']),
{'role': 'user', 'content': user_prompt},
],
api_key=cfg.get('api_key'),
api_base=cfg.get('api_base'),
max_tokens=apply_local_token_multiplier(JSON_RESPONSE_MAX_TOKENS, cfg),
extra_body=_thinking_extra_body(cfg['model'], settings.thinking_budget),
debug=settings.debug,
)
_check_token_budget(watch, cfg, tokens)
accumulate_global_tokens(datastore, tokens, model=cfg['model'])
result = parse_setup_response(raw)
watch['llm_prefilter'] = result['selector']
logger.debug(f"LLM setup for {watch.get('uuid')}: prefilter={result['selector']} reason={result['reason']}")
except Exception as e:
logger.warning(f"LLM setup call failed for {watch.get('uuid')}: {e}")
watch['llm_prefilter'] = None
# ---------------------------------------------------------------------------
# AI Change Summary — human-readable description of what changed
# ---------------------------------------------------------------------------
def get_effective_summary_prompt(watch, datastore) -> str:
"""Return the prompt that summarise_change will use.
Cascade: watch → tag → global settings default → hardcoded fallback.
"""
prompt, _ = resolve_llm_field(watch, datastore, 'llm_change_summary')
if prompt:
return prompt
global_default = get_llm_settings(datastore).change_summary_default.strip()
return global_default or DEFAULT_CHANGE_SUMMARY_PROMPT
def compute_summary_cache_key(diff_text: str, prompt: str) -> str:
"""Stable 16-char hex key for a (diff, prompt) pair. Stored alongside the summary file."""
h = hashlib.md5()
h.update(diff_text.encode('utf-8', errors='replace'))
h.update(b'\x00')
h.update(prompt.encode('utf-8', errors='replace'))
return h.hexdigest()[:16]
@dataclass(frozen=True)
class DiffPrefs:
"""
User-facing diff display preferences. Part of the LLM summary cache key so
that toggling a preference produces a fresh summary.
Field defaults are the single source of truth — the UI query-arg defaults in
diff.py's from_request_args() and the worker pre-cache's bare DiffPrefs()
both rely on these.
"""
all_changes: bool = False
ignore_whitespace: bool = False
show_removed: bool = True
show_added: bool = True
@classmethod
def from_request_args(cls, args) -> 'DiffPrefs':
"""Parse from a Flask request.args (or any .get(key, default)-shaped mapping)."""
return cls(
all_changes = args.get('all_changes', '0') == '1',
ignore_whitespace = args.get('ignore_whitespace', '0') == '1',
show_removed = args.get('removed', '1') == '1',
show_added = args.get('added', '1') == '1',
)
def cache_key_suffix(self) -> str:
return (
f'\x00prefs:all={int(self.all_changes)},ws={int(self.ignore_whitespace)}'
f',rm={int(self.show_removed)},add={int(self.show_added)}'
)
def build_summary_cache_prompt(effective_prompt: str, max_summary_tokens: int,
prefs: DiffPrefs = None, model: str = '') -> str:
"""
Compose the full cache-key string passed to save/get_llm_diff_summary.
Default prefs are DiffPrefs() — must match the UI's query-arg defaults so a
worker-side pre-cache is hit by an unmodified UI request. Same helper must
be used by both the worker pre-cache write and the UI diff route read,
otherwise the prompt hashes diverge and the cache file isn't found.
The active model name is folded into the key so switching models
(e.g. qwen3 → gpt-4o) invalidates stale summaries that were generated
by a different model with potentially different phrasing/quality.
"""
if prefs is None:
prefs = DiffPrefs()
return (
effective_prompt
+ prefs.cache_key_suffix()
+ f'\x00sys:{build_change_summary_system_prompt()}'
+ f'\x00max_tokens:{max_summary_tokens}'
+ f'\x00model:{model}'
)
def summarise_change(watch, datastore, diff: str, current_snapshot: str = '') -> str:
"""
Generate a plain-language summary of the change using the watch's
llm_change_summary prompt (cascades from tag if not set on watch).
Returns the summary string, or '' on failure.
The result replaces {{ diff }} in notifications so the user gets a
readable description instead of raw +/- diff lines.
"""
cfg = _runtime_llm_config(datastore)
if not cfg:
return ''
if is_global_token_budget_exceeded(datastore):
budget = get_global_token_budget_month(datastore)
llm_cfg = datastore.data['settings']['application'].get('llm') or {}
used = llm_cfg.get('tokens_this_month', 0)
logger.warning(
f"LLM summarise_change skipped: monthly budget {budget:,} reached "
f"({used:,} used this month)"
)
return ''
custom_prompt = get_effective_summary_prompt(watch, datastore)
if not diff.strip():
return ''
_check_input_size(diff, _get_max_input_chars(datastore))
url = watch.get('url', '')
title = watch.get('page_title') or watch.get('title') or ''
system_prompt = build_change_summary_system_prompt()
user_prompt = build_change_summary_prompt(
diff=diff,
custom_prompt=custom_prompt,
current_snapshot=current_snapshot,
url=url,
title=title,
)
settings = get_llm_settings(datastore)
_extra_body = _thinking_extra_body(cfg['model'], settings.thinking_budget)
try:
_resp = llm_client.completion(
model=cfg['model'],
messages=[
_cached_system(system_prompt, model=cfg['model']),
{'role': 'user', 'content': user_prompt},
],
api_key=cfg.get('api_key'),
api_base=cfg.get('api_base'),
max_tokens=apply_local_token_multiplier(
_summary_max_tokens(diff, max_cap=settings.max_summary_tokens),
cfg,
),
extra_body=_extra_body,
debug=settings.debug,
)
raw, tokens = _resp[0], _resp[1]
input_tokens = _resp[2] if len(_resp) > 2 else 0
output_tokens = _resp[3] if len(_resp) > 3 else 0
summary = raw.strip()
_check_token_budget(watch, cfg, tokens)
watch['llm_last_tokens_used'] = tokens
watch['llm_tokens_used_cumulative'] = (watch.get('llm_tokens_used_cumulative') or 0) + tokens
accumulate_global_tokens(datastore, tokens,
input_tokens=input_tokens,
output_tokens=output_tokens,
model=cfg['model'])
logger.debug(
f"LLM change summary {watch.get('uuid')}: tokens={tokens} "
f"summary={summary[:80]}"
)
return summary
except Exception as e:
raise
# ---------------------------------------------------------------------------
# Live-preview extraction (current content, no diff)
# ---------------------------------------------------------------------------
def preview_extract(watch, datastore, content: str) -> dict | None:
"""
For the live-preview endpoint: extract relevant information from the
*current* page content according to the watch's intent.
Unlike evaluate_change (which compares a diff), this asks the LLM to
directly answer the intent against the current snapshot — giving the user
immediate feedback like "30 articles listed" or "Price: $149, 25% off".
Returns {'found': bool, 'answer': str} or None if LLM not configured / no intent.
"""
cfg = _runtime_llm_config(datastore)
if not cfg:
return None
intent, _ = resolve_intent(watch, datastore)
if not intent or not content.strip():
return None
_check_input_size(content, _get_max_input_chars(datastore))
url = watch.get('url', '')
title = watch.get('page_title') or watch.get('title') or ''
system_prompt = build_preview_system_prompt()
user_prompt = build_preview_prompt(intent, content, url=url, title=title)
settings = get_llm_settings(datastore)
try:
raw, tokens, *_ = llm_client.completion(
model=cfg['model'],
messages=[
_cached_system(system_prompt, model=cfg['model']),
{'role': 'user', 'content': user_prompt},
],
api_key=cfg.get('api_key'),
api_base=cfg.get('api_base'),
max_tokens=apply_local_token_multiplier(JSON_RESPONSE_MAX_TOKENS, cfg),
extra_body=_thinking_extra_body(cfg['model'], settings.thinking_budget),
debug=settings.debug,
)
accumulate_global_tokens(datastore, tokens, model=cfg['model'])
result = parse_preview_response(raw)
logger.debug(
f"LLM preview {watch.get('uuid')}: found={result['found']} "
f"tokens={tokens} answer={result['answer'][:80]}"
)
return result
except Exception as e:
logger.warning(f"LLM preview extraction failed for {watch.get('uuid')}: {e}")
return None
# ---------------------------------------------------------------------------
# Per-change evaluation
# ---------------------------------------------------------------------------
def evaluate_change(watch, datastore, diff: str, current_snapshot: str = '') -> dict | None:
"""
Evaluate whether `diff` matches the watch's intent.
Returns {'important': bool, 'summary': str} or None if LLM not configured / no intent.
Results are cached by (intent, diff) hash — each unique diff is evaluated exactly once.
"""
cfg = _runtime_llm_config(datastore)
if not cfg:
return None
intent, source = resolve_intent(watch, datastore)
if not intent:
return None
if not diff or not diff.strip():
return {'important': False, 'summary': ''}
_check_input_size(diff, _get_max_input_chars(datastore))
# Cache lookup — evaluations are deterministic once cached
cache_key = hashlib.sha256(f"{intent}||{diff}".encode()).hexdigest()
cache = watch.get('llm_evaluation_cache') or {}
if cache_key in cache:
logger.debug(f"LLM cache hit for {watch.get('uuid')} key={cache_key[:8]}")
return cache[cache_key]
# Check global monthly budget before making the call
if is_global_token_budget_exceeded(datastore):
budget = get_global_token_budget_month(datastore)
llm_cfg = datastore.data['settings']['application'].get('llm') or {}
used = llm_cfg.get('tokens_this_month', 0)
logger.warning(
f"LLM evaluate_change skipped for {watch.get('uuid')}: monthly budget {budget:,} reached "
f"({used:,} used this month) — passing change through as important"
)
# Fail open: don't suppress notifications when budget is exhausted
return {'important': True, 'summary': ''}
# Check per-watch cumulative budget before making the call
if not _check_token_budget(watch, cfg):
# Already over budget — fail open (don't suppress notification)
return {'important': True, 'summary': ''}
url = watch.get('url', '')
title = watch.get('page_title') or watch.get('title') or ''
system_prompt = build_eval_system_prompt()
user_prompt = build_eval_prompt(
intent=intent,
diff=diff,
current_snapshot=current_snapshot,
url=url,
title=title,
)
settings = get_llm_settings(datastore)
try:
_resp = llm_client.completion(
model=cfg['model'],
messages=[
_cached_system(system_prompt, model=cfg['model']),
{'role': 'user', 'content': user_prompt},
],
api_key=cfg.get('api_key'),
api_base=cfg.get('api_base'),
max_tokens=apply_local_token_multiplier(JSON_RESPONSE_MAX_TOKENS, cfg),
extra_body=_thinking_extra_body(cfg['model'], settings.thinking_budget),
debug=settings.debug,
)
raw, tokens = _resp[0], _resp[1]
input_tokens = _resp[2] if len(_resp) > 2 else 0
output_tokens = _resp[3] if len(_resp) > 3 else 0
result = parse_eval_response(raw)
except Exception as e:
logger.warning(f"LLM evaluation failed for {watch.get('uuid')}: {e}")
# On failure: don't suppress the notification — pass through as important
watch['llm_last_tokens_used'] = 0
return {'important': True, 'summary': ''}
# Accumulate token usage: per-watch limit and global monthly budget
_check_token_budget(watch, cfg, tokens)
watch['llm_last_tokens_used'] = tokens
accumulate_global_tokens(datastore, tokens,
input_tokens=input_tokens,
output_tokens=output_tokens,
model=cfg['model'])
# Store in cache
if 'llm_evaluation_cache' not in watch or watch['llm_evaluation_cache'] is None:
watch['llm_evaluation_cache'] = {}
watch['llm_evaluation_cache'][cache_key] = result
logger.debug(
f"LLM eval {watch.get('uuid')} (intent from {source}): "
f"important={result['important']} tokens={tokens} summary={result['summary'][:80]}"
)
return result