CI & Build / Python lint (push) Successful in 4s
CI & Build / Plugin hooks (push) Successful in 8s
CI & Build / integration (push) Successful in 17s
CI & Build / Python tests (push) Failing after 30s
CI & Build / TypeScript typecheck (push) Successful in 33s
CI & Build / Build & push image (push) Skipped
Step 5 of #278, folding in #2534. The operator's no-gate decision for the web UI (#2482 — "an llm attached to this surface is the corrections system") has a precondition nobody had built: the corrector has to be able to SEE what needs correcting. find_duplicate_snippets had no equivalent for notes or tasks, so a duplicate note was only ever noticed by accident. find_duplicate_records(kind="snippet"|"note"|"task") — the same indexed self-join, parameterised. Tasks are notes with a status, not a note_type, so the kind split is a status predicate; mixing them would propose folding a to-do into a write-up. find_duplicate_snippets stays as a wrapper because both surfaces and SnippetListView consume it by name. What differs by kind is the CURE, and the report says so in a `suggestion` field rather than leaving the caller to guess: snippet merge — lossless, the survivor keeps every call site note NEVER merge. A correction pair → supersedes on the newer; state smeared across dated records → extract to the System's reference note; genuinely parallel → leave alone. Choosing needs the records READ, which is the agent's job — so non-snippet groups carry `members` with dates and any `existing_supersessions` already declared inside the group. A pair someone ruled on is not an open question. task usually the same work opened twice — keep the one with the history, cancel the other with a pointer. The snippet sibling filter stays snippet-only: it keys on symbol/code_sha, which other kinds don't carry — and for them a look-alike is a finding. Surfaces: MCP find_duplicate_records (classified into _READ_ONLY_TOOLS — the completeness test would have caught the omission), REST /api/notes/duplicates, and a KnowledgeView panel mirroring SnippetListView's — links only, no merge button, because for notes the report proposes and the correction is a read- and-decide act. The panel follows the type filter and clears when it changes, so a note report can't linger under a task view. Correcting the task's own premise: it claimed the snippet report had "no view consuming it" — stale; SnippetListView has consumed it since it shipped. The UI gap was only ever notes/tasks. Answers the question carried from #2482: yes, the update routes on BOTH surfaces can turn a record into a duplicate — the gate is create-time by design. This report is the mechanism that catches it after the fact, which is the model the operator chose. Refs #278, #2547
328 lines
13 KiB
Python
328 lines
13 KiB
Python
"""Unit tests for the write-time near-duplicate gate (services/dedup.py)."""
|
|
from unittest.mock import AsyncMock, MagicMock, patch
|
|
|
|
import pytest
|
|
|
|
from scribe.services.dedup import (
|
|
DuplicateMatch,
|
|
duplicate_response,
|
|
find_duplicate_note,
|
|
find_duplicate_rule,
|
|
)
|
|
|
|
|
|
def _session_returning(note):
|
|
"""A mocked async_session() whose single execute() yields `note` (or None)."""
|
|
s = AsyncMock()
|
|
s.__aenter__ = AsyncMock(return_value=s)
|
|
s.__aexit__ = AsyncMock(return_value=False)
|
|
result = MagicMock()
|
|
result.scalars.return_value.first.return_value = note
|
|
s.execute = AsyncMock(return_value=result)
|
|
return s
|
|
|
|
|
|
def _fake_note(id=1, title="T", note_type="note"):
|
|
n = MagicMock()
|
|
n.id, n.title, n.note_type = id, title, note_type
|
|
return n
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_title_exact_match_returns_title_duplicate():
|
|
note = _fake_note(id=10, title="Setup CI")
|
|
with patch("scribe.services.dedup.async_session",
|
|
return_value=_session_returning(note)):
|
|
# whitespace/case differences are normalized away
|
|
dup = await find_duplicate_note(7, " setup ci ", project_id=2, is_task=True)
|
|
assert dup is not None
|
|
assert dup.id == 10
|
|
assert dup.reason == "title"
|
|
assert dup.similarity == 1.0
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_short_body_skips_semantic_check():
|
|
sem = AsyncMock()
|
|
with patch("scribe.services.dedup.async_session",
|
|
return_value=_session_returning(None)), \
|
|
patch("scribe.services.dedup.embeddings_svc.semantic_search_notes", sem):
|
|
dup = await find_duplicate_note(7, "Unique", body="too short", project_id=2)
|
|
assert dup is None
|
|
sem.assert_not_called() # body under _MIN_BODY_FOR_SEMANTIC
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_semantic_match_when_body_substantial():
|
|
hit = _fake_note(id=20, title="Existing", note_type="note")
|
|
sem = AsyncMock(return_value=[(0.93, hit)])
|
|
with patch("scribe.services.dedup.async_session",
|
|
return_value=_session_returning(None)), \
|
|
patch("scribe.services.dedup.embeddings_svc.semantic_search_notes", sem):
|
|
dup = await find_duplicate_note(
|
|
7, "Title", body="x" * 250, project_id=2, is_task=False, note_type="note",
|
|
)
|
|
assert dup is not None
|
|
assert dup.id == 20
|
|
assert dup.reason == "semantic"
|
|
assert dup.similarity == 0.93
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_semantic_match_of_other_note_type_is_ignored():
|
|
other = _fake_note(id=21, title="X", note_type="process")
|
|
sem = AsyncMock(return_value=[(0.97, other)])
|
|
with patch("scribe.services.dedup.async_session",
|
|
return_value=_session_returning(None)), \
|
|
patch("scribe.services.dedup.embeddings_svc.semantic_search_notes", sem):
|
|
dup = await find_duplicate_note(7, "Title", body="x" * 250, note_type="note")
|
|
assert dup is None # type mismatch must not block
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_rule_title_match_in_topic():
|
|
rule = _fake_note(id=47, title="Honor the multi-user sharing ACL")
|
|
with patch("scribe.services.dedup.async_session",
|
|
return_value=_session_returning(rule)):
|
|
dup = await find_duplicate_rule(
|
|
"honor the multi-user sharing acl", topic_id=7,
|
|
)
|
|
assert dup is not None
|
|
assert dup.id == 47
|
|
assert dup.reason == "title"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_rule_requires_a_scope():
|
|
# No topic_id and no project_id → nothing to scope to → no match, no query.
|
|
sess = AsyncMock()
|
|
with patch("scribe.services.dedup.async_session", return_value=sess):
|
|
dup = await find_duplicate_rule("anything")
|
|
assert dup is None
|
|
sess.__aenter__.assert_not_called()
|
|
|
|
|
|
def test_duplicate_response_shape():
|
|
dm = DuplicateMatch(id=5, title="Foo", similarity=1.0, reason="title")
|
|
r = duplicate_response(dm, "task")
|
|
assert r["duplicate"] is True
|
|
assert r["existing_id"] == 5
|
|
assert r["match"] == "title"
|
|
assert "force=true" in r["message"]
|
|
assert "update_task" in r["message"]
|
|
|
|
|
|
# --- snippet structural identity (#2518) -------------------------------------
|
|
#
|
|
# The gate used to compare a snippet's rendered DOCUMENT, which is mostly prose
|
|
# about the code. Measured on the button corpus, that failed in both directions
|
|
# at once: two deliberately-parallel variants were refused at 0.92, while a
|
|
# verbatim re-record of one snippet under a different name scored below 0.90 and
|
|
# was created. These tests pin the structural signals that replaced it.
|
|
|
|
|
|
def _session_sequence(results):
|
|
"""A mocked async_session() whose successive execute() calls yield `results`.
|
|
|
|
The single-result helper above can't express this: the structural check runs
|
|
a location query and then a code query, and the whole point is that they
|
|
answer differently.
|
|
"""
|
|
s = AsyncMock()
|
|
s.__aenter__ = AsyncMock(return_value=s)
|
|
s.__aexit__ = AsyncMock(return_value=False)
|
|
wrapped = []
|
|
for note in results:
|
|
r = MagicMock()
|
|
r.scalars.return_value.first.return_value = note
|
|
wrapped.append(r)
|
|
s.execute = AsyncMock(side_effect=wrapped)
|
|
return s
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_same_location_is_a_duplicate_however_it_is_described():
|
|
"""The measured false NEGATIVE: identical code at an identical
|
|
repo·path·symbol was created because the prose around it differed."""
|
|
existing = _fake_note(id=30, title=".btn-primary — a page's main action",
|
|
note_type="snippet")
|
|
sem = AsyncMock()
|
|
with patch("scribe.services.dedup.async_session",
|
|
return_value=_session_sequence([None, existing])), \
|
|
patch("scribe.services.dedup.embeddings_svc.semantic_search_notes", sem):
|
|
dup = await find_duplicate_note(
|
|
7, "primaryButton — something else entirely", body="x" * 400,
|
|
project_id=2, is_task=False, note_type="snippet",
|
|
code=".btn-primary { color: red; }",
|
|
locations=[{"repo": "Scribe", "path": "a/b.css", "symbol": ".btn-primary"}],
|
|
)
|
|
assert dup is not None
|
|
assert dup.reason == "location"
|
|
assert dup.similarity == 1.0
|
|
# Structural identity is certain, so it must not be diluted by asking the
|
|
# embedder for a second opinion.
|
|
sem.assert_not_called()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_identical_code_is_a_duplicate_at_a_different_location():
|
|
existing = _fake_note(id=31, title="group_pairs", note_type="snippet")
|
|
with patch("scribe.services.dedup.async_session",
|
|
return_value=_session_sequence([None, None, existing])), \
|
|
patch("scribe.services.dedup.embeddings_svc.semantic_search_notes",
|
|
AsyncMock(return_value=[])):
|
|
dup = await find_duplicate_note(
|
|
7, "unionFind", body="x" * 400, project_id=2, is_task=False,
|
|
note_type="snippet", code="def f():\n return 1",
|
|
locations=[{"repo": "Scribe", "path": "z.py", "symbol": "f"}],
|
|
)
|
|
assert dup is not None
|
|
assert dup.reason == "code"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_a_location_without_a_symbol_is_not_an_identity():
|
|
"""A path alone is a DIRECTORY of artefacts. Matching on it would refuse
|
|
every second snippet recorded from one file — which is exactly the corpus
|
|
the button recipes form."""
|
|
session = _session_sequence([None])
|
|
sem = AsyncMock(return_value=[])
|
|
with patch("scribe.services.dedup.async_session", return_value=session), \
|
|
patch("scribe.services.dedup.embeddings_svc.semantic_search_notes", sem):
|
|
dup = await find_duplicate_note(
|
|
7, "Some recipe", body="x" * 400, project_id=2, is_task=False,
|
|
note_type="snippet", code="",
|
|
locations=[{"repo": "Scribe", "path": "a/b.css", "symbol": ""}],
|
|
)
|
|
assert dup is None
|
|
# Exactly one query — the title check. With no symbol and no code there is
|
|
# nothing to match structurally, and the sequence above would raise
|
|
# StopIteration if a second query were issued.
|
|
assert session.execute.await_count == 1
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_snippets_use_the_raised_semantic_threshold():
|
|
"""Variants of one component legitimately reach 0.92. The semantic arm has
|
|
to sit above that band or it refuses the corpus it exists to protect."""
|
|
from scribe.services.dedup import (
|
|
_SEMANTIC_THRESHOLD,
|
|
_SNIPPET_SEMANTIC_THRESHOLD,
|
|
)
|
|
sem = AsyncMock(return_value=[])
|
|
with patch("scribe.services.dedup.async_session",
|
|
return_value=_session_sequence([None, None, None])), \
|
|
patch("scribe.services.dedup.embeddings_svc.semantic_search_notes", sem):
|
|
await find_duplicate_note(
|
|
7, "A recipe", body="x" * 400, project_id=2, is_task=False,
|
|
note_type="snippet", code="x",
|
|
locations=[{"repo": "R", "path": "p", "symbol": "s"}],
|
|
)
|
|
assert sem.await_args.kwargs["threshold"] == _SNIPPET_SEMANTIC_THRESHOLD
|
|
assert _SNIPPET_SEMANTIC_THRESHOLD > 0.92, (
|
|
"the observed sibling band tops out at 0.92 (.btn-danger vs "
|
|
".btn-danger-outline); a threshold at or below it blocks legitimate "
|
|
"variants again"
|
|
)
|
|
assert _SNIPPET_SEMANTIC_THRESHOLD > _SEMANTIC_THRESHOLD
|
|
|
|
|
|
def test_sibling_variants_are_not_reported_as_merge_candidates():
|
|
"""The measured false POSITIVE: eight button recipes, every direct pair over
|
|
the floor, proposed as ONE merge set."""
|
|
from scribe.services.dedup import _drop_sibling_pairs
|
|
|
|
records = {
|
|
1: {"locations": [{"repo": "S", "path": "c.css", "symbol": ".btn-primary"}],
|
|
"code_sha": "aaa"},
|
|
2: {"locations": [{"repo": "S", "path": "c.css", "symbol": ".btn-secondary"}],
|
|
"code_sha": "bbb"},
|
|
}
|
|
assert _drop_sibling_pairs([(1, 2, 0.87)], records) == []
|
|
|
|
|
|
def test_identical_code_still_reports_even_with_different_symbols():
|
|
"""The filter keys on "the author named these apart", but a shared code
|
|
fingerprint overrides that — the same code under two names IS the
|
|
copy-paste the report exists to surface."""
|
|
from scribe.services.dedup import _drop_sibling_pairs
|
|
|
|
records = {
|
|
1: {"locations": [{"repo": "S", "path": "a.py", "symbol": "debounce"}],
|
|
"code_sha": "same"},
|
|
2: {"locations": [{"repo": "S", "path": "b.py", "symbol": "useDebounced"}],
|
|
"code_sha": "same"},
|
|
}
|
|
assert _drop_sibling_pairs([(1, 2, 0.9)], records) == [(1, 2, 0.9)]
|
|
|
|
|
|
def test_unnamed_snippets_still_report():
|
|
"""A snippet with no recorded symbol made no identity claim, so the filter
|
|
must not protect it — re-recording without a location is a common way to
|
|
duplicate."""
|
|
from scribe.services.dedup import _drop_sibling_pairs
|
|
|
|
records = {1: {"code_sha": "aaa"}, 2: {"code_sha": "bbb"}}
|
|
assert _drop_sibling_pairs([(1, 2, 0.9)], records) == [(1, 2, 0.9)]
|
|
|
|
|
|
def test_duplicate_response_names_what_matched_for_structural_hits():
|
|
"""A structural hit is certain, so the message must not hedge with
|
|
"similar" — and it points at merge, which is what two records of one
|
|
artefact actually need."""
|
|
r = duplicate_response(
|
|
DuplicateMatch(id=9, title=".btn-primary", similarity=1.0, reason="location"),
|
|
"snippet",
|
|
)
|
|
assert "repo · path · symbol" in r["message"]
|
|
assert "merge_snippets" in r["message"]
|
|
assert "similar" not in r["message"]
|
|
|
|
r = duplicate_response(
|
|
DuplicateMatch(id=9, title="x", similarity=1.0, reason="code"), "snippet",
|
|
)
|
|
assert "identical code" in r["message"]
|
|
|
|
|
|
# --- the generalised report (#2547) ------------------------------------------
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_report_refuses_an_unknown_kind():
|
|
"""A typo'd kind must fail loudly, not scan snippets by default — the
|
|
caller asked a question about a kind that doesn't exist, and answering a
|
|
different question instead is how wrong conclusions get confident."""
|
|
from scribe.services.dedup import find_duplicate_records
|
|
|
|
with pytest.raises(ValueError, match="kind must be one of"):
|
|
await find_duplicate_records(7, kind="rule")
|
|
|
|
|
|
def test_kind_clauses_split_notes_from_tasks_on_status():
|
|
"""Tasks are notes with a status, not a note_type of their own. A report
|
|
that mixed them would propose folding a to-do into a write-up."""
|
|
from scribe.models.note import Note
|
|
from scribe.services.dedup import _kind_clauses
|
|
|
|
note_sql = " AND ".join(str(c) for c in _kind_clauses("note", Note))
|
|
task_sql = " AND ".join(str(c) for c in _kind_clauses("task", Note))
|
|
snip_sql = " AND ".join(str(c) for c in _kind_clauses("snippet", Note))
|
|
|
|
assert "status IS NULL" in note_sql
|
|
assert "status IS NOT NULL" in task_sql
|
|
assert "note_type" in snip_sql and "status" not in snip_sql
|
|
|
|
|
|
def test_every_kind_has_a_suggestion_and_none_proposes_merging_notes():
|
|
"""The suggestion is the report's point: what to DO differs by what the
|
|
records are, and 'merge' is only ever the answer for snippets — folding two
|
|
notes destroys what each said, which is why consolidated_at was dropped
|
|
rather than built (#2483)."""
|
|
from scribe.services.dedup import _KIND_SUGGESTION, _REPORT_KINDS
|
|
|
|
for kind in _REPORT_KINDS:
|
|
assert _KIND_SUGGESTION.get(kind), f"no suggestion for {kind}"
|
|
assert "merge" in _KIND_SUGGESTION["snippet"]
|
|
assert "NOT merge" in _KIND_SUGGESTION["note"]
|
|
assert "supersedes" in _KIND_SUGGESTION["note"]
|