feat(embeddings): per-chunk rows — schema, write path, version-aware backfill (#280 steps 2+3)

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
This commit is contained in:
2026-08-08 23:47:34 -04:00
co-authored by Claude Fable 5
parent 6b5043a69c
commit 0e70a3896b
9 changed files with 277 additions and 41 deletions
+3 -5
View File
@@ -1,5 +1,4 @@
"""Recurring task rule validation, date calculation, and scheduled spawning."""
import asyncio
import calendar
import logging
from datetime import date, datetime, timedelta, timezone
@@ -103,7 +102,6 @@ async def spawn_recurring_tasks() -> int:
Returns the number of tasks spawned.
"""
from scribe.services.embeddings import embedding_text, upsert_note_embedding
from scribe.services.notes import create_note
now = datetime.now(timezone.utc)
@@ -139,9 +137,9 @@ async def spawn_recurring_tasks() -> int:
milestone_id=task.milestone_id,
recurrence_rule=task.recurrence_rule,
)
text = embedding_text(child.title, child.body)
if text:
asyncio.create_task(upsert_note_embedding(child.id, task.user_id, text))
# No explicit embed here: create_note calls embed_note itself, so
# the spawn path stopped being a second copy of the embedding rule
# the moment that moved into the service (#2056, #280).
except Exception:
logger.exception("Failed to spawn recurring task %d", task.id)
continue