diff --git a/semantica/context/context_graph.py b/semantica/context/context_graph.py index abf35213..a6c455aa 100644 --- a/semantica/context/context_graph.py +++ b/semantica/context/context_graph.py @@ -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 = [] diff --git a/semantica/context/decision_models.py b/semantica/context/decision_models.py index 59fe6069..ca2de217 100644 --- a/semantica/context/decision_models.py +++ b/semantica/context/decision_models.py @@ -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: diff --git a/tests/context/test_context_graphs_examples.py b/tests/context/test_context_graphs_examples.py new file mode 100644 index 00000000..37fc4e15 --- /dev/null +++ b/tests/context/test_context_graphs_examples.py @@ -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"])