Making retrieval, quality grading, and tool decisions visible — not just to developers, but to users during every conversation turn.
Prometheus is not a generic LLM used for grading — it is an evaluator model purpose-built for explainability. Its design principles map directly onto the XAI gaps in LoCAL2:
| Prometheus capability | How LoCAL2 uses it | XAI gap (current) |
|---|---|---|
| Fine-grained natural language feedback — explains why a score was given in prose, not just a number | CriticAgent._grade() extracts the feedback text from Prometheus output and stores it in CritiqueResult.feedback |
Feedback is set as a tooltip on a 16px label — invisible unless hovered, truncated even then |
| Rubric-driven evaluation — the rubric is the evaluation contract; feedback is always written against it | Rubric is in config/critic.yaml; injected into the Prometheus prompt at grading time |
Rubric never shown to users; feedback references criteria the user hasn't seen |
| Controllability — open-weight, runs locally, no version drift or data privacy concerns | Prometheus runs via Ollama as prometheus-7b:latest — fully local |
✓ Already realized — no action needed |
| Customizable rubrics — domain-specific criteria beyond generic helpfulness (toxicity, hallucination, compliance) | One generic rubric in critic.yaml |
Future: rubric presets selectable per session (see Future Extensions) |
The central insight: the Prometheus feedback text is already the XAI explanation — a per-response natural language narrative that Prometheus generates every turn. It is being produced and then buried. Phase 18f surfaces it as a first-class artifact.
LoCAL2 already has strong process transparency (state machine transitions, thinking tokens, tool activity panels). The gaps are in connecting those artifacts to each other and to the final answer.
| Gap | Current state | Addressed by |
|---|---|---|
| Retrieval attribution Which engrams/chunks influenced this answer? |
Source IDs exist in ChromaDB results; stripped to plain text before tool result is returned to Gemma. No link from answer back to sources. | 18d |
| Retrieval score visibility Why did these 5 engrams rank first? |
MemoryWindow Browse shows critic score but no similarity distance. Search mode returns ranked results with no scores shown. | 18a |
| Rubric disclosure What does 3/5 mean? |
Prometheus rubric lives in config/critic.yaml only. Users see a number with no grading criteria. |
18b |
| Tool decision trace Why was web_search called? What was the call chain? |
Tool activity panels show each tool's log. Main window doesn't show the within-turn call sequence inline with the response card. | 18c |
| Groundedness signal Did Gemma use retrieved sources or answer from training knowledge? |
No distinction. A grounded response and a hallucinated one look identical in the UI. | 18e |
| Prometheus feedback buried Prometheus generates a natural language explanation for every score — the "why" behind 3/5. |
CritiqueResult.feedback is populated every turn but rendered only as a hover tooltip on the score label. Never visible without deliberate hover; truncated even then. |
18f |
MemoryService.search_episodic() already returns a composite score per result (cosine similarity + entity overlap + critic weight). The MemoryWindow Search mode discards it — only content and metadata are displayed.
ui/memory_window.py — add "Score" column to _EPISODIC_COLS (between "Age" and "Query"). Format as "0.87" (2 decimal places)._populate_episodic() — read row_data.get("score") and include it in the per-row values list. It is already present in every dict returned by search_episodic."—".search_episodic, not in the table)The absolute grading rubric — what distinguishes a 1 from a 3 from a 5 — is buried in config/critic.yaml. Users see a numeric badge with no grading criteria. This undermines trust in the score, because the Prometheus feedback (Phase 18f) references criteria the user hasn't seen.
The rubric is the evaluation contract: Prometheus feedback is always written against it. Disclosing the rubric completes the XAI chain — users can read the score, read the feedback, and then read the rubric to understand exactly what criteria were applied.
ui/critic_window.py — add a third stack page (alongside the existing activity log and settings pages) that shows the rubric text. CriticWindow inherits BaseObservabilityWindow's two-page stack; extend to three pages.config/critic.yaml, reads the rubric key, displays as a styled QTextEdit (read-only, monospace).BaseObservabilityWindow may need a small refactor to support a third page, or CriticWindow can manage the extra page itself since it's critic-specific.The rubric in critic.yaml is a multi-line string already formatted for human reading (Prometheus-style). Just render it as-is — no parsing needed.
Thinking tokens show Gemma's reasoning. The answer shows the result. But the tool calls that happened between — Gemma decided to call search_memory, then web_search — are only visible in the side panels. The main response card should show the step chain.
[10:42:12] RESPONSE ✓
◈ thinking ▼
[collapsed thinking block]
→ search_memory "previous discussion about flux"
→ web_search "flux capacitor timeline 2024"
→ web_fetch "https://example.com/flux"
[answer text here]
● 4/5 👍 👎
ui/main_window.py — StreamingResponseWidget:
QVBoxLayout container (between thinking block and answer) called self._trace_layout.add_tool_step(tool_name: str, preview: str) appends a dim label row: "→ tool_name <preview>". Preview is the first arg truncated to 60 chars.ui/main_window.py — MainWindow:
tool.activity.* subjects (already done via _TOOL_ACTIVITY_SUBJECTS)."request": look up the pending response widget by correlation_id and call add_tool_step(tool_name, query_preview).correlation_id on tool activity events matches the original query_id (confirmed: GeneratorAgent threads it through all tool calls).self._finalized flag.
This is the core XAI gap. When Gemma uses search_memory or search_library, the retrieved snippets are passed as text in the tool result — but which specific engrams or document chunks were fetched is lost before the answer is rendered. The user cannot tell whether Gemma cited two engrams from last week or three chunks from a PDF they uploaded.
In tools/search_memory_tool.py, _search() iterates candidates (which already carries id, score, content, metadata). Before returning the text string, collect source metadata and pass it to _publish_activity:
sources = [
{
"id": c["id"],
"score": round(c["score"], 3),
"snippet": c["content"][:80],
"query": c["metadata"].get("query", ""),
}
for c in candidates
]
self._publish_activity("result", {"result": result, "sources": sources}, correlation_id)
In tools/search_library_tool.py, _search() iterates hits (already has source_file, chunk_index, page, content). Collect and emit:
sources = [
{
"source_file": h["source_file"],
"chunk_index": h.get("chunk_index"),
"page": h.get("page"),
"snippet": h["content"].strip()[:80],
}
for h in hits
]
self._publish_activity("result", {"result": result, "sources": sources}, correlation_id)
Add self._pending_sources: dict[str, list[dict]] = {}. In the tool activity handler, on event "result" for search tools, merge incoming sources into self._pending_sources[correlation_id].
After the response widget is finalized, if query_id is in self._pending_sources, call widget.set_sources(sources) and remove from the pending dict to avoid leaking.
Add a collapsible "Sources" section at the bottom of the response card, below the answer and above the score/thumbs row.
▼ 3 sources
┌ memory: "What is the capital of…" (score 0.87, 2d ago)
├ memory: "Discussed flux capacitor…" (score 0.71, 1h ago)
└ library: research-paper.pdf p.4, chunk 12
The sources strip is collapsed by default; a "▼ N sources" toggle expands it. Each source row is a label — memory rows show query preview + age, library rows show filename + page.
ToolActivity.to_envelope() spreads self.data as **self.data into the payload dict. The sources list is a JSON-serializable structure (list of dicts with str/int/float values). No protocol changes needed — ToolActivity already accepts arbitrary data.
Even with the sources strip (18d), a user glancing at the conversation log can't immediately tell whether a response drew on retrieved evidence or pure training knowledge. A groundedness indicator makes this scannable at a glance.
A small badge in the response card header row, next to the timestamp:
[10:42:12] RESPONSE ✓ ⊙ grounded ← retrieved sources used
[10:44:05] RESPONSE ✓ ○ knowledge ← answered from training only
Three groundedness levels, tracked by which tools fired:
If both web and memory tools fire, show the highest-confidence indicator (web > grounded).
StreamingResponseWidget: add a small QLabel self._ground_label in the header row. Initially hidden. Method set_groundedness(level: str) sets text and color.MainWindow: add self._pending_ground: dict[str, set[str]] = {}. In the tool activity handler on event "request", add the tool name to the set for that correlation_id._on_response, look up the tool set, derive groundedness level, call widget.set_groundedness(level).Every response already receives a paragraph of natural language feedback from Prometheus explaining why it earned its score. This is the primary XAI artifact the system produces. It is currently set as a tooltip on the score label — hover-only, invisible by default, truncated to a single line.
The fix mirrors the thinking token pattern already in StreamingResponseWidget: a collapsible toggle that expands to show the full feedback text below the score badge.
[10:42:12] RESPONSE ✓ ⊙ grounded
◈ thinking ▼
[collapsed]
→ search_memory "previous discussion…"
[answer text]
▼ sources
● 4/5 ◈ Prometheus ▼ ← new toggle, replaces bare label
The response correctly identifies the core mechanism and provides
a clear explanation. Minor gaps: it doesn't address edge cases
mentioned in the rubric's Score 5 criteria.
👍 👎
ui/main_window.py — StreamingResponseWidget:
self._score_label (plain QLabel) with a QPushButton styled as a toggle — same pattern as self._toggle_btn for thinking.self._feedback_box: a QTextEdit (read-only, max height ~120px, initially hidden) below the score button.set_score(score, feedback): set button text to f"● {score}/5 ◈ Prometheus ▼"; store feedback text; connect button click to toggle self._feedback_box visibility.setToolTip(feedback) call — tooltip is superseded by the expandable block.CritiqueResult.feedback is already delivered to MainWindow._on_critique() and passed to widget.set_score().● 4/5 ◈ Prometheus ▼score=None, the toggle is absent (no feedback to show)These ideas are valid extensions of the Prometheus XAI capability but are deferred to avoid scope creep:
config/critic.yaml gains a rubric_presets map.critic_comparison tool (the infrastructure exists as CriticAgentTool, removed in Phase 5 when RespondentB was cut). With single-respondent architecture, pairwise comparison would evaluate two user-supplied alternatives rather than two system respondents. Prometheus provides a reasoned ranking with explanation — the XAI output, not just a winner flag.| Phase | Est. | Dependencies | Risk |
|---|---|---|---|
| 18a — Score column in MemoryWindow | 30m | None | None — read-only UI change |
| 18b — Rubric disclosure in CriticWindow | 1h | None | Low — BaseObservabilityWindow stack extension |
| 18c — Tool call trace in response card | 2h | None (uses existing tool.activity subscription) | Medium — race between activity and response events; handle with finalized flag |
| 18d — Retrieval attribution strip | 3h | None protocol changes needed; ToolActivity already supports arbitrary data | Medium — need to verify correlation_id threading end-to-end |
| 18e — Groundedness indicator | 1h | 18c groundwork (pending_ground dict is similar to trace logic) | Low — purely additive to response widget |
| 18f — Prometheus feedback expansion | 1h | None — feedback already delivered to widget via set_score() | Low — UI-only; score label → toggle button + QTextEdit |
Recommended order: 18f → 18b → 18a → 18e → 18c → 18d.
pending_ground / tool activity dict pattern; do in sequence.tool.activity.* data (enriched) and existing event subscriptions. The bus is the coordination mechanism, not a new XAI channel.ToolActivity.to_envelope() spreads self.data into the payload. Adding sources to the data dict requires no protocol changes.query_id == correlation_id for top-level queries (set in MainWindow at send time). GeneratorAgent threads it through tool calls. Verified in tool_dispatcher.py.Plan written 2026-06-14, updated 2026-06-14 (Prometheus XAI framing + 18f) · LoCAL2 v0.2.4 · Next phase after 17 (packaging)