dgtlmoon / dgtlmoon/changedetection.io
Levenshtein condition compares content against a raw history timestamp, not a snapshot, when the current text is empty
- Dominant language
- Python
- Stars
- 34.3k
- Forks
- 2.1k
- Avg merge
- 22h 52m
- Merged PRs (30d)
- 70
Description
**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.
```python
# 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**
```python
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**
- Failure is silent: no exception, no log line distinguishable from the healthy path, so an affected user has no way to notice the condition is misbehaving.
- `git log -G 'levenshtein_ratio_recent_history'` shows only two touches to this function: the original feature (`af568d06`, #3108) and the introducing commit above (`5fd8200f`, #3161); no prior fix attempt.
- Searched open issues/PRs for `levenshtein` and found nothing describing this.
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.
Contributor guide
Research direction
Start in changedetectionio/conditions/plugins/levenshtein_plugin.py at levenshtein_ratio_recent_history(), then trace its call path through processor.py's evaluate_conditions() and execute_ruleset_against_all_plugins(). Reproduce the empty incoming_text case with at least two history snapshots and verify that the calculated similarity uses the previous snapshot text; done when a regression test covers this path and the condition reads the expected similarity.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- python
- Domain
- backend
- Issue type
- Bug
- Difficulty
- 2/5
- Estimated time
- 1-3 hours
- Activity status
- Quiet
- Clarity
- Clearly specified
- Newbie friendliness
- 80/100