Skip to content

zettelkasten.frame_answer

zettelkasten.frame_answer

Optional LLM synthesis of a short narrative answer over a frame result.

Kept strictly separate from :mod:zettelkasten.framing (which is deterministic, LLM-free, and shared by the MCP frame_question tool): this module layers an optional, grounded paragraph on top of an already-computed frame, for the DASHBOARD only. The answer is synthesized from the claims/findings the deterministic frame already surfaced and NOTHING else: it runs on a tool-free agent (no MCP servers attached), so the model physically cannot read the graph — grounding is a hard guarantee, not merely a prompt instruction.

Degrades to None (the UI then renders no paragraph) when: * the frame surfaced no established/contested facet (nothing to ground on), or * the LLM stack is unavailable or errors (the dashboard/LLM deps are optional).

Answers are cached by :func:frame_signature — a hash over the surfaced evidence — so re-submitting the same question over an unchanged corpus skips the LLM call, while a corpus change that alters the retrieved evidence yields a fresh answer.

frame_signature

frame_signature(frame: dict) -> str

A content hash over the surfaced evidence: the cache key AND its invalidation.

Covers the question, project, and — per established/contested facet — the coverage label plus, for exactly the evidence rows the prompt actually emits (see :func:_facet_evidence), each note's id, content proxy (title + preview), its corroborated flag, and its source_graph/namespace. It also folds each facet's contested_points (tensions). Folding in content, corroboration and tensions means an edit that adds a corroborating backlink, a second contradiction (coverage stays "contested"), or changes a note's title/preview invalidates the cache, so a stale paragraph is never served; source_graph avoids cross-repo federated cache collisions; a pure re-submit over unchanged notes still hits the cache. Deterministic.

Source code in zettelkasten/frame_answer.py
def frame_signature(frame: dict) -> str:
    """A content hash over the surfaced evidence: the cache key AND its invalidation.

    Covers the question, project, and — per established/contested facet — the
    coverage label plus, for exactly the evidence rows the prompt actually emits
    (see :func:`_facet_evidence`), each note's id, content proxy (title +
    preview), its ``corroborated`` flag, and its ``source_graph``/namespace. It
    also folds each facet's ``contested_points`` (tensions). Folding in content,
    corroboration and tensions means an edit that adds a corroborating backlink,
    a second contradiction (coverage stays "contested"), or changes a note's
    title/preview invalidates the cache, so a stale paragraph is never served;
    source_graph avoids cross-repo federated cache collisions; a pure re-submit
    over unchanged notes still hits the cache. Deterministic.
    """
    parts: list[Any] = [frame.get("question", ""), frame.get("project", "")]
    for f in _covered_facets(frame):
        ev = [
            [
                n.get("id") or "",
                n.get("title") or "",
                n.get("preview") or "",
                bool(n.get("corroborated")),
                n.get("source_graph") or "",
            ]
            for n in _facet_evidence(f)
        ]
        # Fold EXACTLY the fields `_build_prompt` renders per tension —
        # ``from_title`` (the contesting note's title), relation, and ``to_note``
        # — so renaming a tension's "from" note (which changes the prompt) also
        # changes the signature and never serves a stale cached paragraph. The
        # prompt caps nothing here, so all contested_points are folded.
        tensions = sorted(
            [c.get("from_title") or "", c.get("relation") or "", c.get("to_note") or ""]
            for c in f.get("contested_points", [])
        )
        parts.append([f.get("facet"), f.get("coverage"), ev, tensions])
    blob = json.dumps(parts, sort_keys=True, ensure_ascii=False)
    return hashlib.sha256(blob.encode("utf-8")).hexdigest()

synthesize_answer

synthesize_answer(frame: dict, *, use_cache: bool = True) -> 'str | None'

Return a short grounded answer paragraph for a frame, or None.

None means "render no paragraph": either the frame had no established/contested facet, or the optional LLM stack is unavailable/errored.

Source code in zettelkasten/frame_answer.py
def synthesize_answer(frame: dict, *, use_cache: bool = True) -> "str | None":
    """Return a short grounded answer paragraph for a frame, or ``None``.

    ``None`` means "render no paragraph": either the frame had no
    established/contested facet, or the optional LLM stack is unavailable/errored.
    """
    if not _covered_facets(frame):
        return None

    key = frame_signature(frame)
    if use_cache:
        with _cache_lock:
            if key in _cache:
                _cache.move_to_end(key)
                return _cache[key] or None

    try:
        from zettelkasten.llm_adapter import run_toolless_agent

        # Tool-free: the agent has no MCP servers, so it can only use the evidence
        # in the prompt — a hard grounding guarantee against outside retrieval.
        text = run_toolless_agent(
            "zettelkasten-frame-answer",
            _SYSTEM,
            _build_prompt(frame),
            error_message="frame answer synthesis failed",
        )
    except Exception:  # noqa: BLE001 — optional LLM stack; degrade to no paragraph
        return None

    text = (text or "").strip()
    if use_cache:
        with _cache_lock:
            _cache[key] = text
            _cache.move_to_end(key)
            while len(_cache) > _CACHE_MAX:
                _cache.popitem(last=False)
    return text or None