CI & Build / Plugin hooks (push) Successful in 8s
CI & Build / Python lint (push) Successful in 3s
CI & Build / TypeScript typecheck (push) Successful in 33s
CI & Build / Python tests (push) Failing after 45s
CI & Build / Build & push image (push) Skipped
CI & Build / integration (push) Successful in 29s
verify_with / expires_when now reach a rule through both doors and come back on every read. The open question this step existed to settle was how to UNSET a nullable field, and the answer is one convention per door: - MCP: "" still means "leave unchanged" — an agent filling three fields must not wipe the other five — so clearing is explicit, clear_fields=["..."]. Naming the field is the one form that cannot happen by accident. - REST: a cleared form input arrives as "", and the service normalises "" to NULL for every nullable rule column, so an emptied input does what it looks like it does. Two idioms, one outcome, and the normalisation is what makes the step-3 sweep correct: `verify_with IS NOT NULL` would otherwise be true for every rule ever touched through the UI, and the sweep would list the whole rulebook and mean nothing. to_dict renders "" and NULL identically, so this is only visible against a real column — hence the integration module rather than a mock. Editing verify_with drops verified_at. A stamp certifies A CHECK, not a rule; reword the check and the old stamp vouches for something that no longer exists. Safe direction, same asymmetry as _valid_tier: a rule wrongly listed as due costs one look, a rule wrongly vouched for costs the thing the sweep exists to catch. Editing anything else leaves the stamp alone, or a rulebook tidy-up would reset every constraint and the ordering would carry nothing. Reads: rule_brief attaches `last_verified` ONLY to a rule that carries a check — its presence is the signal, and it says both "this asserts a fact that can go false" and "here is how long ago anyone confirmed it". "never" rather than null, per #2483. The check text itself stays in get_rule; a listing needs to know which rules can rot, not how to test them. Search hits carry the full trio, since a hit is exactly the moment someone is about to act on a rule. Also folds in the #3078 finding, which had been sitting as a note: create_rule now teaches that when_to_apply is the retrieval surface and must carry the SYMPTOM — the words you would type while stuck — not just the situation. fake_rule gains the three fields as None for the reason the helper already documents one line up: unnamed, verify_with is a truthy MagicMock and every stand-in rule would claim a check it does not have. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
201 lines
8.8 KiB
Python
201 lines
8.8 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 import rulebooks as rulebooks_svc
|
|
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. It also carries the rule's check (`verify_with`,
|
|
`expires_when`, `last_verified`) when it has one — a search hit is exactly
|
|
the moment someone is about to act on a rule, and "this asserts a fact
|
|
nobody has confirmed" is part of what the rule says.
|
|
|
|
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 "",
|
|
"verify_with": rule.verify_with or "",
|
|
"expires_when": rule.expires_when or "",
|
|
# Only on a rule that carries a check; its absence means the
|
|
# rule is a decision, not that nobody has looked.
|
|
**(
|
|
{"last_verified": rulebooks_svc.last_verified_label(rule)}
|
|
if rule.verify_with else {}
|
|
),
|
|
"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)
|