feat(scribe): snippet management UI + REST routes; embed snippets on create
CI & Build / Python lint (push) Successful in 3s
CI & Build / integration (push) Successful in 23s
CI & Build / TypeScript typecheck (push) Successful in 33s
CI & Build / Python tests (push) Successful in 48s
CI & Build / Build & push image (push) Successful in 1m4s

Step 6 of the Drafter recall milestone (#227): a human-facing surface for
the reusable-code snippets that agents record via MCP, plus the REST API
behind it. Backend embeds snippets inline on create/update so they're
recallable immediately, not only after a restart.

Backend:
- routes/snippets.py: GET/POST /api/snippets, GET/PATCH/DELETE
  /api/snippets/<id>. Share-aware per rule #78 (get_note_for_user +
  can_write_note), writes performed as the owner; list owner-scoped —
  mirrors routes/notes.py. Registered in app.py.
- services/snippets.py: embed on create/update via a _embed_snippet
  fire-and-forget helper, covering BOTH the MCP tool and the REST route
  by construction. A snippet's value is immediate recall, so it can't wait
  for the startup-only backfill (see issue: MCP create path doesn't embed
  inline for notes/tasks generally).
- tests/test_routes_snippets.py: structural registration + handler/service
  contract + PATCH-field ↔ update_snippet-kwarg parity (rule #33).

Frontend (Vue 3 + TS):
- api/snippets.ts: typed client, modeled on api/systems.ts.
- views: SnippetListView (search, skeleton/empty/error states),
  SnippetDetailView (read + copy-to-clipboard, ConfirmDialog delete),
  SnippetEditorView (create/edit all fields, Ctrl/Cmd+S, Esc, autofocus,
  validation). v1 quality per rules #24/#27.
- router: /snippets, /snippets/new, /snippets/:id, /snippets/:id/edit.
- NoteType union widened to include 'snippet'; Snippets nav link added to
  AppHeader (desktop pill bar + mobile menu).
- Design system: Moss --color-action-primary for action buttons, accent
  --color-primary reserved for tags/brand (Hybrid rule); focus rings;
  JetBrains Mono for code/name/signature/location.

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 14:16:02 -04:00
parent 0ea3bff797
commit d257c0fd67
11 changed files with 1355 additions and 4 deletions
+30 -3
View File
@@ -23,6 +23,7 @@ changing any caller (MCP tool, REST route, UI).
"""
from __future__ import annotations
import asyncio
import re
from scribe.services import knowledge as knowledge_svc
@@ -32,6 +33,25 @@ SNIPPET_NOTE_TYPE = "snippet"
SNIPPET_TAG = "snippet"
def _embed_snippet(note) -> None:
"""Fire-and-forget embedding refresh for a snippet.
A snippet's whole value is *immediate* recall — it must join the semantic /
auto-inject pool the moment it's recorded, not wait for the startup backfill.
Unlike a plain note (embedded at the REST-route boundary only, so its MCP
create path defers to restart-backfill), a snippet is recorded primarily via
MCP, so we embed here in the service — covering BOTH the MCP tool and the
REST route by construction. Mirrors the route pattern: fire-and-forget,
text = title + body. Import lazily so the pure serialize/parse helpers can be
imported without pulling in the embedding model.
"""
text = f"{note.title}\n{note.body}".strip() if note.body else (note.title or "")
if not text:
return
from scribe.services.embeddings import upsert_note_embedding
asyncio.create_task(upsert_note_embedding(note.id, note.user_id, text))
# --- serialize: structured fields -> note (title/body/tags) ------------------
def compose_title(name: str, when_to_use: str = "") -> str:
@@ -164,8 +184,9 @@ async def create_snippet(
tags: list[str] | None = None,
project_id: int | None = None,
):
"""Create a snippet note. Returns the created Note."""
return await notes_svc.create_note(
"""Create a snippet note (embedded on create for immediate recall). Returns
the created Note."""
note = await notes_svc.create_note(
user_id,
title=compose_title(name, when_to_use),
body=compose_body(
@@ -176,6 +197,8 @@ async def create_snippet(
tags=compose_tags(language, tags),
project_id=project_id,
)
_embed_snippet(note)
return note
async def get_snippet(user_id: int, snippet_id: int):
@@ -254,4 +277,8 @@ async def update_snippet(
if project_id is not None:
fields["project_id"] = project_id
return await notes_svc.update_note(user_id, snippet_id, **fields)
updated = await notes_svc.update_note(user_id, snippet_id, **fields)
if updated is not None:
# Title/body changed → refresh the embedding so recall reflects the edit.
_embed_snippet(updated)
return updated