fix(dedup): fail open when the duplicate check can't run
The Phase 5 gate added a DB query before every create_note/create_task. When that query fails (DB unreachable, etc.) the create must NOT error — a dedup check is advisory infrastructure, not a correctness gate. Wrap the title query so any failure degrades to "no duplicate found" and the create proceeds. Also fixes 7 existing create tests that don't mock the DB: they now exercise the fail-open path (no Postgres in the unit-test job) instead of erroring. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -22,6 +22,7 @@ Two signals, both scoped to the same owner + project + kind:
|
|||||||
"""
|
"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
|
|
||||||
from sqlalchemy import func, select
|
from sqlalchemy import func, select
|
||||||
@@ -30,6 +31,8 @@ from scribe.models import async_session
|
|||||||
from scribe.models.note import Note
|
from scribe.models.note import Note
|
||||||
from scribe.services import embeddings as embeddings_svc
|
from scribe.services import embeddings as embeddings_svc
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
# Run the semantic check only when the incoming body has at least this many
|
# Run the semantic check only when the incoming body has at least this many
|
||||||
# characters — below it, embeddings are dominated by the title and false-positive.
|
# characters — below it, embeddings are dominated by the title and false-positive.
|
||||||
_MIN_BODY_FOR_SEMANTIC = 200
|
_MIN_BODY_FOR_SEMANTIC = 200
|
||||||
@@ -82,25 +85,31 @@ async def find_duplicate_note(
|
|||||||
norm = " ".join((title or "").split()).lower()
|
norm = " ".join((title or "").split()).lower()
|
||||||
|
|
||||||
# --- Signal 1: normalized-title exact match (same scope) ---
|
# --- Signal 1: normalized-title exact match (same scope) ---
|
||||||
|
# Fail-open: a dedup-check failure (DB down, etc.) must never block a
|
||||||
|
# legitimate create — degrade to "no duplicate found" and let it through.
|
||||||
if norm:
|
if norm:
|
||||||
async with async_session() as session:
|
try:
|
||||||
stmt = select(Note).where(
|
async with async_session() as session:
|
||||||
Note.user_id == user_id,
|
stmt = select(Note).where(
|
||||||
Note.deleted_at.is_(None),
|
Note.user_id == user_id,
|
||||||
Note.note_type == note_type,
|
Note.deleted_at.is_(None),
|
||||||
func.lower(func.trim(Note.title)) == norm,
|
Note.note_type == note_type,
|
||||||
)
|
func.lower(func.trim(Note.title)) == norm,
|
||||||
if project_id is not None:
|
)
|
||||||
stmt = stmt.where(Note.project_id == project_id)
|
if project_id is not None:
|
||||||
else:
|
stmt = stmt.where(Note.project_id == project_id)
|
||||||
stmt = stmt.where(Note.project_id.is_(None))
|
else:
|
||||||
if is_task is True:
|
stmt = stmt.where(Note.project_id.is_(None))
|
||||||
stmt = stmt.where(Note.status.isnot(None))
|
if is_task is True:
|
||||||
elif is_task is False:
|
stmt = stmt.where(Note.status.isnot(None))
|
||||||
stmt = stmt.where(Note.status.is_(None))
|
elif is_task is False:
|
||||||
existing = (await session.execute(stmt.limit(1))).scalars().first()
|
stmt = stmt.where(Note.status.is_(None))
|
||||||
if existing is not None:
|
existing = (await session.execute(stmt.limit(1))).scalars().first()
|
||||||
return DuplicateMatch(existing.id, existing.title, 1.0, "title")
|
if existing is not None:
|
||||||
|
return DuplicateMatch(existing.id, existing.title, 1.0, "title")
|
||||||
|
except Exception:
|
||||||
|
logger.debug("dedup title check skipped — query failed", exc_info=True)
|
||||||
|
return None
|
||||||
|
|
||||||
# --- Signal 2: semantic similarity (only with a substantial body) ---
|
# --- Signal 2: semantic similarity (only with a substantial body) ---
|
||||||
if body and len(body.strip()) >= _MIN_BODY_FOR_SEMANTIC:
|
if body and len(body.strip()) >= _MIN_BODY_FOR_SEMANTIC:
|
||||||
|
|||||||
Reference in New Issue
Block a user