mirror of
https://github.com/dgtlmoon/changedetection.io.git
synced 2026-05-29 21:11:50 +00:00
217 lines
10 KiB
Python
217 lines
10 KiB
Python
"""
|
|
Prompt construction for LLM evaluation calls.
|
|
Pure functions — no side effects, fully testable.
|
|
"""
|
|
|
|
import re
|
|
|
|
from .bm25_trim import trim_to_relevant
|
|
|
|
_AGO_RE = re.compile(r'^\d+\s+\w+\s+ago$', re.IGNORECASE)
|
|
|
|
SNAPSHOT_CONTEXT_CHARS = 3_000 # current page state excerpt sent alongside the diff
|
|
|
|
|
|
def _annotate_moved_lines(diff_text: str) -> str:
|
|
"""
|
|
Pre-process a unified diff to mark lines that appear on both the + and - sides
|
|
as [MOVED] rather than genuinely added/removed. This prevents the LLM from
|
|
incorrectly classifying repositioned content as new or deleted.
|
|
|
|
Lines are compared after stripping leading +/- and whitespace so that
|
|
indentation changes don't prevent matching.
|
|
"""
|
|
lines = diff_text.splitlines()
|
|
added_texts = {l[1:].strip().lower() for l in lines if l.startswith('+') and l[1:].strip()}
|
|
removed_texts = {l[1:].strip().lower() for l in lines if l.startswith('-') and l[1:].strip()}
|
|
moved_texts = added_texts & removed_texts
|
|
|
|
if not moved_texts:
|
|
return diff_text
|
|
|
|
result = []
|
|
for line in lines:
|
|
if line.startswith(('+', '-')):
|
|
bare = line[1:].strip().lower()
|
|
if bare in moved_texts or _AGO_RE.match(line[1:].strip()):
|
|
result.append(f'~{line[1:]}') # ~ prefix = moved/reordered/trivial, skip
|
|
continue
|
|
result.append(line)
|
|
return '\n'.join(result)
|
|
|
|
|
|
def build_eval_prompt(intent: str, diff: str, current_snapshot: str = '',
|
|
url: str = '', title: str = '') -> str:
|
|
"""
|
|
Build the user message for a diff evaluation call.
|
|
The system prompt is kept separate (see build_eval_system_prompt).
|
|
"""
|
|
parts = []
|
|
|
|
if url:
|
|
parts.append(f"URL: {url}")
|
|
if title:
|
|
parts.append(f"Page title: {title}")
|
|
|
|
parts.append(f"Intent: {intent}")
|
|
|
|
if current_snapshot:
|
|
excerpt = trim_to_relevant(current_snapshot, intent, max_chars=SNAPSHOT_CONTEXT_CHARS)
|
|
if excerpt:
|
|
parts.append(f"\nCurrent page state (relevant excerpt):\n{excerpt}")
|
|
|
|
parts.append(f"\nWhat changed (diff):\n{diff}")
|
|
|
|
return '\n'.join(parts)
|
|
|
|
|
|
def build_eval_system_prompt() -> str:
|
|
return (
|
|
"You are a precise, reliable website-change evaluator for a monitoring tool.\n"
|
|
"Your job is to read a unified diff and decide whether it matches a user's stated intent.\n"
|
|
"Accuracy is critical — false positives waste the user's attention; false negatives miss what they care about.\n\n"
|
|
"Diff format:\n"
|
|
"- Lines starting with '+' are newly ADDED content\n"
|
|
"- Lines starting with '-' are REMOVED content\n"
|
|
"- Lines starting with ' ' (space) are unchanged context\n\n"
|
|
"Respond with ONLY a JSON object — no markdown, no explanation outside it:\n"
|
|
'{"important": true/false, "summary": "one sentence describing the relevant change, or why it doesn\'t match"}\n\n'
|
|
"Rules:\n"
|
|
"- important=true ONLY when the diff clearly and specifically matches the intent — be strict\n"
|
|
"- Pay close attention to direction: an intent about price drops means removed (-) prices and added (+) lower prices\n"
|
|
"- The user's intent always wins. If the intent explicitly asks about timestamps, numbers, counters, "
|
|
"thresholds, or any specific value (e.g. 'when the timestamp is greater than 1778599592', "
|
|
"'when stock count > 5'), evaluate the diff against that intent — do NOT dismiss it as cosmetic.\n"
|
|
"- Otherwise: empty, trivial, or genuinely cosmetic diffs (heartbeat timestamps, view counters, "
|
|
"whitespace, navigation tweaks) default to important=false\n"
|
|
"- For numeric comparisons in the intent, parse the values explicitly and compare them — "
|
|
"do not eyeball or round\n"
|
|
"- If the same text appears in both removed (-) and added (+) lines the content has likely just "
|
|
"shifted or been reordered. Treat pure reordering as important=false unless the intent "
|
|
"explicitly asks about order or position.\n"
|
|
"- Use OR logic when the intent lists multiple triggers — any one matching is sufficient\n"
|
|
"- When uncertain whether a change truly matches, prefer important=false and explain why in the summary\n"
|
|
"- Summary must be in the same language as the intent\n"
|
|
"- If important=false, the summary must clearly explain what changed and why it does not match"
|
|
)
|
|
|
|
|
|
def build_preview_prompt(intent: str, content: str, url: str = '', title: str = '') -> str:
|
|
"""
|
|
Build the user message for a live-preview extraction call.
|
|
Unlike build_eval_prompt (which analyses a diff), this asks the LLM to
|
|
extract relevant information from the *current* page content — giving the
|
|
user a direct answer to their intent so they can verify it makes sense
|
|
before saving.
|
|
"""
|
|
parts = []
|
|
if url:
|
|
parts.append(f"URL: {url}")
|
|
if title:
|
|
parts.append(f"Page title: {title}")
|
|
parts.append(f"Intent / question: {intent}")
|
|
parts.append(f"\nPage content:\n{content[:6_000]}")
|
|
return '\n'.join(parts)
|
|
|
|
|
|
def build_preview_system_prompt() -> str:
|
|
return (
|
|
"You are a precise, detail-oriented web page content analyst for a website monitoring tool.\n"
|
|
"Given the user's intent or question and the current page content, extract and directly answer "
|
|
"what the intent is looking for. Never guess or paraphrase — report only what the page actually contains.\n\n"
|
|
"Respond with ONLY a JSON object — no markdown, no explanation outside it:\n"
|
|
'{"found": true/false, "answer": "concise direct answer or extraction"}\n\n'
|
|
"Rules:\n"
|
|
"- found=true when the page clearly contains something relevant to the intent\n"
|
|
"- answer must directly address the intent with specific values where possible "
|
|
"(e.g. for 'current price?' → '$149.99', not 'a price is shown')\n"
|
|
"- answer must be in the same language as the intent\n"
|
|
"- Keep answer brief — one or two sentences maximum\n"
|
|
"- If found=false, briefly state what the page contains instead"
|
|
)
|
|
|
|
|
|
def build_change_summary_prompt(diff: str, custom_prompt: str,
|
|
current_snapshot: str = '', url: str = '', title: str = '') -> str:
|
|
"""
|
|
Build the user message for an AI Change Summary call.
|
|
The user supplies their own instructions (custom_prompt); this wraps them
|
|
with the diff (which carries its own surrounding context via unified_diff's
|
|
n=3 context lines, marked '~' by _annotate_moved_lines).
|
|
|
|
NOTE: current_snapshot is accepted for caller compatibility but intentionally
|
|
unused. A wholesale page excerpt caused the LLM to report unchanged page
|
|
content (e.g. old release-note bullets) as "what changed" — hallucinations
|
|
drawn from the excerpt rather than the diff. The in-diff context lines give
|
|
the model enough surrounding text to describe each change accurately.
|
|
"""
|
|
parts = []
|
|
if url:
|
|
parts.append(f"URL: {url}")
|
|
if title:
|
|
parts.append(f"Page title: {title}")
|
|
parts.append(f"Instructions: {custom_prompt}")
|
|
parts.append(f"\nWhat changed (diff):\n{_annotate_moved_lines(diff)}")
|
|
return '\n'.join(parts)
|
|
|
|
|
|
def build_change_summary_system_prompt() -> str:
|
|
"""
|
|
Universal, format-agnostic instructions: how to READ a diff and accuracy rules.
|
|
All output-format choices (prose vs JSON, sections, bullets, language, length)
|
|
are owned by the user prompt — including the default in
|
|
DEFAULT_CHANGE_SUMMARY_PROMPT — so that a user replacing the user-prompt
|
|
(e.g. asking for raw JSON) is not overridden by hard-coded format rules here.
|
|
"""
|
|
return (
|
|
"You analyse a unified-diff document showing how a monitored web page changed, "
|
|
"and produce exactly the output the user asks for.\n\n"
|
|
"Rules for reading the diff:\n"
|
|
"- Lines starting with + are genuinely new content.\n"
|
|
"- Lines starting with - are genuinely removed content.\n"
|
|
"- Lines starting with ~ have been PRE-IDENTIFIED as moved/reordered or trivial — "
|
|
"the same text exists on both sides of the diff, or the line is a standalone timestamp. "
|
|
"Do NOT treat ~ lines as added or removed.\n\n"
|
|
"Accuracy: only report what the +/- lines actually contain. Never invent details, "
|
|
"never speculate, never add information that isn't in the diff.\n\n"
|
|
"Follow the user's instructions exactly — including the requested output format "
|
|
"(plain text, JSON, Markdown, single value, etc.), structure, language, and length. "
|
|
"Do not add preamble, meta-commentary, or self-introduction. Produce only the output "
|
|
"the user asked for — nothing before it, nothing after it."
|
|
)
|
|
|
|
|
|
def build_setup_prompt(intent: str, snapshot_text: str, url: str = '') -> str:
|
|
"""
|
|
Build the prompt for the one-time setup call that decides whether
|
|
a CSS pre-filter would improve evaluation precision.
|
|
"""
|
|
excerpt = trim_to_relevant(snapshot_text, intent, max_chars=4_000)
|
|
|
|
parts = []
|
|
if url:
|
|
parts.append(f"URL: {url}")
|
|
parts.append(f"Intent: {intent}")
|
|
parts.append(f"\nPage content excerpt:\n{excerpt}")
|
|
|
|
return '\n'.join(parts)
|
|
|
|
|
|
def build_setup_system_prompt() -> str:
|
|
return (
|
|
"You help configure a website change monitor.\n"
|
|
"Given a monitoring intent and a sample of the page content, decide if a CSS pre-filter "
|
|
"would improve evaluation precision by scoping the content to a specific structural section.\n\n"
|
|
"Respond with ONLY a JSON object:\n"
|
|
'{"needs_prefilter": true/false, "selector": "CSS selector or null", "reason": "one sentence"}\n\n'
|
|
"Rules:\n"
|
|
"- Only recommend a pre-filter when the intent references a specific structural section "
|
|
"(e.g. 'footer', 'sidebar', 'nav', 'header', 'main', 'article') OR the page clearly "
|
|
"has high-noise sections unrelated to the intent\n"
|
|
"- Use ONLY semantic element selectors: footer, nav, header, main, article, aside, "
|
|
"or attribute-based like [id*='price'], [class*='sidebar'] — NEVER positional selectors "
|
|
"like div:nth-child(3) or //*[2]\n"
|
|
"- Default to needs_prefilter=false — most intents don't need one\n"
|
|
"- selector must be null when needs_prefilter=false"
|
|
)
|