diff --git a/tests/unit/test_provenance/test_explainability.py b/tests/unit/test_provenance/test_explainability.py index a0a0d566..62498c61 100644 --- a/tests/unit/test_provenance/test_explainability.py +++ b/tests/unit/test_provenance/test_explainability.py @@ -142,7 +142,7 @@ class TestExplainEntityFromTriples: ] entity = ExplainEntity.from_triples("urn:syn:1", triples) assert isinstance(entity, Synthesis) - assert entity.document_uri == "urn:doc:answer-1" + assert entity.document == "urn:doc:answer-1" def test_synthesis_no_document(self): triples = [ @@ -150,7 +150,7 @@ class TestExplainEntityFromTriples: ] entity = ExplainEntity.from_triples("urn:syn:2", triples) assert isinstance(entity, Synthesis) - assert entity.document_uri == "" + assert entity.document == "" def test_reflection_thought(self): triples = [ @@ -161,7 +161,7 @@ class TestExplainEntityFromTriples: entity = ExplainEntity.from_triples("urn:ref:1", triples) assert isinstance(entity, Reflection) assert entity.reflection_type == "thought" - assert entity.document_uri == "urn:doc:thought-1" + assert entity.document == "urn:doc:thought-1" def test_reflection_observation(self): triples = [ @@ -172,7 +172,7 @@ class TestExplainEntityFromTriples: entity = ExplainEntity.from_triples("urn:ref:2", triples) assert isinstance(entity, Reflection) assert entity.reflection_type == "observation" - assert entity.document_uri == "urn:doc:obs-1" + assert entity.document == "urn:doc:obs-1" def test_analysis(self): triples = [ @@ -186,8 +186,8 @@ class TestExplainEntityFromTriples: assert isinstance(entity, Analysis) assert entity.action == "graph-rag-query" assert entity.arguments == '{"query": "test"}' - assert entity.thought_uri == "urn:ref:thought-1" - assert entity.observation_uri == "urn:ref:obs-1" + assert entity.thought == "urn:ref:thought-1" + assert entity.observation == "urn:ref:obs-1" def test_conclusion_with_document(self): triples = [ @@ -196,7 +196,7 @@ class TestExplainEntityFromTriples: ] entity = ExplainEntity.from_triples("urn:conc:1", triples) assert isinstance(entity, Conclusion) - assert entity.document_uri == "urn:doc:final" + assert entity.document == "urn:doc:final" def test_conclusion_no_document(self): triples = [ @@ -204,7 +204,7 @@ class TestExplainEntityFromTriples: ] entity = ExplainEntity.from_triples("urn:conc:2", triples) assert isinstance(entity, Conclusion) - assert entity.document_uri == "" + assert entity.document == "" def test_unknown_type(self): triples = [ diff --git a/trustgraph-base/trustgraph/api/explainability.py b/trustgraph-base/trustgraph/api/explainability.py index 26fb77fd..1c986efb 100644 --- a/trustgraph-base/trustgraph/api/explainability.py +++ b/trustgraph-base/trustgraph/api/explainability.py @@ -212,32 +212,32 @@ class Focus(ExplainEntity): @dataclass class Synthesis(ExplainEntity): """Synthesis entity - the final answer.""" - document_uri: str = "" # Reference to librarian document + document: str = "" @classmethod def from_triples(cls, uri: str, triples: List[Tuple[str, str, Any]]) -> "Synthesis": - document_uri = "" + document = "" for s, p, o in triples: if p == TG_DOCUMENT: - document_uri = o + document = o return cls( uri=uri, entity_type="synthesis", - document_uri=document_uri + document=document ) @dataclass class Reflection(ExplainEntity): """Reflection entity - intermediate commentary (Thought or Observation).""" - document_uri: str = "" # Reference to content in librarian + document: str = "" reflection_type: str = "" # "thought" or "observation" @classmethod def from_triples(cls, uri: str, triples: List[Tuple[str, str, Any]]) -> "Reflection": - document_uri = "" + document = "" reflection_type = "" types = [o for s, p, o in triples if p == RDF_TYPE] @@ -249,12 +249,12 @@ class Reflection(ExplainEntity): for s, p, o in triples: if p == TG_DOCUMENT: - document_uri = o + document = o return cls( uri=uri, entity_type="reflection", - document_uri=document_uri, + document=document, reflection_type=reflection_type ) @@ -264,15 +264,15 @@ class Analysis(ExplainEntity): """Analysis entity - one think/act/observe cycle (Agent only).""" action: str = "" arguments: str = "" # JSON string - thought_uri: str = "" # URI of thought sub-entity - observation_uri: str = "" # URI of observation sub-entity + thought: str = "" + observation: str = "" @classmethod def from_triples(cls, uri: str, triples: List[Tuple[str, str, Any]]) -> "Analysis": action = "" arguments = "" - thought_uri = "" - observation_uri = "" + thought = "" + observation = "" for s, p, o in triples: if p == TG_ACTION: @@ -280,37 +280,37 @@ class Analysis(ExplainEntity): elif p == TG_ARGUMENTS: arguments = o elif p == TG_THOUGHT: - thought_uri = o + thought = o elif p == TG_OBSERVATION: - observation_uri = o + observation = o return cls( uri=uri, entity_type="analysis", action=action, arguments=arguments, - thought_uri=thought_uri, - observation_uri=observation_uri + thought=thought, + observation=observation ) @dataclass class Conclusion(ExplainEntity): """Conclusion entity - final answer (Agent only).""" - document_uri: str = "" # Reference to librarian document + document: str = "" @classmethod def from_triples(cls, uri: str, triples: List[Tuple[str, str, Any]]) -> "Conclusion": - document_uri = "" + document = "" for s, p, o in triples: if p == TG_DOCUMENT: - document_uri = o + document = o return cls( uri=uri, entity_type="conclusion", - document_uri=document_uri + document=document ) @@ -782,8 +782,8 @@ class ExplainabilityClient: """ Fetch the complete DocumentRAG trace starting from a question URI. - Follows the provenance chain: Question -> Exploration -> Synthesis - (No Focus step for DocRAG since it doesn't do edge selection) + Follows the provenance chain: + Question -> Grounding -> Exploration -> Synthesis Args: question_uri: The question entity URI @@ -794,13 +794,14 @@ class ExplainabilityClient: max_content: Maximum content length for synthesis Returns: - Dict with question, exploration, synthesis entities + Dict with question, grounding, exploration, synthesis entities """ if graph is None: graph = "urn:graph:retrieval" trace = { "question": None, + "grounding": None, "exploration": None, "synthesis": None, } @@ -811,8 +812,8 @@ class ExplainabilityClient: return trace trace["question"] = question - # Find exploration: ?exploration prov:wasGeneratedBy question_uri - exploration_triples = self.flow.triples_query( + # Find grounding: ?grounding prov:wasGeneratedBy question_uri + grounding_triples = self.flow.triples_query( p=PROV_WAS_GENERATED_BY, o=question_uri, g=graph, @@ -821,6 +822,30 @@ class ExplainabilityClient: limit=10 ) + if grounding_triples: + grounding_uris = [ + extract_term_value(t.get("s", {})) + for t in grounding_triples + ] + for gnd_uri in grounding_uris: + grounding = self.fetch_entity(gnd_uri, graph, user, collection) + if isinstance(grounding, Grounding): + trace["grounding"] = grounding + break + + if not trace["grounding"]: + return trace + + # Find exploration: ?exploration prov:wasDerivedFrom grounding_uri + exploration_triples = self.flow.triples_query( + p=PROV_WAS_DERIVED_FROM, + o=trace["grounding"].uri, + g=graph, + user=user, + collection=collection, + limit=10 + ) + if exploration_triples: exploration_uris = [ extract_term_value(t.get("s", {})) @@ -836,7 +861,6 @@ class ExplainabilityClient: return trace # Find synthesis: ?synthesis prov:wasDerivedFrom exploration_uri - # (DocRAG goes directly from exploration to synthesis, no focus step) synthesis_triples = self.flow.triples_query( p=PROV_WAS_DERIVED_FROM, o=trace["exploration"].uri, diff --git a/trustgraph-cli/trustgraph/cli/invoke_agent.py b/trustgraph-cli/trustgraph/cli/invoke_agent.py index dedb2f34..9879025f 100644 --- a/trustgraph-cli/trustgraph/cli/invoke_agent.py +++ b/trustgraph-cli/trustgraph/cli/invoke_agent.py @@ -204,15 +204,15 @@ def question_explainable( print(f"\n [iteration] {prov_id}", file=sys.stderr) if entity.action: print(f" Action: {entity.action}", file=sys.stderr) - if entity.thought_uri: - print(f" Thought: {entity.thought_uri}", file=sys.stderr) - if entity.observation_uri: - print(f" Observation: {entity.observation_uri}", file=sys.stderr) + if entity.thought: + print(f" Thought: {entity.thought}", file=sys.stderr) + if entity.observation: + print(f" Observation: {entity.observation}", file=sys.stderr) elif isinstance(entity, Conclusion): print(f"\n [conclusion] {prov_id}", file=sys.stderr) - if entity.document_uri: - print(f" Document: {entity.document_uri}", file=sys.stderr) + if entity.document: + print(f" Document: {entity.document}", file=sys.stderr) else: if debug: diff --git a/trustgraph-cli/trustgraph/cli/invoke_document_rag.py b/trustgraph-cli/trustgraph/cli/invoke_document_rag.py index 381b6924..7da9d779 100644 --- a/trustgraph-cli/trustgraph/cli/invoke_document_rag.py +++ b/trustgraph-cli/trustgraph/cli/invoke_document_rag.py @@ -82,8 +82,8 @@ def question_explainable( elif isinstance(entity, Synthesis): print(f"\n [synthesis] {prov_id}", file=sys.stderr) - if entity.document_uri: - print(f" Document: {entity.document_uri}", file=sys.stderr) + if entity.document: + print(f" Document: {entity.document}", file=sys.stderr) else: if debug: diff --git a/trustgraph-cli/trustgraph/cli/invoke_graph_rag.py b/trustgraph-cli/trustgraph/cli/invoke_graph_rag.py index 870576c3..1e530c03 100644 --- a/trustgraph-cli/trustgraph/cli/invoke_graph_rag.py +++ b/trustgraph-cli/trustgraph/cli/invoke_graph_rag.py @@ -728,8 +728,8 @@ def _question_explainable_api( elif isinstance(entity, Synthesis): print(f"\n [synthesis] {prov_id}", file=sys.stderr) - if entity.document_uri: - print(f" Document: {entity.document_uri}", file=sys.stderr) + if entity.document: + print(f" Document: {entity.document}", file=sys.stderr) else: if debug: diff --git a/trustgraph-cli/trustgraph/cli/show_explain_trace.py b/trustgraph-cli/trustgraph/cli/show_explain_trace.py index d7392b99..ba4f9c25 100644 --- a/trustgraph-cli/trustgraph/cli/show_explain_trace.py +++ b/trustgraph-cli/trustgraph/cli/show_explain_trace.py @@ -162,7 +162,7 @@ def format_provenance_chain(chain): return " -> ".join(labels) -def print_graphrag_text(trace, explain_client, flow, user, collection, show_provenance=False): +def print_graphrag_text(trace, explain_client, flow, user, collection, api=None, show_provenance=False): """Print GraphRAG trace in text format.""" question = trace.get("question") @@ -224,17 +224,24 @@ def print_graphrag_text(trace, explain_client, flow, user, collection, show_prov print("--- Synthesis ---") synthesis = trace.get("synthesis") if synthesis: - if synthesis.content: + content = "" + if synthesis.document and api: + content = explain_client.fetch_document_content( + synthesis.document, api, user + ) + if content: print("Answer:") - for line in synthesis.content.split("\n"): + for line in content.split("\n"): print(f" {line}") + elif synthesis.document: + print(f"Document: {synthesis.document}") else: print("No answer content found") else: print("No synthesis data found") -def print_docrag_text(trace): +def print_docrag_text(trace, explain_client, api, user): """Print DocRAG trace in text format.""" question = trace.get("question") @@ -247,6 +254,13 @@ def print_docrag_text(trace): print(f"Time: {question.timestamp}") print() + # Grounding + grounding = trace.get("grounding") + if grounding: + print("--- Grounding ---") + print(f"Concepts: {', '.join(grounding.concepts)}") + print() + # Exploration print("--- Exploration ---") exploration = trace.get("exploration") @@ -260,17 +274,24 @@ def print_docrag_text(trace): print("--- Synthesis ---") synthesis = trace.get("synthesis") if synthesis: - if synthesis.content: + content = "" + if synthesis.document and api: + content = explain_client.fetch_document_content( + synthesis.document, api, user + ) + if content: print("Answer:") - for line in synthesis.content.split("\n"): + for line in content.split("\n"): print(f" {line}") + elif synthesis.document: + print(f"Document: {synthesis.document}") else: print("No answer content found") else: print("No synthesis data found") -def print_agent_text(trace): +def print_agent_text(trace, explain_client, api, user): """Print Agent trace in text format.""" question = trace.get("question") @@ -292,6 +313,7 @@ def print_agent_text(trace): print(f" Thought: {analysis.thought or 'N/A'}") print(f" Action: {analysis.action or 'N/A'}") + if analysis.arguments: # Try to pretty-print JSON arguments try: @@ -317,10 +339,20 @@ def print_agent_text(trace): # Conclusion print("--- Conclusion ---") conclusion = trace.get("conclusion") - if conclusion and conclusion.answer: - print("Answer:") - for line in conclusion.answer.split("\n"): - print(f" {line}") + if conclusion: + content = "" + if conclusion.document and api: + content = explain_client.fetch_document_content( + conclusion.document, api, user + ) + if content: + print("Answer:") + for line in content.split("\n"): + print(f" {line}") + elif conclusion.document: + print(f"Document: {conclusion.document}") + else: + print("No conclusion recorded") else: print("No conclusion recorded") @@ -346,11 +378,12 @@ def trace_to_dict(trace, trace_type): ], "conclusion": { "id": trace["conclusion"].uri, - "answer": trace["conclusion"].answer, + "document": trace["conclusion"].document, } if trace.get("conclusion") else None, } elif trace_type == "docrag": question = trace.get("question") + grounding = trace.get("grounding") exploration = trace.get("exploration") synthesis = trace.get("synthesis") @@ -359,14 +392,17 @@ def trace_to_dict(trace, trace_type): "question_id": question.uri if question else None, "question": question.query if question else None, "time": question.timestamp if question else None, + "grounding": { + "id": grounding.uri, + "concepts": grounding.concepts, + } if grounding else None, "exploration": { "id": exploration.uri, "chunk_count": exploration.chunk_count, } if exploration else None, "synthesis": { "id": synthesis.uri, - "document_uri": synthesis.document_uri, - "answer": synthesis.content, + "document": synthesis.document, } if synthesis else None, } else: @@ -397,8 +433,7 @@ def trace_to_dict(trace, trace_type): } if focus else None, "synthesis": { "id": synthesis.uri, - "document_uri": synthesis.document_uri, - "answer": synthesis.content, + "document": synthesis.document, } if synthesis else None, } @@ -496,7 +531,7 @@ def main(): if args.format == 'json': print(json.dumps(trace_to_dict(trace, "agent"), indent=2)) else: - print_agent_text(trace) + print_agent_text(trace, explain_client, api, args.user) elif trace_type == "docrag": # Fetch and display DocRAG trace @@ -512,7 +547,7 @@ def main(): if args.format == 'json': print(json.dumps(trace_to_dict(trace, "docrag"), indent=2)) else: - print_docrag_text(trace) + print_docrag_text(trace, explain_client, api, args.user) else: # Fetch and display GraphRAG trace @@ -531,6 +566,7 @@ def main(): print_graphrag_text( trace, explain_client, flow, args.user, args.collection, + api=api, show_provenance=args.show_provenance )