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
+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