Skip to content

zettelkasten.spine_suggest

zettelkasten.spine_suggest

Propose a NEW spine (comparison matrix) from a frame result — READ-ONLY.

Phase 1 of the "frame a question, then seed a spine" feature. This is the structural sibling of :mod:zettelkasten.frame_answer: it layers an optional, grounded PROPOSAL on top of an already-computed deterministic frame, for the DASHBOARD only. The proposal is assembled from material the caller already surfaced — the frame's facets/hubs and the suggest kind=structure clusters — plus a tool-free LLM pass that drafts DIMENSION COLUMNS from the question.

Hard guarantees:

  • Write-free. Only read functions are called: the pre-computed frame dict, the caller's get_graph resolver plus :func:zettelkasten.server._structure_clusters (tag-cluster analysis only — no ghost graph is ever cached), and :func:zettelkasten.extraction_schemas.expand_spec (validation only). Nothing here saves, promotes, materializes, or otherwise mutates a graph or any state.
  • Never raises. The optional LLM stack degrades to a deterministic fallback set of columns; schema validation failures are reported as valid: False rather than propagated. A missing/federated source graph is skipped, not fatal.

Phase 1 only PROPOSES. It does not reorganize existing notes (Phase 2) or run grounded extraction (a future opt-in). The default intent it records is "reorganize existing notes", which a later phase will act on.

Drafted columns are cached by :func:spine_signature — a hash over the question/project/facet-coverage/gaps and the facets' evidence source graphs — so re-proposing over an unchanged frame skips the LLM call while a frame change (or a federated cross-repo source) yields a fresh draft.

spine_signature

spine_signature(frame: dict) -> str

Content hash over the frame's shape — the column-draft cache key.

Covers the question, project, the (facet, coverage) pairs, the gap labels, and each facet's evidence source_graph values. Column drafting depends only on this surface (not on individual evidence rows), so a change to any of them yields a fresh draft while a pure re-submit over an unchanged frame hits the cache. Folding the source graphs mirrors :func:frame_answer.frame_signature's guard: a federated frame (whose notes carry namespaced repoid:graph source graphs) cannot collide in the cross-repo cache with a same-shaped local frame. Deterministic.

Source code in zettelkasten/spine_suggest.py
def spine_signature(frame: dict) -> str:
    """Content hash over the frame's shape — the column-draft cache key.

    Covers the question, project, the (facet, coverage) pairs, the gap labels,
    and each facet's evidence ``source_graph`` values. Column drafting depends
    only on this surface (not on individual evidence rows), so a change to any of
    them yields a fresh draft while a pure re-submit over an unchanged frame hits
    the cache. Folding the source graphs mirrors
    :func:`frame_answer.frame_signature`'s guard: a federated frame (whose notes
    carry namespaced ``repoid:graph`` source graphs) cannot collide in the
    cross-repo cache with a same-shaped local frame. Deterministic.
    """
    parts: list[Any] = [frame.get("question", ""), frame.get("project", "")]
    facets = sorted(
        [f.get("facet") or "", f.get("coverage") or ""]
        for f in frame.get("facets", [])
    )
    parts.append(facets)
    parts.append(sorted(str(g) for g in frame.get("gaps", []) or []))
    sources: set[str] = set()
    for f in frame.get("facets", []):
        for kind in ("findings", "claims", "other"):
            for n in f.get(kind, []) or []:
                sg = n.get("source_graph")
                if sg:
                    sources.add(str(sg))
    parts.append(sorted(sources))
    blob = json.dumps(parts, sort_keys=True, ensure_ascii=False)
    return hashlib.sha256(blob.encode("utf-8")).hexdigest()

suggest_spine

suggest_spine(frame: dict, get_graph: Callable[[str], Any], *, project: str = '', search_sources=None, use_cache: bool = True) -> dict

Assemble a write-free spine PROPOSAL from a pre-computed frame result.

frame is the already-computed deterministic output of :func:zettelkasten.framing.frame_question (never recomputed here). Rows are derived from the frame's facets/hubs and from suggest kind=structure clusters per source graph; columns are drafted from the question by a tool-free LLM (with a deterministic fallback); thin facets become gaps. The derived spec is validated write-free via :func:extraction_schemas.expand_spec.

Returns {"proposal", "validated_spec", "valid"} — the exact response contract the dashboard route re-exposes. Never raises and never writes.

Source code in zettelkasten/spine_suggest.py
def suggest_spine(
    frame: dict,
    get_graph: Callable[[str], Any],
    *,
    project: str = "",
    search_sources=None,
    use_cache: bool = True,
) -> dict:
    """Assemble a write-free spine PROPOSAL from a pre-computed frame result.

    ``frame`` is the already-computed deterministic output of
    :func:`zettelkasten.framing.frame_question` (never recomputed here). Rows are
    derived from the frame's facets/hubs and from ``suggest kind=structure``
    clusters per source graph; columns are drafted from the question by a
    tool-free LLM (with a deterministic fallback); thin facets become gaps. The
    derived spec is validated write-free via
    :func:`extraction_schemas.expand_spec`.

    Returns ``{"proposal", "validated_spec", "valid"}`` — the exact response
    contract the dashboard route re-exposes. Never raises and never writes.
    """
    columns = _draft_columns(frame, use_cache=use_cache)
    rows = _assemble_rows(frame, get_graph, search_sources)
    gaps = [str(g) for g in frame.get("gaps", []) or []]
    title = _title_from_question(frame.get("question", ""))

    proposal = {
        "title": title,
        "intent": "reorganize existing notes",
        "rows": rows,
        "columns": columns,
        "gaps": gaps,
    }

    spec = _build_spec(title, columns)
    try:
        from zettelkasten import extraction_schemas

        validated_spec = extraction_schemas.expand_spec("(draft)", spec)
        valid = True
    except Exception:  # noqa: BLE001 — invalid draft (or missing dep): report, never raise
        validated_spec = None
        valid = False

    return {"proposal": proposal, "validated_spec": validated_spec, "valid": valid}