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
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:
@@ -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.
|
||||
|
||||
@@ -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
|
||||
@@ -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:
|
||||
|
||||
@@ -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])
|
||||
|
||||
@@ -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"
|
||||
Reference in New Issue
Block a user