CI & Build / Python lint (push) Failing after 9s
CI & Build / Plugin hooks (push) Successful in 12s
CI & Build / TypeScript typecheck (push) Successful in 44s
CI & Build / integration (push) Successful in 45s
CI & Build / Python tests (push) Successful in 1m26s
CI & Build / Build & push image (push) Skipped
Rules were the only major record type with no vector, so `search` could never return one and a rule could arrive only by being preloaded. That single fact is what made every rule compete for one always-on budget. THE DECISION THE TASK ASKED FOR, made explicitly: a sibling rule_embeddings table, not a polymorphic embedding row. The ROW could have been generalised; the SEARCH could not. semantic_search_notes is Note-specific scoping end to end — the visibility clause, the supersession penalty, note_type/task_kind/system filters — and a rule shares none of it, scoping instead by rulebook ownership or project. Generalising the row while still needing two searches is the worst of both: a key with referential integrity to neither table, on the path every session start runs, to share four columns. What is genuinely common is BEHAVIOUR — get_embedding, chunk_document, embedding_text, CHUNKER_VERSION — and those are reused as-is. Sharing them is the DRY win; sharing the table would have been the DRY costume. The document shape is measured, not chosen (note 2485). That pass found the snippet was the only discriminative record in the corpus — a 0.153 top-to-second gap against 0.010-0.023 — and that the cause was its SHAPE: purpose stated twice in a short single-topic document. rule_document reproduces it: the trigger in the title AND as the body's first line. And it excludes `why`, which matters more than any of it. `why` is dated incident narrative — rule 46's runs to 4,300 characters — and long multi-topic prose is exactly what made sixteen dev-logs mutually indistinguishable. Adding it would not give the vector more to work with; it would give every rule the SAME thing to work with. rule_document takes no `why` parameter at all, so a well-meaning caller cannot pass one. A rule with no trigger degrades to title + statement — findable, less sharp. That is an argument for backfilling triggers (step 6), not for padding the document with whatever text is nearby. search(content_type="rule") returns the rule WITH its why and how_to_apply: they are its operational half, the session payload never carries them, and a caller who went looking should not have to re-fetch. Writes re-index fire-and-forget like notes; startup backfills in its own try block so neither backfill can skip the other. rule_embeddings is derived, so it joins note_embeddings in the backup's explicitly-NOT-included list. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
189 lines
8.1 KiB
Python
189 lines
8.1 KiB
Python
"""search — semantic search across the user's notes and tasks.
|
|
|
|
Mirrors the existing fable-mcp contract so Claude's prior usage pattern keeps
|
|
working. Differences from fable-mcp:
|
|
- calls services.embeddings.semantic_search_notes directly instead of HTTP
|
|
- user_id comes from mcp.current_user_id() rather than a global API key
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import time
|
|
|
|
from scribe.mcp._context import current_user_id
|
|
from scribe.services.access import owner_names_for
|
|
from scribe.services.embeddings import (
|
|
DEFAULT_SIMILARITY_THRESHOLD, semantic_search_notes, semantic_search_rules,
|
|
)
|
|
from scribe.services.retrieval_telemetry import record_retrieval, retrieval_summary
|
|
|
|
|
|
async def _search_rules(uid: int, q: str, limit: int) -> dict:
|
|
"""Rules by meaning — a separate result shape because a rule IS different.
|
|
|
|
A rule hit carries `why` and `how_to_apply`: they are the operational half
|
|
of a rule and the session-start payload never includes them, so a caller
|
|
who went looking should get the whole thing rather than a summary they then
|
|
have to re-fetch.
|
|
|
|
Rules are not project-scoped the way notes are (a family rule belongs to no
|
|
project), so `project_id` and `system_id` do not apply here.
|
|
"""
|
|
raw = await semantic_search_rules(uid, q, limit=limit)
|
|
return {
|
|
"results": [
|
|
{
|
|
"id": rule.id,
|
|
"title": rule.title,
|
|
"statement": rule.statement,
|
|
"when_to_apply": rule.when_to_apply or "",
|
|
"tier": rule.tier,
|
|
"why": rule.why or "",
|
|
"how_to_apply": rule.how_to_apply or "",
|
|
"topic_id": rule.topic_id,
|
|
"project_id": rule.project_id,
|
|
"similarity": float(score),
|
|
}
|
|
for score, rule in raw
|
|
],
|
|
"total": len(raw),
|
|
}
|
|
|
|
|
|
async def search(
|
|
q: str,
|
|
content_type: str = "all",
|
|
limit: int = 10,
|
|
project_id: int = 0,
|
|
system_id: int = 0,
|
|
) -> dict:
|
|
"""Semantic search over the user's existing notes and tasks — Scribe's recall.
|
|
|
|
Reach for this BEFORE answering a question about the user's work or starting
|
|
a task: the user's second-brain almost always already holds related prior
|
|
art. Check for an existing ticket before opening a new one (search with
|
|
content_type='task'), and for prior notes/decisions before re-deriving them.
|
|
Treating Scribe as the first place to look — not a place to only write — is
|
|
the difference between it being a trustworthy record and a write-only log.
|
|
|
|
Args:
|
|
q: search query string.
|
|
content_type: 'all' (default), 'note' (notes only), 'task' (tasks
|
|
only), or 'rule' (RULES only — the operator's standing
|
|
instructions, searchable by meaning since milestone 307).
|
|
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`,
|
|
which the session-start payload does not.
|
|
limit: maximum number of results (1-50).
|
|
project_id: Scope results to one project. PASS THE ACTIVE PROJECT'S ID
|
|
whenever a project is in scope (the one you entered with
|
|
enter_project) — otherwise this searches across ALL projects and
|
|
bleeds unrelated work into the result set. 0 = search everything
|
|
(use only when you genuinely want a cross-project sweep).
|
|
system_id: Narrow to records tagged to one System (a named
|
|
subsystem/area — enter_project lists them). Use when investigating
|
|
a specific subsystem: it cuts the candidates to records someone
|
|
deliberately filed under that area. 0 = no system filter.
|
|
list_system_records gives the same slice unranked.
|
|
|
|
Returns:
|
|
{"results": [{"id", "title", "body", "is_task", "tags", "similarity"}],
|
|
"total": int}
|
|
|
|
A result marked `shared: true` with an `owner` belongs to another user —
|
|
that person's suggestion, not the operator's own record or settled practice.
|
|
Weigh it on its merits and say whose it is when you use it.
|
|
"""
|
|
uid = current_user_id()
|
|
limit = max(1, min(limit, 50))
|
|
if content_type == "rule":
|
|
return await _search_rules(uid, q, limit)
|
|
is_task = {"note": False, "task": True}.get(content_type) # None => any
|
|
t0 = time.perf_counter()
|
|
raw = await semantic_search_notes(
|
|
uid, q, limit=limit, is_task=is_task,
|
|
project_id=project_id or None,
|
|
system_id=system_id or None,
|
|
# An explicit search reaches everything the operator may read, including
|
|
# records shared with them one-to-one.
|
|
scope="read",
|
|
)
|
|
record_retrieval(
|
|
user_id=uid, source="mcp_search", query=q,
|
|
threshold=DEFAULT_SIMILARITY_THRESHOLD, limit=limit,
|
|
project_id=project_id or None, is_task=is_task, results=raw,
|
|
duration_ms=(time.perf_counter() - t0) * 1000.0,
|
|
)
|
|
owners = await owner_names_for(
|
|
{int(note.user_id) for _s, note in raw if note.user_id != uid}
|
|
)
|
|
return {
|
|
"results": [
|
|
{
|
|
"id": note.id,
|
|
"title": note.title,
|
|
"body": (note.body or "")[:240],
|
|
"is_task": bool(note.is_task),
|
|
"tags": list(note.tags or []),
|
|
"similarity": float(score),
|
|
**(
|
|
{"shared": True, "owner": owners.get(int(note.user_id))}
|
|
if note.user_id != uid else {}
|
|
),
|
|
}
|
|
for score, note in raw
|
|
],
|
|
"total": len(raw),
|
|
}
|
|
|
|
|
|
async def retrieval_telemetry(days: int = 30) -> dict:
|
|
"""What the retrieval telemetry says about YOUR surfaces, over a window.
|
|
|
|
The read half of the loop the ranker's thresholds are meant to be tuned
|
|
from (#2975). Reach for it before changing a similarity threshold, a top-k,
|
|
or deciding whether a reranker is worth building — the alternative is
|
|
hand-probing the live instance, which is how the last such decision had to
|
|
be made.
|
|
|
|
Two readouts, from the two tables built for them:
|
|
|
|
`sources` — per retrieval surface (`auto_inject`, `write_path`,
|
|
`mcp_search`, …), from `retrieval_logs`: `calls`, `zero_result_calls`,
|
|
`cleared_threshold` (how often the best hit beat the threshold in force for
|
|
that call), the `top_score` spread (p10/p50/p90/min/max), `avg_result_count`
|
|
and `p90_duration_ms`. THE number to read first is `cleared_threshold`
|
|
against `calls`, with the spread beside it: a surface that clears its bar
|
|
on nearly every call is either well-tuned or too loose, and p10 says which.
|
|
|
|
`usage` — from `note_usage_events`, at the per-note grain
|
|
`retrieval_logs` cannot be indexed at: `surfaced` (ranked surfacings — a
|
|
scored surface CHOSE the record), `ambient` (the rest), `pulled` split into
|
|
`pulled_by_agent` / `pulled_by_human`, the distinct-note counts, and
|
|
`pull_through`. That ratio is the corpus-side precision signal: records
|
|
surfaced often and opened never are dead weight competing for the injection
|
|
budget every turn.
|
|
|
|
`pull_through` is AGENT pulls over RANKED surfacings, and both halves of
|
|
that matter. "Is this record dead weight?" is answered by any pull; "was
|
|
that injected line useful?" — the question a threshold or a reranker is
|
|
tuned against — only by a pull the agent made. Aggregating across the
|
|
mcp_/rest_ prefix would silently answer the wrong one.
|
|
|
|
Scoped to your own telemetry — a retrieval log records what your agent
|
|
asked for, query text included, and is not a shared record kind.
|
|
|
|
`read_failed: true` means the query itself failed — deliberately distinct
|
|
from an empty window, because those two looked identical for weeks once
|
|
(#2663) and every counter silently read zero.
|
|
|
|
Args:
|
|
days: window size, default 30. Clamped to at least 1.
|
|
"""
|
|
return await retrieval_summary(current_user_id(), days=days)
|
|
|
|
|
|
def register(mcp) -> None:
|
|
mcp.tool(name="search")(search)
|
|
mcp.tool(name="retrieval_telemetry")(retrieval_telemetry)
|