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
|