feat(dedup): a note or task blocks only as a copy; a close match is surfaced for judgement (#4306)
CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 10s
CI & Build / TypeScript typecheck (push) Successful in 58s
CI & Build / integration (push) Successful in 1m12s
CI & Build / Python tests (push) Failing after 1m29s
CI & Build / Build & push image (push) Skipped

Measured on the live corpus, the 74 note pairs at or above the old 0.90 bar
were almost all distinct siblings — consecutive dev-logs, sub-notes of one
design, research parts — and the one clear copy sat at 0.997. The block
refused the next dev-log and taught force=true, as #4134 found for rules.

- The semantic arm blocks notes and tasks only at >= 0.98. The title block
  stays; processes keep 0.90 (not measured).
- 0.87 to 0.98 comes back as `overlaps` on the create reply, from the same
  per-chunk searches, with a note that leaves the call to the session:
  fold in and delete if it is the same record, keep both if a sibling.
- create_note, create_task, create_records and start_planning's steps all
  carry it; a batch names the record each overlap belongs to.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-09-23 18:53:22 -04:00
co-authored by Claude Opus 5.5
parent 4ae18a9dd9
commit fc1c463641
4 changed files with 221 additions and 23 deletions
+98 -2
View File
@@ -94,6 +94,25 @@ _SNIPPET_SEMANTIC_THRESHOLD = 0.96
# records that can still be merged by hand.
_LESSON_SEMANTIC_THRESHOLD = 0.96
# NOTES AND TASKS BLOCK ONLY A COPY, and surface the rest (#4306). Measured
# 2026-09-22 with find_duplicate_records(note, 0.85): of the 74 note pairs at or
# above the old 0.90 bar, almost all were DISTINCT siblings — consecutive
# dev-logs (0.90–0.94), sub-notes of one design (0.90–0.94), research parts
# (0.90–0.97), lore entries (0.95–0.98). The one clear copy sat at 0.997. A
# block in that band refused the next dev-log and taught force=true, the same
# finding #4134 made for rules. So only the copy band blocks; below it, a
# near match is shown on the create reply for the session to judge.
_NOTE_COPY_THRESHOLD = 0.98
# Where a near match starts being worth reading. The measured pair counts
# climb steeply under 0.87 (36 pairs at 0.87 against 200 capped at 0.85), and
# the reply lists at most _NOTE_OVERLAP_LIMIT, so this is a cost floor — what
# decides whether a match matters is the session reading it.
_NOTE_OVERLAP_FLOOR = 0.87
_NOTE_OVERLAP_LIMIT = 3
# The note_types the copy band applies to. A process is prose too, but its
# gate was not part of the measurement, so it keeps the general bar.
_COPY_BAND_TYPES = {"note"}
# The gate queries per CHUNK of the candidate (#280) — this caps how many
# searches one save may cost. Eight chunks ≈ five thousand words of candidate;
# a duplicate hiding past that is the duplicate report's job to find, not a
@@ -236,9 +255,20 @@ def _semantic_threshold(note_type: str) -> float:
return _SNIPPET_SEMANTIC_THRESHOLD
if note_type == LESSON_NOTE_TYPE:
return _LESSON_SEMANTIC_THRESHOLD
if note_type in _COPY_BAND_TYPES:
return _NOTE_COPY_THRESHOLD
return _SEMANTIC_THRESHOLD
@dataclass
class NoteOverlap:
"""An existing note or task close enough to read before keeping a new one,
and not close enough to be called a copy."""
id: int
title: str
similarity: float
async def find_duplicate_note(
user_id: int,
title: str,
@@ -249,6 +279,7 @@ async def find_duplicate_note(
code: str = "",
locations: list[dict] | None = None,
data: dict | None = None,
overlaps: list[NoteOverlap] | None = None,
) -> DuplicateMatch | None:
"""Best near-duplicate of (title, body) within the same owner + project +
kind, or None. Title match first (cheap, exact), then — for snippets — the
@@ -264,6 +295,11 @@ async def find_duplicate_note(
carries the trigger, which the TITLE no longer does (milestone 427): the
title check compares names, and the semantic check rebuilds the embedded
document from `data`.
`overlaps`, when given, is filled with the near matches below the copy band
(#4306) — from the SAME searches, so asking costs nothing extra. Only the
kinds in _COPY_BAND_TYPES collect them. The caller creates the record and
returns them with `note_overlap_response`.
"""
norm = " ".join((title or "").split()).lower()
@@ -319,6 +355,9 @@ async def find_duplicate_note(
# under its name and embedded under `name — trigger`, so the query
# document is built the way the corpus was, from `data`.
doc_title = embeddings_svc.document_title(title, note_type, data, body)
block_at = _semantic_threshold(note_type)
collect = overlaps is not None and note_type in _COPY_BAND_TYPES
near: dict[int, NoteOverlap] = {}
for query in embeddings_svc.chunk_document(doc_title, body)[:_GATE_MAX_CHUNKS]:
# Scope the semantic check the same way as the title check: a record
# in project P compares only to P; a project-less (orphan) record
@@ -330,7 +369,7 @@ async def find_duplicate_note(
user_id, query, project_id=project_id, is_task=is_task,
orphan_only=(project_id is None),
limit=3,
threshold=_semantic_threshold(note_type),
threshold=_NOTE_OVERLAP_FLOOR if collect else block_at,
# Owner-only, deliberately: this gate BLOCKS a create and tells
# the caller to update the match instead. Matching someone
# else's record would refuse their write and point them at
@@ -346,12 +385,69 @@ async def find_duplicate_note(
for score, note in hits:
# semantic_search_notes doesn't filter note_type — enforce it
# here so a note doesn't shadow a task of the same wording, etc.
if note.note_type == note_type:
if note.note_type != note_type:
continue
if score >= block_at:
return DuplicateMatch(note.id, note.title, round(score, 3), "semantic")
# Best chunk wins per record: one long note matching in two
# sections is one overlap, not two.
prior = near.get(note.id)
if prior is None or score > prior.similarity:
near[note.id] = NoteOverlap(note.id, note.title, round(score, 3))
if collect:
overlaps.extend(sorted(
near.values(), key=lambda o: o.similarity, reverse=True,
)[:_NOTE_OVERLAP_LIMIT])
return None
def note_overlap_response(overlaps: list[NoteOverlap], kind: str) -> dict:
"""The keys a note or task create adds to its reply when an existing record
reads closely like the one just written (#4306). Empty when none.
The judgement is the session's: the embedding cannot tell a restatement
from the next dev-log in a series, and a reader can in one look."""
if not overlaps:
return {}
top = overlaps[0]
named = "; ".join(f'#{o.id} "{o.title}" ({o.similarity})' for o in overlaps)
return {
"overlaps": [
{"id": o.id, "title": o.title, "similarity": o.similarity}
for o in overlaps
],
"overlap_note": (
f"Created — and it reads closely like: {named}. Open #{top.id} and "
f"judge it. If it records the same thing, fold what is new into it "
f"(update_{kind}) and delete this {kind}: two copies are found "
f"apart and drift apart. If it is a sibling — the next entry in a "
f"series, another part of one design — keep both."
),
}
def batch_overlap_response(per_record: dict[int, list[NoteOverlap]]) -> dict:
"""`note_overlap_response` for a batch create: each overlap names the
1-based record it belongs to, so the reader knows which new id to judge."""
rows = [
{"record": i, "id": o.id, "title": o.title, "similarity": o.similarity}
for i, found in sorted(per_record.items()) for o in found
]
if not rows:
return {}
return {
"overlaps": rows,
"overlap_note": (
"Created — and some records read closely like existing ones (see "
"`overlaps`, by record). Open each and judge it: the same thing "
"means fold what is new into the existing record and delete the "
"new one; a sibling (the next entry in a series, another part of "
"one design) means keep both."
),
}
# --- corpus-wide near-duplicate report (#2088) -------------------------------
# The gate above PREVENTS a new duplicate; merge_snippets CURES one you point it
# at. Neither FINDS the duplicates already sitting in the record — someone had to