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:
@@ -304,10 +304,14 @@ async def create_task(
|
||||
arose_from_id: For an issue, the id of the task/feature it arose from;
|
||||
for a spike, the record that raised the question — including a
|
||||
standing rule whose check just failed. 0 = none.
|
||||
force: Bypass the near-duplicate gate. By default, if a title- or
|
||||
meaning-similar task already exists in the same project, creation is
|
||||
BLOCKED and the existing task's id is returned so you update it
|
||||
instead. Set true only for a genuinely distinct task.
|
||||
force: Bypass the near-duplicate gate. By default, a task with the
|
||||
same title, or one that reads as a copy, in the same project BLOCKS
|
||||
the create and its id is returned so you update it instead. A task
|
||||
that reads CLOSE but not identical does not block: the task is
|
||||
created and the reply carries `overlaps` — open the top one and
|
||||
judge whether it is the same work (fold in, delete the new one) or
|
||||
separate work (keep both). Set force true only when a blocked task
|
||||
is genuinely distinct.
|
||||
|
||||
AN ID EXISTS ONLY ONCE A CREATE RETURNS IT. Never write the id you expect
|
||||
a record to get: every session and user draws from one sequence, so the
|
||||
@@ -332,10 +336,11 @@ async def create_task(
|
||||
"task with create_task(milestone_id=<that milestone>)."
|
||||
)
|
||||
await refuse_guessed_ids(title, body)
|
||||
overlaps: list = []
|
||||
if not force:
|
||||
dup = await dedup_svc.find_duplicate_note(
|
||||
uid, title, body, project_id=project_id or None,
|
||||
is_task=True, note_type="note",
|
||||
is_task=True, note_type="note", overlaps=overlaps,
|
||||
)
|
||||
if dup is not None:
|
||||
return dedup_svc.duplicate_response(dup, "task")
|
||||
@@ -356,6 +361,7 @@ async def create_task(
|
||||
await systems_svc.set_record_systems(uid, note.id, system_ids)
|
||||
data = note.to_dict()
|
||||
await systems_tools.attach_systems(uid, uid, data, note.id, project_id or None)
|
||||
data.update(dedup_svc.note_overlap_response(overlaps, "task"))
|
||||
return await placement_svc.attach_placement(uid, data, note)
|
||||
|
||||
|
||||
@@ -556,13 +562,23 @@ def _batch_items(records: list[dict], *, what: str = "record") -> list[batch_svc
|
||||
return items
|
||||
|
||||
|
||||
async def _first_duplicate(uid: int, items: list, project_id: int | None) -> dict | None:
|
||||
"""The duplicate gate over a whole batch — the first hit blocks all of it."""
|
||||
async def _first_duplicate(
|
||||
uid: int, items: list, project_id: int | None,
|
||||
overlaps: dict | None = None,
|
||||
) -> dict | None:
|
||||
"""The duplicate gate over a whole batch — the first hit blocks all of it.
|
||||
|
||||
`overlaps`, when given, collects each record's near matches below the copy
|
||||
band by its 1-based position (#4306), for the reply once the batch is
|
||||
created."""
|
||||
for i, item in enumerate(items, start=1):
|
||||
found: list = []
|
||||
dup = await dedup_svc.find_duplicate_note(
|
||||
uid, item.title, item.body, project_id=project_id,
|
||||
is_task=item.is_task, note_type="note",
|
||||
is_task=item.is_task, note_type="note", overlaps=found,
|
||||
)
|
||||
if found and overlaps is not None:
|
||||
overlaps[i] = found
|
||||
if dup is not None:
|
||||
payload = dedup_svc.duplicate_response(dup, "task" if item.is_task else "note")
|
||||
payload["record"] = i
|
||||
@@ -616,14 +632,17 @@ async def create_records(
|
||||
uid = current_user_id()
|
||||
items = _batch_items(records)
|
||||
await refuse_guessed_ids(*[t for item in items for t in (item.title, item.body)])
|
||||
overlaps: dict = {}
|
||||
if not force:
|
||||
dup = await _first_duplicate(uid, items, project_id or None)
|
||||
dup = await _first_duplicate(uid, items, project_id or None, overlaps)
|
||||
if dup is not None:
|
||||
return dup
|
||||
_ms, notes = await batch_svc.create_batch(
|
||||
uid, items, project_id=project_id or None, milestone_id=milestone_id or None,
|
||||
)
|
||||
return {"ids": [n.id for n in notes], "records": [n.to_dict() for n in notes]}
|
||||
out = {"ids": [n.id for n in notes], "records": [n.to_dict() for n in notes]}
|
||||
out.update(dedup_svc.batch_overlap_response(overlaps))
|
||||
return out
|
||||
|
||||
|
||||
async def start_planning(
|
||||
@@ -699,14 +718,18 @@ async def start_planning(
|
||||
)
|
||||
if match is not None:
|
||||
return match
|
||||
overlaps: dict = {}
|
||||
if items and not force:
|
||||
dup = await _first_duplicate(uid, items, project_id or None)
|
||||
dup = await _first_duplicate(uid, items, project_id or None, overlaps)
|
||||
if dup is not None:
|
||||
return dup
|
||||
return await planning_svc.start_planning(
|
||||
result = await planning_svc.start_planning(
|
||||
user_id=uid, project_id=project_id, title=title,
|
||||
body=body or None, steps=items or None,
|
||||
)
|
||||
if isinstance(result, dict):
|
||||
result.update(dedup_svc.batch_overlap_response(overlaps))
|
||||
return result
|
||||
|
||||
|
||||
async def delete_task(task_id: int) -> dict:
|
||||
|
||||
Reference in New Issue
Block a user