feat(mcp): extend dedup gate to create_rule / create_project_rule
Completes the Phase 5 follow-up: rules now get the same update-over-create gate. Title-based only (rules aren't a semantic-retrieval/RAG surface), scoped to the same topic (rulebook rule) or same project (project rule). force=true overrides; fail-open like the note/task gate. Deferred-item decisions (operator): REST/web gating SKIPPED (kept MCP-only — humans rarely double-create and a hard block needs UI affordance); orphan scope kept orphan↔orphan (no change). So this rule gate is the only remaining build. - services/dedup.py: find_duplicate_rule(title, topic_id|project_id). - create_rule + create_project_rule: force param + gate. - tests: rule title match, scope-required guard, tool gate (block + force). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -3,7 +3,12 @@ from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from scribe.services.dedup import DuplicateMatch, duplicate_response, find_duplicate_note
|
||||
from scribe.services.dedup import (
|
||||
DuplicateMatch,
|
||||
duplicate_response,
|
||||
find_duplicate_note,
|
||||
find_duplicate_rule,
|
||||
)
|
||||
|
||||
|
||||
def _session_returning(note):
|
||||
@@ -74,6 +79,29 @@ async def test_semantic_match_of_other_note_type_is_ignored():
|
||||
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")
|
||||
|
||||
Reference in New Issue
Block a user