CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 16s
CI & Build / TypeScript typecheck (push) Successful in 53s
CI & Build / integration (push) Successful in 59s
CI & Build / Python tests (push) Successful in 1m38s
CI & Build / Build & push image (push) Successful in 27s
Step 1 of #4251. A work log is the richest prose Scribe holds about WHY something is the way it is — written during the work, recording what was tried and ruled out. #4241 made it readable from the agent's door. It was still not findable, so "has anyone tried this approach?" — precisely the question a log answers — could not reach one. The cost is not hypothetical: #4208 was rebuilt in this session because its logs were unreachable. THE DESIGN QUESTION the issue left open was whether logs embed as part of their task's document or as rows of their own. As their own rows, a hit has to be resolved back to a task to be worth anything, and it needs a fourth search, a fourth result shape and a fourth arm. As part of the task, the objection is that a long log drowns a short title. That objection was true before #280 and is not true now. Chunking made one record into one vector per section, so each log becomes its own title-anchored chunk, scored separately, and the task's own prose keeps the chunk it always had — a task is as findable as its best-matching log rather than as the average of everything in it. The other half is this session's other build: a search hands back the chunk that won (#4243), so a hit earned by a log shows that log's passage under the task's title. Without that a reader would have got body[:240] of the task — the opening of a record whose relevance lives three hundred lines further down. So `task_document(title, body, logs)` sits beside `rule_document`: a synthesised embed-time shape, because the stored record is the task row and the logs live in their own table, so the document that should be searchable exists nowhere until it is built. A task with no logs is returned untouched — most notes are not tasks and most tasks carry no log, and their vectors are the corpus every tuned number here was measured against. CHUNKER_VERSION 1 → 2, and its comment now says what the version actually means. It used to read "whenever chunk_document's output can change for the same input", which this change would slip past: `chunk_document` is untouched and every task with a log now embeds differently while its title, body and the chunker all stand still. The invariant is the document a record is embedded as. The startup backfill re-embeds on that. Create, edit and delete of a log all refresh the task through `embed_note`, the one path every writer shares — an edited log whose vectors still carry its old wording keeps matching what it no longer says. No new kind enters the auto-inject menu: tasks were always in it, and this makes recall on them better rather than changing what the menu spans. The calibration stamp will now report shape_version 2 against numbers measured at 1, which is exactly the report #4104 built it to make. Also promoted `session_returning` into tests/helpers beside `make_mock_session` (#2834) — two files had spelled it out identically and a third was about to. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01821k5B3Ysecp9fNYs92Kuy
309 lines
12 KiB
Python
309 lines
12 KiB
Python
"""chunk_document — the document shape a record is embedded as (#280).
|
|
|
|
The model window is 512 tokens and fastembed truncates silently, so before
|
|
chunking, everything past ~400 words of a record was PERMANENTLY invisible to
|
|
semantic search. These tests pin the two halves of the fix's contract: short
|
|
records keep the exact historical shape (the corpus's sharpest vectors are
|
|
untouched), and long records lose NOTHING — every line of the body lands in
|
|
some chunk, each chunk inside the window budget, each carrying the title as
|
|
its topical anchor.
|
|
"""
|
|
from scribe.services.embeddings import (
|
|
_CHUNK_CHAR_BUDGET,
|
|
chunk_document,
|
|
embedding_text,
|
|
)
|
|
|
|
|
|
def _long_section(tag: str, paragraphs: int = 6, sentence: str = None) -> str:
|
|
sentence = sentence or f"This paragraph discusses {tag} in useful detail."
|
|
para = " ".join([sentence] * 6)
|
|
return "\n\n".join(f"{para} (p{i})" for i in range(paragraphs))
|
|
|
|
|
|
# --- the identity half: short records are byte-for-byte unaffected -----------
|
|
|
|
|
|
def test_a_short_record_yields_exactly_the_historical_shape():
|
|
"""Snippets and reference notes are the sharpest records in the corpus
|
|
(#2485) precisely because of this shape — chunking must not touch them."""
|
|
assert chunk_document("A title", "A short body") == [
|
|
embedding_text("A title", "A short body")
|
|
]
|
|
|
|
|
|
def test_a_bodyless_record_is_one_chunk_of_its_title():
|
|
assert chunk_document("Just a title", "") == ["Just a title"]
|
|
|
|
|
|
def test_an_empty_record_yields_no_chunks():
|
|
"""Callers gate on falsiness to skip embedding entirely."""
|
|
assert chunk_document("", "") == []
|
|
assert chunk_document(None, None) == []
|
|
|
|
|
|
def test_a_record_exactly_at_budget_stays_whole():
|
|
body = "x" * (_CHUNK_CHAR_BUDGET - len("T\n"))
|
|
assert chunk_document("T", body) == [embedding_text("T", body)]
|
|
|
|
|
|
# --- the no-lost-data half: this is what the build is FOR --------------------
|
|
|
|
|
|
def test_every_line_of_a_long_body_lands_in_some_chunk():
|
|
"""The point of #280. Before chunking, a 2,000-word dev-log's last three
|
|
quarters could not influence retrieval at all. Nothing may be dropped."""
|
|
sections = [
|
|
f"## Topic {i}\n\n{_long_section(f'topic-{i}')}" for i in range(8)
|
|
]
|
|
body = "Intro paragraph before any heading.\n\n" + "\n\n".join(sections)
|
|
chunks = chunk_document("A very long dev-log", body)
|
|
|
|
assert len(chunks) > 1
|
|
joined = "\n".join(chunks)
|
|
for line in body.splitlines():
|
|
if line.strip():
|
|
assert line.strip() in joined, f"content dropped: {line[:60]!r}"
|
|
|
|
|
|
def test_every_chunk_fits_the_window_budget():
|
|
body = "\n\n".join(_long_section(f"t{i}") for i in range(10))
|
|
for chunk in chunk_document("T", body):
|
|
assert len(chunk) <= _CHUNK_CHAR_BUDGET + len("T") + 1
|
|
|
|
|
|
def test_every_chunk_is_anchored_by_the_title():
|
|
"""Each vector must carry its own topical anchor — the property that makes
|
|
snippets discriminative. An unanchored mid-document chunk would embed as
|
|
free-floating prose about nothing in particular."""
|
|
body = "\n\n".join(
|
|
f"## Section {i}\n\n{_long_section(f'sec-{i}')}" for i in range(6)
|
|
)
|
|
chunks = chunk_document("Retrieval reference", body)
|
|
assert len(chunks) > 1
|
|
for chunk in chunks:
|
|
assert chunk.startswith("Retrieval reference\n")
|
|
|
|
|
|
# --- boundary behaviour ------------------------------------------------------
|
|
|
|
|
|
def test_sections_split_at_markdown_headings_and_stay_whole_when_they_fit():
|
|
a = "## Alpha\n\nShort alpha content."
|
|
b = "## Beta\n\n" + _long_section("beta")
|
|
c = "## Gamma\n\n" + _long_section("gamma")
|
|
chunks = chunk_document("T", f"{a}\n\n{b}\n\n{c}")
|
|
# Beta's content never shares a chunk with Gamma's heading-onward content:
|
|
# heading boundaries are chunk boundaries unless merging small sections.
|
|
for chunk in chunks:
|
|
assert not ("(p5)" in chunk and "## Gamma" in chunk and "beta" in chunk)
|
|
|
|
|
|
def test_small_adjacent_sections_merge_instead_of_each_spending_a_vector():
|
|
body = (
|
|
"\n\n".join(f"## S{i}\n\nTiny." for i in range(4))
|
|
+ "\n\n## Big\n\n"
|
|
+ _long_section("big", paragraphs=10)
|
|
)
|
|
chunks = chunk_document("T", body)
|
|
tiny_chunks = [c for c in chunks if "Tiny." in c]
|
|
assert len(tiny_chunks) == 1, "four tiny sections should share one chunk"
|
|
|
|
|
|
def test_a_heading_inside_a_code_fence_does_not_split():
|
|
"""A commented `# step` in a recorded shell snippet is content, not
|
|
structure."""
|
|
body = "Intro.\n\n```bash\n# not a heading\necho hi\n```\n\nOutro."
|
|
chunks = chunk_document("T", body)
|
|
assert chunks == [embedding_text("T", body)]
|
|
|
|
|
|
def test_pieces_subsplit_from_one_section_repeat_its_heading():
|
|
"""'Which part of which topic' must survive the split — a continuation
|
|
piece without its heading embeds as context-free prose."""
|
|
body = "## The Only Topic\n\n" + _long_section("only", paragraphs=40)
|
|
chunks = chunk_document("T", body)
|
|
assert len(chunks) > 1
|
|
for chunk in chunks:
|
|
assert "## The Only Topic" in chunk
|
|
|
|
|
|
def test_a_monster_single_paragraph_is_hard_split_not_dropped():
|
|
body = "word " * 2000 # one paragraph, no newlines to split at
|
|
chunks = chunk_document("T", body)
|
|
assert len(chunks) > 1
|
|
total_words = sum(chunk.count("word") for chunk in chunks)
|
|
assert total_words == 2000
|
|
|
|
|
|
# --- the read path: best chunk wins (#280 step 4) ----------------------------
|
|
|
|
|
|
async def test_search_collapses_chunk_rows_to_best_chunk_per_note():
|
|
"""Rows arrive at CHUNK grain ordered by distance; a note appearing via
|
|
several chunks must come back ONCE, scored by its best chunk — otherwise a
|
|
long record fills the top-k with copies of itself."""
|
|
from unittest.mock import AsyncMock, MagicMock, patch
|
|
|
|
from scribe.services import embeddings as emb
|
|
|
|
note_a, note_b = MagicMock(id=1), MagicMock(id=2)
|
|
# Rows are (Note, distance, chunk_index, chunk_text) — the chunk columns
|
|
# ride along so the collapse can report WHICH passage won (#4243).
|
|
rows = [
|
|
(note_a, 0.10, 3, "the passage that actually matched"),
|
|
(note_b, 0.20, 0, "b's best"),
|
|
(note_a, 0.25, 7, "a worse chunk of a"),
|
|
(note_a, 0.30, 1, "a worse chunk of a"),
|
|
]
|
|
result = MagicMock()
|
|
result.all.return_value = rows
|
|
session, ctx = _session_ctx()
|
|
session.execute = AsyncMock(return_value=result)
|
|
|
|
report: dict = {}
|
|
with (
|
|
patch.object(emb, "async_session", return_value=ctx),
|
|
patch.object(emb, "get_embedding", AsyncMock(return_value=[0.0] * 384)),
|
|
):
|
|
out = await emb.semantic_search_notes(
|
|
1, "a query", limit=8, demote_superseded=False, report=report
|
|
)
|
|
|
|
assert [note.id for _s, note in out] == [1, 2]
|
|
assert out[0][0] == 1.0 - 0.10 # the BEST chunk's score, not a later one
|
|
|
|
# And the winning chunk is reported, not merely used for scoring. Without
|
|
# this a caller can only preview the head of the body — a span this query
|
|
# has already ranked lower than the one that won (#4243).
|
|
assert report["best_chunk"][1] == {
|
|
"index": 3, "text": "the passage that actually matched",
|
|
}
|
|
assert report["best_chunk"][2]["index"] == 0
|
|
|
|
|
|
# --- the write path: one row per chunk (#280 step 3) -------------------------
|
|
|
|
|
|
def _session_ctx(log_rows=()):
|
|
"""A session stand-in. `log_rows` answers the work-log read a task's
|
|
document now needs (#4251) — answered explicitly rather than left to
|
|
autovivify, because `list(result.all())` on a bare MagicMock raises and is
|
|
swallowed, which would quietly make every one of these a no-log test."""
|
|
from unittest.mock import AsyncMock, MagicMock
|
|
|
|
session = MagicMock()
|
|
result = MagicMock()
|
|
result.all.return_value = list(log_rows)
|
|
session.execute = AsyncMock(return_value=result)
|
|
session.commit = AsyncMock()
|
|
ctx = MagicMock()
|
|
ctx.__aenter__ = AsyncMock(return_value=session)
|
|
ctx.__aexit__ = AsyncMock(return_value=False)
|
|
return session, ctx
|
|
|
|
|
|
async def test_upsert_stores_one_versioned_row_per_chunk():
|
|
from unittest.mock import AsyncMock, patch
|
|
|
|
from scribe.services import embeddings as emb
|
|
|
|
body = "\n\n".join(
|
|
f"## Section {i}\n\n{_long_section(f'sec-{i}')}" for i in range(6)
|
|
)
|
|
chunks = chunk_document("T", body)
|
|
assert len(chunks) > 1
|
|
|
|
session, ctx = _session_ctx()
|
|
with (
|
|
patch.object(emb, "async_session", return_value=ctx),
|
|
patch.object(
|
|
emb, "get_embeddings",
|
|
AsyncMock(return_value=[[0.0] * 384 for _ in chunks]),
|
|
),
|
|
):
|
|
await emb.upsert_note_embedding(7, 42, "T", body)
|
|
|
|
rows = [call.args[0] for call in session.add.call_args_list]
|
|
assert [r.chunk_index for r in rows] == list(range(len(chunks)))
|
|
assert [r.chunk_text for r in rows] == chunks
|
|
assert {r.chunker_version for r in rows} == {emb.CHUNKER_VERSION}
|
|
assert {r.user_id for r in rows} == {42}
|
|
session.execute.assert_awaited() # the delete that makes replacement atomic
|
|
|
|
|
|
async def test_a_tasks_work_logs_reach_the_rows_it_is_stored_as():
|
|
"""The wiring half of #4251: the shaper is pure and tested next door, so
|
|
what is pinned here is that the WRITER actually asks for the logs and
|
|
embeds what comes back. A log that is written, readable (#4241) and absent
|
|
from the index is the same half-surface one layer down."""
|
|
import datetime
|
|
from unittest.mock import AsyncMock, patch
|
|
|
|
from scribe.services import embeddings as emb
|
|
|
|
session, ctx = _session_ctx(
|
|
log_rows=[(datetime.datetime(2026, 9, 20), "ruled out the cache theory")]
|
|
)
|
|
with (
|
|
patch.object(emb, "async_session", return_value=ctx),
|
|
patch.object(emb, "get_embeddings", AsyncMock(return_value=[[0.0] * 384])),
|
|
):
|
|
await emb.upsert_note_embedding(7, 42, "A task", "Its own prose.")
|
|
|
|
rows = [call.args[0] for call in session.add.call_args_list]
|
|
stored = "\n".join(r.chunk_text for r in rows)
|
|
assert "ruled out the cache theory" in stored
|
|
assert "Its own prose." in stored
|
|
assert emb.WORK_LOG_HEADING in stored
|
|
|
|
|
|
async def test_upsert_of_an_emptied_record_clears_rows_instead_of_embedding():
|
|
"""An empty embedding is worse than none, and a STALE one is worse than
|
|
that — a record emptied of content must stop being findable by what it no
|
|
longer says."""
|
|
from unittest.mock import patch
|
|
|
|
from scribe.services import embeddings as emb
|
|
|
|
session, ctx = _session_ctx()
|
|
with (
|
|
patch.object(emb, "async_session", return_value=ctx),
|
|
patch.object(emb, "get_embeddings") as embedder,
|
|
):
|
|
await emb.upsert_note_embedding(7, 42, "", "")
|
|
|
|
embedder.assert_not_called()
|
|
session.execute.assert_awaited() # the delete
|
|
session.add.assert_not_called()
|
|
session.commit.assert_awaited()
|
|
|
|
|
|
async def test_backfill_reembeds_notes_with_a_stale_chunker_version():
|
|
"""The reason chunker_version exists: a shape change becomes a version
|
|
bump that re-embeds exactly the stale notes, instead of another 0077-style
|
|
table wipe. Only rows AT the current version count as done."""
|
|
from unittest.mock import AsyncMock, MagicMock, patch
|
|
|
|
from scribe.services import embeddings as emb
|
|
|
|
current_rows = MagicMock()
|
|
current_rows.fetchall.return_value = [(1,)] # note 1 is current
|
|
note_rows = MagicMock()
|
|
note_rows.fetchall.return_value = [
|
|
(1, 42, "current", "body"),
|
|
(2, 42, "stale-version", "body"),
|
|
]
|
|
session, ctx = _session_ctx()
|
|
session.execute = AsyncMock(side_effect=[current_rows, note_rows])
|
|
|
|
with (
|
|
patch.object(emb, "async_session", return_value=ctx),
|
|
patch.object(emb, "upsert_note_embedding", AsyncMock()) as upsert,
|
|
patch.object(emb.asyncio, "sleep", AsyncMock()),
|
|
):
|
|
await emb.backfill_note_embeddings()
|
|
|
|
embedded = [call.args[0] for call in upsert.call_args_list]
|
|
assert embedded == [2], "only the stale note is re-embedded"
|