trustgraph/tests/unit/test_gateway/test_explain_triples.py
cybermaggedon d2751553a3
Add agent explainability instrumentation and unify envelope field naming (#795)
Addresses recommendations from the UX developer's agent experience report.
Adds provenance predicates, DAG structure changes, error resilience, and
a published OWL ontology.

Explainability additions:

- Tool candidates: tg:toolCandidate on Analysis events lists the tools
  visible to the LLM for each iteration (names only, descriptions in config)
- Termination reason: tg:terminationReason on Conclusion/Synthesis events
  (final-answer, plan-complete, subagents-complete)
- Step counter: tg:stepNumber on iteration events
- Pattern decision: new tg:PatternDecision entity in the DAG between
  session and first iteration, carrying tg:pattern and tg:taskType
- Latency: tg:llmDurationMs on Analysis events, tg:toolDurationMs on
  Observation events
- Token counts on events: tg:inToken/tg:outToken/tg:llmModel on
  Grounding, Focus, Synthesis, and Analysis events
- Tool/parse errors: tg:toolError on Observation events with tg:Error
  mixin type. Parse failures return as error observations instead of
  crashing the agent, giving it a chance to retry.

Envelope unification:

- Rename chunk_type to message_type across AgentResponse schema,
  translator, SDK types, socket clients, CLI, and all tests.
  Agent and RAG services now both use message_type on the wire.

Ontology:

- specs/ontology/trustgraph.ttl — OWL vocabulary covering all 26 classes,
  7 object properties, and 36+ datatype properties including new predicates.

DAG structure tests:

- tests/unit/test_provenance/test_dag_structure.py verifies the
  wasDerivedFrom chain for GraphRAG, DocumentRAG, and all three agent
  patterns (react, plan, supervisor) including the pattern-decision link.
2026-04-13 16:16:42 +01:00

359 lines
11 KiB
Python

"""
Tests for inline explainability triples in response translators
and ProvenanceEvent parsing.
"""
import pytest
from trustgraph.schema import (
GraphRagResponse, DocumentRagResponse, AgentResponse,
Term, Triple, IRI, LITERAL, Error,
)
from trustgraph.messaging.translators.retrieval import (
GraphRagResponseTranslator,
DocumentRagResponseTranslator,
)
from trustgraph.messaging.translators.agent import (
AgentResponseTranslator,
)
from trustgraph.api.types import ProvenanceEvent
# --- Helpers ---
def make_triple(s_iri, p_iri, o_value, o_type=LITERAL):
"""Create a Triple with IRI subject/predicate and typed object."""
o = Term(type=IRI, iri=o_value) if o_type == IRI else Term(type=LITERAL, value=o_value)
return Triple(
s=Term(type=IRI, iri=s_iri),
p=Term(type=IRI, iri=p_iri),
o=o,
)
def sample_triples():
"""A few provenance triples for a question entity."""
return [
make_triple(
"urn:trustgraph:question:abc123",
"http://www.w3.org/1999/02/22-rdf-syntax-ns#type",
"https://trustgraph.ai/ns/GraphRagQuestion",
o_type=IRI,
),
make_triple(
"urn:trustgraph:question:abc123",
"https://trustgraph.ai/ns/query",
"What is the internet?",
),
make_triple(
"urn:trustgraph:question:abc123",
"http://www.w3.org/ns/prov#startedAtTime",
"2026-04-07T09:00:00Z",
),
]
# --- GraphRag Translator ---
class TestGraphRagExplainTriples:
def test_explain_triples_encoded(self):
translator = GraphRagResponseTranslator()
triples = sample_triples()
response = GraphRagResponse(
message_type="explain",
explain_id="urn:trustgraph:question:abc123",
explain_graph="urn:graph:retrieval",
explain_triples=triples,
)
result = translator.encode(response)
assert "explain_triples" in result
assert len(result["explain_triples"]) == 3
# Check first triple is properly encoded
t = result["explain_triples"][0]
assert t["s"]["t"] == "i"
assert t["s"]["i"] == "urn:trustgraph:question:abc123"
assert t["p"]["t"] == "i"
def test_explain_triples_empty_not_included(self):
translator = GraphRagResponseTranslator()
response = GraphRagResponse(
message_type="chunk",
response="Some answer text",
)
result = translator.encode(response)
assert "explain_triples" not in result
def test_explain_with_completion_returns_not_final(self):
translator = GraphRagResponseTranslator()
response = GraphRagResponse(
message_type="explain",
explain_id="urn:trustgraph:question:abc123",
explain_triples=sample_triples(),
end_of_session=False,
)
result, is_final = translator.encode_with_completion(response)
assert is_final is False
def test_explain_id_and_graph_included(self):
translator = GraphRagResponseTranslator()
response = GraphRagResponse(
message_type="explain",
explain_id="urn:trustgraph:question:abc123",
explain_graph="urn:graph:retrieval",
explain_triples=sample_triples(),
)
result = translator.encode(response)
assert result["explain_id"] == "urn:trustgraph:question:abc123"
assert result["explain_graph"] == "urn:graph:retrieval"
# --- DocumentRag Translator ---
class TestDocumentRagExplainTriples:
def test_explain_triples_encoded(self):
translator = DocumentRagResponseTranslator()
response = DocumentRagResponse(
response=None,
message_type="explain",
explain_id="urn:trustgraph:docrag:abc123",
explain_graph="urn:graph:retrieval",
explain_triples=sample_triples(),
)
result = translator.encode(response)
assert "explain_triples" in result
assert len(result["explain_triples"]) == 3
def test_explain_triples_empty_not_included(self):
translator = DocumentRagResponseTranslator()
response = DocumentRagResponse(
response="Answer text",
message_type="chunk",
)
result = translator.encode(response)
assert "explain_triples" not in result
# --- Agent Translator ---
class TestAgentExplainTriples:
def test_explain_triples_encoded(self):
translator = AgentResponseTranslator()
response = AgentResponse(
message_type="explain",
content="",
explain_id="urn:trustgraph:agent:session:abc123",
explain_graph="urn:graph:retrieval",
explain_triples=sample_triples(),
)
result = translator.encode(response)
assert "explain_triples" in result
assert len(result["explain_triples"]) == 3
t = result["explain_triples"][1]
assert t["p"]["i"] == "https://trustgraph.ai/ns/query"
assert t["o"]["t"] == "l"
assert t["o"]["v"] == "What is the internet?"
def test_explain_triples_empty_not_included(self):
translator = AgentResponseTranslator()
response = AgentResponse(
message_type="thought",
content="I need to think...",
)
result = translator.encode(response)
assert "explain_triples" not in result
def test_explain_with_completion_not_final(self):
translator = AgentResponseTranslator()
response = AgentResponse(
message_type="explain",
explain_id="urn:trustgraph:agent:session:abc123",
explain_triples=sample_triples(),
end_of_dialog=False,
)
result, is_final = translator.encode_with_completion(response)
assert is_final is False
def test_explain_with_completion_final(self):
translator = AgentResponseTranslator()
response = AgentResponse(
message_type="answer",
content="The answer is...",
end_of_dialog=True,
)
result, is_final = translator.encode_with_completion(response)
assert is_final is True
# --- ProvenanceEvent ---
class TestProvenanceEvent:
def test_question_event_type(self):
event = ProvenanceEvent(
explain_id="urn:trustgraph:question:abc123",
)
assert event.event_type == "question"
def test_exploration_event_type(self):
event = ProvenanceEvent(
explain_id="urn:trustgraph:exploration:abc123",
)
assert event.event_type == "exploration"
def test_focus_event_type(self):
event = ProvenanceEvent(
explain_id="urn:trustgraph:focus:abc123",
)
assert event.event_type == "focus"
def test_synthesis_event_type(self):
event = ProvenanceEvent(
explain_id="urn:trustgraph:synthesis:abc123",
)
assert event.event_type == "synthesis"
def test_grounding_event_type(self):
event = ProvenanceEvent(
explain_id="urn:trustgraph:grounding:abc123",
)
assert event.event_type == "grounding"
def test_session_event_type(self):
event = ProvenanceEvent(
explain_id="urn:trustgraph:agent:session:abc123",
)
assert event.event_type == "session"
def test_iteration_event_type(self):
event = ProvenanceEvent(
explain_id="urn:trustgraph:agent:iteration:abc123:1",
)
assert event.event_type == "iteration"
def test_observation_event_type(self):
event = ProvenanceEvent(
explain_id="urn:trustgraph:agent:observation:abc123:1",
)
assert event.event_type == "observation"
def test_conclusion_event_type(self):
event = ProvenanceEvent(
explain_id="urn:trustgraph:agent:conclusion:abc123",
)
assert event.event_type == "conclusion"
def test_decomposition_event_type(self):
event = ProvenanceEvent(
explain_id="urn:trustgraph:agent:decomposition:abc123",
)
assert event.event_type == "decomposition"
def test_finding_event_type(self):
event = ProvenanceEvent(
explain_id="urn:trustgraph:agent:finding:abc123:0",
)
assert event.event_type == "finding"
def test_plan_event_type(self):
event = ProvenanceEvent(
explain_id="urn:trustgraph:agent:plan:abc123",
)
assert event.event_type == "plan"
def test_step_result_event_type(self):
event = ProvenanceEvent(
explain_id="urn:trustgraph:agent:step-result:abc123:0",
)
assert event.event_type == "step-result"
def test_defaults(self):
event = ProvenanceEvent(
explain_id="urn:trustgraph:question:abc123",
)
assert event.entity is None
assert event.triples == []
assert event.explain_graph == ""
def test_with_triples(self):
raw = [{"s": {"t": "i", "i": "urn:x"}, "p": {"t": "i", "i": "urn:y"}, "o": {"t": "l", "v": "z"}}]
event = ProvenanceEvent(
explain_id="urn:trustgraph:question:abc123",
triples=raw,
)
assert len(event.triples) == 1
# --- Build ProvenanceEvent with entity parsing ---
class TestBuildProvenanceEvent:
def _make_client(self):
"""Create a minimal WebSocketClient-like object with _build_provenance_event."""
from trustgraph.api.socket_client import WebSocketClient
# We can't instantiate WebSocketClient easily, so test the method logic directly
return None
def test_entity_parsed_from_wire_triples(self):
"""Test that wire-format triples are parsed into an ExplainEntity."""
from trustgraph.api.explainability import ExplainEntity
wire_triples = [
{
"s": {"t": "i", "i": "urn:trustgraph:question:abc123"},
"p": {"t": "i", "i": "http://www.w3.org/1999/02/22-rdf-syntax-ns#type"},
"o": {"t": "i", "i": "https://trustgraph.ai/ns/GraphRagQuestion"},
},
{
"s": {"t": "i", "i": "urn:trustgraph:question:abc123"},
"p": {"t": "i", "i": "https://trustgraph.ai/ns/query"},
"o": {"t": "l", "v": "What is the internet?"},
},
]
# Parse triples the same way _build_provenance_event does
parsed = []
for t in wire_triples:
s = t.get("s", {}).get("i", "")
p = t.get("p", {}).get("i", "")
o_term = t.get("o", {})
if o_term.get("t") == "i":
o = o_term.get("i", "")
else:
o = o_term.get("v", "")
parsed.append((s, p, o))
entity = ExplainEntity.from_triples(
"urn:trustgraph:question:abc123", parsed
)
assert entity.entity_type == "question"
assert entity.query == "What is the internet?"
assert entity.question_type == "graph-rag"