fix(knowledge): the browse vocabulary catches up three kinds, and a snippet's mirror survives the generic door (#3128 recs 2-6)
CI & Build / Python lint (push) Successful in 4s
CI & Build / Plugin hooks (push) Successful in 12s
CI & Build / integration (push) Successful in 27s
CI & Build / TypeScript typecheck (push) Successful in 33s
CI & Build / Python tests (push) Successful in 1m6s
CI & Build / Build & push image (push) Successful in 33s

Spike #3128 found the storage sound and the retrieval vocabulary frozen
before `issue` shipped (0065). Five things, in the order they had to land.

**The mirror (rec 5, the data-integrity one).** `notes.data` is DERIVED from
a snippet's body, but only `update_snippet` knew that. `update_note` is a
hasattr loop with no snippet awareness, and both doors reach it — so PATCH
/api/notes/<snippet_id> {body} rewrote the body and left the mirror behind.
`snippet_fields` PREFERS the mirror, so the row went on reporting its old
repo/path/symbol to the location reverse lookup and to prior-art recall while
displaying its new body: surfaced with full authority, and wrong.
`snippets.recompose_data` rebuilds it from the body, carrying `verification`
and `provenance` (neither is in the body to parse). An explicit `data` still
wins, so every snippet-service write is untouched.

**One facet table (rec 3), before adding any facet.** The type predicate was
written three times — SQL, Python over semantic candidates, and a ternary
computing the `is_task` pre-filter — and agreed only by luck. Adding `issue`
to the SQL arm alone would have set the pre-filter to is_task=False, handed
the Python arm a candidate set with no tasks in it, and returned an empty
semantic half for the Issues facet forever with nothing red. `_FACETS` now
generates all three. The Python arm also regains the `status IS NULL` half its
SQL twin always had.

**Issue and spike become facets (rec 2).** 435 issues — 17% of every task —
were filterable nowhere on the human surface, while retired `plan` (90 rows)
had a chip of its own. `_VALID_TYPES` was a hand-kept copy and is now derived.
`plan` stays a valid facet for its legacy rows; it loses its chip.

**Snippets stop being half-present in the feed (rec 4).** All 90 were in the
All list, in no count, wearing an empty badge, and opening in the note editor.
Counts now group by task_kind — every kind for the same two round-trips, which
is why `issue` had no number — and total includes snippets, so the All chip
matches the list it labels. Snippet cards route to /snippets/:id.

**The prose that excused it (rec 6).** `snippet_fields` and the `data` column
both still said pre-0070 rows were "never backfilled". True when 0070 landed,
false since `backfill_snippet_data` shipped, and it read as licence for a
stale mirror.

Tests: the pre-filter can never exclude a row its own facet accepts (the
regression, parameterised over every facet); both dialects select exactly
their own rows; an unknown facet matches nothing; the mirror follows a body or
title write, carries the verdict, and yields to an explicit `data`.
`compiled_sql` moves to tests/helpers rather than becoming a third copy.

Write-up: note #3161.
This commit is contained in:
2026-08-28 12:06:43 -04:00
parent d0a2733cb6
commit f80401d58e
11 changed files with 576 additions and 104 deletions
+10
View File
@@ -12,6 +12,16 @@ from types import SimpleNamespace
from unittest.mock import AsyncMock, MagicMock
def compiled_sql(element) -> str:
"""A SQLAlchemy clause or statement rendered as literal SQL text.
For asserting on the shape of a predicate without a database — which is how
the visibility clauses and the knowledge facets are both tested. Was a
private copy in each of those modules before #3128 needed a third.
"""
return str(element.compile(compile_kwargs={"literal_binds": True}))
def make_mock_session() -> AsyncMock:
"""A stand-in for ``async_session()`` — usable as ``async with``, with the
commit/refresh/add surface a service touches.
+120
View File
@@ -0,0 +1,120 @@
"""The type facet, in both of its dialects.
The facet predicate is written twice by necessity — as SQL for rows the
database hands back, and as Python for candidates the vector search has
already fetched — plus a third time as the `is_task` pre-filter that narrows
the candidate set before it exists. Those three used to be hand-written and
agreed only by luck: adding `issue` to the SQL arm alone would have set the
pre-filter to is_task=False, handed the Python arm a candidate set with no
tasks in it, and returned an empty semantic half for the Issues facet forever
with nothing red anywhere (#3128).
They are now generated from one table. These tests pin the property that made
the trap possible, so it cannot come back by a different route.
"""
import pytest
from scribe.services.knowledge import (
_FACETS,
FACET_TYPES,
NON_TASK_FACETS,
_apply_type_filter,
facet_is_task,
matches_facet,
)
from tests.helpers import compiled_sql, fake_note, fake_snippet, fake_task
def _sql(note_type):
from sqlalchemy import select
from scribe.models.note import Note
return compiled_sql(_apply_type_filter(select(Note.id), note_type))
# One representative row per shape the corpus actually holds.
ROWS = {
"plain note": fake_note(note_type="note"),
"process": fake_note(note_type="process"),
"snippet": fake_snippet(),
"work task": fake_task(task_kind="work", note_type="note"),
"issue": fake_task(task_kind="issue", note_type="note"),
"spike": fake_task(task_kind="spike", note_type="note"),
"legacy plan": fake_task(task_kind="plan", note_type="note"),
}
@pytest.mark.parametrize("facet", sorted(FACET_TYPES))
def test_pre_filter_never_excludes_a_row_the_facet_wants(facet):
"""THE regression. `facet_is_task` narrows the semantic candidate set before
`matches_facet` ever sees it, so a pre-filter that disagrees with the
predicate doesn't return wrong rows — it returns NO rows, silently, on one
half of a hybrid search."""
want = facet_is_task(facet)
for label, row in ROWS.items():
if matches_facet(row, facet):
assert want is None or want == row.is_task, (
f"facet {facet!r} accepts the {label} row, but its pre-filter "
f"asks for is_task={want} while the row has is_task={row.is_task} "
f"— the semantic arm would never be handed this row"
)
@pytest.mark.parametrize(
"facet,expected",
[
("task", {"work task", "issue", "spike", "legacy plan"}),
("issue", {"issue"}),
("spike", {"spike"}),
("work", {"work task"}),
("plan", {"legacy plan"}),
("note", {"plain note"}),
("process", {"process"}),
("snippet", {"snippet"}),
("", set(ROWS)),
],
)
def test_each_facet_selects_exactly_its_own_rows(facet, expected):
assert {k for k, row in ROWS.items() if matches_facet(row, facet)} == expected
def test_a_plain_note_is_not_selected_by_its_own_type_when_it_is_a_task():
"""A task's `note_type` is 'note' — that column says nothing about task-ness.
The hand-written Python arm omitted the `status IS NULL` half its SQL twin
carried, so it only avoided returning every task under the Notes facet
because the pre-filter had already dropped them."""
assert matches_facet(fake_task(note_type="note"), "note") is False
def test_an_unknown_facet_matches_nothing_rather_than_everything():
"""A typo must return an empty list, never the whole corpus."""
assert all(not matches_facet(row, "wrok") for row in ROWS.values())
assert "notes.status IS NULL" in _sql("wrok")
def test_the_live_task_kinds_are_all_facets():
"""`issue` shipped in 0065 and `spike` in 0091; the browse vocabulary went
three kinds without noticing either."""
for kind in ("work", "issue", "spike"):
assert kind in FACET_TYPES and _FACETS[kind][0] is True
def test_non_task_facets_are_the_note_types_and_only_those():
assert set(NON_TASK_FACETS) == {"note", "process", "snippet"}
@pytest.mark.parametrize("facet", sorted(FACET_TYPES))
def test_sql_arm_constrains_the_axis_the_facet_lives_on(facet):
"""The SQL dialect of the same table. A task facet must pin `status` (and,
for a single kind, `task_kind`); a record-type facet must pin `note_type`
AND exclude tasks."""
sql = _sql(facet)
is_task, value = _FACETS[facet]
assert "notes.deleted_at IS NULL" in sql
if is_task:
assert "notes.status IS NOT NULL" in sql
assert (f"notes.task_kind = '{value}'" in sql) is (value is not None)
else:
assert "notes.status IS NULL" in sql
assert f"notes.note_type = '{value}'" in sql
+2 -3
View File
@@ -20,10 +20,9 @@ from scribe.services.access import (
notes_visibility_clause,
readable_notes_clause,
)
from tests.helpers import compiled_sql
def _sql(clause) -> str:
return str(clause.compile(compile_kwargs={"literal_binds": True}))
_sql = compiled_sql
def _read(user_id: int = 7) -> str:
+69 -22
View File
@@ -1,4 +1,12 @@
"""get_knowledge_counts includes the 'process' type and counts it in total."""
"""get_knowledge_counts — one number per facet, and an honest total.
Two grouped queries, one per typing axis. It used to be three: a grouped count
over note_type restricted to ("note", "process"), a scalar count of tasks, and
a second scalar just for plans. That shape is why `issue` had no number —
every kind needed a query of its own and nobody added one — and why the "All"
chip sat ~90 below the list it labelled, since snippets were in the feed but
in no count (#3128).
"""
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
@@ -11,29 +19,68 @@ def _grouped(rows):
return r
def _scalar(n):
r = MagicMock()
r.scalar_one.return_value = n
return r
@pytest.mark.asyncio
async def test_counts_include_process_in_facet_and_total():
async def _counts(non_task_rows, kind_rows, **kwargs):
session = make_mock_session()
# 1) grouped non-task counts, 2) task count, 3) plan count
session.execute = AsyncMock(side_effect=[
_grouped([("note", 3), ("process", 2)]),
_scalar(1), # tasks
_scalar(0), # plans
])
# 1) non-task rows grouped by note_type, 2) task rows grouped by task_kind
session.execute = AsyncMock(
side_effect=[_grouped(non_task_rows), _grouped(kind_rows)]
)
with patch("scribe.services.knowledge.async_session") as cls:
cls.return_value = session
from scribe.services.knowledge import get_knowledge_counts
counts = await get_knowledge_counts(user_id=1)
return await get_knowledge_counts(user_id=1, **kwargs), session
assert counts["process"] == 2
# facet keys all present (setdefault)
for key in ("note", "task", "plan", "process"):
assert key in counts
# total = note(3) + task(1) + process(2)
assert counts["total"] == 6
@pytest.mark.asyncio
async def test_every_facet_gets_a_number_including_the_kinds():
counts, _ = await _counts(
[("note", 395), ("process", 3), ("snippet", 90)],
[("work", 2104), ("issue", 435), ("spike", 1), ("plan", 90)],
)
assert counts["note"] == 395
assert counts["process"] == 3
assert counts["snippet"] == 90
assert counts["issue"] == 435
assert counts["spike"] == 1
assert counts["plan"] == 90
assert counts["work"] == 2104
@pytest.mark.asyncio
async def test_task_is_the_sum_of_its_kinds():
"""`task` is not counted separately any more — it is what the kinds add up
to, so the two can't disagree."""
counts, _ = await _counts([], [("work", 2104), ("issue", 435), ("spike", 1)])
assert counts["task"] == 2540
@pytest.mark.asyncio
async def test_total_counts_snippets_and_counts_no_task_twice():
"""The All chip labels a feed that contains every kind, so it has to count
every kind — and exactly once. Kinds are subsets of `task`; adding them
would count each issue a second time."""
counts, _ = await _counts(
[("note", 10), ("process", 2), ("snippet", 5)],
[("work", 20), ("issue", 4)],
)
assert counts["task"] == 24
assert counts["total"] == 10 + 2 + 5 + 24
@pytest.mark.asyncio
async def test_absent_facets_report_zero_rather_than_missing():
counts, _ = await _counts([("note", 1)], [])
assert counts["note"] == 1
for key in ("process", "snippet", "task", "work", "issue", "spike", "plan"):
assert counts[key] == 0, key
assert counts["total"] == 1
@pytest.mark.asyncio
async def test_a_tag_filter_narrows_both_axes():
"""A tag has to reach both queries, or the chips would disagree with each
other under a filter — tasks narrowed, notes not."""
_, session = await _counts([], [], tags=["python"])
assert session.execute.await_count == 2
for call in session.execute.await_args_list:
assert "notes.tags" in str(call.args[0])
+111
View File
@@ -0,0 +1,111 @@
"""A snippet's `data` mirror survives the GENERIC note door.
`notes.data` is derived from the body. The snippet service always composed it
from the field set it had just merged, so `update_snippet` was never the
problem — the problem was every other way a snippet's body could be written.
`update_note` is a `hasattr` loop with no snippet awareness, and both doors
reach it: PATCH /api/notes/<id> and the MCP update_note tool. The Knowledge
feed handed you that path, because a snippet card there routed to /notes/:id.
The failure was silent and the wrong way round: `snippet_fields` PREFERS the
mirror, so the row went on reporting its old repo/path/symbol to the location
reverse lookup and to prior-art recall while displaying its new body — a record
surfaced with full authority and wrong, which the drift-check docstring calls
worse than having no record at all (#3128).
"""
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from tests.helpers import fake_note, fake_snippet, make_mock_session
OLD_MIRROR = {
"name": "debounce",
"language": "javascript",
"locations": [{"repo": "Scribe", "path": "old/place.js", "symbol": "debounce"}],
"verification": {"status": "ok", "code_sha": "abc", "checked_at": "2026-01-01"},
"provenance": {"commit_sha": "deadbeef"},
}
MOVED_BODY = (
"**Locations:**\n"
"- `Scribe` · `new/place.ts` · `debounce`\n\n"
"```typescript\nexport const debounce = 1;\n```\n"
)
async def _update(note, **fields):
session = make_mock_session()
result = MagicMock()
result.scalars.return_value.first.return_value = note
session.execute = AsyncMock(return_value=result)
with patch("scribe.services.notes.async_session") as cls, \
patch("scribe.services.notes.embed_note", MagicMock()), \
patch("scribe.services.notes._maybe_reactivate_project", AsyncMock()), \
patch("scribe.services.note_versions.create_version", AsyncMock()):
cls.return_value = session
from scribe.services.notes import update_note
await update_note(user_id=7, note_id=note.id, **fields)
return note
@pytest.mark.asyncio
async def test_a_body_write_moves_the_mirror_with_it():
note = fake_snippet(data=dict(OLD_MIRROR), project_id=None)
await _update(note, body=MOVED_BODY)
assert note.data["locations"] == [
{"repo": "Scribe", "path": "new/place.ts", "symbol": "debounce"}
], "the mirror still describes where the snippet used to live"
assert note.data["language"] == "typescript"
@pytest.mark.asyncio
async def test_the_verdict_and_provenance_are_carried_not_dropped():
"""Neither is in the body to parse, so recomposing must carry them. An
ordinary edit must not erase the last drift check — and it needs no
invalidation branch either: `code_sha` is recomputed from the new code, so
a verdict stamped against the old code expires itself on read."""
note = fake_snippet(data=dict(OLD_MIRROR), project_id=None)
await _update(note, body=MOVED_BODY)
assert note.data["verification"] == OLD_MIRROR["verification"]
assert note.data["provenance"] == OLD_MIRROR["provenance"]
assert note.data["code_sha"] != OLD_MIRROR["verification"]["code_sha"]
@pytest.mark.asyncio
async def test_an_explicit_data_wins_over_recomposition():
"""`update_snippet` composes the mirror from the merged field set it holds
and passes it here. That caller knows things the body cannot be re-read for
— which locations were replaced, whether provenance survives the edit — so
an explicit mirror must not be recomputed out from under it."""
note = fake_snippet(data=dict(OLD_MIRROR), project_id=None)
authoritative = {"name": "from the service", "locations": []}
await _update(note, body=MOVED_BODY, data=authoritative)
assert note.data == authoritative
@pytest.mark.asyncio
async def test_a_plain_note_is_left_alone():
"""Only snippets carry a mirror; a note's `data` must not be invented."""
note = fake_note(note_type="note", data=None, project_id=None)
await _update(note, body="just some prose")
assert note.data is None
@pytest.mark.asyncio
async def test_a_write_that_cannot_change_the_parse_does_not_touch_the_mirror():
"""Status, priority, project — none of them is an input to the body parser,
so recomposing on them would be work for nothing and would rebuild a mirror
from a body nobody claimed to have changed."""
note = fake_snippet(data=dict(OLD_MIRROR), project_id=None)
await _update(note, project_id=4)
assert note.data == OLD_MIRROR
@pytest.mark.asyncio
async def test_a_title_change_reaches_the_mirror_too():
"""A snippet's NAME lives in its title, not its body — `parse_snippet_fields`
reads both, so both are triggers."""
note = fake_snippet(data=dict(OLD_MIRROR), project_id=None)
await _update(note, title="throttle — cap a callback's rate")
assert note.data["name"] == "throttle"
assert note.data["when_to_use"] == "cap a callback's rate"