fix(dedup): fail open when the duplicate check can't run
CI & Build / Python lint (push) Successful in 3s
CI & Build / TypeScript typecheck (push) Successful in 33s
CI & Build / Python tests (push) Successful in 50s
CI & Build / Build & push image (push) Successful in 56s

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:
2026-06-14 13:24:20 -04:00
parent 322cbc3b5e
commit 5102ffb558
+27 -18
View File
@@ -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: