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
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>
167 lines
6.7 KiB
Python
167 lines
6.7 KiB
Python
"""Every semantic search hands on the passage that matched.
|
|
|
|
Three searches collapse chunk rows to the best one per record, so each of them
|
|
KNOWS which passage earned the hit. A search that drops it leaves every door to
|
|
preview the head of the document instead — a span the search has already scored
|
|
lower, with nothing saying so.
|
|
|
|
These pin the mechanism (`report["best_chunk"]` from all three searches) and
|
|
each surface that reads it, because the failure mode is silent: a door that
|
|
quietly reverts to the body's opening still returns a plausible-looking string
|
|
and no test that only checks "a preview exists" would notice.
|
|
"""
|
|
from unittest.mock import AsyncMock, MagicMock, patch
|
|
|
|
import pytest
|
|
|
|
from grimoire.services import embeddings as emb
|
|
from grimoire.services.text import (
|
|
BODY_OPENING,
|
|
MATCHED_PASSAGE,
|
|
excerpt_fields,
|
|
matched_excerpt,
|
|
)
|
|
from tests.helpers import make_mock_session
|
|
|
|
|
|
def _searching(rows):
|
|
"""A patched session whose one query returns `rows` — the shared
|
|
make_mock_session rather than a third local copy of the
|
|
__aenter__/__aexit__ dance."""
|
|
session = make_mock_session()
|
|
result = MagicMock()
|
|
result.all.return_value = rows
|
|
session.execute = AsyncMock(return_value=result)
|
|
return session
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# The shared choice of span
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def test_the_matched_passage_wins_over_the_opening():
|
|
body = "An opening about nothing much. " * 20 + " THE ANSWER."
|
|
text, kind, _cut = matched_excerpt(body, {"index": 4, "text": "THE ANSWER."}, 1000)
|
|
assert text == "THE ANSWER."
|
|
assert kind == MATCHED_PASSAGE
|
|
|
|
|
|
def test_without_a_chunk_the_opening_is_named_as_the_opening():
|
|
"""The fallback is legitimate — a plain listing matched nothing — but it
|
|
must not pass for the passage that matched."""
|
|
text, kind, _cut = matched_excerpt("just a body", None, 1000)
|
|
assert kind == BODY_OPENING
|
|
|
|
|
|
def test_an_empty_chunk_is_not_mistaken_for_a_passage():
|
|
"""A record embedded from its title alone stores an empty body chunk;
|
|
rendering that as "the passage that matched" would be a blank line
|
|
presented as evidence."""
|
|
_t, kind, _c = matched_excerpt("real body", {"index": 0, "text": " "}, 1000)
|
|
assert kind == BODY_OPENING
|
|
|
|
|
|
def test_the_field_names_travel_together():
|
|
"""A door renames the text field to keep its consumers working; the label
|
|
has to follow it, or a row carries an excerpt under one name and its
|
|
meaning under another."""
|
|
out = excerpt_fields("b" * 500, {"index": 1, "text": "hit"}, 100, key="snippet")
|
|
assert out["snippet"] == "hit"
|
|
assert out["snippet_is"] == MATCHED_PASSAGE
|
|
assert out["body_length"] == 500
|
|
assert "read_full" in out
|
|
|
|
|
|
def test_a_record_shown_whole_advertises_nothing_further():
|
|
out = excerpt_fields("short", None, 1000)
|
|
assert out["excerpt"] == "short"
|
|
assert "read_full" not in out
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# All three searches publish the winning chunk
|
|
# ---------------------------------------------------------------------------
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_rule_search_reports_the_clause_that_matched():
|
|
"""A rule's `why` and `how_to_apply` run long. A caller shown only the head
|
|
cannot see the clause the query actually hit."""
|
|
r1, r2 = MagicMock(id=1), MagicMock(id=2)
|
|
session = _searching([
|
|
(r1, 0.10, 2, "the clause that matched"),
|
|
(r2, 0.20, 0, "r2 best"),
|
|
(r1, 0.40, 9, "a worse clause of r1"),
|
|
])
|
|
report: dict = {}
|
|
with (
|
|
patch.object(emb, "async_session", return_value=session),
|
|
patch.object(emb, "get_embedding", AsyncMock(return_value=[0.0] * 384)),
|
|
patch.object(emb, "can_read_project", AsyncMock(return_value=True)),
|
|
):
|
|
out = await emb.semantic_search_rules(1, "q", limit=5, threshold=0.0,
|
|
report=report)
|
|
assert [r.id for _s, r in out] == [1, 2]
|
|
assert report["best_chunk"][1] == {"index": 2, "text": "the clause that matched"}
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_milestone_search_reports_the_passage_of_the_plan_that_matched():
|
|
"""A milestone's body IS the plan and search shows its short description,
|
|
which need not mention the part the query was about."""
|
|
m1 = MagicMock(id=7)
|
|
session = _searching([(m1, 0.15, 5, "step 6 — the acceptance case")])
|
|
report: dict = {}
|
|
with (
|
|
patch.object(emb, "async_session", return_value=session),
|
|
patch.object(emb, "get_embedding", AsyncMock(return_value=[0.0] * 384)),
|
|
patch.object(emb, "can_read_project", AsyncMock(return_value=True)),
|
|
):
|
|
out = await emb.semantic_search_milestones(
|
|
1, "acceptance", limit=5, threshold=0.0, report=report,
|
|
)
|
|
assert [m.id for _s, m in out] == [7]
|
|
assert report["best_chunk"][7]["text"] == "step 6 — the acceptance case"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_a_caller_that_passes_no_report_still_works():
|
|
"""Every one of these searches fails open by design — a recall aid must
|
|
never break the call it serves — and that includes the chunk channel."""
|
|
session = _searching([(MagicMock(id=1), 0.1, 0, "text")])
|
|
with (
|
|
patch.object(emb, "async_session", return_value=session),
|
|
patch.object(emb, "get_embedding", AsyncMock(return_value=[0.0] * 384)),
|
|
patch.object(emb, "can_read_project", AsyncMock(return_value=True)),
|
|
):
|
|
out = await emb.semantic_search_milestones(1, "q", limit=5, threshold=0.0)
|
|
assert len(out) == 1
|
|
|
|
|
|
def test_record_best_chunk_on_no_report_is_a_no_op():
|
|
emb.record_best_chunk(None, {1: {"index": 0, "text": "x"}}) # must not raise
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Only what survived the bar is published
|
|
# ---------------------------------------------------------------------------
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_chunks_are_published_only_for_results_that_came_back():
|
|
"""Keyed off the returned rows, so a caller can look up every result it has
|
|
and never holds passages for records it was not shown."""
|
|
keep, drop = MagicMock(id=1), MagicMock(id=2)
|
|
session = _searching([
|
|
(keep, 0.10, 0, "kept"),
|
|
(drop, 0.95, 0, "below the bar"),
|
|
])
|
|
report: dict = {}
|
|
with (
|
|
patch.object(emb, "async_session", return_value=session),
|
|
patch.object(emb, "get_embedding", AsyncMock(return_value=[0.0] * 384)),
|
|
patch.object(emb, "can_read_project", AsyncMock(return_value=True)),
|
|
):
|
|
await emb.semantic_search_milestones(
|
|
1, "q", limit=5, threshold=0.5, report=report,
|
|
)
|
|
assert set(report["best_chunk"]) == {1}
|