note_embeddings becomes one row per chunk: PK (note_id, chunk_index), plus chunk_text (what this vector actually encodes) and chunker_version. Migration 0077 clears the table — embeddings are derived (0067 precedent) and the old whole-document rows are indistinguishable from single-chunk notes, so the startup backfill regenerates the corpus at the new shape. The backfill is now version-aware: a future shape change is a CHUNKER_VERSION bump that re-embeds exactly the stale notes, not another wipe. upsert_note_embedding takes (title, body) and chunks internally — one path for the write path, the recurrence spawn and the backfill. The recurrence spawn's own embed call is deleted outright: create_note already embeds via embed_note (#2056), so the spawn was a second copy of the rule. An emptied record now CLEARS its stale vectors instead of leaving them findable. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UaYUaouG9jjhATyuxCKrQs
72 lines
3.3 KiB
Python
72 lines
3.3 KiB
Python
"""services/notes.py — the inline embedding moved here from the routes (#2056).
|
|
|
|
Why it moved: embedding was triggered at each REST route and nowhere else, so a
|
|
record created through MCP stayed out of semantic search and auto-inject until
|
|
the next restart's backfill. Putting it in the service means every caller — REST,
|
|
MCP, recurrence, snippets — gets it by construction rather than by remembering.
|
|
|
|
These test the helper directly. The point of the change is that there is now ONE
|
|
place to test.
|
|
"""
|
|
from scribe.services import notes as notes_svc
|
|
|
|
# --- inline embedding (#2056) -----------------------------------------------
|
|
|
|
def test_embed_note_uses_the_OWNER_not_the_caller():
|
|
"""LOAD-BEARING for shared records. An embedding belongs to the record; a
|
|
collaborator editing a shared note must refresh the owner's row rather than
|
|
mint a second one under their own id. The routes this replaced passed the
|
|
caller's uid on one path and the owner's on another — exactly the kind of
|
|
inconsistency that moving it to one place removes."""
|
|
from unittest.mock import MagicMock, patch
|
|
|
|
note = MagicMock(id=5, user_id=42, title="T", body="B")
|
|
with patch("scribe.services.embeddings.upsert_note_embedding") as upsert, \
|
|
patch("asyncio.create_task") as create_task:
|
|
notes_svc.embed_note(note)
|
|
|
|
assert create_task.called
|
|
upsert.assert_called_once()
|
|
assert upsert.call_args.args[0] == 5
|
|
assert upsert.call_args.args[1] == 42 # owner, never the caller
|
|
# Title and body travel separately since #280 — chunking happens inside
|
|
# upsert_note_embedding, the one path every writer shares.
|
|
assert upsert.call_args.args[2] == "T"
|
|
assert upsert.call_args.args[3] == "B"
|
|
|
|
|
|
def test_embed_note_hands_even_an_empty_record_to_the_one_path():
|
|
"""The empty-record decision moved INTO upsert_note_embedding (#280): an
|
|
emptied record must have its stale vectors CLEARED, not merely skipped —
|
|
so embed_note schedules the call unconditionally rather than deciding
|
|
here. The clearing behaviour itself is pinned in test_chunking.py."""
|
|
from unittest.mock import MagicMock, patch
|
|
|
|
note = MagicMock(id=5, user_id=42, title="", body="")
|
|
with patch("scribe.services.embeddings.upsert_note_embedding") as upsert, \
|
|
patch("asyncio.create_task") as create_task:
|
|
notes_svc.embed_note(note)
|
|
assert create_task.called
|
|
upsert.assert_called_once()
|
|
|
|
|
|
def test_embed_note_without_a_running_loop_is_not_an_error():
|
|
"""Unit tests and scripts call create_note with no event loop. That must be
|
|
an ordinary case: a write that succeeded cannot be failed by its index
|
|
refresh."""
|
|
from unittest.mock import MagicMock, patch
|
|
|
|
note = MagicMock(id=5, user_id=42, title="T", body="B")
|
|
with patch("asyncio.create_task", side_effect=RuntimeError("no running loop")):
|
|
notes_svc.embed_note(note) # must not raise
|
|
|
|
|
|
def test_embed_note_swallows_an_indexing_failure():
|
|
"""Same reason, wider net: the embedding model being unavailable must not
|
|
turn a successful save into a 500."""
|
|
from unittest.mock import MagicMock, patch
|
|
|
|
note = MagicMock(id=5, user_id=42, title="T", body="B")
|
|
with patch("asyncio.create_task", side_effect=ValueError("model gone")):
|
|
notes_svc.embed_note(note) # must not raise
|