Fix Context Graphs decision tracking and add comprehensive tests

- Fix empty/None decision ID handling in ContextGraph.add_decision()
- Fix None metadata handling to prevent TypeError
- Fix causal chain depth logic and node exclusion
- Fix nonexistent node handling in add_causal_relationship()
- Add missing properties field in to_dict serialization
- Add missing from_dict method for graph deserialization
- Fix precedent search direction in find_precedents()
- Fix UUID generation logic in all decision models
- Add comprehensive test suite with 9 tests covering all features
- Test coverage: decision tracking, graph analytics, use cases, performance
- All 71 context tests now passing (100% success rate)

Resolves critical bugs in Context Graphs feature (#290) implementation
This commit is contained in:
KaifAhmad1
2026-02-15 12:52:19 +05:30
parent 780f8adfbe
commit 4e31296c1e
3 changed files with 609 additions and 35 deletions
+82 -29
View File
@@ -77,6 +77,7 @@ from collections import defaultdict, deque
from dataclasses import dataclass, field
from datetime import datetime
from typing import Any, Dict, List, Optional, Set, Tuple, Union
import uuid
from ..utils.logging import get_logger
from ..utils.progress_tracker import get_progress_tracker
@@ -841,6 +842,7 @@ class ContextGraph:
"id": n.node_id,
"type": n.node_type,
"content": n.content,
"properties": n.properties,
"metadata": n.metadata,
}
for n in self.nodes.values()
@@ -860,6 +862,34 @@ class ContextGraph:
},
}
def from_dict(self, graph_dict: Dict[str, Any]) -> None:
"""Load graph from dictionary format."""
# Clear existing graph
self.nodes.clear()
self.edges.clear()
# Add nodes
for node_data in graph_dict.get("nodes", []):
node = ContextNode(
node_id=node_data["id"],
node_type=node_data["type"],
content=node_data.get("content", ""),
properties=node_data.get("properties", {}),
metadata=node_data.get("metadata", {})
)
self._add_internal_node(node)
# Add edges
for edge_data in graph_dict.get("edges", []):
edge = ContextEdge(
source_id=edge_data["source"],
target_id=edge_data["target"],
edge_type=edge_data["type"],
weight=edge_data.get("weight", 1.0),
metadata=edge_data.get("metadata", {})
)
self._add_internal_edge(edge)
# Decision Support Methods
def add_decision(self, decision: "Decision") -> None:
"""
@@ -870,8 +900,14 @@ class ContextGraph:
"""
from .decision_models import Decision
# Handle empty decision ID by generating UUID only if None
node_id = decision.decision_id if decision.decision_id is not None else str(uuid.uuid4())
# Handle None metadata
metadata = decision.metadata or {}
node = ContextNode(
node_id=decision.decision_id,
node_id=node_id,
node_type="Decision",
content=decision.scenario,
properties={
@@ -883,7 +919,7 @@ class ContextGraph:
"decision_maker": decision.decision_maker,
"reasoning_embedding": decision.reasoning_embedding,
"node2vec_embedding": decision.node2vec_embedding,
**decision.metadata
**metadata
}
)
self._add_internal_node(node)
@@ -906,6 +942,15 @@ class ContextGraph:
if relationship_type not in valid_types:
raise ValueError(f"Relationship type must be one of: {valid_types}")
# Check if decisions exist - if not, skip adding relationship
if source_decision_id not in self.nodes or target_decision_id not in self.nodes:
return
# Check if nodes are decision nodes - if not, skip adding relationship
if (self.nodes[source_decision_id].node_type != "Decision" or
self.nodes[target_decision_id].node_type != "Decision"):
return
edge = ContextEdge(
source_id=source_decision_id,
target_id=target_decision_id,
@@ -949,41 +994,49 @@ class ContextGraph:
visited.add(current_id)
# Get decision node
if current_id in self.nodes:
node = self.nodes[current_id]
if node.node_type == "Decision":
decision_data = node.properties
decision = Decision(
decision_id=current_id,
category=decision_data.get("category", ""),
scenario=node.content,
reasoning=decision_data.get("reasoning", ""),
outcome=decision_data.get("outcome", ""),
confidence=decision_data.get("confidence", 0.0),
timestamp=datetime.fromisoformat(decision_data.get("timestamp", datetime.now().isoformat())),
decision_maker=decision_data.get("decision_maker", ""),
reasoning_embedding=decision_data.get("reasoning_embedding"),
node2vec_embedding=decision_data.get("node2vec_embedding"),
metadata={k: v for k, v in decision_data.items() if k not in [
"category", "reasoning", "outcome", "confidence",
"timestamp", "decision_maker", "reasoning_embedding", "node2vec_embedding"
]}
)
decision.metadata["causal_distance"] = depth
decisions.append(decision)
# Skip the starting decision - only add connected decisions
if current_id != decision_id:
# Get decision node
if current_id in self.nodes:
node = self.nodes[current_id]
if node.node_type == "Decision":
decision_data = node.properties
decision = Decision(
decision_id=current_id,
category=decision_data.get("category", ""),
scenario=node.content,
reasoning=decision_data.get("reasoning", ""),
outcome=decision_data.get("outcome", ""),
confidence=decision_data.get("confidence", 0.0),
timestamp=datetime.fromisoformat(decision_data.get("timestamp", datetime.now().isoformat())),
decision_maker=decision_data.get("decision_maker", ""),
reasoning_embedding=decision_data.get("reasoning_embedding"),
node2vec_embedding=decision_data.get("node2vec_embedding"),
metadata={k: v for k, v in decision_data.items() if k not in [
"category", "reasoning", "outcome", "confidence",
"timestamp", "decision_maker", "reasoning_embedding", "node2vec_embedding"
]}
)
decision.metadata["causal_distance"] = depth
decisions.append(decision)
# Find connected decisions
for edge in self.edges:
if direction == "upstream":
if edge.target_id == current_id and edge.edge_type in ["CAUSED", "INFLUENCED", "PRECEDENT_FOR"]:
if edge.source_id not in visited:
if edge.source_id not in visited and depth < max_depth:
queue.append((edge.source_id, depth + 1))
else: # downstream
if edge.source_id == current_id and edge.edge_type in ["CAUSED", "INFLUENCED", "PRECEDENT_FOR"]:
if edge.target_id not in visited:
if edge.target_id not in visited and depth < max_depth:
queue.append((edge.target_id, depth + 1))
# Sort by depth for upstream (most distant first) and downstream (closest first)
if direction == "upstream":
decisions.sort(key=lambda d: d.metadata.get("causal_distance", 0), reverse=True)
else:
decisions.sort(key=lambda d: d.metadata.get("causal_distance", 0))
return decisions
def find_precedents(self, decision_id: str, limit: int = 10) -> List["Decision"]:
@@ -1000,8 +1053,8 @@ class ContextGraph:
# Find decisions connected via PRECEDENT_FOR relationships
precedent_ids = []
for edge in self.edges:
if edge.source_id == decision_id and edge.edge_type == "PRECEDENT_FOR":
precedent_ids.append(edge.target_id)
if edge.target_id == decision_id and edge.edge_type == "PRECEDENT_FOR":
precedent_ids.append(edge.source_id)
# Convert to Decision objects
decisions = []
+6 -6
View File
@@ -101,7 +101,7 @@ class Decision:
def __post_init__(self):
"""Validate decision data."""
if not self.decision_id:
if self.decision_id is None:
self.decision_id = str(uuid.uuid4())
if not 0 <= self.confidence <= 1:
raise ValueError("Confidence must be between 0 and 1")
@@ -143,7 +143,7 @@ class DecisionContext:
def __post_init__(self):
"""Validate context data."""
if not self.context_id:
if self.context_id is None:
self.context_id = str(uuid.uuid4())
def to_dict(self) -> Dict[str, Any]:
@@ -179,7 +179,7 @@ class Policy:
def __post_init__(self):
"""Validate policy data."""
if not self.policy_id:
if self.policy_id is None:
self.policy_id = str(uuid.uuid4())
def to_dict(self) -> Dict[str, Any]:
@@ -220,7 +220,7 @@ class PolicyException:
def __post_init__(self):
"""Validate exception data."""
if not self.exception_id:
if self.exception_id is None:
self.exception_id = str(uuid.uuid4())
def to_dict(self) -> Dict[str, Any]:
@@ -256,7 +256,7 @@ class Precedent:
def __post_init__(self):
"""Validate precedent data."""
if not self.precedent_id:
if self.precedent_id is None:
self.precedent_id = str(uuid.uuid4())
if not 0 <= self.similarity_score <= 1:
raise ValueError("Similarity score must be between 0 and 1")
@@ -294,7 +294,7 @@ class ApprovalChain:
def __post_init__(self):
"""Validate approval data."""
if not self.approval_id:
if self.approval_id is None:
self.approval_id = str(uuid.uuid4())
valid_methods = ["slack_dm", "zoom_call", "email", "system"]
if self.approval_method not in valid_methods:
@@ -0,0 +1,521 @@
#!/usr/bin/env python3
"""
Comprehensive test suite for Context Graphs feature examples from issue #290.
This tests all the example use cases provided in the feature description.
"""
import pytest
import sys
import os
from datetime import datetime
from unittest.mock import Mock, patch
# Add the semantica package to Python path for testing
sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..', '..'))
from semantica.context import AgentContext
from semantica.context.context_graph import ContextGraph
from semantica.context.decision_models import Decision, Policy, PolicyException
from semantica.vector_store import VectorStore
from semantica.embeddings import EmbeddingGenerator
class TestContextGraphsExamples:
"""Test suite for Context Graphs feature examples."""
@pytest.fixture
def mock_vector_store(self):
"""Create a mock vector store for testing."""
store = Mock(spec=VectorStore)
store.store = Mock(return_value="test_memory_id")
store.retrieve = Mock(return_value=[])
store.embed = Mock(return_value=[0.1] * 384) # Mock embedding
return store
@pytest.fixture
def mock_knowledge_graph(self):
"""Create a mock knowledge graph for testing."""
kg = Mock(spec=ContextGraph)
kg.execute_query = Mock(return_value=[])
kg.build_from_conversations = Mock(return_value={"statistics": {"node_count": 0, "edge_count": 0}})
return kg
def test_context_graph_direct_functionality(self):
"""Test ContextGraph directly with decision support features."""
print("Testing ContextGraph Direct Functionality...")
# Create context graph with advanced features
graph = ContextGraph(
enable_advanced_analytics=True,
enable_centrality_analysis=True,
enable_community_detection=True,
enable_node_embeddings=True
)
# Add a decision
decision = Decision(
decision_id="test_decision_001",
category="test",
scenario="Test scenario for credit approval",
reasoning="Good credit history and stable income",
outcome="approved",
confidence=0.95,
timestamp=datetime.now(),
decision_maker="ai_agent"
)
graph.add_decision(decision)
assert len(graph.nodes) == 1
print("+ Added decision to context graph")
# Add another decision and causal relationship
decision2 = Decision(
decision_id="test_decision_002",
category="test",
scenario="Related credit decision",
reasoning="Based on previous approval",
outcome="approved",
confidence=0.90,
timestamp=datetime.now(),
decision_maker="ai_agent"
)
graph.add_decision(decision2)
graph.add_causal_relationship("test_decision_001", "test_decision_002", "CAUSED")
assert len(graph.nodes) == 2
assert len(graph.edges) == 1
print("+ Added causal relationship")
# Test causal chain
chain = graph.get_causal_chain("test_decision_002", direction="upstream")
assert len(chain) == 1
assert chain[0].decision_id == "test_decision_001"
print("+ Found causal chain with decisions")
# Test precedent search
precedents = graph.find_precedents("test_decision_002")
assert len(precedents) == 0 # No precedent relationships added
# Add precedent relationship and test again
graph.add_causal_relationship("test_decision_001", "test_decision_002", "PRECEDENT_FOR")
precedents = graph.find_precedents("test_decision_002")
assert len(precedents) == 1
assert precedents[0].decision_id == "test_decision_001"
print("+ Found precedents")
# Test serialization
graph_dict = graph.to_dict()
assert len(graph_dict['nodes']) == 2
assert len(graph_dict['edges']) == 2
assert 'properties' in graph_dict['nodes'][0]
print("+ Serialized graph correctly")
# Test deserialization
new_graph = ContextGraph()
new_graph.from_dict(graph_dict)
assert len(new_graph.nodes) == 2
assert len(new_graph.edges) == 2
print("+ Deserialized graph correctly")
print("✓ ContextGraph direct functionality test passed")
def test_financial_services_example(self, mock_vector_store, mock_knowledge_graph):
"""Test the financial services example from the feature description."""
print("Testing Financial Services Example...")
# Initialize context with decision tracking
context = AgentContext(
vector_store=mock_vector_store,
knowledge_graph=mock_knowledge_graph,
enable_decision_tracking=True,
enable_advanced_analytics=True,
enable_kg_algorithms=True,
enable_vector_store_features=True
)
# Credit decision with precedent search
decision_id = context.record_decision(
category="credit_approval",
scenario="High-risk credit limit increase",
reasoning="Past fraud flag with velocity check failure",
outcome="rejected",
confidence=0.788,
entities=["customer:jessica_norris"]
)
assert decision_id is not None
print("+ Recorded decision")
# Find similar precedents
precedents = context.find_precedents(
scenario="High-risk customer credit increase",
category="credit_approval",
limit=5
)
assert isinstance(precedents, list)
print("+ Found precedents")
# Analyze causal chain
causal_chain = context.get_causal_chain(decision_id, max_depth=5)
assert isinstance(causal_chain, list)
print("+ Analyzed causal chain")
print("✓ Financial services example test passed")
def test_healthcare_example(self, mock_vector_store, mock_knowledge_graph):
"""Test the healthcare example from the feature description."""
print("Testing Healthcare Example...")
# Initialize context with decision tracking
context = AgentContext(
vector_store=mock_vector_store,
knowledge_graph=mock_knowledge_graph,
enable_decision_tracking=True,
enable_advanced_analytics=True,
enable_kg_algorithms=True,
enable_vector_store_features=True
)
# Treatment decision with policy compliance
decision_id = context.record_decision(
category="treatment_plan",
scenario="Diabetic patient with comorbidities",
reasoning="Standard protocol contraindicated due to renal function",
outcome="modified_treatment",
confidence=0.92
)
assert decision_id is not None
print("+ Recorded decision")
# Check policy engine availability
policy_engine = context.get_policy_engine()
if policy_engine:
# Create a test policy
policy = Policy(
policy_id="diabetes_protocol_v2",
name="Diabetes Treatment Protocol v2",
description="Standard treatment protocol for diabetes patients",
rules={"contraindications": ["renal_impairment"], "max_dosage": 100},
category="treatment",
version="v2",
created_at=datetime.now(),
updated_at=datetime.now()
)
# Test policy operations
assert policy.policy_id == "diabetes_protocol_v2"
assert policy.category == "treatment"
print("+ Policy operations working")
print("✓ Healthcare example test passed")
def test_legal_example(self, mock_vector_store, mock_knowledge_graph):
"""Test the legal example from the feature description."""
print("Testing Legal Example...")
# Initialize context with decision tracking
context = AgentContext(
vector_store=mock_vector_store,
knowledge_graph=mock_knowledge_graph,
enable_decision_tracking=True,
enable_advanced_analytics=True,
enable_kg_algorithms=True,
enable_vector_store_features=True
)
# Legal decision with precedent analysis
decision_id = context.record_decision(
category="contract_review",
scenario="Non-standard liability clause",
reasoning="Precedent cases show similar clauses upheld",
outcome="approved_with_modifications",
confidence=0.85
)
assert decision_id is not None
print("+ Recorded decision")
# Find legal precedents
precedents = context.find_precedents(
scenario="Liability limitation clauses",
category="contract_review",
limit=10
)
assert isinstance(precedents, list)
print("+ Found legal precedents")
print("✓ Legal example test passed")
def test_decision_models_functionality(self):
"""Test decision models functionality."""
print("Testing Decision Models...")
# Test Decision model
decision = Decision(
decision_id="test_decision",
category="test_category",
scenario="Test scenario",
reasoning="Test reasoning",
outcome="approved",
confidence=0.95,
timestamp=datetime.now(),
decision_maker="test_agent"
)
assert decision.decision_id == "test_decision"
assert decision.category == "test_category"
assert 0 <= decision.confidence <= 1
# Test serialization
decision_dict = decision.to_dict()
assert decision_dict["decision_id"] == "test_decision"
assert "timestamp" in decision_dict
# Test deserialization
restored_decision = Decision.from_dict(decision_dict)
assert restored_decision.decision_id == decision.decision_id
assert restored_decision.category == decision.category
print("+ Decision model serialization working")
# Test Policy model
policy = Policy(
policy_id="test_policy",
name="Test Policy",
description="Test policy description",
rules={"max_amount": 1000},
category="test",
version="1.0",
created_at=datetime.now(),
updated_at=datetime.now()
)
assert policy.policy_id == "test_policy"
assert policy.rules["max_amount"] == 1000
# Test PolicyException model
exception = PolicyException(
exception_id="test_exception",
decision_id="test_decision",
policy_id="test_policy",
reason="Test exception",
approver="test_approver",
approval_timestamp=datetime.now(),
justification="Test justification"
)
assert exception.exception_id == "test_exception"
assert exception.decision_id == "test_decision"
print("+ Policy models working")
print("✓ Decision models functionality test passed")
def test_context_graph_edge_cases(self):
"""Test ContextGraph edge cases and error handling."""
print("Testing ContextGraph Edge Cases...")
graph = ContextGraph()
# Test empty decision ID handling
decision_empty_id = Decision(
decision_id="", # Empty ID
category="test",
scenario="test scenario",
reasoning="test reasoning",
outcome="test outcome",
confidence=0.8,
timestamp=datetime.now(),
decision_maker="test_agent"
)
graph.add_decision(decision_empty_id)
assert "" in graph.nodes # Empty string should be preserved as key
print("+ Empty decision ID handling working")
# Test None decision ID handling
decision_none_id = Decision(
decision_id=None, # None ID
category="test",
scenario="test scenario 2",
reasoning="test reasoning 2",
outcome="test outcome 2",
confidence=0.8,
timestamp=datetime.now(),
decision_maker="test_agent"
)
graph.add_decision(decision_none_id)
assert len(graph.nodes) == 2 # Should have generated UUID
print("+ None decision ID handling working")
# Test causal relationship with nonexistent nodes (should not raise error)
graph.add_causal_relationship("nonexistent1", "nonexistent2", "CAUSED")
assert len(graph.edges) == 0 # Should not add relationship
print("+ Nonexistent node handling working")
# Test invalid relationship type
with pytest.raises(ValueError):
graph.add_causal_relationship("test", "test2", "INVALID_TYPE")
print("+ Invalid relationship type validation working")
# Test causal chain with nonexistent decision
chain = graph.get_causal_chain("nonexistent", direction="upstream")
assert len(chain) == 0
print("+ Nonexistent decision handling working")
print("✓ ContextGraph edge cases test passed")
def test_advanced_features_integration(self, mock_vector_store, mock_knowledge_graph):
"""Test advanced features integration."""
print("Testing Advanced Features Integration...")
# Test with all features enabled
context = AgentContext(
vector_store=mock_vector_store,
knowledge_graph=mock_knowledge_graph,
enable_decision_tracking=True,
enable_advanced_analytics=True,
enable_kg_algorithms=True,
enable_vector_store_features=True,
use_graph_expansion=True,
max_expansion_hops=3,
hybrid_alpha=0.7
)
# Verify configuration
assert context.config["enable_decision_tracking"] is True
assert context.config["enable_advanced_analytics"] is True
assert context.config["enable_kg_algorithms"] is True
assert context.config["enable_vector_store_features"] is True
assert context.config["use_graph_expansion"] is True
assert context.config["max_expansion_hops"] == 3
assert context.config["hybrid_alpha"] == 0.7
print("+ Configuration validation working")
# Test decision tracking with advanced features
decision_id = context.record_decision(
category="advanced_test",
scenario="Advanced feature test scenario",
reasoning="Testing advanced analytics integration",
outcome="processed",
confidence=0.88,
entities=["entity1", "entity2"]
)
assert decision_id is not None
print("+ Advanced decision recording working")
# Test context insights
insights = context.get_context_insights()
assert isinstance(insights, dict)
print("+ Context insights working")
print("✓ Advanced features integration test passed")
class TestContextGraphsPerformance:
"""Performance tests for Context Graphs feature."""
def test_large_decision_network(self):
"""Test handling of large decision networks."""
print("Testing Large Decision Network...")
graph = ContextGraph()
# Create a network of 100 decisions
decisions = []
for i in range(100):
decision = Decision(
decision_id=f"decision_{i:03d}",
category="performance_test",
scenario=f"Performance test scenario {i}",
reasoning=f"Performance test reasoning {i}",
outcome="processed",
confidence=0.8 + (i % 20) * 0.01, # Varying confidence
timestamp=datetime.now(),
decision_maker="performance_agent"
)
decisions.append(decision)
graph.add_decision(decision)
assert len(graph.nodes) == 100
print("+ Created 100 decisions")
# Add causal relationships to create a network
for i in range(99):
# Create a mix of relationship types
relationship_type = ["CAUSED", "INFLUENCED", "PRECEDENT_FOR"][i % 3]
graph.add_causal_relationship(f"decision_{i:03d}", f"decision_{i+1:03d}", relationship_type)
assert len(graph.edges) == 99
print("+ Created 99 causal relationships")
# Test causal chain performance
chain = graph.get_causal_chain("decision_099", direction="upstream", max_depth=50)
assert len(chain) > 0
print("+ Causal chain analysis working")
# Test precedent search performance
precedents = graph.find_precedents("decision_050", limit=20)
assert isinstance(precedents, list)
print("+ Precedent search working")
# Test serialization performance
graph_dict = graph.to_dict()
assert len(graph_dict['nodes']) == 100
assert len(graph_dict['edges']) == 99
print("+ Large graph serialization working")
print("✓ Large decision network test passed")
def test_concurrent_operations(self):
"""Test concurrent decision operations."""
print("Testing Concurrent Operations...")
import threading
import time
graph = ContextGraph()
results = []
errors = []
def add_decisions(start_id, count):
"""Add decisions in a separate thread."""
try:
for i in range(count):
decision = Decision(
decision_id=f"concurrent_decision_{start_id + i:03d}",
category="concurrent_test",
scenario=f"Concurrent test {start_id + i}",
reasoning="Concurrent reasoning",
outcome="processed",
confidence=0.8,
timestamp=datetime.now(),
decision_maker="concurrent_agent"
)
graph.add_decision(decision)
time.sleep(0.001) # Small delay to simulate real work
results.append(f"Thread {start_id} completed")
except Exception as e:
errors.append(f"Thread {start_id} error: {e}")
# Create multiple threads
threads = []
for i in range(5):
thread = threading.Thread(target=add_decisions, args=(i * 20, 20))
threads.append(thread)
thread.start()
# Wait for all threads to complete
for thread in threads:
thread.join()
# Verify results
assert len(errors) == 0, f"Errors occurred: {errors}"
assert len(results) == 5
assert len(graph.nodes) == 100 # 5 threads * 20 decisions each
print("+ Concurrent operations completed successfully")
print("✓ Concurrent operations test passed")
if __name__ == "__main__":
# Run tests when script is executed directly
pytest.main([__file__, "-v"])