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
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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user