Add duplicate entry prevention to LLM tool pipeline
Three-layer guard on create_note and create_task: 1. Exact title match (get_note_by_title, case-insensitive) → hard block, redirects model to use update_note instead 2. Fuzzy title match (SequenceMatcher ≥82%, punct-stripped word search for candidates) → hard block; catches "Game Premise" vs "Game Premise Notes" 3. Semantic content similarity (semantic_search_notes threshold=0.87) → soft block with requires_confirmation:true; model asks user then retries with confirmed:true; graceful no-op if embedding model is unavailable create_project and create_milestone now always require confirmed:true before creation (structural entities — must be intentional). Both are also guarded by exact + fuzzy title checks post-confirmation. create_note and create_task gain an optional confirmed parameter (not required by default; only used to bypass a content-similarity soft-block after the user has been asked). Helpers added to tools.py: - _fuzzy_title_match(title, candidates, threshold=0.82) - _PUNCT_RE for stripping punctuation before word-search candidate fetch - difflib.SequenceMatcher and re imported at module level Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -2,7 +2,9 @@
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import re
|
||||
from datetime import date, datetime
|
||||
from difflib import SequenceMatcher
|
||||
|
||||
from fabledassistant.services.caldav import (
|
||||
create_event,
|
||||
@@ -67,6 +69,10 @@ _CORE_TOOLS = [
|
||||
"type": "string",
|
||||
"description": "Optional milestone title within the project to assign this task to",
|
||||
},
|
||||
"confirmed": {
|
||||
"type": "boolean",
|
||||
"description": "Set to true only when the user has explicitly confirmed creation after being warned about a similar existing task.",
|
||||
},
|
||||
},
|
||||
"required": ["title"],
|
||||
},
|
||||
@@ -97,6 +103,10 @@ _CORE_TOOLS = [
|
||||
"type": "string",
|
||||
"description": "Optional project name to assign this note to",
|
||||
},
|
||||
"confirmed": {
|
||||
"type": "boolean",
|
||||
"description": "Set to true only when the user has explicitly confirmed creation after being warned about a similar existing note.",
|
||||
},
|
||||
},
|
||||
"required": ["title"],
|
||||
},
|
||||
@@ -338,15 +348,16 @@ _CORE_TOOLS = [
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "create_project",
|
||||
"description": "Create a new project. Use when the user asks to create a project, initiative, or campaign to organize related tasks and notes.",
|
||||
"description": "Create a new project. IMPORTANT: You MUST ask the user to confirm the project name and intent before calling this tool. Only call with confirmed=true after the user has explicitly approved creation.",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"title": {"type": "string", "description": "Project title"},
|
||||
"description": {"type": "string", "description": "Brief description"},
|
||||
"goal": {"type": "string", "description": "The goal or desired outcome"},
|
||||
"confirmed": {"type": "boolean", "description": "Must be true — only set after the user has explicitly confirmed they want this project created."},
|
||||
},
|
||||
"required": ["title"],
|
||||
"required": ["title", "confirmed"],
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -402,15 +413,16 @@ _CORE_TOOLS = [
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "create_milestone",
|
||||
"description": "Create a new milestone inside a project for grouping and tracking related tasks.",
|
||||
"description": "Create a new milestone inside a project. IMPORTANT: You MUST ask the user to confirm the milestone name and which project it belongs to before calling this tool. Only call with confirmed=true after the user has explicitly approved creation.",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"project": {"type": "string", "description": "Project title (will be created if not found)"},
|
||||
"project": {"type": "string", "description": "Project title"},
|
||||
"title": {"type": "string", "description": "Milestone title"},
|
||||
"description": {"type": "string", "description": "Optional description"},
|
||||
"confirmed": {"type": "boolean", "description": "Must be true — only set after the user has explicitly confirmed they want this milestone created."},
|
||||
},
|
||||
"required": ["project", "title"],
|
||||
"required": ["project", "title", "confirmed"],
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -710,6 +722,27 @@ def _parse_due_date(value: str | None) -> date | None:
|
||||
return None
|
||||
|
||||
|
||||
_PUNCT_RE = re.compile(r"[^\w\s]")
|
||||
|
||||
|
||||
def _fuzzy_title_match(title: str, candidates, threshold: float = 0.82):
|
||||
"""Return (best_match, ratio) if any candidate's title is similar enough to *title*.
|
||||
|
||||
Uses SequenceMatcher ratio, which handles insertions/deletions/substitutions.
|
||||
A threshold of 0.82 catches near-duplicates like "Game Premise" / "Game Premise Notes"
|
||||
while leaving clearly different titles alone.
|
||||
Returns (None, 0.0) when no candidate meets the threshold.
|
||||
"""
|
||||
needle = title.lower().strip()
|
||||
best, best_r = None, 0.0
|
||||
for c in candidates:
|
||||
r = SequenceMatcher(None, needle, c.title.lower().strip()).ratio()
|
||||
if r >= threshold and r > best_r:
|
||||
best, best_r = c, r
|
||||
return best, best_r
|
||||
|
||||
|
||||
|
||||
async def execute_tool(user_id: int, tool_name: str, arguments: dict) -> dict:
|
||||
"""Execute a tool call and return the result."""
|
||||
try:
|
||||
@@ -742,6 +775,27 @@ async def execute_tool(user_id: int, tool_name: str, arguments: dict) -> dict:
|
||||
return {"success": False, "error": f"Milestone '{milestone_name}' not found in project '{proj.title}'. Use list_milestones to see available milestones."}
|
||||
pre_fields["milestone_id"] = ms.id
|
||||
|
||||
existing = await get_note_by_title(user_id, task_title)
|
||||
if existing is not None:
|
||||
item_type = "task" if existing.status is not None else "note"
|
||||
return {"success": False, "error": f"A {item_type} titled '{task_title}' already exists (id: {existing.id}). Use update_note to modify it instead of creating a duplicate."}
|
||||
|
||||
clean_q = _PUNCT_RE.sub(" ", task_title).strip()
|
||||
candidates, _ = await list_notes(user_id=user_id, q=clean_q, limit=20)
|
||||
near, ratio = _fuzzy_title_match(task_title, candidates)
|
||||
if near is not None:
|
||||
item_type = "task" if near.status is not None else "note"
|
||||
return {"success": False, "error": f"A {item_type} with a very similar title '{near.title}' already exists (id: {near.id}, similarity: {ratio:.0%}). Use update_note to modify it instead of creating a duplicate."}
|
||||
|
||||
if not arguments.get("confirmed"):
|
||||
from fabledassistant.services.embeddings import semantic_search_notes as _ssn
|
||||
sem_query = f"{task_title}\n{task_body}".strip()
|
||||
sem_hits = await _ssn(user_id, sem_query, limit=3, threshold=0.87)
|
||||
if sem_hits:
|
||||
best_score, best_note = sem_hits[0]
|
||||
item_type = "task" if best_note.status is not None else "note"
|
||||
return {"success": False, "requires_confirmation": True, "error": f"A {item_type} with very similar content exists: '{best_note.title}' (id: {best_note.id}, semantic similarity: {best_score:.0%}). Ask the user: 'An existing {item_type} covers very similar ground. Shall I still create a separate task titled \"{task_title}\"?' Then retry with confirmed=true if they agree."}
|
||||
|
||||
note = await create_note(
|
||||
user_id=user_id,
|
||||
title=task_title,
|
||||
@@ -799,6 +853,27 @@ async def execute_tool(user_id: int, tool_name: str, arguments: dict) -> dict:
|
||||
return {"success": False, "error": f"Project '{project_name}' not found. Use list_projects to see available projects or create_project to create one."}
|
||||
project_id = proj.id
|
||||
|
||||
existing = await get_note_by_title(user_id, note_title)
|
||||
if existing is not None:
|
||||
item_type = "task" if existing.status is not None else "note"
|
||||
return {"success": False, "error": f"A {item_type} titled '{note_title}' already exists (id: {existing.id}). Use update_note to modify it instead of creating a duplicate."}
|
||||
|
||||
clean_q = _PUNCT_RE.sub(" ", note_title).strip()
|
||||
candidates, _ = await list_notes(user_id=user_id, q=clean_q, limit=20)
|
||||
near, ratio = _fuzzy_title_match(note_title, candidates)
|
||||
if near is not None:
|
||||
item_type = "task" if near.status is not None else "note"
|
||||
return {"success": False, "error": f"A {item_type} with a very similar title '{near.title}' already exists (id: {near.id}, similarity: {ratio:.0%}). Use update_note to modify it instead of creating a duplicate."}
|
||||
|
||||
if not arguments.get("confirmed"):
|
||||
from fabledassistant.services.embeddings import semantic_search_notes as _ssn
|
||||
sem_query = f"{note_title}\n{note_body}".strip()
|
||||
sem_hits = await _ssn(user_id, sem_query, limit=3, threshold=0.87)
|
||||
if sem_hits:
|
||||
best_score, best_note = sem_hits[0]
|
||||
item_type = "task" if best_note.status is not None else "note"
|
||||
return {"success": False, "requires_confirmation": True, "error": f"A {item_type} with very similar content exists: '{best_note.title}' (id: {best_note.id}, semantic similarity: {best_score:.0%}). Ask the user: 'An existing {item_type} covers very similar ground. Shall I still create a separate note titled \"{note_title}\"?' Then retry with confirmed=true if they agree."}
|
||||
|
||||
note = await create_note(
|
||||
user_id=user_id,
|
||||
title=note_title,
|
||||
@@ -1191,8 +1266,11 @@ async def execute_tool(user_id: int, tool_name: str, arguments: dict) -> dict:
|
||||
from fabledassistant.services.projects import get_project_by_title as _gpbt, list_projects as _lp
|
||||
from fabledassistant.services.milestones import create_milestone as _cm, get_project_milestone_summary
|
||||
project_name = arguments.get("project", "")
|
||||
ms_title_pre = arguments.get("title", "Untitled Milestone")
|
||||
if not project_name:
|
||||
return {"success": False, "error": "project is required"}
|
||||
if not arguments.get("confirmed"):
|
||||
return {"success": False, "requires_confirmation": True, "error": f"Milestone creation requires explicit user confirmation. Ask the user: 'Shall I create a milestone titled \"{ms_title_pre}\" in project \"{project_name}\"?' Then retry with confirmed=true if they agree."}
|
||||
proj = await _gpbt(user_id, project_name)
|
||||
if proj is None:
|
||||
all_p = await _lp(user_id)
|
||||
@@ -1200,10 +1278,19 @@ async def execute_tool(user_id: int, tool_name: str, arguments: dict) -> dict:
|
||||
proj = matches[0] if matches else None
|
||||
if proj is None:
|
||||
return {"success": False, "error": f"Project '{project_name}' not found. Use list_projects to see available projects or create_project to create one."}
|
||||
ms_title = ms_title_pre
|
||||
from fabledassistant.services.milestones import get_milestone_by_title as _gmbt2, list_milestones as _lms
|
||||
existing_ms = await _gmbt2(user_id, proj.id, ms_title)
|
||||
if existing_ms is not None:
|
||||
return {"success": False, "error": f"A milestone titled '{existing_ms.title}' already exists in '{proj.title}' (id: {existing_ms.id}). Use update_milestone to modify it instead."}
|
||||
all_ms = await _lms(user_id, proj.id)
|
||||
near_ms, ratio = _fuzzy_title_match(ms_title, all_ms)
|
||||
if near_ms is not None:
|
||||
return {"success": False, "error": f"A milestone with a very similar title '{near_ms.title}' already exists in '{proj.title}' (id: {near_ms.id}, similarity: {ratio:.0%}). Use update_milestone to modify it instead."}
|
||||
ms = await _cm(
|
||||
user_id,
|
||||
proj.id,
|
||||
title=arguments.get("title", "Untitled Milestone"),
|
||||
title=ms_title,
|
||||
description=arguments.get("description"),
|
||||
)
|
||||
return {"success": True, "type": "milestone", "data": ms.to_dict()}
|
||||
@@ -1253,10 +1340,20 @@ async def execute_tool(user_id: int, tool_name: str, arguments: dict) -> dict:
|
||||
}
|
||||
|
||||
elif tool_name == "create_project":
|
||||
from fabledassistant.services.projects import create_project as _create_project
|
||||
from fabledassistant.services.projects import create_project as _create_project, get_project_by_title as _gpbt, list_projects as _lp
|
||||
proj_title = arguments.get("title", "New Project")
|
||||
if not arguments.get("confirmed"):
|
||||
return {"success": False, "requires_confirmation": True, "error": f"Project creation requires explicit user confirmation. Ask the user: 'Shall I create a new project titled \"{proj_title}\"?' Then retry with confirmed=true if they agree."}
|
||||
existing_proj = await _gpbt(user_id, proj_title)
|
||||
if existing_proj is not None:
|
||||
return {"success": False, "error": f"A project titled '{existing_proj.title}' already exists (id: {existing_proj.id}). Use update_project to modify it instead of creating a duplicate."}
|
||||
all_projects = await _lp(user_id)
|
||||
near_proj, ratio = _fuzzy_title_match(proj_title, all_projects)
|
||||
if near_proj is not None:
|
||||
return {"success": False, "error": f"A project with a very similar title '{near_proj.title}' already exists (id: {near_proj.id}, similarity: {ratio:.0%}). Use update_project to modify it instead of creating a duplicate."}
|
||||
project = await _create_project(
|
||||
user_id,
|
||||
title=arguments.get("title", "New Project"),
|
||||
title=proj_title,
|
||||
description=arguments.get("description", ""),
|
||||
goal=arguments.get("goal", ""),
|
||||
)
|
||||
|
||||
+11
-2
@@ -12,7 +12,7 @@
|
||||
> Include file-level details in the commit body when the change is non-trivial.
|
||||
|
||||
## Last Updated
|
||||
2026-03-03 — Project/milestone association bug fixes (tasks route was silently dropping project_id/milestone_id); PATCH /api/notes endpoint; parent_title in task API response; milestone_id support in NoteEditorView + notes store; sub-tasks inherit milestone; list_notes OR query for project view; AppHeader redesigned (centered nav, gear dropdown for settings)
|
||||
2026-03-03 — Duplicate entry prevention in LLM tool pipeline: exact-title block, fuzzy-title block (SequenceMatcher ≥82%), semantic content check (≥87% cosine similarity via existing `semantic_search_notes`); `create_project`/`create_milestone` require explicit `confirmed:true`; `create_note`/`create_task` have optional `confirmed` for content-similarity soft-block; `create_project`/`create_milestone` also protected by exact+fuzzy title checks
|
||||
|
||||
## Project Overview
|
||||
Fabled Assistant is a self-hosted note-taking and task-tracking application with
|
||||
@@ -612,7 +612,15 @@ When adding a new migration, follow these conventions:
|
||||
System prompt includes today's date for relative date resolution. Graceful degradation: models without
|
||||
tool support respond normally.
|
||||
Full tool suite:
|
||||
- `create_task` / `create_note` — create new items
|
||||
- `create_task` / `create_note` — create new items; protected by a three-layer duplicate guard:
|
||||
1. **Exact title** (case-insensitive via `get_note_by_title`) → hard block, redirect to `update_note`
|
||||
2. **Fuzzy title** (`SequenceMatcher` ≥82%; strips punctuation before word search for candidates) → hard block
|
||||
3. **Semantic content** (`semantic_search_notes` threshold 0.87, graceful no-op if embedding model down) → soft block with `requires_confirmation:true`; model must ask user then retry with `confirmed:true`
|
||||
Both tools have an optional `confirmed` parameter (not required by default — only needed when a content-similarity warning fires).
|
||||
- `create_project` — always requires `confirmed:true` before creation (structural, must be intentional);
|
||||
additionally guarded by exact + fuzzy title checks (≥82%) against existing projects
|
||||
- `create_milestone` — always requires `confirmed:true`; additionally guarded by exact + fuzzy title
|
||||
checks against existing milestones within the same project
|
||||
- `update_note` — edit note content (replace/append) AND update task fields: `status`, `priority`,
|
||||
`due_date`, `tags` (with `tag_mode`: replace/add/remove); finds by exact title first, falls back to fuzzy search
|
||||
- `delete_note` / `delete_task` — permanently delete (require user confirmation via confirm UI;
|
||||
@@ -625,6 +633,7 @@ When adding a new migration, follow these conventions:
|
||||
- Full CalDAV suite: `create_event`, `list_events`, `search_events`, `update_event`, `delete_event`,
|
||||
`list_calendars`, `create_todo`, `list_todos`, `search_todos`, `update_todo`, `complete_todo`, `delete_todo`
|
||||
(`search_todos` keyword-filters the todo list — companion to `list_todos`)
|
||||
- **Duplicate guard helpers** in `services/tools.py`: `_fuzzy_title_match(title, candidates, threshold=0.82)` uses `SequenceMatcher`; semantic check uses `semantic_search_notes` at threshold 0.87 (vs. 0.45 for RAG suggestions). `_PUNCT_RE` strips punctuation from title before word-search to avoid false-negative candidates (e.g. "Shackled - Game Premise" → "Shackled Game Premise" query).
|
||||
- **Streaming status transparency:** The backend emits `status` SSE events at each pipeline stage
|
||||
so the user always sees what's happening instead of a blank progress dot. Stages:
|
||||
(1) intent ack text streamed as a `chunk` event (tool responses — TTFT ~400ms) or
|
||||
|
||||
Reference in New Issue
Block a user