explain
LangGraph explain agent: click history -> profile -> one-sentence 'why ranked here'.
Hermes is the orchestrator; the chat model is a swappable OpenAI-compatible backend (see src/attestation/llm.py).
Reliability contract: lazy, cached, degrades to None. Ranking never waits on this.
ExplainResult
Bases: NamedTuple
explain()'s answer: the text, or which of three unrelated causes
produced none.
A bare None return collapsed "this user does not exist" (the caller's
argument is wrong), "the model is unreachable" (retry later), and "the
model answered but produced nothing usable" (also worth a retry, but not
the same failure) into one value -- so _explain_item could only ever
raise one generic ToolError for all three, and its own docstring had to
spell out the ambiguity a return type should have carried. reason="ok"
is set only when text is not None, so a caller can also just check
truthiness of .text the way the old return worked.
ExplainState
Bases: BaseModel
LangGraph state threaded profile -> explanation across the two nodes.
Explanation
Bases: BaseModel
The chat model's structured reply: one short sentence, nothing else.
explain(conn, user_id, item_id, chat_fn)
Why this item was ranked here for this reader, cached after the first successful call.
Never raises -- per this module's reliability contract, ranking never
waits on an explanation, so this must never raise into that path. But it
no longer collapses every failure into the same None: reason names
which of three unrelated causes produced no text -- an unknown user_id
(the caller's argument is wrong), the chat backend being unreachable
(retry later), or the model answering with nothing usable (also worth a
retry, but a different failure) -- so a caller does not have to
re-derive the distinction _explain_item used to reconstruct by hand.
explanation_messages(profile, title, summary)
The ONE renderer of the explanation prompt.
generate_explanation and evals/explanation_eval.py both call this, so
a score is always a score of the prompt feed.explain actually sends.
profile_synthesis_messages(titles)
The ONE renderer of the profile-synthesis fallback prompt.
synthesize_profile calls this when a persona has clicks but no stored
interests text -- see its docstring for why that stays the fallback
rather than the default. Giving it a renderer is what makes it scoreable
the way the other three prompts already are (no corpus or optimizer is
added by this change; the renderer only makes one possible).