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