Skip to content

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).