fix(embeddings): embed in the service, so every caller gets it (#2056)
CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 8s
CI & Build / integration (push) Successful in 15s
CI & Build / TypeScript typecheck (push) Successful in 33s
CI & Build / Python tests (push) Successful in 57s
CI & Build / Build & push image (push) Successful in 29s

A note or task created through MCP was not semantically searchable until the
next restart's backfill ran. Embedding fired at the five REST route handlers
and nowhere else; the MCP tools call the service directly, so they skipped it.

The shape of this bug is the reason to care: it is invisible on an instance
that redeploys constantly (this one does, per rule 46) and permanent on one
that doesn't. Rule 115 — the product has to stand up for the install that
restarts twice a year, not just for the one that restarts hourly.

Moved to services/notes.embed_note(), called from create_note and update_note,
and deleted from all five routes. Every caller — REST, MCP, recurrence,
snippets — now gets it by construction rather than by remembering.

Two things fall out of having one implementation instead of six:

- It uses note.user_id, the OWNER. The routes were inconsistent: some passed
  the caller's uid, some the owner's. On a shared record the caller's id mints
  a second embedding row that nothing reads.
- services/snippets.py's _embed_snippet existed only because snippets are
  created via MCP and the routes couldn't cover them. Every one of its four
  call sites goes through notes_svc, so the helper and its four calls are gone,
  along with the eight test patches that existed to neutralise it.

RuntimeError (no running loop — unit tests, scripts) and any indexing failure
are both swallowed: a write that succeeded must not be failed by its index.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UaYUaouG9jjhATyuxCKrQs
This commit is contained in:
2026-07-31 22:46:57 -04:00
co-authored by Claude Opus 5
parent 4c9a637507
commit 5c51e29f26
8 changed files with 107 additions and 58 deletions
-11
View File
@@ -1,8 +1,6 @@
import asyncio
import logging
import re
from scribe.services.embeddings import upsert_note_embedding
from quart import Blueprint, jsonify, request
@@ -113,9 +111,6 @@ async def create_note_route():
)
except ValueError as e:
return jsonify({"error": str(e)}), 400
text = f"{note.title}\n{note.body}".strip() if note.body else (note.title or "")
if text:
asyncio.create_task(upsert_note_embedding(note.id, uid, text))
return jsonify(note.to_dict()), 201
@@ -221,9 +216,6 @@ async def update_note_route(note_id: int):
return jsonify({"error": str(e)}), 400
if note is None:
return not_found("Note")
text = f"{note.title}\n{note.body}".strip() if note.body else (note.title or "")
if text:
asyncio.create_task(upsert_note_embedding(note.id, owner_uid, text))
return jsonify(note.to_dict())
@@ -259,9 +251,6 @@ async def patch_note_route(note_id: int):
return jsonify({"error": str(e)}), 400
if note is None:
return not_found("Note")
text = f"{note.title}\n{note.body}".strip() if note.body else (note.title or "")
if text:
asyncio.create_task(upsert_note_embedding(note.id, owner_uid, text))
return jsonify(note.to_dict())
-8
View File
@@ -1,4 +1,3 @@
import asyncio
from datetime import date
from quart import Blueprint, jsonify, request
@@ -8,7 +7,6 @@ from scribe.models.note import TaskPriority, TaskStatus
from scribe.routes.utils import not_found, parse_iso_date, parse_pagination
from scribe.services.access import can_write_note
from scribe.services import systems as systems_svc
from scribe.services.embeddings import upsert_note_embedding
from scribe.services.notes import (
create_note,
get_note_for_user,
@@ -149,9 +147,6 @@ async def create_task_route():
)
if data.get("system_ids") is not None:
await systems_svc.set_record_systems(uid, task.id, data["system_ids"])
text = f"{task.title}\n{task.body}".strip() if task.body else (task.title or "")
if text:
asyncio.create_task(upsert_note_embedding(task.id, uid, text))
out = task.to_dict()
out["systems"] = [s.to_dict() for s in await systems_svc.list_record_systems(uid, task.id)]
return jsonify(out), 201
@@ -255,9 +250,6 @@ async def update_task_route(task_id: int):
return not_found("Task")
if data.get("system_ids") is not None:
await systems_svc.set_record_systems(uid, task_id, data["system_ids"])
text = f"{task.title}\n{task.body}".strip() if task.body else (task.title or "")
if text:
asyncio.create_task(upsert_note_embedding(task.id, task_note.user_id, text))
out = task.to_dict()
out["systems"] = [s.to_dict() for s in await systems_svc.list_record_systems(uid, task_id)]
return jsonify(out)
+38
View File
@@ -10,6 +10,40 @@ from scribe.models.note import Note, TaskPriority, TaskStatus
logger = logging.getLogger(__name__)
def embed_note(note) -> None:
"""Refresh a note's embedding, fire-and-forget.
Lives HERE — at the service, not the route — so every caller gets it by
construction. Previously each REST route made this call itself and the MCP
tools did not, so a record created through MCP stayed out of semantic search
and auto-inject until the next restart's backfill ran (#2056). That is
invisible on an instance that redeploys constantly and permanent on one that
doesn't, which is the worst shape a bug can have: it only appears where
nobody is looking.
Uses `note.user_id` — the OWNER — rather than the caller. Embeddings belong
to the record, and a collaborator editing a shared note must refresh the
owner's row rather than mint a second one under their own id.
Import is lazy so importing this module doesn't pull in the embedding model;
exceptions are swallowed because a record that saved must not fail on its
index refresh. No running loop (unit tests, scripts) is an ordinary case,
not an error.
"""
text = f"{note.title}\n{note.body}".strip() if note.body else (note.title or "")
if not text:
return
try:
import asyncio
from scribe.services.embeddings import upsert_note_embedding
asyncio.create_task(upsert_note_embedding(note.id, note.user_id, text))
except RuntimeError:
pass # no running loop — a sync caller, not a failure
except Exception: # noqa: BLE001 - never let indexing break a write
logger.exception("embedding refresh failed for note %s", note.id)
def _normalize_tags(tags: list[str]) -> list[str]:
"""Lowercase, strip, deduplicate, and drop empty tags."""
seen: set[str] = set()
@@ -115,6 +149,8 @@ async def create_note(
await session.commit()
await session.refresh(note)
embed_note(note)
if project_id is not None:
await _maybe_reactivate_project(project_id)
@@ -329,6 +365,8 @@ async def update_note(user_id: int, note_id: int, **fields: object) -> Note | No
from scribe.services.note_versions import create_version
await create_version(user_id, note_id, old_body, old_title, old_tags)
embed_note(note)
if note.project_id is not None:
await _maybe_reactivate_project(note.project_id)
-26
View File
@@ -28,7 +28,6 @@ came from.
"""
from __future__ import annotations
import asyncio
import hashlib
import logging
import re
@@ -52,25 +51,6 @@ SNIPPET_TAG = "snippet"
UNSET: object = object()
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:
@@ -640,7 +620,6 @@ async def create_snippet(
language=language, code=code, locations=locations,
),
)
_embed_snippet(note)
return note
@@ -801,9 +780,6 @@ async def update_snippet(
# As the OWNER: update_note is owner-scoped, so a shared editor's own id
# would find nothing. The write was authorised by can_write_note above.
updated = await notes_svc.update_note(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
@@ -1025,7 +1001,6 @@ async def merge_snippets(user_id: int, target_id: int, source_ids: list[int]):
if batch is not None:
merged_ids.append(s.id)
_embed_snippet(updated)
return updated, merged_ids
@@ -1134,5 +1109,4 @@ async def unmerge_snippet(user_id: int, survivor_id: int, source_id: int):
)
if updated is None:
return None
_embed_snippet(updated)
return updated, restored