LoCAL2 Phase 18 — Explainable AI (XAI)

Making retrieval, quality grading, and tool decisions visible — not just to developers, but to users during every conversation turn.

Prometheus as the XAI Engine

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.

Gap Inventory

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

Phase 18a — Retrieval Score Column in MemoryWindow

trivial Add composite similarity score to Search results table ~30 min

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.

What changes

Data flow (unchanged)

search_episodic() → [{id, content, metadata, score}] _populate_episodic(rows) Score column in table
Acceptance

Phase 18b — Rubric Disclosure in CriticWindow

small Surface the Prometheus grading rubric in the UI ~1 hr

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.

What changes

Design note

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.

Acceptance

Phase 18c — Inline Tool Call Trace in Response Card

medium Show tool call chain inline between thinking and answer ~2 hrs

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.

Visual target

[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  👍 👎

What changes

Data flow

tool.activity.search_memory {event:"request", query:"...", correlation_id} MainWindow looks up self._pending[correlation_id] widget.add_tool_step("search_memory", "...")
Note: Some tool activities may arrive after the response (race condition in async processing). Guard against adding steps to a finalized widget by checking self._finalized flag.
Acceptance

Phase 18d — Retrieval Attribution Strip

core Link each response back to the specific engrams/chunks it drew from ~3 hrs

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.

What changes

1. SearchMemoryTool — emit source IDs in activity

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)

2. SearchLibraryTool — emit source refs in activity

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)

3. MainWindow — accumulate sources per turn

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

4. MainWindow._on_response — inject sources into widget

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.

5. StreamingResponseWidget — sources strip

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.

Data flow

tool.activity.search_memory {event:"result", result:"…", sources:[{id,score,snippet}]} MainWindow accumulates in _pending_sources[correlation_id] response.generation arrives widget.set_sources(sources) Sources strip renders
ToolActivity wire format: 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.
Acceptance

Phase 18e — Groundedness Indicator

lightweight Show whether the answer is grounded in retrieved sources ~1 hr

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.

What changes

Indicator design

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

Implementation

Why separate from 18d: Groundedness is a scannable summary — visible even when the sources strip is collapsed. It answers "should I trust this?" at a glance. The sources strip answers "what exactly was used?" on demand. They serve different user needs.
Acceptance

Phase 18f — Prometheus Feedback Expansion in Response Card

small Surface Prometheus natural language feedback as a collapsible block per response ~1 hr

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.

Visual target

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

👍 👎

What changes

Data flow (unchanged — feedback already arrives)

Prometheus → CriticAgent._grade() → CritiqueResult.feedback critique.result bus event MainWindow._on_critique() widget.set_score(score, feedback) _feedback_box (collapsible)
Why this matters: The XAI chain is: rubric (what was evaluated) → feedback (what Prometheus said about this response) → score (the summary judgment). Phase 18b exposes the rubric; 18f exposes the feedback. Together they give users a complete, auditable quality narrative per response.
Acceptance

Future Extensions (out of scope for Phase 18)

These ideas are valid extensions of the Prometheus XAI capability but are deferred to avoid scope creep:

Implementation Order & Dependencies

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.

Architecture Invariants

Plan written 2026-06-14, updated 2026-06-14 (Prometheus XAI framing + 18f) · LoCAL2 v0.2.4 · Next phase after 17 (packaging)