Skip to content

zettelkasten.dashboard.backend.routes.outline

zettelkasten.dashboard.backend.routes.outline

Outline build/stream, violation-fix, and review-outline CRUD routes.

Split out of the former flat routes.py; behaviour is unchanged.

BuildOutlineRequest

Bases: BaseModel

Optional body for the outline build endpoints (all fields default).

Mirrors the build_outline engine signature so a thin POST delegates the full scope through: name (defaults to the graph/project), the exact claim_ids to outline (else all claims), an as_of trend replay year, force to redraft past an unchanged cached signature, and peek for a cost-free read-only staleness probe (never gathers/drafts/writes).

Source code in zettelkasten/dashboard/backend/routes/outline.py
class BuildOutlineRequest(BaseModel):
    """Optional body for the outline build endpoints (all fields default).

    Mirrors the ``build_outline`` engine signature so a thin POST delegates the
    full scope through: ``name`` (defaults to the graph/project), the exact
    ``claim_ids`` to outline (else all claims), an ``as_of`` trend replay year,
    ``force`` to redraft past an unchanged cached signature, and ``peek`` for a
    cost-free read-only staleness probe (never gathers/drafts/writes).
    """

    name: str = ""
    outline_id: str = ""
    claim_ids: list[str] | None = None
    as_of: str = ""
    # Optional SPINE section partition (an organization id in the scope's owner
    # registry). When set, the outline's sections become that spine's dimensions
    # instead of the default theme/settled partition — the section-axis analogue
    # of the matrix's ``row_axis``. Empty = the historical theme partition. It
    # folds into the generation signature, so switching/editing a spine busts the
    # cached scaffold exactly as a scope/column change does.
    spine: str = ""
    # Section-partition MODE for an OVERARCHING ``spine`` selection (a
    # ``schema:<hash>`` group or a nested parent). One of ``per_dimension`` /
    # ``per_subspine`` / ``nested``; empty = the historical single-spine
    # partition. Folds into the generation signature so switching mode busts the
    # cached scaffold like a spine change does.
    spine_mode: str = ""
    # NESTED composition tiers. When ``spine`` names a subject spine that
    # participates in a nested composition, the outline stacks its ANCESTOR framing
    # tiers above the subject's leaf partition (drawn from the same
    # ``detect_nested_spines`` tree the frontend displays).
    #
    # ``ancestor_uids`` is the AUTHORITATIVE selection: the EXPLICIT, ordered
    # (root->leaf) set of ancestor node uids to keep — exactly the checked tiers of
    # the frontend's ``projectTiers`` left pane, so the drafted scaffold matches the
    # displayed tree even for a GAPPED selection (unchecked intermediate tiers are
    # pruned, children reparent to the nearest kept ancestor). ``null``/omitted =
    # no explicit selection.
    #
    # ``ancestor_levels`` is the historical COUNT-based fallback, kept for
    # back-compat with MCP/programmatic callers: ``0`` = today's flat single-spine
    # partition (byte-identical), ``N`` = the nearest ``N`` ancestors, ``-1`` =
    # every ancestor up to the composition root. It is used ONLY when
    # ``ancestor_uids`` is not provided. The RESOLVED kept-tier uid list folds into
    # the generation signature, so nested-vs-flat and each DISTINCT selection cache
    # separately.
    ancestor_levels: int = 0
    ancestor_uids: list[str] | None = None
    force: bool = False
    peek: bool = False
    # DEEPEN: when ``expand_header`` is set, the STREAM endpoints re-draft only the
    # section under that exact ``## header`` (splicing it back) instead of building
    # the whole scaffold. ``expand_occurrence`` disambiguates a repeated header
    # (rare — the contract mandates unique declarative headers). Ignored by the
    # JSON build/peek path, which stays whole-document.
    expand_header: str = ""
    expand_occurrence: int = 0

FixViolationRequest

Bases: BaseModel

Body for POST /reviews/{name}/outline/fix-violation.

Identifies ONE integrity violation in a built scaffold (its kind / offending ref / 1-based line) and the action to take. "remove" strips the offending span; "fix" resolves it in place (a chip is fuzzy-resolved to a real material unit, a quote is replaced with a verbatim passage re-extracted + re-verified from the section's sources). outline_id selects which of the review's outlines to edit (empty = the manifest-backed default).

Source code in zettelkasten/dashboard/backend/routes/outline.py
class FixViolationRequest(BaseModel):
    """Body for ``POST /reviews/{name}/outline/fix-violation``.

    Identifies ONE integrity violation in a built scaffold (its ``kind`` /
    offending ``ref`` / 1-based ``line``) and the ``action`` to take. ``"remove"``
    strips the offending span; ``"fix"`` resolves it in place (a ``chip`` is
    fuzzy-resolved to a real material unit, a ``quote`` is replaced with a
    verbatim passage re-extracted + re-verified from the section's sources).
    ``outline_id`` selects which of the review's outlines to edit (empty = the
    manifest-backed default).
    """

    outline_id: str = ""
    kind: str
    ref: str = ""
    line: int | None = None
    action: str
    # The active SPINE section partition (org id), if any. Forwarded so the
    # re-stamped generation signature matches the spine-aware build's — otherwise
    # a Fix/Remove on a spine-partitioned scaffold would store a spine-less
    # signature and the next peek would read it as stale. Empty = theme partition.
    spine: str = ""
    # The active NESTED nesting selection, if any. Forwarded (like ``spine``) so the
    # re-stamped generation signature matches the nested build's — a Fix/Remove on
    # a nested scaffold must keep gathering the same tier material and re-stamp the
    # same ancestor-aware signature. ``ancestor_uids`` is the authoritative explicit
    # (root->leaf) kept-tier set; ``ancestor_levels`` is the count-based fallback
    # (0 = the flat single-spine/theme partition), used only when ``ancestor_uids``
    # is absent.
    # The active overarching-spine section MODE, if any. Forwarded (like ``spine``)
    # so the re-stamped generation signature matches the mode-aware build's.
    spine_mode: str = ""
    ancestor_levels: int = 0
    ancestor_uids: list[str] | None = None
    # A CONTENT hash of the scaffold markdown the client currently displays (the
    # ``content_signature`` from the build/get-outline payload). Forwarded so the
    # engine can refuse a Fix/Remove against a draft that has moved on since it was
    # loaded (stale banner / second tab / 503-retry, OR a force/deepen that rewrote
    # the prose for the same material) → 409. Unlike the generation ``signature``,
    # this changes on ANY draft rewrite, so it catches stale line numbers. Optional
    # for backward compatibility; omitted/empty disables the staleness guard.
    content_signature: str = ""

CreateOutlineRequest

Bases: BaseModel

Body for POST /reviews/{name}/outlines — create an additional outline.

Source code in zettelkasten/dashboard/backend/routes/outline.py
class CreateOutlineRequest(BaseModel):
    """Body for ``POST /reviews/{name}/outlines`` — create an additional outline."""

    title: str = ""
    question: str = ""
    outline_id: str = ""  # optional explicit slug; otherwise derived from title
    # Optional organization id the outline is spined by (persisted on the record,
    # seeds the scaffold's native partition). May be a single spine id OR an
    # overarching selection (``schema:<hash>``). Empty/omitted = no spine.
    spine: str = ""
    # Section-partition mode for an overarching ``spine`` selection
    # (per_dimension / per_subspine / nested). Empty = single-spine partition.
    spine_mode: str = ""

EditOutlineRequest

Bases: BaseModel

Body for PATCH /reviews/{name}/outlines/{outline_id} (all optional).

Source code in zettelkasten/dashboard/backend/routes/outline.py
class EditOutlineRequest(BaseModel):
    """Body for ``PATCH /reviews/{name}/outlines/{outline_id}`` (all optional)."""

    title: str | None = None
    question: str | None = None
    # Optional organization id the outline is spined by. ``None`` = leave
    # unchanged; empty string = clear the spine.
    spine: str | None = None
    # Section-partition mode for an overarching ``spine`` selection. Applied
    # together with ``spine`` (a mode with no spine is meaningless).
    spine_mode: str = ""

graph_outline

graph_outline(name: str, req: BuildOutlineRequest | None = None) -> dict

Build the grounded writing-scaffold outline for a single source graph.

Runs GATHER→DRAFT→integrity→durable write via outline.build_outline and returns {markdown, artifact_path, signature, violations, cached}. The artifact is a regenerable derived _reviews/<name>.md (the durable authored state stays the _reviews/<name>.yaml overlay). Federated targets are a read-only overlay → 403 before any write (mirrors the review POST/PUT).

Source code in zettelkasten/dashboard/backend/routes/outline.py
@router.post("/graphs/{name}/outline")
def graph_outline(name: str, req: BuildOutlineRequest | None = None) -> dict:
    """Build the grounded writing-scaffold outline for a single source graph.

    Runs GATHER→DRAFT→integrity→durable write via ``outline.build_outline`` and
    returns ``{markdown, artifact_path, signature, violations, cached}``. The
    artifact is a regenerable derived ``_reviews/<name>.md`` (the durable
    authored state stays the ``_reviews/<name>.yaml`` overlay). Federated targets
    are a read-only overlay → 403 before any write (mirrors the review POST/PUT).
    """
    _get_graph_structural(name)  # 404s on an unknown/unsafe name (no embedding build)
    local, repo = _resolve_ref(name)
    if repo is not None:
        raise HTTPException(status_code=403, detail="Federated repos are read-only.")
    req = req or BuildOutlineRequest()
    loc = lambda d: _resolve_ref(d)[0]
    try:
        return outline.build_outline(
            _get_graph,
            graph=local,
            name=req.name,
            outline_id=req.outline_id,
            claim_ids=req.claim_ids,
            as_of=req.as_of,
            spine=req.spine,
            spine_mode=req.spine_mode,
            ancestor_levels=req.ancestor_levels,
            ancestor_uids=req.ancestor_uids,
            force=req.force,
            peek=req.peek,
            graphs_dir=GRAPHS_DIR,
            localize=loc,
        )
    except ValueError as e:
        raise HTTPException(status_code=400, detail=str(e))
    except (RuntimeError, FileNotFoundError, OSError) as e:
        # The DRAFT pass (in-process LLM agent) or its skill contract failed — a
        # runtime/upstream failure, not a bad request. Surface the REAL message
        # (502) so the dashboard shows it instead of an opaque 500.
        raise HTTPException(status_code=502, detail=f"Outline draft failed: {e}")

project_outline

project_outline(name: str, req: BuildOutlineRequest | None = None) -> dict

Build the grounded writing-scaffold outline across a project's sources.

The project companion to graph_outline: resolves the project via _load_project_or_404 (404 missing / 422 corrupt manifest), then runs GATHER→DRAFT→integrity→durable write via outline.build_outline and returns {markdown, artifact_path, signature, violations, cached}. Federated targets are read-only → 403 before any write.

Source code in zettelkasten/dashboard/backend/routes/outline.py
@router.post("/projects/{name}/outline")
def project_outline(name: str, req: BuildOutlineRequest | None = None) -> dict:
    """Build the grounded writing-scaffold outline across a project's sources.

    The project companion to ``graph_outline``: resolves the project via
    ``_load_project_or_404`` (404 missing / 422 corrupt manifest), then runs
    GATHER→DRAFT→integrity→durable write via ``outline.build_outline`` and
    returns ``{markdown, artifact_path, signature, violations, cached}``.
    Federated targets are read-only → 403 before any write.
    """
    local, repo = _resolve_ref(name)
    if repo is not None:
        raise HTTPException(status_code=403, detail="Federated repos are read-only.")
    _load_project_or_404(name)
    req = req or BuildOutlineRequest()
    loc = lambda d: _resolve_ref(d)[0]
    try:
        return outline.build_outline(
            _get_graph,
            project=local,
            name=req.name,
            outline_id=req.outline_id,
            claim_ids=req.claim_ids,
            as_of=req.as_of,
            spine=req.spine,
            spine_mode=req.spine_mode,
            ancestor_levels=req.ancestor_levels,
            ancestor_uids=req.ancestor_uids,
            force=req.force,
            peek=req.peek,
            graphs_dir=GRAPHS_DIR,
            localize=loc,
        )
    except ValueError as e:
        raise HTTPException(status_code=400, detail=str(e))
    except (RuntimeError, FileNotFoundError, OSError) as e:
        # See graph_outline: a DRAFT/skill-contract failure is a runtime/upstream
        # failure — surface the real message (502) rather than an opaque 500.
        raise HTTPException(status_code=502, detail=f"Outline draft failed: {e}")

graph_outline_stream

graph_outline_stream(name: str, req: BuildOutlineRequest | None = None)

Streaming twin of graph_outline (SSE: stage/token/result frames).

The Regenerate path uses this so the scaffold draft streams in live. Guards match the JSON route: 404 unknown source, 403 federated (read-only).

Source code in zettelkasten/dashboard/backend/routes/outline.py
@router.post("/graphs/{name}/outline/stream")
def graph_outline_stream(name: str, req: BuildOutlineRequest | None = None):
    """Streaming twin of ``graph_outline`` (SSE: stage/token/result frames).

    The Regenerate path uses this so the scaffold draft streams in live. Guards
    match the JSON route: 404 unknown source, 403 federated (read-only).
    """
    _get_graph_structural(name)  # 404s on an unknown/unsafe name (no embedding build)
    local, repo = _resolve_ref(name)
    if repo is not None:
        raise HTTPException(status_code=403, detail="Federated repos are read-only.")
    return _outline_stream_response(graph=local, req=req or BuildOutlineRequest())

project_outline_stream

project_outline_stream(name: str, req: BuildOutlineRequest | None = None)

Streaming twin of project_outline (SSE: stage/token/result frames).

Source code in zettelkasten/dashboard/backend/routes/outline.py
@router.post("/projects/{name}/outline/stream")
def project_outline_stream(name: str, req: BuildOutlineRequest | None = None):
    """Streaming twin of ``project_outline`` (SSE: stage/token/result frames)."""
    local, repo = _resolve_ref(name)
    if repo is not None:
        raise HTTPException(status_code=403, detail="Federated repos are read-only.")
    _load_project_or_404(name)
    return _outline_stream_response(project=local, req=req or BuildOutlineRequest())

graph_outline_build async

graph_outline_build(name: str, req: BuildOutlineRequest | None = None) -> dict

Schedule a durable background outline build for a source graph (fire-and-poll).

Declared async so scheduling never takes the single web-threadpool token. Guards match the stream route: 404 unknown source, 403 federated (read-only).

Source code in zettelkasten/dashboard/backend/routes/outline.py
@router.post("/graphs/{name}/outline/build")
async def graph_outline_build(name: str, req: BuildOutlineRequest | None = None) -> dict:
    """Schedule a durable background outline build for a source graph (fire-and-poll).

    Declared ``async`` so scheduling never takes the single web-threadpool token.
    Guards match the stream route: 404 unknown source, 403 federated (read-only).
    """
    _get_graph_structural(name)  # 404s on an unknown/unsafe name (no embedding build)
    local, repo = _resolve_ref(name)
    if repo is not None:
        raise HTTPException(status_code=403, detail="Federated repos are read-only.")
    return _schedule_outline_build(graph=local, req=req or BuildOutlineRequest())

project_outline_build async

project_outline_build(name: str, req: BuildOutlineRequest | None = None) -> dict

Schedule a durable background outline build across a project's sources.

Source code in zettelkasten/dashboard/backend/routes/outline.py
@router.post("/projects/{name}/outline/build")
async def project_outline_build(name: str, req: BuildOutlineRequest | None = None) -> dict:
    """Schedule a durable background outline build across a project's sources."""
    local, repo = _resolve_ref(name)
    if repo is not None:
        raise HTTPException(status_code=403, detail="Federated repos are read-only.")
    _load_project_or_404(name)
    return _schedule_outline_build(project=local, req=req or BuildOutlineRequest())

fix_outline_violation_route

fix_outline_violation_route(name: str, req: FixViolationRequest) -> dict

Resolve one integrity violation in a review's scaffold and persist the draft.

The actionable side of the integrity banner: applies a per-violation Fix / Remove via outline.fix_outline_violation and returns the refreshed {markdown, violations, resolved, message, …} so the dashboard swaps the document in exactly like a regenerate/deepen result. Scope is taken from the review manifest's project/graph. The only write is the durable artifact write (the same crash-safe path the build uses); a FIX never fabricates (re-verified against the source), and an unresolved FIX leaves the draft untouched. Federated (read-only) review → 403; missing → 404 / corrupt → 422; a bad action/kind → 400.

Source code in zettelkasten/dashboard/backend/routes/outline.py
@router.post("/reviews/{name}/outline/fix-violation")
def fix_outline_violation_route(name: str, req: FixViolationRequest) -> dict:
    """Resolve one integrity violation in a review's scaffold and persist the draft.

    The actionable side of the integrity banner: applies a per-violation Fix /
    Remove via ``outline.fix_outline_violation`` and returns the refreshed
    ``{markdown, violations, resolved, message, …}`` so the dashboard swaps the
    document in exactly like a regenerate/deepen result. Scope is taken from the
    review manifest's ``project``/``graph``. The only write is the durable
    artifact write (the same crash-safe path the build uses); a FIX never
    fabricates (re-verified against the source), and an unresolved FIX leaves the
    draft untouched. Federated (read-only) review → 403; missing → 404 / corrupt
    → 422; a bad action/kind → 400.
    """
    local, repo = _resolve_ref(name)
    if repo is not None:
        raise HTTPException(status_code=403, detail="Federated repos are read-only.")
    manifest, _repo = _load_review_or_error(name)
    graph = manifest.get("graph", "") or ""
    project = manifest.get("project", "") or ""
    loc = lambda d: _resolve_ref(d)[0]
    try:
        return outline.fix_outline_violation(
            _get_graph,
            project=("" if graph else project),
            graph=graph,
            name=local,
            outline_id=req.outline_id,
            kind=req.kind,
            ref=req.ref,
            line=req.line,
            action=req.action,
            expected_content_signature=req.content_signature,
            spine=req.spine,
            spine_mode=req.spine_mode,
            ancestor_levels=req.ancestor_levels,
            ancestor_uids=req.ancestor_uids,
            graphs_dir=GRAPHS_DIR,
            localize=loc,
        )
    except TimeoutError:
        raise HTTPException(status_code=503, detail="Review write lock busy; please retry.")
    except outline.OutlineConflictError as e:
        raise HTTPException(status_code=409, detail=str(e))
    except ValueError as e:
        raise HTTPException(status_code=400, detail=str(e))
    except (RuntimeError, FileNotFoundError, OSError) as e:
        raise HTTPException(status_code=502, detail=f"Outline fix failed: {e}")

list_review_outlines

list_review_outlines(name: str) -> dict

List a review's outlines (default first, then additional). Read-only.

Source code in zettelkasten/dashboard/backend/routes/outline.py
@router.get("/reviews/{name}/outlines")
def list_review_outlines(name: str) -> dict:
    """List a review's outlines (default first, then additional). Read-only."""
    local, repo = _resolve_ref(name)
    base = repo.zettel_dir if repo is not None else GRAPHS_DIR
    _load_review_or_error(name)
    return {"outlines": outlines.list_outlines(local, graphs_dir=base)}

create_review_outline

create_review_outline(name: str, req: CreateOutlineRequest) -> dict

Create an additional outline under a review (never the default).

Federated targets are read-only → 403. The new outline's ordering is seeded from the review's projection (themes then claims), mirroring review creation. Returns the new outline record.

Source code in zettelkasten/dashboard/backend/routes/outline.py
@router.post("/reviews/{name}/outlines")
def create_review_outline(name: str, req: CreateOutlineRequest) -> dict:
    """Create an additional outline under a review (never the default).

    Federated targets are read-only → 403. The new outline's ``ordering`` is
    seeded from the review's projection (themes then claims), mirroring review
    creation. Returns the new outline record.
    """
    local, repo = _resolve_ref(name)
    if repo is not None:
        raise HTTPException(status_code=403, detail="Federated repos are read-only.")
    manifest, _repo = _load_review_or_error(name)
    try:
        return outlines.create_outline(
            local,
            title=req.title,
            question=req.question,
            outline_id=req.outline_id or None,
            spine=req.spine or None,
            spine_mode=req.spine_mode or None,
            project=manifest.get("project", "") or "",
            graph=manifest.get("graph", "") or "",
            graphs_dir=GRAPHS_DIR,
            get_graph=_get_graph,
        )
    except TimeoutError:
        raise HTTPException(status_code=503, detail="Review write lock busy; please retry.")
    except ValueError as e:
        raise HTTPException(status_code=400, detail=str(e))

edit_review_outline

edit_review_outline(name: str, outline_id: str, req: EditOutlineRequest) -> dict

Rename and/or re-question an outline (default → patches the review).

Federated targets are read-only → 403. An unknown outline id → 404.

Source code in zettelkasten/dashboard/backend/routes/outline.py
@router.patch("/reviews/{name}/outlines/{outline_id}")
def edit_review_outline(name: str, outline_id: str, req: EditOutlineRequest) -> dict:
    """Rename and/or re-question an outline (default → patches the review).

    Federated targets are read-only → 403. An unknown outline id → 404.
    """
    local, repo = _resolve_ref(name)
    if repo is not None:
        raise HTTPException(status_code=403, detail="Federated repos are read-only.")
    _load_review_or_error(name)
    try:
        ok = True
        if req.title is not None:
            ok = outlines.rename_outline(local, outline_id, req.title, graphs_dir=GRAPHS_DIR) and ok
        if req.question is not None:
            ok = outlines.set_outline_question(local, outline_id, req.question, graphs_dir=GRAPHS_DIR) and ok
        if req.spine is not None:
            ok = outlines.set_outline_spine(
                local, outline_id, req.spine, spine_mode=req.spine_mode or None,
                graphs_dir=GRAPHS_DIR,
            ) and ok
    except TimeoutError:
        raise HTTPException(status_code=503, detail="Review write lock busy; please retry.")
    if not ok:
        raise HTTPException(status_code=404, detail=f"Outline '{outline_id}' not found.")
    rec = outlines.get_outline(local, outline_id, graphs_dir=GRAPHS_DIR)
    if rec is None:
        raise HTTPException(status_code=404, detail=f"Outline '{outline_id}' not found.")
    return rec

delete_review_outline

delete_review_outline(name: str, outline_id: str) -> dict

Delete an additional outline (the default outline cannot be deleted).

Federated targets are read-only → 403; deleting the default → 400; an unknown id → 404.

Source code in zettelkasten/dashboard/backend/routes/outline.py
@router.delete("/reviews/{name}/outlines/{outline_id}")
def delete_review_outline(name: str, outline_id: str) -> dict:
    """Delete an additional outline (the default outline cannot be deleted).

    Federated targets are read-only → 403; deleting the default → 400; an unknown
    id → 404.
    """
    local, repo = _resolve_ref(name)
    if repo is not None:
        raise HTTPException(status_code=403, detail="Federated repos are read-only.")
    _load_review_or_error(name)
    if outline_id == outlines.DEFAULT_OUTLINE_ID:
        raise HTTPException(status_code=400, detail="The default outline cannot be deleted.")
    try:
        removed = outlines.delete_outline(local, outline_id, graphs_dir=GRAPHS_DIR)
    except TimeoutError:
        raise HTTPException(status_code=503, detail="Review write lock busy; please retry.")
    if not removed:
        raise HTTPException(status_code=404, detail=f"Outline '{outline_id}' not found.")
    return {"name": local, "outline_id": outline_id, "deleted": True}