feat(search): milestones are searchable by meaning — "is there already a plan for this?" (#4078)
CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 11s
CI & Build / TypeScript typecheck (push) Successful in 52s
CI & Build / integration (push) Successful in 56s
CI & Build / Python tests (push) Successful in 1m37s
CI & Build / Build & push image (push) Successful in 28s

`search` covered notes, tasks and rules, and a milestone — the record a plan
lives in — could not be found. A project whose roadmap was written as
milestones had every later plan opened beside the one that already described
it, because nothing could have told the session it existed.

- milestone_embeddings (migration 0102): the third sibling of note_ and
  rule_embeddings, for note 3163's reason — the search is milestone-specific.
  The document is title — description, then description and the plan body,
  so a roadmap milestone with no description is still found by its design.
- Written on create, on a title/description/body update, and for a plan made
  through start_planning / create_records, fire-and-forget with the parent-row
  claim (#3262); a startup backfill covers every existing milestone. Derived,
  so it joins _NOT_INCLUDED beside the other embeddings.
- semantic_search_milestones: a project's milestones when the caller can read
  it (access.can_read_project), otherwise the caller's own; optional status.
- search(content_type="milestone"): id, title, description, status, project
  and progress. Its own shape, and not part of "all", whose results are
  note-shaped. The docstring says what it is for: ask before start_planning.
- Integration test on real Postgres: found in its project and not another,
  status narrows, an unreadable project returns nothing.

Milestone 415 step 3.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01821k5B3Ysecp9fNYs92Kuy
This commit is contained in:
2026-09-15 13:34:03 -04:00
co-authored by Claude Opus 5
parent 6d0dee48fa
commit 3a501c2cac
12 changed files with 463 additions and 7 deletions
+44 -2
View File
@@ -12,7 +12,8 @@ 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,
DEFAULT_SIMILARITY_THRESHOLD, semantic_search_milestones, semantic_search_notes,
semantic_search_rules,
)
from scribe.services import rulebooks as rulebooks_svc
from scribe.services.retrieval_telemetry import record_retrieval, retrieval_summary
@@ -67,6 +68,40 @@ async def _search_rules(uid: int, q: str, limit: int, project_id: int) -> dict:
}
async def _search_milestones(uid: int, q: str, limit: int, project_id: int) -> dict:
"""Milestones by meaning — "is there already a plan for this?" (milestone 415).
Its own result shape, like rules: a milestone is a plan with progress, not
a note with a body. The plan itself is left out — get_milestone reads it —
because a search hit is for recognising a plan, and bodies run long.
Not part of content_type="all", whose results are note-shaped.
"""
raw = await semantic_search_milestones(uid, q, project_id=project_id or None, limit=limit)
progress: dict[int, dict] = {}
if raw:
from scribe.services import milestones as milestones_svc
for pid in {m.project_id for _s, m in raw}:
for row in await milestones_svc.get_project_milestone_summary(uid, pid):
progress[row["id"]] = row
return {
"results": [
{
"id": m.id,
"title": m.title,
"description": m.description or "",
"status": m.status,
"project_id": m.project_id,
"total": progress.get(m.id, {}).get("total", 0),
"completed": progress.get(m.id, {}).get("completed", 0),
"similarity": float(score),
}
for score, m in raw
],
"total": len(raw),
}
async def search(
q: str,
content_type: str = "all",
@@ -93,7 +128,12 @@ async def search(
tagging?". A hit carries the rule's `why` and `how_to_apply`,
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.
with 0, every rule in the rulebook. Or 'milestone' (PLANS):
reach for it before start_planning to ask whether a plan for
this work already exists — a match is where new steps go
(create_records(milestone_id=…)), not a reason to open a second
milestone. Hits carry title, description, status and progress;
get_milestone reads the plan. Not included in 'all'.
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
@@ -118,6 +158,8 @@ async def search(
limit = max(1, min(limit, 50))
if content_type == "rule":
return await _search_rules(uid, q, limit, project_id)
if content_type == "milestone":
return await _search_milestones(uid, q, limit, project_id)
is_task = {"note": False, "task": True}.get(content_type) # None => any
t0 = time.perf_counter()
report: dict = {}