Files
FabledGrimoire/tests/test_knowledge_facets.py
T
bvandeusenandClaude Opus 5.5 0f5ef0be63
CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 12s
CI & Build / TypeScript typecheck (push) Successful in 56s
CI & Build / integration (push) Successful in 1m4s
CI & Build / Python tests (push) Successful in 1m55s
CI & Build / Build & push image (push) Successful in 44s
rename(547): the server is grimoire — package, image, DB defaults, backups
Step 2 of the Scribe -> Grimoire rename.

- src/scribe -> src/grimoire, and every import, patch target, module path,
  hypercorn entry point and `python -m grimoire.restore` with it; pyproject,
  uv.lock and the frontend package are named grimoire.
- Image git.fabledsword.com/bvandeusen/fabledgrimoire; CI's Postgres service
  runs as grimoire / grimoire_test.
- Compose files default the DB user, database and (prod) volume to grimoire,
  each overridable from .env, so an install created as Scribe keeps its data
  by setting POSTGRES_USER, POSTGRES_DB and PGDATA_VOLUME.
- Backups are written as grimoire-<ts>.tar with format grimoire-backup-tar.
  Archives named scribe-* with the scribe-backup-tar manifest still restore
  and are still counted at the destination (folder and S3), with tests.
- Exports, the MCP auth realm, the ASGI scope key and the persistent logger
  carry the new name.

Kept on purpose: the secret-sealing key label stays "scribe.secrets.v1",
because every stored secret was derived under it.

Plugin identity (plugin name, hooks, env vars, .scribe marker, skills) is
step 3.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 10:50:45 -04:00

126 lines
4.9 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. Hand-written, those three
agree only by luck: adding `issue` to the SQL arm alone would set the
pre-filter to is_task=False, hand the Python arm a candidate set with no
tasks in it, and return an empty semantic half for the Issues facet forever
with nothing red anywhere.
So they are generated from one table, and these tests pin the property that
makes the trap possible, so it cannot come back by a different route.
"""
import pytest
from grimoire.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 grimoire.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(),
"lesson": fake_note(note_type="lesson"),
"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"}),
("lesson", {"lesson"}),
("", 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():
"""Spelled out rather than derived, so adding a kind to `_FACETS` has to
be a deliberate edit in two places. `lesson` is non-task on purpose: `is_task` IS `status is not None`,
so a lesson that acquired a status would stop being a lesson."""
assert set(NON_TASK_FACETS) == {"note", "process", "snippet", "lesson"}
@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