feat(mcp): Phase 5 — write-time near-duplicate gate (update-over-create)
#755 Phase 5. create_note / create_task now BLOCK a near-duplicate instead of silently inserting: they return {"duplicate": true, "existing_id", message} pointing at the record to UPDATE. Fights store bloat and stale competing copies that semantic search (RAG) would otherwise resurface for reconciliation. A force=true override creates anyway for genuinely-distinct records. - services/dedup.py: find_duplicate_note — two signals, scoped to owner + same project + same kind: (1) normalized-title exact match (cheap, always); (2) semantic cosine ≥ 0.90 but ONLY when body ≥ 200 chars (short/title-only embeddings false-positive — the pre-pivot lesson). Project-less (orphan) records compare only to other orphans on BOTH signals (orphan_only on the semantic call) — they're not matched across every project. - Gate wired into the MCP create_note/create_task tools (the LLM write path) with force override; _INSTRUCTIONS documents the duplicate response + force. - Opt-in by design: the service helper is only called from the interactive create tools. Internal/programmatic creates (recurrence spawn, imports) go straight through services.create_note and are NOT gated — a recurring task spawning its next same-titled instance must not be blocked. - Scope v1: MCP tools only. REST/web (human CRUD, needs a UI affordance) and create_rule (not a RAG surface; _INSTRUCTIONS already steer it) are follow-ups. - tests: dedup service (title/semantic/body-gate/type-filter) + tool gate (blocks, force bypasses) for notes and tasks. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -25,6 +25,32 @@ def _fake_note(**overrides) -> MagicMock:
|
||||
return note
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_note_blocked_by_duplicate_gate():
|
||||
from scribe.services.dedup import DuplicateMatch
|
||||
dup = DuplicateMatch(id=88, title="Embeddings notes", similarity=0.94, reason="semantic")
|
||||
create_mock = AsyncMock()
|
||||
with patch("scribe.mcp.tools.notes.dedup_svc.find_duplicate_note",
|
||||
AsyncMock(return_value=dup)), \
|
||||
patch("scribe.mcp.tools.notes.notes_svc.create_note", create_mock):
|
||||
out = await create_note(title="embeddings", body="notes about embeddings")
|
||||
assert out["duplicate"] is True
|
||||
assert out["existing_id"] == 88
|
||||
assert out["match"] == "semantic"
|
||||
create_mock.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_note_force_bypasses_duplicate_gate():
|
||||
find_mock = AsyncMock()
|
||||
with patch("scribe.mcp.tools.notes.dedup_svc.find_duplicate_note", find_mock), \
|
||||
patch("scribe.mcp.tools.notes.notes_svc.create_note",
|
||||
AsyncMock(return_value=_fake_note(id=3))):
|
||||
out = await create_note(title="dup", force=True)
|
||||
assert out["id"] == 3
|
||||
find_mock.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_notes_repackages_tuple_into_dict():
|
||||
rows = [_fake_note(id=1), _fake_note(id=2)]
|
||||
|
||||
@@ -113,6 +113,33 @@ async def test_create_task_passes_status():
|
||||
assert mock.call_args.kwargs["status"] == "todo"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_task_blocked_by_duplicate_gate():
|
||||
"""A near-duplicate blocks creation and returns the existing id (no insert)."""
|
||||
from scribe.services.dedup import DuplicateMatch
|
||||
dup = DuplicateMatch(id=42, title="Set up CI", similarity=1.0, reason="title")
|
||||
create_mock = AsyncMock()
|
||||
with patch("scribe.mcp.tools.tasks.dedup_svc.find_duplicate_note",
|
||||
AsyncMock(return_value=dup)), \
|
||||
patch("scribe.mcp.tools.tasks.notes_svc.create_note", create_mock):
|
||||
out = await create_task(title="set up ci")
|
||||
assert out["duplicate"] is True
|
||||
assert out["existing_id"] == 42
|
||||
create_mock.assert_not_called() # nothing was created
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_task_force_bypasses_duplicate_gate():
|
||||
"""force=true skips the gate entirely and creates."""
|
||||
find_mock = AsyncMock()
|
||||
with patch("scribe.mcp.tools.tasks.dedup_svc.find_duplicate_note", find_mock), \
|
||||
patch("scribe.mcp.tools.tasks.notes_svc.create_note",
|
||||
AsyncMock(return_value=_fake_task(id=9))):
|
||||
out = await create_task(title="dup", force=True)
|
||||
assert out["id"] == 9
|
||||
find_mock.assert_not_called() # gate not even consulted
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_task_rejects_retired_plan_kind():
|
||||
"""kind=plan is hard-retired — plans are milestones (start_planning)."""
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
"""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
|
||||
|
||||
|
||||
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
|
||||
|
||||
|
||||
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"]
|
||||
Reference in New Issue
Block a user