feat(planning): start_planning hands back the active plan that already covers the work (#4079)
CI & Build / Python lint (push) Successful in 5s
CI & Build / Plugin hooks (push) Successful in 13s
CI & Build / integration (push) Successful in 1m10s
CI & Build / TypeScript typecheck (push) Successful in 1m15s
CI & Build / Python tests (push) Failing after 1m22s
CI & Build / Build & push image (push) Skipped
CI & Build / Python lint (push) Successful in 5s
CI & Build / Plugin hooks (push) Successful in 13s
CI & Build / integration (push) Successful in 1m10s
CI & Build / TypeScript typecheck (push) Successful in 1m15s
CI & Build / Python tests (push) Failing after 1m22s
CI & Build / Build & push image (push) Skipped
Step 4 of milestone 415 "An existing plan is found before a new one is made". A session that could not see an existing plan made a second one beside it. start_planning and create_milestone now ask first: an ACTIVE milestone in the project with the same title, or one that reads as the same plan (title, design and steps against milestone embeddings), is returned with its progress and a pointer to create_records(milestone_id=...). Nothing is created; force=true bypasses. - dedup.find_matching_plan / plan_gate / plan_match_response; access-checked before either arm (rule 78), fail-open like the other gates. - Done milestones never block; the semantic arm needs 200+ chars of candidate. - kb_plan_match_threshold (default 0.90) is a setting, in the Settings view, and pinned against the Python default by test_settings_defaults_agree. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01821k5B3Ysecp9fNYs92Kuy
This commit is contained in:
@@ -33,6 +33,7 @@ from scribe.models.embedding import NoteEmbedding
|
||||
from scribe.models.note import Note
|
||||
from scribe.models.rulebook import Rule
|
||||
from scribe.models.base import iso
|
||||
from scribe.services.access import can_read_project
|
||||
from scribe.services import embeddings as embeddings_svc
|
||||
# Imported rather than redeclared: no service imports this module (the create
|
||||
# gate is called from the routes/tools layer), so there is no cycle to dodge,
|
||||
@@ -720,3 +721,175 @@ async def find_duplicate_rule(
|
||||
except Exception:
|
||||
logger.debug("dedup rule title check skipped — query failed", exc_info=True)
|
||||
return None
|
||||
|
||||
|
||||
# --- the plan gate (milestone 415) -------------------------------------------
|
||||
# A session asked "what work is open?" that cannot see an existing plan makes a
|
||||
# second one: a new milestone beside the one that already covers the work, or
|
||||
# loose tasks beside it. Each copy then collects its own steps, and neither
|
||||
# shows the whole. This gate asks the question before start_planning (or
|
||||
# create_milestone) writes: is there an ACTIVE plan in this project for this?
|
||||
#
|
||||
# Active only: a done milestone is history, and planning the next round of
|
||||
# the same area is legitimate work rather than a copy of it.
|
||||
#
|
||||
# Project-scoped, not owner-scoped like the note gate. The note gate refuses to
|
||||
# point at someone else's record because they may not be able to edit it; a
|
||||
# plan is different. Creating one needs write on the project, and write on the
|
||||
# project is exactly what adding steps to its existing plan needs, so a caller
|
||||
# who reaches this gate can act on whatever it returns.
|
||||
#
|
||||
# Its own threshold, as a setting (rule 25). The note gate's 0.90 is where it
|
||||
# starts, because it asks the same question ("the same thing, reworded") with
|
||||
# the same embedder. Plan documents are shaped differently, though: a milestone
|
||||
# is embedded as title, description and plan (embeddings.milestone_document),
|
||||
# while the candidate usually has no description and carries its steps
|
||||
# instead. That difference has not been measured, and a gate that blocks on
|
||||
# noise teaches sessions to pass force=true every time, which is worse than no
|
||||
# gate. So the default is conservative and the operator can lower it.
|
||||
PLAN_MATCH_THRESHOLD_KEY = "kb_plan_match_threshold"
|
||||
PLAN_MATCH_DEFAULT_THRESHOLD = 0.90
|
||||
|
||||
|
||||
async def get_plan_match_threshold(user_id: int) -> float:
|
||||
"""The user's plan-gate similarity floor, clamped to [0, 1]."""
|
||||
from scribe.services.settings import get_setting
|
||||
|
||||
try:
|
||||
value = float(await get_setting(
|
||||
user_id, PLAN_MATCH_THRESHOLD_KEY, str(PLAN_MATCH_DEFAULT_THRESHOLD)
|
||||
))
|
||||
except (TypeError, ValueError):
|
||||
value = PLAN_MATCH_DEFAULT_THRESHOLD
|
||||
return min(1.0, max(0.0, value))
|
||||
|
||||
|
||||
def plan_candidate_text(
|
||||
description: str | None = None,
|
||||
body: str | None = None,
|
||||
step_texts: list[str] | None = None,
|
||||
) -> str:
|
||||
"""What a plan that doesn't exist yet says about itself, for the gate.
|
||||
|
||||
The steps belong in it: a plan passed with steps and no design is still
|
||||
recognisable by them, and what its steps say is most of what makes two
|
||||
plans the same plan.
|
||||
"""
|
||||
parts = [(description or "").strip(), (body or "").strip()]
|
||||
parts += [t.strip() for t in (step_texts or [])]
|
||||
return "\n\n".join(p for p in parts if p)
|
||||
|
||||
|
||||
async def find_matching_plan(
|
||||
user_id: int,
|
||||
project_id: int,
|
||||
title: str,
|
||||
text: str = "",
|
||||
) -> DuplicateMatch | None:
|
||||
"""An ACTIVE milestone in the project that already is this plan, or None.
|
||||
|
||||
Normalized-title match first, then semantic when `text` (from
|
||||
plan_candidate_text) is long enough to mean something, the same floor the
|
||||
note gate uses and for the same reason: a title-only embedding sits in a
|
||||
tight neighbourhood and false-positives. Never raises; a failed check lets
|
||||
the plan through, because a create must not depend on a recall aid.
|
||||
"""
|
||||
from scribe.models.milestone import Milestone
|
||||
|
||||
if not project_id:
|
||||
return None
|
||||
# Rule 78, before either arm: a match names a milestone, and a caller who
|
||||
# cannot read the project must not learn its plans by guessing titles.
|
||||
try:
|
||||
if not await can_read_project(user_id, project_id):
|
||||
return None
|
||||
except Exception:
|
||||
logger.debug("plan gate access check failed — letting the plan through", exc_info=True)
|
||||
return None
|
||||
norm = " ".join((title or "").split()).lower()
|
||||
if norm:
|
||||
try:
|
||||
async with async_session() as session:
|
||||
existing = (await session.execute(
|
||||
select(Milestone).where(
|
||||
Milestone.project_id == project_id,
|
||||
Milestone.deleted_at.is_(None),
|
||||
Milestone.status == "active",
|
||||
func.lower(func.trim(Milestone.title)) == norm,
|
||||
).limit(1)
|
||||
)).scalars().first()
|
||||
if existing is not None:
|
||||
return DuplicateMatch(existing.id, existing.title, 1.0, "title")
|
||||
except Exception:
|
||||
logger.debug("plan gate title check skipped — query failed", exc_info=True)
|
||||
return None
|
||||
|
||||
if len((text or "").strip()) < _MIN_BODY_FOR_SEMANTIC:
|
||||
return None
|
||||
doc_title, doc_body = embeddings_svc.milestone_document(title, None, text)
|
||||
query = "\n\n".join(p for p in (doc_title, doc_body) if p)
|
||||
try:
|
||||
hits = await embeddings_svc.semantic_search_milestones(
|
||||
user_id, query, project_id=project_id, status="active", limit=1,
|
||||
threshold=await get_plan_match_threshold(user_id),
|
||||
)
|
||||
except Exception:
|
||||
logger.debug("plan gate semantic check skipped", exc_info=True)
|
||||
return None
|
||||
if hits:
|
||||
score, milestone = hits[0]
|
||||
return DuplicateMatch(milestone.id, milestone.title, round(score, 3), "semantic")
|
||||
return None
|
||||
|
||||
|
||||
def plan_match_response(dup: DuplicateMatch, progress: dict | None = None) -> dict:
|
||||
"""The payload start_planning / create_milestone return instead of a second
|
||||
plan: the existing one, how far along it is, and how to add to it."""
|
||||
progress = progress or {}
|
||||
total, completed = progress.get("total", 0), progress.get("completed", 0)
|
||||
how = "has the same title" if dup.reason == "title" else "reads as the same plan"
|
||||
return {
|
||||
"duplicate": True,
|
||||
"existing_id": dup.id,
|
||||
"existing_title": dup.title,
|
||||
"existing_milestone": {
|
||||
"id": dup.id,
|
||||
"title": dup.title,
|
||||
"description": progress.get("description") or "",
|
||||
"total": total,
|
||||
"completed": completed,
|
||||
},
|
||||
"similarity": dup.similarity,
|
||||
"match": dup.reason,
|
||||
"message": (
|
||||
f'An active plan in this project {how}: milestone {dup.id} '
|
||||
f'"{dup.title}" ({completed} of {total} steps done). Nothing was '
|
||||
f"created. Add your steps to it with create_records(milestone_id="
|
||||
f"{dup.id}, ...), and revise its design with update_milestone if the "
|
||||
f"scope has grown. Read it first with get_milestone({dup.id}). If this "
|
||||
f"really is a separate plan, retry with force=true."
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
async def plan_gate(
|
||||
user_id: int,
|
||||
project_id: int,
|
||||
title: str,
|
||||
text: str = "",
|
||||
) -> dict | None:
|
||||
"""find_matching_plan, answered: the plan_match_response to return in
|
||||
place of a new plan, or None to go ahead and create it."""
|
||||
from scribe.services import milestones as milestones_svc
|
||||
|
||||
dup = await find_matching_plan(user_id, project_id, title, text)
|
||||
if dup is None:
|
||||
return None
|
||||
try:
|
||||
rows = await milestones_svc.get_project_milestone_summary(user_id, project_id)
|
||||
progress = next((r for r in rows if r.get("id") == dup.id), None)
|
||||
except Exception:
|
||||
# The match stands without its progress; losing the count must not
|
||||
# turn a found plan into a second one.
|
||||
progress = None
|
||||
return plan_match_response(dup, progress)
|
||||
|
||||
Reference in New Issue
Block a user