fix(planning): the plan gate joins step text through embedding_text (#4079)

test_nothing_else_builds_the_embedding_document_itself caught start_planning
building f"{title}\n{body}" inline for the plan gate's candidate text. That is
the embedded-document shape; plan_candidate_text now takes (title, body) pairs
and calls embedding_text, so the candidate moves with the corpus it is ranked
against (#2486).

Also carries the create_task / create_records milestone_id docstring lines from
step 5 (#4080), which share the file.

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:45:52 -04:00
co-authored by Claude Opus 5
parent 59407728e6
commit 2811fc9025
3 changed files with 13 additions and 6 deletions
+6 -1
View File
@@ -152,6 +152,9 @@ async def create_task(
priority: One of: low, medium, high, or 'none'. Omit (empty string) to leave unset. priority: One of: low, medium, high, or 'none'. Omit (empty string) to leave unset.
project_id: Associate with a project (0 = no project). project_id: Associate with a project (0 = no project).
milestone_id: Place within a project milestone (0 = no milestone). milestone_id: Place within a project milestone (0 = no milestone).
When the work belongs to an active plan — a milestone in
enter_project's lists or found by search(content_type=
"milestone") — pass its id, so the plan shows all of its work.
parent_id: Make this a sub-task of another task (0 = top-level). parent_id: Make this a sub-task of another task (0 = top-level).
tags: List of plain-string tags without # prefix. tags: List of plain-string tags without # prefix.
kind: 'work' (default), 'issue', or 'spike'. kind: 'work' (default), 'issue', or 'spike'.
@@ -462,6 +465,8 @@ async def create_records(
project_id: The project every record belongs to (0 = none, or taken project_id: The project every record belongs to (0 = none, or taken
from milestone_id). from milestone_id).
milestone_id: File every record under this existing milestone (0 = none). milestone_id: File every record under this existing milestone (0 = none).
This is how steps are added to a plan that already exists,
including one start_planning handed back as `existing_milestone`.
force: Bypass the near-duplicate gate for the whole batch. By default force: Bypass the near-duplicate gate for the whole batch. By default
the first record that near-duplicates an existing one BLOCKS the the first record that near-duplicates an existing one BLOCKS the
batch, and its existing id comes back so you can update it instead. batch, and its existing id comes back so you can update it instead.
@@ -553,7 +558,7 @@ async def start_planning(
match = await dedup_svc.plan_gate( match = await dedup_svc.plan_gate(
uid, project_id, title, uid, project_id, title,
dedup_svc.plan_candidate_text( dedup_svc.plan_candidate_text(
body=body, step_texts=[f"{i.title}\n{i.body or ''}" for i in items], body=body, steps=[(i.title, i.body) for i in items],
), ),
) )
if match is not None: if match is not None:
+4 -3
View File
@@ -767,16 +767,17 @@ async def get_plan_match_threshold(user_id: int) -> float:
def plan_candidate_text( def plan_candidate_text(
description: str | None = None, description: str | None = None,
body: str | None = None, body: str | None = None,
step_texts: list[str] | None = None, steps: list[tuple[str | None, str | None]] | None = None,
) -> str: ) -> str:
"""What a plan that doesn't exist yet says about itself, for the gate. """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 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 recognisable by them, and what its steps say is most of what makes two
plans the same plan. plans the same plan. Each step is (title, body), joined by embedding_text
like every other record that becomes embedded text (#2486).
""" """
parts = [(description or "").strip(), (body or "").strip()] parts = [(description or "").strip(), (body or "").strip()]
parts += [t.strip() for t in (step_texts or [])] parts += [embeddings_svc.embedding_text(t, b) for t, b in (steps or [])]
return "\n\n".join(p for p in parts if p) return "\n\n".join(p for p in parts if p)
+3 -2
View File
@@ -408,8 +408,9 @@ async def test_a_bad_threshold_setting_falls_back_to_the_default():
def test_plan_candidate_text_carries_the_steps(): def test_plan_candidate_text_carries_the_steps():
text = plan_candidate_text(description=None, body=" design ", step_texts=["Step one\n", ""]) text = plan_candidate_text(description=None, body=" design ",
assert text == "design\n\nStep one" steps=[("Step one", None), ("Step two", "with a body"), (None, None)])
assert text == "design\n\nStep one\n\nStep two\nwith a body"
def test_plan_match_response_points_at_adding_steps_not_a_second_plan(): def test_plan_match_response_points_at_adding_steps_not_a_second_plan():