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.
121 lines
4.6 KiB
Python
121 lines
4.6 KiB
Python
"""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
|