feat(mcp): Phase 5 — write-time near-duplicate gate (update-over-create)
CI & Build / Python lint (push) Successful in 3s
CI & Build / TypeScript typecheck (push) Successful in 35s
CI & Build / Python tests (push) Failing after 42s
CI & Build / Build & push image (push) Has been skipped

#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:
2026-06-14 13:21:35 -04:00
parent 33f9a0a4d4
commit 322cbc3b5e
7 changed files with 301 additions and 1 deletions
+17 -1
View File
@@ -14,6 +14,7 @@ Sentinel conventions (inherited from existing fable-mcp tools):
from __future__ import annotations
from scribe.mcp._context import current_user_id
from scribe.services import dedup as dedup_svc
from scribe.services import notes as notes_svc
from scribe.services import systems as systems_svc
from scribe.services import trash as trash_svc
@@ -70,6 +71,7 @@ async def create_note(
tags: list[str] | None = None,
project_id: int = 0,
system_ids: list[int] | None = None,
force: bool = False,
) -> dict:
"""Create a new note in Scribe.
@@ -80,10 +82,24 @@ async def create_note(
project_id: Associate with a project (use 0 for no project / orphan note).
system_ids: Ids of the project's Systems to associate this note with
(e.g. research about a subsystem). See list_systems / create_system.
force: Bypass the near-duplicate gate. By default, if a title- or
meaning-similar note already exists in the same project, creation is
BLOCKED and the existing note's id is returned so you update it
instead (no duplicate bloat / no stale RAG copies). Set true only
when you're sure this is a genuinely distinct note.
Returns the created note object including its assigned id.
Returns the created note object including its assigned id, OR — when a
near-duplicate is found and force is false — {"duplicate": true,
"existing_id": ..., "message": ...} and nothing is created.
"""
uid = current_user_id()
if not force:
dup = await dedup_svc.find_duplicate_note(
uid, title, body, project_id=project_id or None,
is_task=False, note_type="note",
)
if dup is not None:
return dedup_svc.duplicate_response(dup, "note")
note = await notes_svc.create_note(
uid,
title=title,