feat(retrieval): a System's charter becomes an answer, not just a filter (#4251)
CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 13s
CI & Build / TypeScript typecheck (push) Successful in 54s
CI & Build / integration (push) Successful in 58s
CI & Build / Python tests (push) Successful in 1m38s
CI & Build / Build & push image (push) Successful in 27s

Step 2 of #4251. A System's `description` is a charter — several hundred words
saying what belongs in that area and what does not — and it is the answer to
"which part of this codebase does X live in". There was no semantic path to
one: `list_systems` enumerates, and `search(system_id=…)` uses a System as a
FILTER over notes. So a System could narrow a search and could never be the
answer to one, and an agent asking where a record belonged had to read every
charter or guess.

ITS OWN SEARCH, not a `content_type` over notes, for the reason note 3163
gives about milestones: the row could be shared, the search cannot. A charter
competing with the whole note corpus for one top-k is outranked by the records
filed under it — the right answer crowded out by its own contents — and "where
does this belong?" is a different question from "what prior art is there?",
which a caller asking one should not have to read past answers to.

So `system_embeddings` (0107) joins note_, rule_ and milestone_embeddings as
the fourth sibling, with `system_document`, `upsert_system_embedding`,
`semantic_search_systems`, a startup backfill and `search(content_type=
"system")`. Scoped like milestones: with a project_id, that project's Systems
if the caller can read the project (rule 78); without one, the caller's own.
Archived Systems are excluded — an archived area is one the operator has said
is no longer where things go, which is exactly the question being asked.

`system_document` is the plainest of the four shapes on purpose. A charter is
already written as the thing this search has to match, in the words someone
asking would use — so there is no trigger to synthesise as `rule_document`
must, and no second record to gather as `task_document` must. The stored
charter IS the sharp document, the way a snippet's is. `color`, `status` and
`order_index` stay out: presentation and bookkeeping, and a vector carrying
them would be answering a question nobody asks of a charter.

The search publishes `report["best_chunk"]` from the start rather than being
retrofitted, which is what #4251 asked of any fourth search. It matters more
here than anywhere: a charter runs long and a result shows its NAME, so a match
on the paragraph that actually decides where a record belongs would otherwise
be previewed by two words that cannot say.

The id that comes back is the one `system_id`, `system_ids` and
`list_system_records` already take, so the answer to "where does this belong?"
is directly usable as "show me what is there" and as "file it here".

`embed_system` sits beside `notes.embed_note` at the service for #2056's
reason — every door gets it by construction. Not called on delete: that is a
soft delete and the search joins through `System`, so the vectors are already
unreachable, and leaving them means a restore is findable again immediately.
`system_embeddings` is declared in backup's `_NOT_INCLUDED` as derived, beside
its three siblings.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01821k5B3Ysecp9fNYs92Kuy
This commit is contained in:
2026-09-21 11:25:53 -04:00
co-authored by Claude Opus 5
parent aa95c109ea
commit 1fca8c2808
10 changed files with 663 additions and 9 deletions
@@ -0,0 +1,67 @@
"""system_embeddings — a charter becomes an ANSWER, not just a filter (#4251)
Revision ID: 0107
Revises: 0106
Create Date: 2026-09-21
A System's `description` is a charter: several hundred words saying what
belongs in that area and what does not. It is the answer to "which part of
this codebase does X live in", and there was no semantic path to it.
`list_systems` enumerates and `search(system_id=…)` uses a System as a FILTER
over notes — so a System could narrow a search and could never be the answer
to one, and an agent asking where a record belonged had to read every charter
or guess.
The fourth sibling of note_embeddings (0067), rule_embeddings (0089) and
milestone_embeddings (0102), and for the same reason note 3163 gives about the
third: the row could be shared, the search cannot. "Where does this belong?"
is a different question from "what prior art is there?", and no note or rule
search can answer it, because a charter is not a note.
The vectors are DERIVED: nothing is backfilled here, the startup backfill
writes them.
"""
import sqlalchemy as sa
from alembic import op
revision = "0107"
down_revision = "0106"
branch_labels = None
depends_on = None
# Matches its three siblings — bge-small-en-v1.5, 384-dim.
_EMBEDDING_DIM = 384
def upgrade() -> None:
op.create_table(
"system_embeddings",
sa.Column(
"system_id", sa.Integer(),
sa.ForeignKey("systems.id", ondelete="CASCADE"), primary_key=True,
),
sa.Column("chunk_index", sa.Integer(), primary_key=True),
sa.Column("chunk_text", sa.Text(), nullable=False),
sa.Column("chunker_version", sa.Integer(), nullable=False),
sa.Column(
"updated_at", sa.DateTime(timezone=True), nullable=False,
server_default=sa.text("now()"),
),
)
# Raw DDL for the vector column, as 0067, 0089 and 0102 do: the type comes
# from the pgvector extension, not SQLAlchemy's type system.
op.execute(
f"ALTER TABLE system_embeddings ADD COLUMN embedding vector({_EMBEDDING_DIM}) NOT NULL"
)
op.execute(
"""
CREATE INDEX ix_system_embeddings_embedding_hnsw
ON system_embeddings
USING hnsw (embedding vector_cosine_ops)
"""
)
def downgrade() -> None:
op.execute("DROP INDEX IF EXISTS ix_system_embeddings_embedding_hnsw")
op.drop_table("system_embeddings")
+9 -1
View File
@@ -173,7 +173,8 @@ def create_app() -> Quart:
from scribe.services.auth import start_auth_token_retention_loop
from scribe.services.embeddings import (
backfill_milestone_embeddings, backfill_note_embeddings, backfill_rule_embeddings,
backfill_milestone_embeddings, backfill_note_embeddings,
backfill_rule_embeddings, backfill_system_embeddings,
)
from scribe.services.logging import start_log_retention_loop
from scribe.services.notifications import start_notification_loop
@@ -243,6 +244,13 @@ def create_app() -> Quart:
await backfill_milestone_embeddings()
except Exception:
logger.warning("Milestone embedding backfill failed", exc_info=True)
# Systems got vectors in #4251, so before this pass every charter
# ever written is unfindable — a System could narrow a search and
# never be the answer to one.
try:
await backfill_system_embeddings()
except Exception:
logger.warning("System embedding backfill failed", exc_info=True)
# Snippets written before migration 0070 have no `notes.data` mirror,
# and the location reverse lookup queries that column — an unfilled
# row would read as "no snippet here" rather than as a gap. Separate
+59 -4
View File
@@ -15,7 +15,7 @@ from scribe.services.knowledge import content_type_filters
from scribe.services.text import MATCHED_PASSAGE, excerpt_fields
from scribe.services.embeddings import (
DEFAULT_SIMILARITY_THRESHOLD, semantic_search_milestones, semantic_search_notes,
semantic_search_rules,
semantic_search_rules, semantic_search_systems,
)
from scribe.services import rulebooks as rulebooks_svc
from scribe.services.retrieval_telemetry import record_retrieval, retrieval_summary
@@ -38,7 +38,7 @@ _EXCERPT_CHARS = 1000
# These two do not reach `semantic_search_notes` at all: they have their own
# search and their own result shape, so they are dispatched before the mapping
# and passed in only so the refusal message lists everything THIS door takes.
_OWN_SEARCH = ("rule", "milestone")
_OWN_SEARCH = ("rule", "milestone", "system")
async def _search_rules(uid: int, q: str, limit: int, project_id: int) -> dict:
@@ -141,6 +141,53 @@ async def _search_milestones(uid: int, q: str, limit: int, project_id: int) -> d
}
async def _search_systems(uid: int, q: str, limit: int, project_id: int) -> dict:
"""Systems by meaning — "where does this belong?" (#4251).
A System's `description` is a charter: several hundred words saying what
belongs in that area and what does not. `list_systems` enumerates them and
`system_id` filters by one, so before this a System could NARROW a search
and could never be the answer to one — an agent asking where a record
belonged had to read every charter or guess.
Its own result shape and its own search, not a `content_type` over notes,
because the question is different: "where does this belong?" is not "what
prior art is there?". A charter competing with the whole note corpus for
one top-k would also be outranked by the records filed under it, and the
right answer would be crowded out by its own contents.
The charter's `matched` passage comes along rather than the whole thing.
A charter is long and the paragraph that decides where a record belongs is
the one worth reading; the rest is get_system (#4243).
The id that comes back is the one `search(system_id=…)`,
`list_system_records` and every `system_ids` argument take — so the answer
to "where does this belong?" is directly usable as "show me what is there"
and as "file it here".
"""
report: dict = {}
raw = await semantic_search_systems(
uid, q, project_id=project_id or None, limit=limit, report=report,
)
chunks = report.get("best_chunk") or {}
return {
"results": [
{
"id": sys_.id,
"name": sys_.name,
**excerpt_fields(
sys_.description or "", chunks.get(int(sys_.id)),
_EXCERPT_CHARS, key="matched",
),
"project_id": sys_.project_id,
"similarity": float(score),
}
for score, sys_ in raw
],
"total": len(raw),
}
def result_excerpt(note, chunk: dict | None) -> dict:
"""The part of a record a caller judges "should I open this?" on.
@@ -204,8 +251,14 @@ async def search(
claim that the corpus holds nothing, and a typo must not be able
to make that claim.
Or 'rule' (RULES only — the operator's standing
instructions, searchable by meaning since milestone 307).
THREE KINDS WITH THEIR OWN SEARCH AND THEIR OWN RESULT SHAPE,
because each answers a question no note search can:
'rule' (the operator's standing instructions — searchable by
meaning since milestone 307), 'milestone' ("is there already a
plan for this?"), and 'system' ("where does this belong?" — a
System's charter says what belongs in an area and what does not,
and the id it returns is the one `system_id` and `system_ids`
take).
Reach for 'rule' when you want to know whether a standing
instruction covers something: "is there a rule about release
tagging?". A hit carries the rule's `why` and `how_to_apply`,
@@ -258,6 +311,8 @@ async def search(
return await _search_rules(uid, q, limit, project_id)
if content_type == "milestone":
return await _search_milestones(uid, q, limit, project_id)
if content_type == "system":
return await _search_systems(uid, q, limit, project_id)
filters = content_type_filters(content_type, extra=_OWN_SEARCH)
is_task = filters.get("is_task")
t0 = time.perf_counter()
+3 -1
View File
@@ -53,7 +53,9 @@ from scribe.models.user import User # noqa: E402, F401
from scribe.models.app_log import AppLog # noqa: E402, F401
from scribe.models.password_reset import PasswordResetToken # noqa: E402, F401
from scribe.models.invitation import InvitationToken # noqa: E402, F401
from scribe.models.embedding import MilestoneEmbedding, NoteEmbedding, RuleEmbedding # noqa: E402, F401
from scribe.models.embedding import ( # noqa: E402, F401
MilestoneEmbedding, NoteEmbedding, RuleEmbedding, SystemEmbedding,
)
from scribe.models.retrieval_log import RetrievalLog # noqa: E402, F401
from scribe.models.retrieval_tuning import RetrievalTuningEvent # noqa: E402, F401
from scribe.models.note_usage import NoteUsageEvent # noqa: E402, F401
+39
View File
@@ -128,3 +128,42 @@ class MilestoneEmbedding(Base):
DateTime(timezone=True),
default=lambda: datetime.now(timezone.utc),
)
class SystemEmbedding(Base):
"""One embedding vector per CHUNK of a System's charter (#4251).
The fourth sibling, for the reason note 3163 gives about the third: the row
could be shared, the search cannot. A System's `description` is a charter —
several hundred words saying what belongs in that area and what does not —
and it is the answer to "which part of this codebase does X live in".
Before this there was no semantic path to one: `list_systems` enumerates,
and `search(system_id=…)` uses a System as a FILTER over notes. So a System
could narrow a search and could never be the answer to one.
"Where does this belong?" is a different question from "what prior art is
there?", which is why this is its own search rather than a note_type: a
charter competing with two thousand notes for the same top-k would be
outranked by the records filed under it, and the right answer would be
crowded out by its own contents.
The document is the name and the charter. Derived data: the startup
backfill regenerates it, which is also how a chunker-version bump is
handled.
"""
__tablename__ = "system_embeddings"
system_id: Mapped[int] = mapped_column(
Integer,
ForeignKey("systems.id", ondelete="CASCADE"),
primary_key=True,
)
chunk_index: Mapped[int] = mapped_column(Integer, primary_key=True)
embedding: Mapped[list] = mapped_column(Vector(EMBEDDING_DIM), nullable=False)
chunk_text: Mapped[str] = mapped_column(Text, nullable=False)
chunker_version: Mapped[int] = mapped_column(Integer, nullable=False)
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
default=lambda: datetime.now(timezone.utc),
)
+2 -1
View File
@@ -123,7 +123,7 @@ _BACKED_UP = [
# Tables intentionally NOT in the backup, surfaced in the payload so the gap is
# explicit rather than silent. ACL (groups/shares) is a coherent follow-up;
# note_embeddings and rule_embeddings are derived (regenerated at startup
# the four *_embeddings tables are derived (regenerated at startup
# from the records themselves, which is also how a chunker bump is handled); api_keys are
# sensitive credentials; retrieval_logs is observational telemetry that nothing
# reads for correctness and that grows per query; the rest are
@@ -135,6 +135,7 @@ _BACKED_UP = [
_NOT_INCLUDED = [
"groups", "group_memberships", "project_shares", "note_shares",
"api_keys", "note_embeddings", "rule_embeddings", "milestone_embeddings",
"system_embeddings",
"app_logs", "notifications",
"invitation_tokens", "password_reset_tokens", "user_profiles",
"retrieval_logs",
+206
View File
@@ -29,6 +29,7 @@ from scribe.services.access import can_read_project, notes_visibility_clause
if TYPE_CHECKING: # resolves forward refs without importing at runtime
from scribe.models.milestone import Milestone
from scribe.models.rulebook import Rule
from scribe.models.system import System
logger = logging.getLogger(__name__)
@@ -1451,6 +1452,211 @@ async def semantic_search_milestones(
return kept
# --- Systems: the charter as an ANSWER, not a filter (#4251) -----------------
def system_document(
name: str | None, description: str | None
) -> tuple[str | None, str | None]:
"""The (title, body) a System is EMBEDDED as — its name and its charter.
The plainest of the four document shapes, and deliberately so. A System's
`description` is already written as the thing this search has to match: it
says what belongs in the area and what does not, which is the answer to
"where does this go?" in the words someone asking would use. There is no
trigger to synthesise, as `rule_document` must, and no separate record to
gather in, as `task_document` must — the stored charter IS the sharp
document, the way a snippet's is.
`color`, `status` and `order_index` are left out. They are presentation and
bookkeeping; a vector that carried them would be answering a question
nobody asks of a charter.
A System with no charter yet degrades to its name. It still embeds, just
weakly — a bare name is exactly what the model docstring says is never
enough, and this is an argument for writing the charter, not for padding
the document with whatever is to hand.
"""
return (name or "").strip() or None, (description or "").strip() or None
async def upsert_system_embedding(
system_id: int, name: str | None, description: str | None
) -> None:
"""Chunk, embed and persist a System's vectors. Safe to fire-and-forget.
The third sibling's contract exactly: one document definition shared by the
write path and the backfill, and an atomic per-System replacement guarded
by the parent-row claim (#3262), so a System deleted mid-refresh wins.
"""
from scribe.models.embedding import SystemEmbedding
from scribe.models.system import System
doc_title, doc_body = system_document(name, description)
chunks = chunk_document(doc_title, doc_body)
try:
if not chunks:
async with async_session() as session:
await session.execute(
delete(SystemEmbedding).where(SystemEmbedding.system_id == system_id)
)
await session.commit()
return
except Exception:
logger.warning("Failed to clear embedding for system %d", system_id, exc_info=True)
return
try:
vectors = await get_embeddings(chunks)
except Exception:
logger.debug("Skipping embedding for system %d — embedder unavailable", system_id)
return
try:
async with async_session() as session:
if not await _claim_parent_row(session, System.id, system_id, "system"):
return
await session.execute(
delete(SystemEmbedding).where(SystemEmbedding.system_id == system_id)
)
for index, (chunk, vector) in enumerate(zip(chunks, vectors)):
session.add(SystemEmbedding(
system_id=system_id, chunk_index=index, embedding=vector,
chunk_text=chunk, chunker_version=CHUNKER_VERSION,
))
await session.commit()
except Exception:
logger.warning("Failed to persist embedding for system %d", system_id, exc_info=True)
async def semantic_search_systems(
user_id: int,
query: str,
*,
project_id: int | None = None,
limit: int = 5,
threshold: float = _SIMILARITY_THRESHOLD,
report: dict | None = None,
) -> list[tuple[float, "System"]]:
"""Return up to *limit* (score, system) pairs most like *query*.
Answers "where does this belong?" — the question asked before filing a
record or opening a file, and the one that had no tool: `list_systems`
enumerates and `system_id` filters, so a charter could narrow a search and
could never be the answer to one.
ITS OWN SEARCH rather than a note kind, for what note 3163 says about
milestones: a charter competing with the whole note corpus for one top-k
would be outranked by the records filed under it, and the right answer
would be crowded out by its own contents. The questions are different too —
"where does this belong?" is not "what prior art is there?" — and a caller
asking one should not have to read past answers to the other.
SCOPE. With `project_id`, that project's Systems, provided the caller can
read the project (access.can_read_project, rule 78) — a collaborator on a
shared project sees its areas, which is the point of a charter. Without
one, the Systems the caller owns across their projects. Archived Systems
are excluded: an archived area is one the operator has said is no longer
where things go, and that is exactly the question being asked.
Collapses to best-chunk-per-System and publishes the winning chunk, like
the sibling searches — from the start rather than retrofitted (#4243).
Returns an empty list if the embedder is unavailable, the project is not
readable, or on any error: a recall aid must never break the call it
serves.
"""
from scribe.models.embedding import SystemEmbedding
from scribe.models.system import System
if not query or not query.strip():
return []
try:
query_vec = await get_embedding(query)
except Exception:
logger.debug("System search skipped — embedder unavailable")
return []
distance = SystemEmbedding.embedding.cosine_distance(query_vec)
try:
if project_id:
if not await can_read_project(user_id, project_id):
return []
scope = System.project_id == project_id
else:
scope = System.user_id == user_id
async with async_session() as session:
rows = (await session.execute(
select(
System,
distance.label("distance"),
SystemEmbedding.chunk_index,
SystemEmbedding.chunk_text,
)
.select_from(SystemEmbedding)
.join(System, SystemEmbedding.system_id == System.id)
.where(
scope,
System.deleted_at.is_(None),
System.status != "archived",
)
.order_by(distance)
.limit(limit * _CHUNK_OVERFETCH)
)).all()
except Exception:
logger.warning("System semantic search failed", exc_info=True)
return []
best: dict[int, tuple[float, object]] = {}
# A charter runs to several hundred words and a result shows its NAME — so
# a match on the paragraph that actually decides where a record belongs
# would be previewed by two words that cannot. The winning chunk is what
# the caller should see (#4243).
won: dict[int, dict] = {}
for system, dist, chunk_index, chunk_text in rows:
score = 1.0 - float(dist)
if system.id not in best or score > best[system.id][0]:
best[system.id] = (score, system)
won[int(system.id)] = {
"index": int(chunk_index), "text": chunk_text or "",
}
ranked = sorted(best.values(), key=lambda pair: pair[0], reverse=True)
kept = [pair for pair in ranked if pair[0] >= threshold][:limit]
record_best_chunk(report, {
int(sys_.id): won[int(sys_.id)] for _s, sys_ in kept if int(sys_.id) in won
})
return kept
async def backfill_system_embeddings() -> None:
"""Embed Systems that have no current vectors. Runs at startup beside the
note, rule and milestone backfills; a CHUNKER_VERSION bump re-embeds.
This is also the pass that makes every existing charter findable at all —
Systems got vectors in #4251, so before it runs there are none."""
from scribe.models.embedding import SystemEmbedding
from scribe.models.system import System
try:
async with async_session() as session:
current = select(SystemEmbedding.system_id).where(
SystemEmbedding.chunker_version == CHUNKER_VERSION
)
stale = (await session.execute(
select(System.id, System.name, System.description)
.where(System.deleted_at.is_(None), System.id.notin_(current))
)).all()
except Exception:
logger.warning("System embedding backfill: failed to query systems", exc_info=True)
return
if not stale:
logger.info("System embedding backfill: all systems current at chunker v%d", CHUNKER_VERSION)
return
logger.info("System embedding backfill: embedding %d system(s)", len(stale))
for system_id, name, description in stale:
await upsert_system_embedding(system_id, name, description)
async def backfill_milestone_embeddings() -> None:
"""Embed milestones that have no current vectors. Runs at startup beside
the note and rule backfills; a CHUNKER_VERSION bump re-embeds."""
+34
View File
@@ -114,6 +114,38 @@ async def seed_standard_systems(user_id: int, project_id: int) -> list[System]:
return out
def embed_system(system) -> None:
"""Refresh a System's charter vectors, fire-and-forget (#4251).
The twin of `notes.embed_note`, and here for the same reason (#2056): at
the service, so every door gets it by construction rather than each route
and tool remembering. A charter edited through one door and not another
would stay findable by what it used to say, and nothing would report it.
Not called on delete. `delete_system` is a SOFT delete and the search joins
through `System`, so a deleted System's vectors are already unreachable —
and leaving them means a restore is findable again immediately instead of
waiting for the next startup backfill.
Import is lazy so importing this module doesn't pull in the embedding
model; a missing event loop (unit tests, scripts) is ordinary, not an
error; exceptions are swallowed because a System that saved must not fail
on its index refresh.
"""
try:
import asyncio
from scribe.services.embeddings import upsert_system_embedding
asyncio.create_task(
upsert_system_embedding(system.id, system.name, system.description)
)
except RuntimeError:
pass # no running loop — a sync caller, not a failure
except Exception: # noqa: BLE001 - never let indexing break a write
logger.exception("embedding refresh failed for system %s", system.id)
async def create_system(
user_id: int,
project_id: int,
@@ -144,6 +176,7 @@ async def create_system(
session.add(system)
await session.commit()
await session.refresh(system)
embed_system(system)
return system
@@ -195,6 +228,7 @@ async def update_system(user_id: int, system_id: int, **fields: object) -> Syste
system.updated_at = datetime.now(timezone.utc)
await session.commit()
await session.refresh(system)
embed_system(system)
return system
+80 -2
View File
@@ -1,7 +1,7 @@
"""search tool — proves the tool pattern (context + service call + dict shape).
Service call is mocked; no DB needed."""
from unittest.mock import AsyncMock, patch
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
@@ -223,7 +223,8 @@ async def test_the_docstring_names_every_kind_the_tool_accepts():
doc = search_tool.__doc__ or ""
for facet in FACET_TYPES:
assert f"'{facet}'" in doc, f"{facet} is accepted but never documented"
assert "'rule'" in doc and "'milestone'" in doc and "'all'" in doc
for own in ("'rule'", "'milestone'", "'system'", "'all'"):
assert own in doc, f"{own} dispatches somewhere and is never documented"
@pytest.mark.asyncio
@@ -329,3 +330,80 @@ async def test_milestone_search_is_its_own_shape_and_scopes_to_the_project():
"status": "active", "project_id": 30, "total": 0, "completed": 0,
"similarity": 0.81,
}]
# --- systems: the charter as an answer, not a filter (#4251) -----------------
@pytest.mark.asyncio
async def test_system_search_is_its_own_shape_and_carries_the_matched_passage():
"""A System's charter runs to several hundred words and a result shows its
NAME — so a match on the paragraph that actually decides where a record
belongs would be previewed by two words that cannot. The passage comes
along, marked as the passage (#4243)."""
_user_id_ctx.set(7)
charter = (
"Opening sentence about the area in general. "
+ "x" * 400
+ " Retrieval telemetry and the floors it judges belong here."
)
fake = MagicMock(id=3, project_id=2, description=charter)
# `name` is MagicMock's own constructor kwarg — passed in, it names the
# mock and leaves `.name` a mock object, which is note #2833's whole point.
fake.name = "Retrieval & recall"
async def _system_search(uid, q, **kwargs):
kwargs["report"]["best_chunk"] = {
3: {"index": 2, "text": "Retrieval telemetry and the floors it judges belong here."}
}
return [(0.81, fake)]
with patch("scribe.mcp.tools.search.semantic_search_systems", _system_search):
out = await search(q="where does retrieval telemetry go?",
content_type="system", project_id=2)
assert out["total"] == 1
row = out["results"][0]
assert row["id"] == 3
assert row["name"] == "Retrieval & recall"
assert row["project_id"] == 2
assert row["similarity"] == 0.81
# The passage that won, not the charter's opening — and SAID to be that.
assert row["matched"] == (
"Retrieval telemetry and the floors it judges belong here."
)
assert row["matched_is"] == "matched_passage"
assert row["body_length"] == len(charter)
assert "read_full" in row, "a 56-character span of a 500-character charter"
# The charter itself does not ride along: get_system reads it.
assert "description" not in row
@pytest.mark.asyncio
async def test_system_search_scopes_to_the_project_it_was_given():
"""Systems are per-project and a charter from another project is not an
answer to "where does this belong here?"."""
_user_id_ctx.set(7)
mock = AsyncMock(return_value=[])
with patch("scribe.mcp.tools.search.semantic_search_systems", mock):
await search(q="x", content_type="system", project_id=30)
assert mock.call_args.kwargs["project_id"] == 30
mock.reset_mock()
with patch("scribe.mcp.tools.search.semantic_search_systems", mock):
await search(q="x", content_type="system")
assert mock.call_args.kwargs["project_id"] is None
@pytest.mark.asyncio
async def test_a_system_search_does_not_go_through_the_note_search():
"""Its own search, not a note_type. A charter competing with the whole note
corpus for one top-k is outranked by the records filed under it."""
_user_id_ctx.set(7)
notes = AsyncMock(return_value=[])
with (
patch("scribe.mcp.tools.search.semantic_search_notes", notes),
patch("scribe.mcp.tools.search.semantic_search_systems", AsyncMock(return_value=[])),
):
await search(q="x", content_type="system")
notes.assert_not_awaited()
+164
View File
@@ -0,0 +1,164 @@
"""Systems become findable by meaning — the charter as an ANSWER (#4251).
A System's `description` is a charter: several hundred words saying what
belongs in that area and what does not. It is the answer to "where does this
go?", and there was no semantic path to it — `list_systems` enumerates and
`search(system_id=…)` uses a System as a FILTER over notes, so a System could
narrow a search and could never be the answer to one.
These pin the three halves: the document shape, the search's scoping and
best-chunk contract, and that a charter edited through any door is re-indexed.
"""
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from scribe.services import embeddings as emb
from scribe.services import systems as systems_svc
from tests.helpers import make_mock_session
def _system(system_id=3, name="Retrieval & recall", description="the charter"):
s = MagicMock(id=system_id, project_id=2, description=description)
s.name = name # never via the constructor — see note #2833
return s
# --- the document shape ------------------------------------------------------
def test_the_document_is_the_name_and_the_charter():
assert emb.system_document("Retrieval", "what belongs here") == (
"Retrieval", "what belongs here"
)
def test_a_system_with_no_charter_yet_degrades_to_its_name():
"""It still embeds, just weakly. That is an argument for writing the
charter, not for padding the document with whatever is to hand."""
assert emb.system_document("Retrieval", None) == ("Retrieval", None)
assert emb.system_document("Retrieval", " ") == ("Retrieval", None)
assert emb.chunk_document(*emb.system_document("Retrieval", None)) == ["Retrieval"]
def test_an_empty_system_produces_no_document_at_all():
"""Callers gate on falsiness to clear vectors rather than embed nothing."""
assert emb.system_document("", "") == (None, None)
assert emb.chunk_document(*emb.system_document("", "")) == []
# --- the search --------------------------------------------------------------
def _rows_ctx(rows):
session = MagicMock()
result = MagicMock()
result.all.return_value = rows
session.execute = AsyncMock(return_value=result)
ctx = MagicMock()
ctx.__aenter__ = AsyncMock(return_value=session)
ctx.__aexit__ = AsyncMock(return_value=False)
return session, ctx
@pytest.mark.asyncio
async def test_the_search_collapses_to_best_chunk_and_reports_which_one_won():
"""A charter is long, and a result shows its NAME — so the paragraph that
actually decides where a record belongs has to come back with it, or the
caller judges from two words that cannot say (#4243). Published from the
start here rather than retrofitted, which is what #4251 asked for."""
a, b = _system(3), _system(4, name="Data Model")
rows = [
(a, 0.10, 2, "retrieval telemetry belongs here"),
(b, 0.22, 0, "the persistence layer"),
(a, 0.40, 0, "a weaker paragraph of the same charter"),
]
_session, ctx = _rows_ctx(rows)
report: dict = {}
with (
patch.object(emb, "async_session", return_value=ctx),
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_systems(
7, "where does telemetry go?",
project_id=2, threshold=0.0, report=report,
)
assert [s.id for _score, s in out] == [3, 4]
assert out[0][0] == 1.0 - 0.10 # the BEST chunk's score
assert report["best_chunk"][3] == {
"index": 2, "text": "retrieval telemetry belongs here",
}
@pytest.mark.asyncio
async def test_a_project_the_caller_cannot_read_returns_nothing():
"""Rule 78 — the charter of a project someone was not given is not an
answer to any question they are entitled to ask."""
_session, ctx = _rows_ctx([])
with (
patch.object(emb, "async_session", return_value=ctx),
patch.object(emb, "get_embedding", AsyncMock(return_value=[0.0] * 384)),
patch.object(emb, "can_read_project", AsyncMock(return_value=False)),
):
assert await emb.semantic_search_systems(7, "q", project_id=99) == []
@pytest.mark.asyncio
async def test_an_empty_query_never_reaches_the_embedder():
with patch.object(emb, "get_embedding", AsyncMock()) as embed:
assert await emb.semantic_search_systems(7, " ") == []
embed.assert_not_awaited()
@pytest.mark.asyncio
async def test_a_search_that_fails_returns_nothing_rather_than_raising():
"""A recall aid must never break the call it serves."""
with patch.object(emb, "get_embedding", AsyncMock(side_effect=RuntimeError)):
assert await emb.semantic_search_systems(7, "q") == []
# --- staying current ---------------------------------------------------------
@pytest.mark.asyncio
async def test_creating_a_system_indexes_its_charter():
session = make_mock_session()
with (
patch.object(systems_svc, "async_session", return_value=session),
patch.object(systems_svc.access, "can_write_project", AsyncMock(return_value=True)),
patch.object(systems_svc, "embed_system") as embed,
):
await systems_svc.create_system(7, 2, "Retrieval", description="the charter")
embed.assert_called_once()
@pytest.mark.asyncio
async def test_editing_a_charter_re_indexes_it():
"""A charter edited and not re-indexed stays findable by what it used to
say — the stale-vector case, and the one that matters most for a record
whose whole job is to say where things belong now."""
system = _system()
session = make_mock_session()
session.get = AsyncMock(return_value=system)
system.deleted_at = None
with (
patch.object(systems_svc, "async_session", return_value=session),
patch.object(systems_svc.access, "can_write_project", AsyncMock(return_value=True)),
patch.object(systems_svc, "embed_system") as embed,
):
await systems_svc.update_system(7, 3, description="a new charter")
embed.assert_called_once_with(system)
def test_embedding_a_system_never_breaks_the_write_that_saved_it():
"""A System that saved must not fail on its index refresh. ValueError
rather than RuntimeError deliberately: RuntimeError is also what "no
running loop" raises, which is an ordinary sync caller and has its own
branch — this has to land in the general swallow to prove it exists."""
with patch(
"scribe.services.embeddings.upsert_system_embedding",
side_effect=ValueError("boom"),
):
systems_svc.embed_system(_system()) # returns quietly, does not raise