feat(scribe): merge_snippets — unify found one-off snippets into one canonical
CI & Build / Python lint (push) Successful in 4s
CI & Build / TypeScript typecheck (push) Successful in 33s
CI & Build / integration (push) Successful in 38s
CI & Build / Python tests (push) Successful in 59s
CI & Build / Build & push image (push) Successful in 1m10s

Step 2 of the snippet-merge milestone (#231). The dedup gate only PREVENTS
new near-duplicates; merge is the CURE for the ones already scattered.

- services/snippets.py: merge_snippets(user_id, target_id, source_ids) —
  keep the target's scalar fields (name/when_to_use/signature/language/
  code), union the sources' locations + extra tags onto it (so the survivor
  carries every call site as a location), trash the sources (recoverable),
  re-embed the survivor. Pure merge_snippet_fields() factored out for unit
  testing. Returns (survivor_note, merged_ids).
- mcp/tools/snippets.py: merge_snippets(target_id, source_ids) tool (5th),
  and a create_snippet dedup-path nudge toward merge over a forced copy.
- routes/snippets.py: POST /api/snippets/<id>/merge {source_ids} — share-
  aware (can_write target + every source, rule #78) with a same-owner guard
  (cross-owner merge is out of scope).
- plugin reusing-code skill + MCP _INSTRUCTIONS: point found-duplicates at
  merge as the cure (rule #119 surfaces, not a Scribe rule). plugin.json
  0.1.13 -> 0.1.14 in the same change (the #1040 marketplace-ship lesson).
- Tests: pure merge-helper union/dedup; MCP tool (requires a source,
  survivor+merged_ids, not-found); route handler + 5-tool registration.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Pa2EsuB54BuWQ8GfJq9c7t
This commit is contained in:
2026-07-25 17:11:47 -04:00
parent eb400a521b
commit 85625de394
9 changed files with 218 additions and 6 deletions
+75
View File
@@ -382,3 +382,78 @@ async def update_snippet(
# Title/body changed → refresh the embedding so recall reflects the edit.
_embed_snippet(updated)
return updated
# --- merge: unify found one-offs into one canonical snippet ------------------
def _extra_tags(tags: list[str] | None, language: str = "") -> list[str]:
"""A snippet's caller tags — everything except the language + `snippet`
markers that compose_tags re-derives."""
lang = (language or "").strip().lower()
return [t for t in (tags or []) if t and t != SNIPPET_TAG and t != lang]
def merge_snippet_fields(
target_fields: dict, target_tags: list[str] | None, sources: list[tuple[dict, list]]
) -> tuple[list[dict], list[str]]:
"""Pure merge: union locations (target's first, then each source in order)
and union extra tags. The target's scalar fields (name/when_to_use/signature/
language/code) win — only locations and tags accumulate. ``sources`` is a
list of (parsed_fields, tags). Returns (locations, extra_tags)."""
locations = list(target_fields.get("locations") or [])
extra = _extra_tags(target_tags, target_fields.get("language", ""))
for sfields, stags in sources:
locations.extend(sfields.get("locations") or [])
for t in _extra_tags(stags, sfields.get("language", "")):
if t not in extra:
extra.append(t)
return _normalize_locations(locations), extra
async def merge_snippets(user_id: int, target_id: int, source_ids: list[int]):
"""Unify source snippets INTO the target (all owned by user_id): union their
locations + tags onto the canonical target, keep the target's scalar fields,
trash the sources (recoverable), re-embed the survivor.
Returns (merged_target_note, merged_source_ids), or None if the target isn't
the user's snippet. Source ids that aren't the user's snippets are skipped."""
target = await notes_svc.get_note(user_id, target_id)
if target is None or target.note_type != SNIPPET_NOTE_TYPE:
return None
sources = []
for sid in source_ids:
if sid == target_id:
continue
s = await notes_svc.get_note(user_id, sid)
if s is not None and s.note_type == SNIPPET_NOTE_TYPE:
sources.append(s)
tgt_fields = parse_snippet_fields(target.title, target.body, target.tags)
parsed_sources = [
(parse_snippet_fields(s.title, s.body, s.tags), s.tags) for s in sources
]
locations, extra_tags = merge_snippet_fields(tgt_fields, target.tags, parsed_sources)
updated = await notes_svc.update_note(
user_id, target_id,
body=compose_body(
code=tgt_fields["code"], language=tgt_fields["language"],
signature=tgt_fields["signature"], when_to_use=tgt_fields["when_to_use"],
locations=locations,
),
tags=compose_tags(tgt_fields["language"], extra_tags),
)
if updated is None:
return None
# Retire the merged-in sources to the trash (recoverable).
from scribe.services.trash import delete as trash_delete
merged_ids: list[int] = []
for s in sources:
batch = await trash_delete(user_id, "note", s.id)
if batch is not None:
merged_ids.append(s.id)
_embed_snippet(updated)
return updated, merged_ids