Files
FabledScribe/src/scribe/services/record_batch.py
T
bvandeusenandClaude Opus 5 3a501c2cac
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
feat(search): milestones are searchable by meaning — "is there already a plan for this?" (#4078)
`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
2026-09-15 13:34:03 -04:00

197 lines
8.1 KiB
Python

"""Create several records at once, so they can cite each other without guessing ids.
Why this exists is in services/record_refs.py (#4016): a session that needs
records to reference one another used to create them one by one and predict
the ids of the ones not yet made — and any concurrent create, from any session
or any user, took those numbers.
Here the whole batch is ONE transaction: insert every record, flush so the
sequence assigns the real ids, rewrite each `{{ref:N}}` with the id and title
it names, commit. Other sessions keep creating throughout and cannot interfere
— the sequence never hands out the same number twice, so another session's
insert just takes a different one. The ids a batch receives are therefore NOT
guaranteed consecutive, and nothing here needs them to be: the placeholders
are filled with whatever ids came back. Forcing consecutive ids would take a
table lock that stalls every user's writes; nobody needs that.
All or nothing: a bad placeholder, an invalid status, or a failure mid-insert
leaves no partial batch behind — which is also why there are no "reserved"
stub records to clean up after a session that dies.
"""
from __future__ import annotations
from dataclasses import dataclass, field
from sqlalchemy import select
from scribe.models import async_session
from scribe.models.milestone import Milestone
from scribe.models.note import Note
from scribe.services import access as access_svc
from scribe.services import milestones as milestones_svc
from scribe.services import notes as notes_svc
from scribe.services import systems as systems_svc
from scribe.services.record_refs import placeholder_keys, resolve_placeholders
# A batch is a plan's steps or a handful of linked records, and every item runs
# the near-duplicate gate (an embedding search) before the transaction. 50 is
# far past any real plan and keeps one call from becoming a bulk import, which
# is a different job with a different door.
MAX_BATCH = 50
@dataclass
class BatchItem:
"""One record in a batch. `is_task` False makes it a plain note."""
title: str
body: str = ""
is_task: bool = True
status: str = "todo"
priority: str | None = None
task_kind: str = "work"
tags: list[str] = field(default_factory=list)
system_ids: list[int] = field(default_factory=list)
@dataclass
class BatchMilestone:
"""The milestone start_planning creates in the same transaction as its steps."""
title: str
body: str
description: str | None = None
def check_batch(items: list[BatchItem], milestone: BatchMilestone | None = None) -> None:
"""Refuse a batch that cannot be written in full, before any write.
Every placeholder must name something the batch will actually create;
otherwise the rewrite has nothing to put there, and the alternatives —
leaving `{{ref:9}}` in a stored body, or dropping it — both store a
reference that points nowhere.
"""
if not items and milestone is None:
raise ValueError("a batch needs at least one record")
if len(items) > MAX_BATCH:
raise ValueError(f"a batch holds at most {MAX_BATCH} records; got {len(items)}")
for i, item in enumerate(items, start=1):
if not (item.title or "").strip():
raise ValueError(f"record {i} has no title")
valid = {str(i) for i in range(1, len(items) + 1)}
if milestone is not None:
valid.add("milestone")
texts = [item.body for item in items] + ([milestone.body] if milestone else [])
unknown = placeholder_keys(*texts) - valid
if unknown:
named = ", ".join("{{ref:%s}}" % k for k in sorted(unknown))
raise ValueError(
f"{named} names no record in this batch. {{{{ref:N}}}} is the Nth "
f"record listed (1 to {len(items)})"
+ (", and {{ref:milestone}} is the milestone being created." if milestone else ".")
)
async def create_batch(
user_id: int,
items: list[BatchItem],
*,
project_id: int | None = None,
milestone_id: int | None = None,
milestone: BatchMilestone | None = None,
) -> tuple[Milestone | None, list[Note]]:
"""Create `items` (and optionally a new milestone they belong to) atomically.
`milestone_id` files the items under an EXISTING milestone the caller owns;
`milestone` creates a new one alongside them and files them there. Passing
both is refused. Returns (the new milestone or None, the notes in input
order). Near-duplicate gating is the door's job, exactly as for a single
create.
"""
if milestone is not None and milestone_id:
raise ValueError("pass milestone_id (an existing milestone) or a new milestone, not both")
check_batch(items, milestone)
if milestone_id:
async with async_session() as session:
existing = (await session.execute(
select(Milestone).where(
Milestone.id == milestone_id, Milestone.deleted_at.is_(None),
)
)).scalars().first()
if existing is None:
raise ValueError(f"milestone {milestone_id} not found")
if project_id and project_id != existing.project_id:
raise ValueError(
f"milestone {milestone_id} belongs to project {existing.project_id}, not {project_id}"
)
project_id = existing.project_id
if milestone is not None and not project_id:
raise ValueError("a new milestone needs a project_id")
# Share-aware (rule 78): a collaborator with write access to a shared
# project may plan in it; a bare owner filter would refuse them.
if project_id and not await access_svc.can_write_project(user_id, project_id):
raise ValueError(f"project {project_id} not found")
async with async_session() as session:
# Validate every record before adding any: build_note raises on a bad
# status/priority, and raising here writes nothing.
notes = [
notes_svc.build_note(
user_id,
title=item.title,
body=item.body,
tags=item.tags,
project_id=project_id,
milestone_id=milestone_id,
status=item.status if item.is_task else None,
priority=item.priority if item.is_task else None,
task_kind=notes_svc.minted_kind(item.task_kind) if item.is_task else "work",
)
for item in items
]
new_ms = None
if milestone is not None:
new_ms = Milestone(
user_id=user_id, project_id=project_id, title=milestone.title,
description=milestone.description, body=milestone.body, status="active",
)
session.add(new_ms)
await session.flush()
for note in notes:
note.milestone_id = new_ms.id
session.add_all(notes)
# The flush is where the sequence assigns ids. Nothing is visible to
# any other session until the commit below, and if anything between
# here and there raises, the context manager rolls it all back.
await session.flush()
refs = {str(i): f'#{n.id} "{n.title}"' for i, n in enumerate(notes, start=1)}
if new_ms is not None:
refs["milestone"] = f'milestone {new_ms.id} "{new_ms.title}"'
new_ms.body = resolve_placeholders(new_ms.body, refs)
for note in notes:
note.body = resolve_placeholders(note.body, refs)
await session.commit()
for note in notes:
await session.refresh(note)
if new_ms is not None:
await session.refresh(new_ms)
# After the commit, as a single create does: embedding and System tags are
# enrichment on records that now exist, and a failure in either must not
# un-create them.
if new_ms is not None:
milestones_svc.embed_milestone(new_ms)
for note, item in zip(notes, items):
notes_svc.embed_note(note)
if item.system_ids:
await systems_svc.set_record_systems(user_id, note.id, item.system_ids)
if project_id is not None:
await notes_svc._maybe_reactivate_project(project_id)
return new_ms, notes