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:
@@ -29,6 +29,7 @@ from sqlalchemy import func, select
|
||||
|
||||
from scribe.models import async_session
|
||||
from scribe.models.note import Note
|
||||
from scribe.models.rulebook import Rule
|
||||
from scribe.services import embeddings as embeddings_svc
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -131,3 +132,33 @@ async def find_duplicate_note(
|
||||
return DuplicateMatch(note.id, note.title, round(score, 3), "semantic")
|
||||
|
||||
return None
|
||||
|
||||
|
||||
async def find_duplicate_rule(
|
||||
title: str,
|
||||
topic_id: int | None = None,
|
||||
project_id: int | None = None,
|
||||
) -> DuplicateMatch | None:
|
||||
"""Title-based near-duplicate of a rule, scoped to the same topic (a rulebook
|
||||
rule) or the same project (a project rule). Rules aren't a semantic-retrieval
|
||||
surface, so a normalized-title match is the right (and only) signal. Fail-open
|
||||
like find_duplicate_note."""
|
||||
norm = " ".join((title or "").split()).lower()
|
||||
if not norm or (topic_id is None and project_id is None):
|
||||
return None
|
||||
try:
|
||||
async with async_session() as session:
|
||||
stmt = select(Rule).where(
|
||||
Rule.deleted_at.is_(None),
|
||||
func.lower(func.trim(Rule.title)) == norm,
|
||||
)
|
||||
if topic_id is not None:
|
||||
stmt = stmt.where(Rule.topic_id == topic_id)
|
||||
else:
|
||||
stmt = stmt.where(Rule.project_id == project_id)
|
||||
existing = (await session.execute(stmt.limit(1))).scalars().first()
|
||||
if existing is not None:
|
||||
return DuplicateMatch(existing.id, existing.title, 1.0, "title")
|
||||
except Exception:
|
||||
logger.debug("dedup rule title check skipped — query failed", exc_info=True)
|
||||
return None
|
||||
|
||||
Reference in New Issue
Block a user