Files
FabledScribe/tests/test_services_dedup.py
T
bvandeusenandClaude Opus 5 aa95c109ea
CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 16s
CI & Build / TypeScript typecheck (push) Successful in 53s
CI & Build / integration (push) Successful in 59s
CI & Build / Python tests (push) Successful in 1m38s
CI & Build / Build & push image (push) Successful in 27s
feat(retrieval): a task's work logs join the document it is embedded as (#4251)
Step 1 of #4251. A work log is the richest prose Scribe holds about WHY
something is the way it is — written during the work, recording what was tried
and ruled out. #4241 made it readable from the agent's door. It was still not
findable, so "has anyone tried this approach?" — precisely the question a log
answers — could not reach one. The cost is not hypothetical: #4208 was rebuilt
in this session because its logs were unreachable.

THE DESIGN QUESTION the issue left open was whether logs embed as part of their
task's document or as rows of their own. As their own rows, a hit has to be
resolved back to a task to be worth anything, and it needs a fourth search, a
fourth result shape and a fourth arm. As part of the task, the objection is
that a long log drowns a short title.

That objection was true before #280 and is not true now. Chunking made one
record into one vector per section, so each log becomes its own title-anchored
chunk, scored separately, and the task's own prose keeps the chunk it always
had — a task is as findable as its best-matching log rather than as the average
of everything in it. The other half is this session's other build: a search
hands back the chunk that won (#4243), so a hit earned by a log shows that
log's passage under the task's title. Without that a reader would have got
body[:240] of the task — the opening of a record whose relevance lives three
hundred lines further down.

So `task_document(title, body, logs)` sits beside `rule_document`: a
synthesised embed-time shape, because the stored record is the task row and the
logs live in their own table, so the document that should be searchable exists
nowhere until it is built. A task with no logs is returned untouched — most
notes are not tasks and most tasks carry no log, and their vectors are the
corpus every tuned number here was measured against.

CHUNKER_VERSION 1 → 2, and its comment now says what the version actually
means. It used to read "whenever chunk_document's output can change for the
same input", which this change would slip past: `chunk_document` is untouched
and every task with a log now embeds differently while its title, body and the
chunker all stand still. The invariant is the document a record is embedded as.
The startup backfill re-embeds on that.

Create, edit and delete of a log all refresh the task through `embed_note`, the
one path every writer shares — an edited log whose vectors still carry its old
wording keeps matching what it no longer says.

No new kind enters the auto-inject menu: tasks were always in it, and this
makes recall on them better rather than changing what the menu spans. The
calibration stamp will now report shape_version 2 against numbers measured at
1, which is exactly the report #4104 built it to make.

Also promoted `session_returning` into tests/helpers beside `make_mock_session`
(#2834) — two files had spelled it out identically and a third was about to.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01821k5B3Ysecp9fNYs92Kuy
2026-09-21 11:20:27 -04:00

421 lines
18 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 (
PLAN_MATCH_DEFAULT_THRESHOLD,
DuplicateMatch,
duplicate_response,
find_duplicate_note,
find_duplicate_rule,
find_matching_plan,
get_plan_match_threshold,
plan_candidate_text,
plan_match_response,
)
from tests.helpers import fake_note, make_mock_session, session_returning
@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_gate_catches_a_duplicate_hiding_in_a_later_chunk():
"""The capability #280 adds to the gate: a long candidate that duplicates
an existing record in ONE SECTION is caught, where the whole-document
query this replaces diluted exactly the section that mattered. The gate
queries once per chunk and any chunk's hit blocks."""
para = ("This section restates an existing decision in enough words to be "
"a real paragraph of content for the chunker to keep. ") * 4
body = "\n\n".join(f"## Topic {i}\n\n{para} (t{i})" for i in range(8))
from scribe.services.embeddings import chunk_document
n_chunks = len(chunk_document("Title", body))
assert n_chunks > 1, "test body must actually chunk"
hit = fake_note(id=30, title="The existing decision", note_type="note")
# Every chunk misses except the LAST one the gate will ask about.
sem = AsyncMock(side_effect=[[] for _ in range(n_chunks - 1)] + [[(0.94, 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=body, project_id=2, is_task=False, note_type="note",
)
assert dup is not None and dup.id == 30
assert sem.await_count == n_chunks
@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 = make_mock_session()
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}"
if kind != "snippet":
# The property, not a spot-check: a sixth kind added to the
# report inherits the bar without anyone editing this test.
assert "merge_snippets" not in _KIND_SUGGESTION[kind]
assert "merge" in _KIND_SUGGESTION["snippet"]
assert "NOT merge" in _KIND_SUGGESTION["note"]
assert "supersedes" in _KIND_SUGGESTION["note"]
# ── the plan gate (milestone 415, step 4) ─────────────────────────────────────
@pytest.mark.asyncio
async def test_plan_title_match_short_circuits_the_semantic_arm():
ms = MagicMock(id=415, title="Plan gate")
sem = AsyncMock()
with patch("scribe.services.dedup.can_read_project", AsyncMock(return_value=True)), \
patch("scribe.services.dedup.async_session", return_value=session_returning(ms)), \
patch("scribe.services.dedup.embeddings_svc.semantic_search_milestones", sem):
dup = await find_matching_plan(7, 2, " plan GATE", "x" * 300)
assert (dup.id, dup.reason, dup.similarity) == (415, "title", 1.0)
sem.assert_not_awaited()
@pytest.mark.asyncio
async def test_plan_semantic_arm_asks_for_active_plans_in_the_project_at_the_setting():
ms = MagicMock(id=9, title="Metadata")
sem = AsyncMock(return_value=[(0.912345, ms)])
with patch("scribe.services.dedup.can_read_project", AsyncMock(return_value=True)), \
patch("scribe.services.dedup.async_session", return_value=session_returning(None)), \
patch("scribe.services.dedup.embeddings_svc.semantic_search_milestones", sem), \
patch("scribe.services.settings.get_setting", AsyncMock(return_value="0.8")):
dup = await find_matching_plan(7, 2, "Book metadata", "x" * 300)
assert (dup.id, dup.reason, dup.similarity) == (9, "semantic", 0.912)
kw = sem.call_args.kwargs
assert (kw["project_id"], kw["status"], kw["threshold"]) == (2, "active", 0.8)
@pytest.mark.asyncio
async def test_plan_gate_fails_open():
boom = MagicMock(side_effect=RuntimeError("db down"))
with patch("scribe.services.dedup.can_read_project", AsyncMock(return_value=True)), \
patch("scribe.services.dedup.async_session", boom):
assert await find_matching_plan(7, 2, "Anything", "x" * 300) is None
with patch("scribe.services.dedup.can_read_project", AsyncMock(side_effect=RuntimeError)):
assert await find_matching_plan(7, 2, "Anything", "x" * 300) is None
assert await find_matching_plan(7, 0, "No project") is None
@pytest.mark.asyncio
async def test_plan_gate_says_nothing_about_a_project_the_caller_cannot_read():
ms = MagicMock(id=415, title="Their plan")
session = MagicMock(return_value=session_returning(ms))
with patch("scribe.services.dedup.can_read_project", AsyncMock(return_value=False)), \
patch("scribe.services.dedup.async_session", session):
assert await find_matching_plan(8, 2, "Their plan") is None
session.assert_not_called()
@pytest.mark.asyncio
async def test_a_bad_threshold_setting_falls_back_to_the_default():
with patch("scribe.services.settings.get_setting", AsyncMock(return_value="lots")):
assert await get_plan_match_threshold(7) == PLAN_MATCH_DEFAULT_THRESHOLD
with patch("scribe.services.settings.get_setting", AsyncMock(return_value="7")):
assert await get_plan_match_threshold(7) == 1.0
def test_plan_candidate_text_carries_the_steps():
text = plan_candidate_text(description=None, body=" design ",
steps=[("Step one", None), ("Step two", "with a body"), (None, None)])
assert text == "design\n\nStep one\n\nStep two\nwith a body"
def test_plan_match_response_points_at_adding_steps_not_a_second_plan():
out = plan_match_response(DuplicateMatch(12, "Metadata", 0.93, "semantic"),
{"total": 5, "completed": 2, "description": "providers"})
assert out["duplicate"] is True and out["existing_id"] == 12
assert out["existing_milestone"] == {
"id": 12, "title": "Metadata", "description": "providers", "total": 5, "completed": 2,
}
for phrase in ("create_records(milestone_id=12", "get_milestone(12)", "force=true",
"2 of 5 steps"):
assert phrase in out["message"]