Skip to content

Levenshtein condition compares content against a raw history timestamp, not a snapshot, when the current text is empty #4287

Description

@AmirF194

Describe the bug

levenshtein_ratio_recent_history() falls back to comparing the current snapshot against a raw history timestamp string instead of the previous snapshot's text, whenever the current check's filtered/extracted text is an empty string. The result feeds res['levenshtein_ratio'] / levenshtein_similarity, the exact fields a Levenshtein condition reads to decide whether to fire a notification, so the condition silently makes its decision from a near-meaningless score instead of a real similarity.

# changedetectionio/conditions/plugins/levenshtein_plugin.py:15-29
elif len(k) >= 1:
    a = watch.get_history_snapshot(timestamp=k[-1])   # latest saved snapshot, correct
    b = incoming_text if incoming_text else k[-2]      # <-- k[-2] is a dict KEY (a timestamp), not a snapshot

An empty incoming_text is a normal, non-error outcome (e.g. a CSS/xpath filter that matches nothing on a given cycle), and Python treats "" as falsy, so this branch is reachable on any ordinary check, not just an edge case. It was introduced in 5fd8200f (#3161), which added the else k[-2] fallback for the single-snapshot case but forgot to wrap it in watch.get_history_snapshot() the way a and the incoming_text is None branch above it both do.

Version: current master, commit aac6fcf

How did you install? Not installed; found by reading the source (the production call path is processor.py's evaluate_conditions() -> execute_ruleset_against_all_plugins() -> this plugin's add_data()) and confirmed with a standalone repro against the extracted function, in a clean python:3.11-slim container.

To Reproduce

from Levenshtein import ratio, distance

history = {
    "1000000000": "The quick brown fox jumps over the lazy dog near the riverbank at dawn.",
    "1000000100": "The quick brown fox jumps over the lazy dog near the riverbank at dusk.",
}
k = list(history.keys())
a = history[k[-1]]
b = "" if False else k[-2]        # incoming_text="" -> falls to the `else k[-2]` branch
print(round(ratio(a, b) * 100, 2))                 # what the condition actually reads
print(round(ratio(a, history[k[-2]]) * 100, 2))    # what it should read (comparing snapshot to snapshot)

Output: 0.0 vs the real 95.77. A condition configured as "block notification if similarity > 90%" would read 0% instead of ~96% whenever this path is hit, so it fails to suppress (or wrongly suppresses, for a low-threshold condition) a notification it was configured to gate on.

Expected behavior

When there's no newer text to compare (empty incoming_text), the fallback should resolve the previous snapshot the same way the incoming_text is None branch already does: watch.get_history_snapshot(timestamp=k[-2]).

Additional context

Happy to send a PR (resolve k[-2] through watch.get_history_snapshot(), with a regression test covering incoming_text="" against >=2 existing snapshots) if useful.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions