feat(dedup): a note or task blocks only as a copy; a close match is surfaced for judgement (#4306)
CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 10s
CI & Build / TypeScript typecheck (push) Successful in 58s
CI & Build / integration (push) Successful in 1m12s
CI & Build / Python tests (push) Failing after 1m29s
CI & Build / Build & push image (push) Skipped
CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 10s
CI & Build / TypeScript typecheck (push) Successful in 58s
CI & Build / integration (push) Successful in 1m12s
CI & Build / Python tests (push) Failing after 1m29s
CI & Build / Build & push image (push) Skipped
Measured on the live corpus, the 74 note pairs at or above the old 0.90 bar were almost all distinct siblings — consecutive dev-logs, sub-notes of one design, research parts — and the one clear copy sat at 0.997. The block refused the next dev-log and taught force=true, as #4134 found for rules. - The semantic arm blocks notes and tasks only at >= 0.98. The title block stays; processes keep 0.90 (not measured). - 0.87 to 0.98 comes back as `overlaps` on the create reply, from the same per-chunk searches, with a note that leaves the call to the session: fold in and delete if it is the same record, keep both if a sibling. - create_note, create_task, create_records and start_planning's steps all carry it; a batch names the record each overlap belongs to. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -43,8 +43,9 @@ async def test_short_body_skips_semantic_check():
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_semantic_match_when_body_substantial():
|
||||
# A note blocks only in the copy band (#4306).
|
||||
hit = fake_note(id=20, title="Existing", note_type="note")
|
||||
sem = AsyncMock(return_value=[(0.93, hit)])
|
||||
sem = AsyncMock(return_value=[(0.99, hit)])
|
||||
with patch("scribe.services.dedup.async_session",
|
||||
return_value=session_returning(None)), \
|
||||
patch("scribe.services.dedup.embeddings_svc.semantic_search_notes", sem):
|
||||
@@ -54,7 +55,80 @@ async def test_semantic_match_when_body_substantial():
|
||||
assert dup is not None
|
||||
assert dup.id == 20
|
||||
assert dup.reason == "semantic"
|
||||
assert dup.similarity == 0.93
|
||||
assert dup.similarity == 0.99
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_a_near_note_is_surfaced_not_blocked():
|
||||
"""#4306: sibling notes — the next dev-log, another part of one design —
|
||||
measured 0.90–0.98, so a match there is shown for the session to judge
|
||||
instead of refusing the write."""
|
||||
from scribe.services.dedup import _NOTE_OVERLAP_FLOOR
|
||||
|
||||
hit = fake_note(id=20, title="Dev-log day 3", note_type="note")
|
||||
sem = AsyncMock(return_value=[(0.93, hit)])
|
||||
overlaps: list = []
|
||||
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, "Dev-log day 4", body="x" * 250, project_id=2, is_task=False,
|
||||
note_type="note", overlaps=overlaps,
|
||||
)
|
||||
assert dup is None
|
||||
assert [(o.id, o.similarity) for o in overlaps] == [(20, 0.93)]
|
||||
# Asked at the overlap floor, so the one search serves both answers.
|
||||
assert sem.await_args.kwargs["threshold"] == _NOTE_OVERLAP_FLOOR
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_a_record_matching_in_two_chunks_is_one_overlap():
|
||||
para = ("A paragraph long enough for the chunker to keep as its own "
|
||||
"section of real content in this test body. ") * 4
|
||||
body = "\n\n".join(f"## Part {i}\n\n{para} (p{i})" for i in range(8))
|
||||
hit = fake_note(id=31, title="Design part one", note_type="note")
|
||||
sem = AsyncMock(return_value=[(0.9, hit)])
|
||||
overlaps: list = []
|
||||
with patch("scribe.services.dedup.async_session",
|
||||
return_value=session_returning(None)), \
|
||||
patch("scribe.services.dedup.embeddings_svc.semantic_search_notes", sem):
|
||||
await find_duplicate_note(
|
||||
7, "Design part two", body=body, project_id=2, note_type="note",
|
||||
overlaps=overlaps,
|
||||
)
|
||||
assert [o.id for o in overlaps] == [31]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_without_an_overlaps_list_the_gate_asks_at_the_copy_band():
|
||||
"""A caller that only wants the block (create_process) does not pay for
|
||||
a wider search it will not read."""
|
||||
from scribe.services.dedup import _NOTE_COPY_THRESHOLD
|
||||
|
||||
sem = AsyncMock(return_value=[])
|
||||
with patch("scribe.services.dedup.async_session",
|
||||
return_value=session_returning(None)), \
|
||||
patch("scribe.services.dedup.embeddings_svc.semantic_search_notes", sem):
|
||||
await find_duplicate_note(7, "T", body="x" * 250, note_type="note")
|
||||
assert sem.await_args.kwargs["threshold"] == _NOTE_COPY_THRESHOLD
|
||||
|
||||
|
||||
def test_the_overlap_reply_leaves_the_judgement_to_the_reader():
|
||||
from scribe.services.dedup import NoteOverlap, note_overlap_response
|
||||
|
||||
assert note_overlap_response([], "note") == {}
|
||||
out = note_overlap_response([NoteOverlap(9, "Dev-log day 3", 0.93)], "task")
|
||||
assert out["overlaps"] == [{"id": 9, "title": "Dev-log day 3", "similarity": 0.93}]
|
||||
assert "update_task" in out["overlap_note"]
|
||||
assert "keep both" in out["overlap_note"]
|
||||
|
||||
|
||||
def test_a_batch_overlap_names_its_record():
|
||||
from scribe.services.dedup import NoteOverlap, batch_overlap_response
|
||||
|
||||
assert batch_overlap_response({}) == {}
|
||||
out = batch_overlap_response({2: [NoteOverlap(9, "X", 0.9)]})
|
||||
assert out["overlaps"] == [{"record": 2, "id": 9, "title": "X", "similarity": 0.9}]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -73,7 +147,7 @@ async def test_gate_catches_a_duplicate_hiding_in_a_later_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)]])
|
||||
sem = AsyncMock(side_effect=[[] for _ in range(n_chunks - 1)] + [[(0.99, hit)]])
|
||||
with patch("scribe.services.dedup.async_session",
|
||||
return_value=session_returning(None)), \
|
||||
patch("scribe.services.dedup.embeddings_svc.semantic_search_notes", sem):
|
||||
|
||||
Reference in New Issue
Block a user