feat(rules): retrieval honours a rule's home — global everywhere, a project's rules only in that project (#4074)
CI & Build / Python lint (push) Successful in 4s
CI & Build / Plugin hooks (push) Successful in 9s
CI & Build / TypeScript typecheck (push) Successful in 54s
CI & Build / integration (push) Successful in 59s
CI & Build / Python tests (push) Successful in 1m37s
CI & Build / Build & push image (push) Successful in 31s
CI & Build / Python lint (push) Successful in 4s
CI & Build / Plugin hooks (push) Successful in 9s
CI & Build / TypeScript typecheck (push) Successful in 54s
CI & Build / integration (push) Successful in 59s
CI & Build / Python tests (push) Successful in 1m37s
CI & Build / Build & push image (push) Successful in 31s
semantic_search_rules searched every rule the user owned, and every hook arm called it without a project, so each project's rules were injected into every other project's sessions and a project rule meant nothing a session could feel. The search now takes a scope: global rules by default (an unbound session, or a caller that forgets to say), global plus project N when given project_id (N's rules only if the caller can read that project, through access.can_read_project), and every owned rule with everywhere=True. The four hook arms and the report preference lookup pass the session's project; an explicit search(content_type="rule") scopes to its project_id, or asks the whole rulebook without one. Milestone 414 step 1. Guarded by an AST walk that every hook call site passes project_id, and an integration test on real Postgres that a rule is reached only from its home. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01821k5B3Ysecp9fNYs92Kuy
This commit is contained in:
@@ -18,7 +18,7 @@ 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:
|
||||
async def _search_rules(uid: int, q: str, limit: int, project_id: 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
|
||||
@@ -29,10 +29,17 @@ async def _search_rules(uid: int, q: str, limit: int) -> dict:
|
||||
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.
|
||||
`project_id` scopes the way it does for notes, with one difference: a
|
||||
GLOBAL rule (one in a rulebook) belongs to no project and applies in every
|
||||
one, so a scoped search returns global rules plus that project's own.
|
||||
Without a project it asks the whole rulebook — every rule, whatever its
|
||||
home — because that is the question an unscoped "is there a rule about
|
||||
this" is asking. `system_id` does not apply to rules.
|
||||
"""
|
||||
raw = await semantic_search_rules(uid, q, limit=limit)
|
||||
if project_id:
|
||||
raw = await semantic_search_rules(uid, q, limit=limit, project_id=project_id)
|
||||
else:
|
||||
raw = await semantic_search_rules(uid, q, limit=limit, everywhere=True)
|
||||
return {
|
||||
"results": [
|
||||
{
|
||||
@@ -84,7 +91,9 @@ async def search(
|
||||
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.
|
||||
which the session-start payload does not. With a project_id,
|
||||
rules come back as the global rules plus that project's own;
|
||||
with 0, every rule in the rulebook.
|
||||
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
|
||||
@@ -108,7 +117,7 @@ async def search(
|
||||
uid = current_user_id()
|
||||
limit = max(1, min(limit, 50))
|
||||
if content_type == "rule":
|
||||
return await _search_rules(uid, q, limit)
|
||||
return await _search_rules(uid, q, limit, project_id)
|
||||
is_task = {"note": False, "task": True}.get(content_type) # None => any
|
||||
t0 = time.perf_counter()
|
||||
report: dict = {}
|
||||
|
||||
@@ -23,7 +23,7 @@ from sqlalchemy import delete, or_, select
|
||||
from scribe.models import async_session
|
||||
from scribe.models.embedding import NoteEmbedding, RuleEmbedding
|
||||
from scribe.models.note import Note
|
||||
from scribe.services.access import notes_visibility_clause
|
||||
from scribe.services.access import can_read_project, notes_visibility_clause
|
||||
|
||||
if TYPE_CHECKING: # resolves the Rule forward ref without importing at runtime
|
||||
from scribe.models.rulebook import Rule
|
||||
@@ -819,6 +819,9 @@ async def semantic_search_rules(
|
||||
threshold: float = _SIMILARITY_THRESHOLD,
|
||||
kind: str | None = None,
|
||||
report: dict | None = None,
|
||||
*,
|
||||
project_id: int | None = None,
|
||||
everywhere: bool = False,
|
||||
) -> list[tuple[float, "Rule"]]:
|
||||
"""Return up to *limit* (score, rule) pairs most relevant to *query*.
|
||||
|
||||
@@ -836,12 +839,26 @@ async def semantic_search_rules(
|
||||
reports a decline the ranker never made (#3765). ABSENT means no search
|
||||
touched the dict at all, which is a stand-in in a test, not a real call.
|
||||
|
||||
Scoped by OWNERSHIP — a rule is the caller's if they own its rulebook or
|
||||
its project. Deliberately not filtered to what currently BINDS a given
|
||||
project: this answers "is there a rule about this", which a person asking
|
||||
wants answered across their whole rulebook. Deciding which rules bind where
|
||||
is the surfacing question, and it has its own machinery
|
||||
(get_applicable_rules) rather than a second, subtly different copy here.
|
||||
SCOPED, and the scope is a rule's home (milestone 414). A rule lives in a
|
||||
rulebook topic — GLOBAL, it applies wherever its owner works — or on one
|
||||
project, where it applies to that project and nowhere else:
|
||||
|
||||
- default (`project_id=None`): global rules only. A hook with no bound
|
||||
project gets these, and so does any caller that forgets to say; the
|
||||
safe failure is surfacing less, not another project's rules.
|
||||
- `project_id=N`: global rules plus project N's own, and N's only when
|
||||
the caller can read that project (access.can_read_project, so a shared
|
||||
project's rules reach its collaborators too).
|
||||
- `everywhere=True`: every rule the caller owns, in any home. Only for an
|
||||
explicit whole-rulebook question — `search(content_type="rule")` with no
|
||||
project — where "is there a rule about this" is asked across everything.
|
||||
|
||||
This used to be scoped by OWNERSHIP alone, on the argument that "is there
|
||||
a rule about this" wants the whole rulebook. That is still right for the
|
||||
explicit ask. It was wrong for the hooks, which inject unasked: every
|
||||
project's rules surfaced in every other project's sessions — one repo's
|
||||
template conventions arriving while editing an unrelated one — and a
|
||||
project rule meant nothing a session could feel.
|
||||
|
||||
THERE IS NO TIER TO NARROW BY ANY MORE (milestone 394). This carried a
|
||||
`tier` parameter, and the arms deliberately passed nothing: filtering on it
|
||||
@@ -883,6 +900,17 @@ async def semantic_search_rules(
|
||||
distance = RuleEmbedding.embedding.cosine_distance(query_vec)
|
||||
|
||||
try:
|
||||
# topic_id XOR project_id (migration 0059), so a rule matches exactly
|
||||
# one arm of whichever clause applies. Inside the try: the access
|
||||
# check reads the database too, and this function fails open.
|
||||
global_rule = Rulebook.owner_user_id == user_id
|
||||
if everywhere:
|
||||
home = or_(global_rule, Project.user_id == user_id)
|
||||
elif project_id and await can_read_project(user_id, project_id):
|
||||
home = or_(global_rule, Rule.project_id == project_id)
|
||||
else:
|
||||
home = global_rule
|
||||
|
||||
async with async_session() as session:
|
||||
rows = (await session.execute(
|
||||
select(Rule, distance.label("distance"))
|
||||
@@ -895,11 +923,7 @@ async def semantic_search_rules(
|
||||
Rule.deleted_at.is_(None),
|
||||
# No threshold predicate — see the note above
|
||||
# semantic_search_notes. Applied below, after the collapse.
|
||||
# topic_id XOR project_id, so exactly one arm can match.
|
||||
or_(
|
||||
Rulebook.owner_user_id == user_id,
|
||||
Project.user_id == user_id,
|
||||
),
|
||||
home,
|
||||
*( [Rule.kind == kind] if kind else [] ),
|
||||
)
|
||||
# Overfetch so collapsing chunks to their best row still fills
|
||||
|
||||
@@ -855,7 +855,7 @@ async def _reserve_slot_for_preference(
|
||||
# be indistinguishable from one that earned its place.
|
||||
found = await semantic_search_rules(
|
||||
user_id, query, limit=1, threshold=threshold,
|
||||
kind="preference", report=_rep,
|
||||
kind="preference", report=_rep, project_id=project_id or None,
|
||||
)
|
||||
fresh = [(s, r) for s, r in found if r.id not in already]
|
||||
# ITS OWN SOURCE, and both sides of the trade logged. #2463's own finding
|
||||
@@ -953,14 +953,15 @@ async def build_prompt_rule_hint(
|
||||
|
||||
t0 = time.perf_counter()
|
||||
_rep: dict = {}
|
||||
# NOT scoped to the project, and that is the corpus's own decision
|
||||
# rather than an omission here — semantic_search_rules is scoped by
|
||||
# OWNERSHIP on purpose, because "is there a rule about this" is asked
|
||||
# across a whole rulebook. `project_id` below reaches the log row and
|
||||
# nothing else.
|
||||
# SCOPED TO THIS SESSION'S PROJECT (milestone 414): global rules plus
|
||||
# the bound project's own. An unbound session (project_id 0) gets
|
||||
# global rules only. This arm used to search every rule the user owned,
|
||||
# so each project's rules were injected into every other project's
|
||||
# sessions — this surface speaks unasked, and a whole-rulebook answer
|
||||
# is only right for someone who asked the whole rulebook.
|
||||
hits = await semantic_search_rules(
|
||||
user_id, q, limit=PROMPTRULE_LIMIT, threshold=threshold,
|
||||
report=_rep,
|
||||
report=_rep, project_id=project_id or None,
|
||||
)
|
||||
duration_ms = (time.perf_counter() - t0) * 1000.0
|
||||
|
||||
@@ -1831,7 +1832,7 @@ async def build_write_path_hint(
|
||||
hits = await semantic_search_rules(
|
||||
user_id, code or path, limit=RULEHINT_LIMIT,
|
||||
threshold=cfg["rule_threshold"],
|
||||
report=_rep_wpr,
|
||||
report=_rep_wpr, project_id=project_id or None,
|
||||
)
|
||||
rule_ms = (time.perf_counter() - rule_t0) * 1000.0
|
||||
# BAND FIRST, dedup second, and the order is the whole point (#3851).
|
||||
@@ -1986,7 +1987,7 @@ async def build_tool_rule_hint(
|
||||
hits = await semantic_search_rules(
|
||||
user_id, query, limit=RULEHINT_LIMIT,
|
||||
threshold=cfg["tool_rule_threshold"],
|
||||
report=_rep_ptr,
|
||||
report=_rep_ptr, project_id=project_id or None,
|
||||
)
|
||||
duration_ms = (time.perf_counter() - t0) * 1000.0
|
||||
|
||||
|
||||
@@ -107,7 +107,7 @@ async def completion_preferences(user_id: int, *, project_id: int | None = None)
|
||||
t0 = time.perf_counter()
|
||||
hits = await semantic_search_rules(
|
||||
user_id, COMPLETION_QUERY, limit=LIMIT, threshold=threshold,
|
||||
kind="preference", report=report,
|
||||
kind="preference", report=report, project_id=project_id,
|
||||
)
|
||||
hits = [(score, rule) for score, rule in hits if rule.kind == "preference"]
|
||||
record_retrieval(
|
||||
|
||||
Reference in New Issue
Block a user