feat(retrieval): a task's work logs join the document it is embedded as (#4251)
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
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
This commit is contained in:
@@ -14,23 +14,14 @@ from scribe.services.dedup import (
|
||||
plan_candidate_text,
|
||||
plan_match_response,
|
||||
)
|
||||
from tests.helpers import fake_note, make_mock_session
|
||||
|
||||
|
||||
def _session_returning(note):
|
||||
"""A mocked async_session() whose single execute() yields `note` (or None)."""
|
||||
s = make_mock_session()
|
||||
result = MagicMock()
|
||||
result.scalars.return_value.first.return_value = note
|
||||
s.execute = AsyncMock(return_value=result)
|
||||
return s
|
||||
from tests.helpers import fake_note, make_mock_session, session_returning
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_title_exact_match_returns_title_duplicate():
|
||||
note = fake_note(id=10, title="Setup CI")
|
||||
with patch("scribe.services.dedup.async_session",
|
||||
return_value=_session_returning(note)):
|
||||
return_value=session_returning(note)):
|
||||
# whitespace/case differences are normalized away
|
||||
dup = await find_duplicate_note(7, " setup ci ", project_id=2, is_task=True)
|
||||
assert dup is not None
|
||||
@@ -43,7 +34,7 @@ async def test_title_exact_match_returns_title_duplicate():
|
||||
async def test_short_body_skips_semantic_check():
|
||||
sem = AsyncMock()
|
||||
with patch("scribe.services.dedup.async_session",
|
||||
return_value=_session_returning(None)), \
|
||||
return_value=session_returning(None)), \
|
||||
patch("scribe.services.dedup.embeddings_svc.semantic_search_notes", sem):
|
||||
dup = await find_duplicate_note(7, "Unique", body="too short", project_id=2)
|
||||
assert dup is None
|
||||
@@ -55,7 +46,7 @@ async def test_semantic_match_when_body_substantial():
|
||||
hit = fake_note(id=20, title="Existing", note_type="note")
|
||||
sem = AsyncMock(return_value=[(0.93, hit)])
|
||||
with patch("scribe.services.dedup.async_session",
|
||||
return_value=_session_returning(None)), \
|
||||
return_value=session_returning(None)), \
|
||||
patch("scribe.services.dedup.embeddings_svc.semantic_search_notes", sem):
|
||||
dup = await find_duplicate_note(
|
||||
7, "Title", body="x" * 250, project_id=2, is_task=False, note_type="note",
|
||||
@@ -84,7 +75,7 @@ async def test_gate_catches_a_duplicate_hiding_in_a_later_chunk():
|
||||
# Every chunk misses except the LAST one the gate will ask about.
|
||||
sem = AsyncMock(side_effect=[[] for _ in range(n_chunks - 1)] + [[(0.94, hit)]])
|
||||
with patch("scribe.services.dedup.async_session",
|
||||
return_value=_session_returning(None)), \
|
||||
return_value=session_returning(None)), \
|
||||
patch("scribe.services.dedup.embeddings_svc.semantic_search_notes", sem):
|
||||
dup = await find_duplicate_note(
|
||||
7, "Title", body=body, project_id=2, is_task=False, note_type="note",
|
||||
@@ -98,7 +89,7 @@ async def test_semantic_match_of_other_note_type_is_ignored():
|
||||
other = fake_note(id=21, title="X", note_type="process")
|
||||
sem = AsyncMock(return_value=[(0.97, other)])
|
||||
with patch("scribe.services.dedup.async_session",
|
||||
return_value=_session_returning(None)), \
|
||||
return_value=session_returning(None)), \
|
||||
patch("scribe.services.dedup.embeddings_svc.semantic_search_notes", sem):
|
||||
dup = await find_duplicate_note(7, "Title", body="x" * 250, note_type="note")
|
||||
assert dup is None # type mismatch must not block
|
||||
@@ -108,7 +99,7 @@ async def test_semantic_match_of_other_note_type_is_ignored():
|
||||
async def test_rule_title_match_in_topic():
|
||||
rule = fake_note(id=47, title="Honor the multi-user sharing ACL")
|
||||
with patch("scribe.services.dedup.async_session",
|
||||
return_value=_session_returning(rule)):
|
||||
return_value=session_returning(rule)):
|
||||
dup = await find_duplicate_rule(
|
||||
"honor the multi-user sharing acl", topic_id=7,
|
||||
)
|
||||
@@ -361,7 +352,7 @@ async def test_plan_title_match_short_circuits_the_semantic_arm():
|
||||
ms = MagicMock(id=415, title="Plan gate")
|
||||
sem = AsyncMock()
|
||||
with patch("scribe.services.dedup.can_read_project", AsyncMock(return_value=True)), \
|
||||
patch("scribe.services.dedup.async_session", return_value=_session_returning(ms)), \
|
||||
patch("scribe.services.dedup.async_session", return_value=session_returning(ms)), \
|
||||
patch("scribe.services.dedup.embeddings_svc.semantic_search_milestones", sem):
|
||||
dup = await find_matching_plan(7, 2, " plan GATE", "x" * 300)
|
||||
assert (dup.id, dup.reason, dup.similarity) == (415, "title", 1.0)
|
||||
@@ -373,7 +364,7 @@ async def test_plan_semantic_arm_asks_for_active_plans_in_the_project_at_the_set
|
||||
ms = MagicMock(id=9, title="Metadata")
|
||||
sem = AsyncMock(return_value=[(0.912345, ms)])
|
||||
with patch("scribe.services.dedup.can_read_project", AsyncMock(return_value=True)), \
|
||||
patch("scribe.services.dedup.async_session", return_value=_session_returning(None)), \
|
||||
patch("scribe.services.dedup.async_session", return_value=session_returning(None)), \
|
||||
patch("scribe.services.dedup.embeddings_svc.semantic_search_milestones", sem), \
|
||||
patch("scribe.services.settings.get_setting", AsyncMock(return_value="0.8")):
|
||||
dup = await find_matching_plan(7, 2, "Book metadata", "x" * 300)
|
||||
@@ -396,7 +387,7 @@ async def test_plan_gate_fails_open():
|
||||
@pytest.mark.asyncio
|
||||
async def test_plan_gate_says_nothing_about_a_project_the_caller_cannot_read():
|
||||
ms = MagicMock(id=415, title="Their plan")
|
||||
session = MagicMock(return_value=_session_returning(ms))
|
||||
session = MagicMock(return_value=session_returning(ms))
|
||||
with patch("scribe.services.dedup.can_read_project", AsyncMock(return_value=False)), \
|
||||
patch("scribe.services.dedup.async_session", session):
|
||||
assert await find_matching_plan(8, 2, "Their plan") is None
|
||||
|
||||
Reference in New Issue
Block a user