mirror of
https://github.com/semantica-agi/semantica.git
synced 2026-08-29 04:26:20 +00:00
Merge main into docs and resolve README Discord badge conflict
This commit is contained in:
@@ -1,17 +0,0 @@
|
||||
{
|
||||
"projectName": "Semantica",
|
||||
"projectOwner": "Hawksight-AI",
|
||||
"repoType": "github",
|
||||
"repoHost": "https://github.com",
|
||||
"files": [
|
||||
"CONTRIBUTORS.md"
|
||||
],
|
||||
"imageSize": 100,
|
||||
"commit": true,
|
||||
"commitConvention": "conventional",
|
||||
"contributors": [],
|
||||
"contributorsPerLine": 7,
|
||||
"badgeTemplate": "[](#contributors)",
|
||||
"skipCi": true
|
||||
}
|
||||
|
||||
+1116
File diff suppressed because it is too large
Load Diff
@@ -17,7 +17,6 @@
|
||||
|
||||
</div>
|
||||
|
||||
|
||||
---
|
||||
|
||||
## 🚀 Why Semantica?
|
||||
@@ -35,30 +34,30 @@ pip install semantica
|
||||
```
|
||||
|
||||
```python
|
||||
from semantica.context import AgentContext
|
||||
from semantica.context import AgentContext, ContextGraph
|
||||
from semantica.vector_store import VectorStore
|
||||
from semantica.kg import GraphBuilder
|
||||
|
||||
# Initialize context with advanced features
|
||||
# Initialize with enhanced context features
|
||||
vs = VectorStore(backend="faiss", dimension=768)
|
||||
kg = GraphBuilder().build({"entities": [], "relationships": []})
|
||||
kg = ContextGraph(advanced_analytics=True)
|
||||
context = AgentContext(
|
||||
vector_store=vs,
|
||||
knowledge_graph=kg,
|
||||
enable_decision_tracking=True,
|
||||
enable_advanced_analytics=True,
|
||||
enable_kg_algorithms=True,
|
||||
enable_vector_store_features=True
|
||||
decision_tracking=True,
|
||||
advanced_analytics=True,
|
||||
kg_algorithms=True,
|
||||
vector_store_features=True,
|
||||
graph_expansion=True
|
||||
)
|
||||
|
||||
# Store memory with context graphs
|
||||
# Store memory with automatic context graph building
|
||||
memory_id = context.store(
|
||||
"User is working on a React project with FastAPI",
|
||||
conversation_id="session_1"
|
||||
)
|
||||
|
||||
# Record decision with full context
|
||||
decision_id = context.record_decision(
|
||||
# Easy decision recording with convenience methods
|
||||
decision_id = context.graph_builder.add_decision(
|
||||
category="technology_choice",
|
||||
scenario="Framework selection for web API",
|
||||
reasoning="React ecosystem with FastAPI provides best performance",
|
||||
@@ -66,15 +65,25 @@ decision_id = context.record_decision(
|
||||
confidence=0.92
|
||||
)
|
||||
|
||||
# Find similar decisions (precedents)
|
||||
precedents = context.find_precedents_advanced(
|
||||
# Find similar decisions with advanced analytics
|
||||
similar_decisions = context.graph_builder.find_similar_decisions(
|
||||
scenario="Framework selection",
|
||||
use_kg_features=True
|
||||
max_results=5
|
||||
)
|
||||
|
||||
# Analyze decision impact and influence
|
||||
impact = context.graph_builder.analyze_decision_impact(decision_id)
|
||||
|
||||
# Check compliance with business rules
|
||||
compliance = context.graph_builder.check_decision_rules({
|
||||
"category": "technology_choice",
|
||||
"confidence": 0.92
|
||||
})
|
||||
|
||||
print(f"Memory stored: {memory_id}")
|
||||
print(f"Decision recorded: {decision_id}")
|
||||
print(f"Found {len(precedents)} precedents")
|
||||
print(f"Found {len(similar_decisions)} similar decisions")
|
||||
print(f"Compliance check: {compliance.get('compliant', False)}")
|
||||
```
|
||||
|
||||
**[📖 Full Quick Start](#-quick-start)** • **[🍳 Cookbook Examples](#-semantica-cookbook)** • **[💬 Join Discord](https://discord.gg/N7WmAuDH)** • **[⭐ Star Us](https://github.com/Hawksight-AI/semantica)**
|
||||
@@ -136,71 +145,167 @@ print(f"Found {len(precedents)} precedents")
|
||||
|
||||
- **Docling Support** — Document parsing with table extraction (PDF, DOCX, PPTX, XLSX)
|
||||
- **AWS Neptune** — Amazon Neptune graph database support with IAM authentication
|
||||
- **Apache AGE** — PostgreSQL graph extension backend (openCypher via SQL)
|
||||
- **Custom Ontology Import** — Import existing ontologies (OWL, RDF, Turtle, JSON-LD)
|
||||
|
||||
> **Built for environments where every answer must be explainable and governed.**
|
||||
|
||||
---
|
||||
|
||||
## 🧠 Context Module: Advanced Context Engineering
|
||||
## 🧠 Context Module: Advanced Context Engineering & Decision Intelligence
|
||||
|
||||
The **Context Module** is Semantica's flagship component, providing sophisticated context management with **context graphs**, **decision tracking**, and **advanced knowledge engineering**.
|
||||
The **Context Module** is Semantica's flagship component, providing sophisticated context management with **context graphs**, **advanced decision tracking**, **knowledge graph analytics**, and **easy-to-use interfaces**.
|
||||
|
||||
### 🎯 Core Capabilities
|
||||
|
||||
| **Feature** | **Description** | **Use Case** |
|
||||
|------------|-------------|------------|
|
||||
| **Context Graphs** | Structured knowledge representation with entity relationships | Knowledge management, decision support |
|
||||
| **Decision Tracking** | Complete decision lifecycle with precedent search | Banking approvals, healthcare decisions |
|
||||
| **KG Algorithms** | Advanced graph analytics (centrality, community detection) | Influence analysis, similarity search |
|
||||
| **Advanced Decision Tracking** | Complete decision lifecycle with precedent search, causal analysis, and policy enforcement | Banking approvals, healthcare decisions |
|
||||
| **Easy-to-Use Methods** | 10 convenience methods for common operations without complexity | Rapid development, user-friendly API |
|
||||
| **KG Algorithms** | Advanced graph analytics (centrality, community detection, Node2Vec) | Influence analysis, similarity search |
|
||||
| **Policy Engine** | Automated compliance checking with business rules and exception handling | Regulatory compliance, business rules |
|
||||
| **Vector Store Integration** | Hybrid search with custom similarity weights | Advanced retrieval and filtering |
|
||||
| **Memory Management** | Hierarchical memory with short-term and long-term storage | Agent conversation history |
|
||||
|
||||
### 🚀 Advanced Features
|
||||
### 🚀 Enhanced Features
|
||||
|
||||
- **Easy Decision Recording**: `add_decision()` with automatic entity linking
|
||||
- **Smart Precedent Search**: `find_similar_decisions()` with hybrid similarity
|
||||
- **Impact Analysis**: `analyze_decision_impact()` with influence scoring
|
||||
- **Policy Compliance**: `check_decision_rules()` with automated validation
|
||||
- **Causal Chains**: `trace_decision_chain()` for decision lineage
|
||||
- **Graph Analytics**: `get_node_importance()`, `analyze_connections()` for insights
|
||||
- **Hybrid Retrieval**: Combines vector search, graph traversal, and keyword matching
|
||||
- **Multi-Hop Reasoning**: Trace relationships across multiple graph hops
|
||||
- **Decision Influence Analysis**: Understand how decisions impact each other
|
||||
- **Policy Engine**: Enforce business rules and compliance automatically
|
||||
- **Causal Chain Analysis**: Trace decision causality and influence paths
|
||||
- **Entity Linking**: Resolve ambiguities and maintain consistent entity references
|
||||
- **Production Ready**: Comprehensive error handling and scalability
|
||||
|
||||
### Examples
|
||||
### 🔧 Easy-to-Use API
|
||||
|
||||
```python
|
||||
# Banking Decision System
|
||||
# Simple usage with convenience methods
|
||||
from semantica.context import ContextGraph
|
||||
|
||||
graph = ContextGraph(advanced_analytics=True)
|
||||
|
||||
# Add decision with ease
|
||||
decision_id = graph.add_decision(
|
||||
category="loan_approval",
|
||||
scenario="Mortgage application",
|
||||
reasoning="Good credit score",
|
||||
outcome="approved",
|
||||
confidence=0.95
|
||||
)
|
||||
|
||||
# Find similar decisions
|
||||
similar = graph.find_similar_decisions("mortgage", max_results=5)
|
||||
|
||||
# Analyze impact
|
||||
impact = graph.analyze_decision_impact(decision_id)
|
||||
|
||||
# Check compliance
|
||||
compliance = graph.check_decision_rules({
|
||||
"category": "loan_approval",
|
||||
"confidence": 0.95
|
||||
})
|
||||
```
|
||||
|
||||
### 🏢 Enterprise Integration
|
||||
|
||||
```python
|
||||
# Full enterprise setup with AgentContext
|
||||
from semantica.context import AgentContext
|
||||
from semantica.vector_store import VectorStore
|
||||
|
||||
context = AgentContext(
|
||||
vector_store=vs,
|
||||
knowledge_graph=kg,
|
||||
enable_decision_tracking=True,
|
||||
enable_kg_algorithms=True
|
||||
vector_store=VectorStore(backend="faiss"),
|
||||
knowledge_graph=ContextGraph(advanced_analytics=True),
|
||||
decision_tracking=True,
|
||||
kg_algorithms=True,
|
||||
vector_store_features=True
|
||||
)
|
||||
|
||||
# Record loan decision
|
||||
# Record decision with full context
|
||||
decision_id = context.record_decision(
|
||||
category="mortgage_approval",
|
||||
scenario="First-time homebuyer application",
|
||||
reasoning="Strong credit score, stable employment",
|
||||
outcome="approved",
|
||||
confidence=0.94
|
||||
category="fraud_detection",
|
||||
scenario="Suspicious transaction pattern",
|
||||
reasoning="Multiple high-value transactions in short timeframe",
|
||||
outcome="flagged_for_review",
|
||||
confidence=0.87,
|
||||
entities=["transaction_123", "customer_456"]
|
||||
)
|
||||
|
||||
# Find similar decisions with KG features
|
||||
precedents = context.find_precedents_advanced(
|
||||
scenario="Mortgage application",
|
||||
use_kg_features=True,
|
||||
similarity_weights={"semantic": 0.5, "structural": 0.3, "category": 0.2}
|
||||
# Advanced precedent search with KG features
|
||||
precedents = context.find_precedents(
|
||||
"suspicious transaction",
|
||||
category="fraud_detection",
|
||||
use_kg_features=True
|
||||
)
|
||||
|
||||
# Analyze decision influence
|
||||
# Comprehensive influence analysis
|
||||
influence = context.analyze_decision_influence(decision_id)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🚨 The Problem: The Semantic Gap
|
||||
## AgentContext - Your Agent's Brain
|
||||
|
||||
The main interface that makes your agent intelligent. It handles memory, decisions, and knowledge organization automatically.
|
||||
|
||||
### Quick Start
|
||||
```python
|
||||
from semantica.context import AgentContext
|
||||
from semantica.vector_store import VectorStore
|
||||
|
||||
# Create your intelligent agent
|
||||
agent = AgentContext(vector_store=VectorStore(backend="inmemory", dimension=384))
|
||||
|
||||
# Your agent can now remember things
|
||||
memory_id = agent.store("User asked about Python programming")
|
||||
print(f"Agent remembered: {memory_id}")
|
||||
|
||||
# And find information when needed
|
||||
results = agent.retrieve("Python tutorials")
|
||||
print(f"Agent found {len(results)} relevant memories")
|
||||
```
|
||||
|
||||
### Easy Decision Learning
|
||||
```python
|
||||
# Your agent learns from its decisions
|
||||
decision_id = agent.record_decision(
|
||||
category="content_recommendation",
|
||||
scenario="User wants Python tutorial",
|
||||
reasoning="User mentioned being a beginner",
|
||||
outcome="recommended_basics",
|
||||
confidence=0.85
|
||||
)
|
||||
|
||||
# Your agent can now find similar past decisions
|
||||
similar_decisions = agent.find_precedents("Python tutorial", limit=3)
|
||||
print(f"Agent found {len(similar_decisions)} similar past decisions")
|
||||
```
|
||||
|
||||
### Getting Smarter Over Time
|
||||
```python
|
||||
# Enable all learning features
|
||||
smart_agent = AgentContext(
|
||||
vector_store=vector_store,
|
||||
decision_tracking=True, # Learn from decisions
|
||||
graph_expansion=True, # Find related information
|
||||
advanced_analytics=True, # Understand patterns
|
||||
kg_algorithms=True, # Advanced analysis
|
||||
vector_store_features=True
|
||||
)
|
||||
|
||||
# Get insights about your agent's learning
|
||||
insights = smart_agent.get_context_insights()
|
||||
print(f"Total decisions learned: {insights.get('total_decisions', 0)}")
|
||||
print(f"Decision categories: {list(insights.get('categories', {}).keys())}")
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## The Problem: The Semantic Gap
|
||||
|
||||
### Most AI systems fail in high-stakes domains because they operate on **text similarity**, not **meaning**.
|
||||
|
||||
@@ -246,44 +351,45 @@ The **semantic gap** is the fundamental disconnect between what AI systems can p
|
||||
|
||||
---
|
||||
|
||||
## 🆚 Semantica vs Traditional RAG
|
||||
## Semantica vs Traditional RAG
|
||||
|
||||
| Feature | Traditional RAG | Semantica |
|
||||
|:--------|:----------------|:----------|
|
||||
| **Reasoning** | ❌ Black-box answers | ✅ Explainable reasoning paths |
|
||||
| **Provenance** | ❌ No provenance | ✅ W3C PROV-O compliant lineage tracking |
|
||||
| **Search** | ⚠️ Vector similarity only | ✅ Semantic + graph reasoning |
|
||||
| **Quality** | ❌ No conflict handling | ✅ Explicit contradiction detection |
|
||||
| **Safety** | ⚠️ Unsafe for high-stakes | ✅ Designed for governed environments |
|
||||
| **Compliance** | ❌ No audit trails | ✅ Complete audit trails with integrity verification |
|
||||
| **Reasoning** | Black-box answers | Explainable reasoning paths |
|
||||
| **Provenance** | No provenance | W3C PROV-O compliant lineage tracking |
|
||||
| **Search** | Vector similarity only | Semantic + graph reasoning |
|
||||
| **Quality** | No conflict handling | Explicit contradiction detection |
|
||||
| **Safety** | Unsafe for high-stakes | Designed for governed environments |
|
||||
| **Compliance** | No audit trails | Complete audit trails with integrity verification |
|
||||
|
||||
---
|
||||
|
||||
## 🧩 Semantica Architecture
|
||||
## Semantica Architecture
|
||||
|
||||
### 1️⃣ Input Layer — Governed Ingestion
|
||||
- 📄 **Multiple Formats** — PDFs, DOCX, HTML, JSON, CSV, Excel, PPTX
|
||||
- 🔧 **Docling Support** — Docling parser for table extraction
|
||||
- 💾 **Data Sources** — Databases, APIs, streams, archives, web content
|
||||
- 🎨 **Media Support** — Image parsing with OCR, audio/video metadata extraction
|
||||
- 📊 **Single Pipeline** — Unified ingestion with metadata and source tracking
|
||||
### Input Layer — Governed Ingestion
|
||||
- **Multiple Formats** — PDFs, DOCX, HTML, JSON, CSV, Excel, PPTX
|
||||
- **Docling Support** — Docling parser for table extraction
|
||||
- **Data Sources** — Databases, APIs, streams, archives, web content
|
||||
- **Media Support** — Image parsing with OCR, audio/video metadata extraction
|
||||
- **Single Pipeline** — Unified ingestion with metadata and source tracking
|
||||
|
||||
### 2️⃣ Semantic Layer — Trust & Reasoning Engine
|
||||
- 🔍 **Entity Extraction** — NER, normalization, classification
|
||||
- 🔗 **Relationship Discovery** — Triplet generation, semantic links
|
||||
- 📐 **Ontology Induction** — Automated domain rule generation
|
||||
- 🔄 **Deduplication** — Jaro-Winkler similarity, conflict resolution
|
||||
- ✅ **Quality Assurance** — Conflict detection, validation
|
||||
- 📊 **Provenance Tracking** — W3C PROV-O compliant lineage tracking across all modules
|
||||
- 🧠 **Reasoning Traces** — Explainable inference paths
|
||||
- 🔐 **Change Management** — Version control with audit trails, checksums, compliance support
|
||||
### Semantic Layer — Trust & Reasoning Engine
|
||||
- **Entity Extraction** — NER, normalization, classification
|
||||
- **Relationship Discovery** — Triplet generation, semantic links
|
||||
- **Ontology Induction** — Automated domain rule generation
|
||||
- **Deduplication** — Jaro-Winkler similarity, conflict resolution
|
||||
- **Quality Assurance** — Conflict detection, validation
|
||||
- **Provenance Tracking** — W3C PROV-O compliant lineage tracking across all modules
|
||||
- **Reasoning Traces** — Explainable inference paths
|
||||
- **Change Management** — Version control with audit trails, checksums, compliance support
|
||||
|
||||
### 3️⃣ Output Layer — Auditable Knowledge Assets
|
||||
- 📊 **Knowledge Graphs** — Queryable, temporal, explainable
|
||||
- 📐 **OWL Ontologies** — HermiT/Pellet validated, custom ontology import support
|
||||
- 🔢 **Vector Embeddings** — FastEmbed by default
|
||||
- ☁️ **AWS Neptune** — Amazon Neptune graph database support
|
||||
- 🔍 **Provenance** — Every AI response links back to:
|
||||
### Output Layer — Auditable Knowledge Assets
|
||||
- **Knowledge Graphs** — Queryable, temporal, explainable
|
||||
- **OWL Ontologies** — HermiT/Pellet validated, custom ontology import support
|
||||
- **Vector Embeddings** — FastEmbed by default
|
||||
- **AWS Neptune** — Amazon Neptune graph database support
|
||||
- **Apache AGE** — PostgreSQL graph extension with openCypher support
|
||||
- **Provenance** — Every AI response links back to:
|
||||
- 📄 Source documents
|
||||
- 🏷️ Extracted entities & relations
|
||||
- 📐 Ontology rules applied
|
||||
@@ -291,27 +397,27 @@ The **semantic gap** is the fundamental disconnect between what AI systems can p
|
||||
|
||||
---
|
||||
|
||||
## 🏥 Built for High-Stakes Domains
|
||||
## Built for High-Stakes Domains
|
||||
|
||||
Designed for domains where **mistakes have real consequences** and **every decision must be accountable**:
|
||||
|
||||
- **🏥 Healthcare & Life Sciences** — Clinical decision support, drug interaction analysis, medical literature reasoning, patient safety tracking
|
||||
- **💰 Finance & Risk** — Fraud detection, regulatory support (SOX, GDPR, MiFID II), credit risk assessment, algorithmic trading validation
|
||||
- **⚖️ Legal & Compliance** — Evidence-backed legal research, contract analysis, regulatory change tracking, case law reasoning
|
||||
- **🔒 Cybersecurity & Intelligence** — Threat attribution, incident response, security audit trails, intelligence analysis
|
||||
- **🏛️ Government & Defense** — Governed AI systems, policy decisions, classified information handling, defense intelligence
|
||||
- **🏭 Critical Infrastructure** — Power grid management, transportation safety, water treatment, emergency response
|
||||
- **🚗 Autonomous Systems** — Self-driving vehicles, drone navigation, robotics safety, industrial automation
|
||||
- **Healthcare & Life Sciences** — Clinical decision support, drug interaction analysis, medical literature reasoning, patient safety tracking
|
||||
- **Finance & Risk** — Fraud detection, regulatory support (SOX, GDPR, MiFID II), credit risk assessment, algorithmic trading validation
|
||||
- **Legal & Compliance** — Evidence-backed legal research, contract analysis, regulatory change tracking, case law reasoning
|
||||
- **Cybersecurity & Intelligence** — Threat attribution, incident response, security audit trails, intelligence analysis
|
||||
- **Government & Defense** — Governed AI systems, policy decisions, classified information handling, defense intelligence
|
||||
- **Critical Infrastructure** — Power grid management, transportation safety, water treatment, emergency response
|
||||
- **Autonomous Systems** — Self-driving vehicles, drone navigation, robotics safety, industrial automation
|
||||
|
||||
---
|
||||
|
||||
## 👥 Who Uses Semantica?
|
||||
|
||||
- **🤖 AI / ML Engineers** — Building explainable GraphRAG & agents
|
||||
- **⚙️ Data Engineers** — Creating governed semantic pipelines
|
||||
- **📊 Knowledge Engineers** — Managing ontologies & KGs at scale
|
||||
- **🏢 Enterprise Teams** — Requiring trustworthy AI infrastructure
|
||||
- **🛡️ Risk & Compliance Teams** — Needing audit-ready systems
|
||||
- **AI / ML Engineers** — Building explainable GraphRAG & agents
|
||||
- **Data Engineers** — Creating governed semantic pipelines
|
||||
- **Knowledge Engineers** — Managing ontologies & KGs at scale
|
||||
- **Enterprise Teams** — Requiring trustworthy AI infrastructure
|
||||
- **Risk & Compliance Teams** — Needing audit-ready systems
|
||||
|
||||
---
|
||||
|
||||
@@ -509,13 +615,13 @@ results = vector_store.search(query="supply chain", top_k=5)
|
||||
|
||||
### Graph Store & Triplet Store
|
||||
|
||||
> **Neo4j, FalkorDB, Amazon Neptune** • **SPARQL queries** • **RDF triplets**
|
||||
> **Neo4j, FalkorDB, Amazon Neptune, Apache AGE** • **SPARQL queries** • **RDF triplets**
|
||||
|
||||
```python
|
||||
from semantica.graph_store import GraphStore
|
||||
from semantica.triplet_store import TripletStore
|
||||
|
||||
# Graph Store (Neo4j, FalkorDB)
|
||||
# Graph Store (Neo4j, FalkorDB, Apache AGE)
|
||||
graph_store = GraphStore(backend="neo4j", uri="bolt://localhost:7687", user="neo4j", password="password")
|
||||
graph_store.add_nodes([{"id": "n1", "labels": ["Person"], "properties": {"name": "Alice"}}])
|
||||
|
||||
@@ -537,6 +643,15 @@ neptune_store.add_nodes([
|
||||
# Query Operations
|
||||
result = neptune_store.execute_query("MATCH (p:Person) RETURN p.name, p.age")
|
||||
|
||||
# Apache AGE Graph Store (PostgreSQL + openCypher)
|
||||
age_store = GraphStore(
|
||||
backend="age",
|
||||
connection_string="host=localhost dbname=agedb user=postgres password=secret",
|
||||
graph_name="semantica",
|
||||
)
|
||||
age_store.connect()
|
||||
age_store.create_node(labels=["Person"], properties={"name": "Alice", "age": 30})
|
||||
|
||||
# Triplet Store (Blazegraph, Jena, RDF4J)
|
||||
triplet_store = TripletStore(backend="blazegraph", endpoint="http://localhost:9999/blazegraph")
|
||||
triplet_store.add_triplet({"subject": "Alice", "predicate": "knows", "object": "Bob"})
|
||||
@@ -592,12 +707,12 @@ is_valid = kg_manager.verify_checksum(snapshot)
|
||||
```
|
||||
|
||||
**What We Provide:**
|
||||
- 🔐 **Persistent Storage** — SQLite and in-memory backends implemented
|
||||
- 📊 **Detailed Diffs** — Entity-level and relationship-level change tracking
|
||||
- ✅ **Data Integrity** — SHA-256 checksums with tamper detection
|
||||
- 📝 **Standardized Metadata** — ChangeLogEntry with author, timestamp, description
|
||||
- ⚡ **Performance Tested** — Tested with large-scale entity datasets
|
||||
- 🧪 **Test Coverage** — Comprehensive test coverage covering core functionality
|
||||
- **Persistent Storage** — SQLite and in-memory backends implemented
|
||||
- **Detailed Diffs** — Entity-level and relationship-level change tracking
|
||||
- **Data Integrity** — SHA-256 checksums with tamper detection
|
||||
- **Standardized Metadata** — ChangeLogEntry with author, timestamp, description
|
||||
- **Performance Tested** — Tested with large-scale entity datasets
|
||||
- **Test Coverage** — Comprehensive test coverage covering core functionality
|
||||
|
||||
**Compliance Note:** Provides technical infrastructure (audit trails, checksums, temporal tracking) that supports compliance efforts for HIPAA, SOX, FDA 21 CFR Part 11. Organizations must implement additional policies and procedures for full regulatory compliance.
|
||||
|
||||
@@ -713,11 +828,11 @@ retriever = context.retriever # Access underlying ContextRetriever
|
||||
results = retriever.retrieve(
|
||||
query="What is the user building?",
|
||||
max_results=10,
|
||||
use_graph_expansion=True
|
||||
graph_expansion=True
|
||||
)
|
||||
|
||||
# Retrieve with context expansion
|
||||
results = context.retrieve("What is the user building?", use_graph_expansion=True)
|
||||
results = context.retrieve("What is the user building?", graph_expansion=True)
|
||||
|
||||
# Query with reasoning and LLM-generated responses
|
||||
llm_provider = Groq(model="llama-3.1-8b-instant", api_key=os.getenv("GROQ_API_KEY"))
|
||||
@@ -733,6 +848,106 @@ reasoned_result = context.query_with_reasoning(
|
||||
- **ContextRetriever**: Performs hybrid retrieval combining vector search, graph traversal, and memory for optimal context relevance
|
||||
- **AgentContext**: High-level interface integrating Context Graph and Context Retriever for GraphRAG applications
|
||||
|
||||
#### Context Graphs: Advanced Decision Tracking & Analytics
|
||||
|
||||
```python
|
||||
from semantica.context import AgentContext, ContextGraph
|
||||
from semantica.vector_store import VectorStore
|
||||
|
||||
# Initialize with advanced decision tracking
|
||||
context = AgentContext(
|
||||
vector_store=VectorStore(backend="inmemory", dimension=128),
|
||||
knowledge_graph=ContextGraph(advanced_analytics=True),
|
||||
decision_tracking=True,
|
||||
kg_algorithms=True, # Enable advanced graph analytics
|
||||
)
|
||||
|
||||
# Easy decision recording with convenience methods
|
||||
decision_id = context.graph_builder.add_decision(
|
||||
category="credit_approval",
|
||||
scenario="High-risk credit limit increase",
|
||||
reasoning="Recent velocity-check failure and prior fraud flag",
|
||||
outcome="rejected",
|
||||
confidence=0.78,
|
||||
entities=["customer:jessica_norris"],
|
||||
)
|
||||
|
||||
# Find similar decisions with advanced analytics
|
||||
similar_decisions = context.graph_builder.find_similar_decisions(
|
||||
scenario="credit increase",
|
||||
category="credit_approval",
|
||||
max_results=5,
|
||||
)
|
||||
|
||||
# Analyze decision impact and influence
|
||||
impact_analysis = context.graph_builder.analyze_decision_impact(decision_id)
|
||||
node_importance = context.graph_builder.get_node_importance("customer:jessica_norris")
|
||||
|
||||
# Check compliance with business rules
|
||||
compliance = context.graph_builder.check_decision_rules({
|
||||
"category": "credit_approval",
|
||||
"scenario": "Credit limit increase",
|
||||
"reasoning": "Risk assessment completed",
|
||||
"outcome": "rejected",
|
||||
"confidence": 0.78
|
||||
})
|
||||
```
|
||||
|
||||
**Enhanced Features:**
|
||||
- **Easy-to-Use Methods**: 10 convenience methods for common operations
|
||||
- **Decision Analytics**: Influence analysis, centrality measures, community detection
|
||||
- **Policy Engine**: Automated compliance checking with business rules
|
||||
- **Causal Analysis**: Trace decision causality and impact chains
|
||||
- **Graph Analytics**: Advanced KG algorithms (Node2Vec, centrality, community detection)
|
||||
- **Hybrid Search**: Semantic + structural + category similarity
|
||||
- **Production Ready**: Scalable architecture with comprehensive error handling
|
||||
|
||||
## Configuration Options
|
||||
|
||||
### Simple Setup (Most Common)
|
||||
```python
|
||||
# Just memory and basic learning
|
||||
agent = AgentContext(vector_store=vector_store)
|
||||
```
|
||||
|
||||
### Smart Setup (Recommended)
|
||||
```python
|
||||
# Memory + decision learning
|
||||
agent = AgentContext(
|
||||
vector_store=vector_store,
|
||||
decision_tracking=True,
|
||||
graph_expansion=True
|
||||
)
|
||||
```
|
||||
|
||||
### Complete Setup (Maximum Power)
|
||||
```python
|
||||
# Everything enabled
|
||||
agent = AgentContext(
|
||||
vector_store=vector_store,
|
||||
knowledge_graph=ContextGraph(advanced_analytics=True),
|
||||
decision_tracking=True,
|
||||
graph_expansion=True,
|
||||
advanced_analytics=True,
|
||||
kg_algorithms=True,
|
||||
vector_store_features=True
|
||||
)
|
||||
```
|
||||
|
||||
### ContextGraph Options
|
||||
```python
|
||||
# Basic knowledge graph
|
||||
graph = ContextGraph()
|
||||
|
||||
# Advanced knowledge graph
|
||||
graph = ContextGraph(
|
||||
advanced_analytics=True, # Enable smart algorithms
|
||||
centrality_analysis=True, # Find important concepts
|
||||
community_detection=True, # Find groups of related concepts
|
||||
node_embeddings=True # Understand concept similarity
|
||||
)
|
||||
```
|
||||
|
||||
**Core Notebooks:**
|
||||
- [**Context Module Introduction**](https://github.com/Hawksight-AI/semantica/tree/main/cookbook/introduction/19_Context_Module.ipynb) - Basic memory and storage.
|
||||
- [**Advanced Context Engineering**](https://github.com/Hawksight-AI/semantica/tree/main/cookbook/advanced/11_Advanced_Context_Engineering.ipynb) - Hybrid retrieval, graph builders, and custom memory policies.
|
||||
|
||||
+1
-1
@@ -332,7 +332,7 @@ from semantica.reasoning import Reasoner
|
||||
context = AgentContext(
|
||||
vector_store=vs,
|
||||
knowledge_graph=kg,
|
||||
use_graph_expansion=True,
|
||||
graph_expansion=True,
|
||||
hybrid_alpha=0.7
|
||||
)
|
||||
|
||||
|
||||
@@ -0,0 +1,243 @@
|
||||
# Apache AGE Graph Store
|
||||
|
||||
**Backend**: PostgreSQL + [Apache AGE](https://age.apache.org/)
|
||||
**Driver**: `psycopg2`
|
||||
|
||||
Apache AGE is a PostgreSQL extension that adds graph database functionality, enabling you to run openCypher queries alongside traditional SQL. This backend lets Semantica use AGE as a property graph store with the same interface as Neo4j and FalkorDB.
|
||||
|
||||
---
|
||||
|
||||
## Prerequisites
|
||||
|
||||
| Component | Version |
|
||||
|-----------|---------|
|
||||
| PostgreSQL | 12+ |
|
||||
| Apache AGE | 1.4+ (compiled and installed) |
|
||||
| psycopg2 | 2.9+ |
|
||||
|
||||
```bash
|
||||
pip install psycopg2-binary
|
||||
```
|
||||
|
||||
> **Note**: Apache AGE must be compiled and installed into your PostgreSQL instance. See the [AGE installation guide](https://age.apache.org/age-manual/master/intro/setup.html).
|
||||
|
||||
---
|
||||
|
||||
## Quick Start
|
||||
|
||||
```python
|
||||
from semantica.graph_store import GraphStore
|
||||
|
||||
# Using the unified GraphStore facade
|
||||
store = GraphStore(
|
||||
backend="age",
|
||||
connection_string="host=localhost dbname=agedb user=postgres password=secret",
|
||||
graph_name="semantica",
|
||||
)
|
||||
store.connect()
|
||||
|
||||
# Create nodes
|
||||
alice = store.create_node(labels=["Person"], properties={"name": "Alice", "age": 30})
|
||||
bob = store.create_node(labels=["Person"], properties={"name": "Bob", "age": 25})
|
||||
|
||||
# Create relationship
|
||||
rel = store.create_relationship(alice["id"], bob["id"], "KNOWS", {"since": 2023})
|
||||
|
||||
# Query
|
||||
result = store.execute_query("MATCH (p:Person) RETURN p", cols="p agtype")
|
||||
print(result["records"])
|
||||
|
||||
store.close()
|
||||
```
|
||||
|
||||
### Direct Usage (without facade)
|
||||
|
||||
```python
|
||||
from semantica.graph_store.age_store import ApacheAgeStore
|
||||
|
||||
store = ApacheAgeStore(
|
||||
connection_string="host=localhost dbname=agedb user=postgres password=secret",
|
||||
graph_name="my_graph",
|
||||
)
|
||||
store.connect()
|
||||
|
||||
node = store.create_node(["Entity"], {"semantica_id": "ent-001", "value": "test"})
|
||||
print(node)
|
||||
# {"id": 844424930131969, "labels": ["Entity"], "properties": {"semantica_id": "ent-001", "value": "test"}}
|
||||
|
||||
store.close()
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Configuration
|
||||
|
||||
### Environment Variables
|
||||
|
||||
| Variable | Description | Default |
|
||||
|----------|-------------|---------|
|
||||
| `GRAPH_STORE_AGE_CONNECTION_STRING` | PostgreSQL connection string | `host=localhost dbname=agedb user=postgres password=postgres` |
|
||||
| `GRAPH_STORE_AGE_GRAPH_NAME` | AGE graph name | `semantica` |
|
||||
|
||||
### Programmatic Configuration
|
||||
|
||||
```python
|
||||
from semantica.graph_store.config import graph_store_config
|
||||
|
||||
graph_store_config.set("age_connection_string", "host=db.example.com dbname=prod_age user=app")
|
||||
graph_store_config.set("age_graph_name", "production")
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Connection & Initialization
|
||||
|
||||
On `connect()`, the store performs idempotent setup:
|
||||
|
||||
1. `CREATE EXTENSION IF NOT EXISTS age;`
|
||||
2. `LOAD 'age';`
|
||||
3. `SET search_path = ag_catalog, "$user", public;`
|
||||
4. Creates the named graph if it does not already exist.
|
||||
|
||||
This is safe to call repeatedly.
|
||||
|
||||
---
|
||||
|
||||
## ID Handling
|
||||
|
||||
Apache AGE auto-generates internal vertex/edge IDs (large integers). These are **not** the same as any semantic or application-level ID you may want to assign.
|
||||
|
||||
| Concept | Description |
|
||||
|---------|-------------|
|
||||
| **AGE internal ID** | Auto-generated by AGE. Exposed as `"id"` in all returned dicts. Used in `delete_node()`, `get_node()`, etc. |
|
||||
| **Semantic ID** | Application-level identifier. Store it in the `semantica_id` property. |
|
||||
|
||||
```python
|
||||
node = store.create_node(
|
||||
labels=["Document"],
|
||||
properties={"semantica_id": "doc-abc-123", "title": "My Doc"},
|
||||
)
|
||||
# node["id"] → AGE internal ID (e.g., 844424930131969)
|
||||
# node["properties"]["semantica_id"] → "doc-abc-123"
|
||||
```
|
||||
|
||||
> **Important**: Never mix AGE internal IDs with semantic IDs. Use `node["id"]` for graph operations (delete, update, traverse) and `node["properties"]["semantica_id"]` for application-level lookups.
|
||||
|
||||
---
|
||||
|
||||
## Label Handling
|
||||
|
||||
AGE supports exactly **one label per vertex**. Semantica handles this transparently:
|
||||
|
||||
- `labels[0]` → used as the primary AGE vertex label.
|
||||
- `labels[1:]` → stored in a `labels` property array on the vertex.
|
||||
|
||||
When reading nodes, the store reconstructs the full label list automatically.
|
||||
|
||||
```python
|
||||
node = store.create_node(
|
||||
labels=["Person", "Employee", "Admin"],
|
||||
properties={"name": "Alice"},
|
||||
)
|
||||
# In AGE: vertex with label "Person" and property labels=["Employee", "Admin"]
|
||||
# Returned: {"id": ..., "labels": ["Person", "Employee", "Admin"], "properties": {"name": "Alice"}}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Cypher Query Execution
|
||||
|
||||
All Cypher queries are executed via AGE's SQL wrapper:
|
||||
|
||||
```sql
|
||||
SELECT * FROM cypher('graph_name', $$ <cypher_query> $$) AS (col1 agtype, ...);
|
||||
```
|
||||
|
||||
### Parameter Substitution
|
||||
|
||||
AGE does not support `$param` style binding inside `cypher()` calls. The store safely converts parameters to Cypher literals with proper escaping:
|
||||
|
||||
```python
|
||||
result = store.execute_query(
|
||||
"MATCH (p:Person) WHERE p.age > $min_age RETURN p",
|
||||
parameters={"min_age": 25},
|
||||
cols="p agtype",
|
||||
)
|
||||
```
|
||||
|
||||
### Column Specification
|
||||
|
||||
For custom queries, pass the `cols` option to specify the `AS` clause:
|
||||
|
||||
```python
|
||||
result = store.execute_query(
|
||||
"MATCH (a)-[r]->(b) RETURN a, r, b",
|
||||
cols="a agtype, r agtype, b agtype",
|
||||
)
|
||||
```
|
||||
|
||||
If omitted, the store attempts to infer columns from the `RETURN` clause.
|
||||
|
||||
---
|
||||
|
||||
## Transactions
|
||||
|
||||
The store uses explicit PostgreSQL transactions:
|
||||
|
||||
- **Success** → `COMMIT`
|
||||
- **Exception** → `ROLLBACK`, then re-raise as `ProcessingError`
|
||||
- No silent failures
|
||||
|
||||
---
|
||||
|
||||
## API Reference
|
||||
|
||||
All methods match the standard Semantica graph store backend interface:
|
||||
|
||||
| Method | Description |
|
||||
|--------|-------------|
|
||||
| `connect(**options)` | Connect and initialize AGE |
|
||||
| `close()` | Close the connection |
|
||||
| `create_node(labels, properties)` | Create a vertex |
|
||||
| `create_nodes(nodes)` | Batch create vertices |
|
||||
| `get_node(node_id)` | Get vertex by AGE ID |
|
||||
| `get_nodes(labels, properties, limit)` | Query vertices |
|
||||
| `update_node(node_id, properties, merge)` | Update vertex properties |
|
||||
| `delete_node(node_id, detach)` | Delete a vertex |
|
||||
| `create_relationship(start_id, end_id, type, properties)` | Create an edge |
|
||||
| `get_relationships(node_id, rel_type, direction, limit)` | Query edges |
|
||||
| `delete_relationship(rel_id)` | Delete an edge |
|
||||
| `execute_query(query, parameters)` | Run arbitrary Cypher |
|
||||
| `get_neighbors(node_id, rel_type, direction, depth)` | Graph traversal |
|
||||
| `shortest_path(start_id, end_id, rel_type, max_depth)` | Path finding |
|
||||
| `create_index(label, property_name, index_type)` | Create a PostgreSQL index |
|
||||
| `get_stats()` | Graph statistics |
|
||||
|
||||
---
|
||||
|
||||
## Docker Setup
|
||||
|
||||
```yaml
|
||||
services:
|
||||
age:
|
||||
image: apache/age:latest
|
||||
ports:
|
||||
- "5432:5432"
|
||||
environment:
|
||||
POSTGRES_USER: postgres
|
||||
POSTGRES_PASSWORD: secret
|
||||
POSTGRES_DB: agedb
|
||||
```
|
||||
|
||||
```bash
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
Then connect:
|
||||
|
||||
```python
|
||||
store = GraphStore(
|
||||
backend="age",
|
||||
connection_string="host=localhost port=5432 dbname=agedb user=postgres password=secret",
|
||||
)
|
||||
```
|
||||
+469
-840
File diff suppressed because it is too large
Load Diff
@@ -46,7 +46,7 @@ Enhanced Analytics:
|
||||
|
||||
Main Classes:
|
||||
- AgentContext: High-level interface with KG integration
|
||||
- ContextGraph: In-memory graph store with KG algorithm support
|
||||
- ContextGraph: In-memory graph store with KG algorithm support and comprehensive decision management
|
||||
- ContextNode/ContextEdge: Graph data structures
|
||||
- AgentMemory: Persistent agent memory with RAG
|
||||
- MemoryItem: Memory item data structure
|
||||
@@ -63,12 +63,13 @@ Decision Tracking Classes:
|
||||
- Policy/Precedent/PolicyException: Decision tracking data structures
|
||||
|
||||
Example Usage:
|
||||
>>> from semantica.context import AgentContext
|
||||
>>> from semantica.context import AgentContext, ContextGraph
|
||||
>>> # Simple AgentContext with decision tracking
|
||||
>>> context = AgentContext(vector_store=vs, knowledge_graph=kg,
|
||||
... enable_decision_tracking=True,
|
||||
... enable_advanced_analytics=True,
|
||||
... enable_kg_algorithms=True,
|
||||
... enable_vector_store_features=True)
|
||||
... decision_tracking=True,
|
||||
... advanced_analytics=True,
|
||||
... kg_algorithms=True,
|
||||
... vector_store_features=True)
|
||||
>>> memory_id = context.store("User asked about Python", conversation_id="conv1")
|
||||
>>> results = context.retrieve("Python programming")
|
||||
>>> decision_id = context.record_decision(category="approval",
|
||||
@@ -81,6 +82,21 @@ Example Usage:
|
||||
... use_kg_features=True)
|
||||
>>> influence = context.analyze_decision_influence(decision_id)
|
||||
>>> insights = context.get_context_insights()
|
||||
|
||||
>>> # Comprehensive ContextGraph with all decision features
|
||||
>>> graph = ContextGraph(advanced_analytics=True, enable_causality=True)
|
||||
>>> decision_id = graph.record_decision(
|
||||
... category="loan_approval",
|
||||
... scenario="First-time homebuyer",
|
||||
... reasoning="Good credit score and stable income",
|
||||
... outcome="approved",
|
||||
... confidence=0.95,
|
||||
... entities=["customer_123", "property_456"]
|
||||
... )
|
||||
>>> precedents = graph.find_precedents("loan_approval", limit=5)
|
||||
>>> influence = graph.analyze_decision_influence(decision_id)
|
||||
>>> insights = graph.get_decision_insights()
|
||||
>>> causality = graph.trace_decision_causality(decision_id)
|
||||
|
||||
Production Examples:
|
||||
- Banking: Mortgage approvals, credit decisions, risk assessment
|
||||
|
||||
+322
-113
@@ -46,10 +46,11 @@ Key Methods:
|
||||
Example Usage:
|
||||
>>> from semantica.context import AgentContext
|
||||
>>> context = AgentContext(vector_store=vs, knowledge_graph=kg,
|
||||
... enable_decision_tracking=True,
|
||||
... enable_advanced_analytics=True,
|
||||
... enable_kg_algorithms=True,
|
||||
... enable_vector_store_features=True)
|
||||
... decision_tracking=True,
|
||||
... advanced_analytics=True,
|
||||
... kg_algorithms=True,
|
||||
... vector_store_features=True,
|
||||
... graph_expansion=True)
|
||||
>>> memory_id = context.store("User asked about Python", conversation_id="conv1")
|
||||
>>> results = context.retrieve("Python programming")
|
||||
>>> decision_id = context.record_decision(category="approval",
|
||||
@@ -124,13 +125,13 @@ class AgentContext:
|
||||
knowledge_graph: Optional[Any] = None,
|
||||
retention_days: Optional[int] = 30,
|
||||
max_memories: int = 10000,
|
||||
use_graph_expansion: bool = True,
|
||||
graph_expansion: bool = True,
|
||||
max_expansion_hops: int = 2,
|
||||
hybrid_alpha: float = 0.5,
|
||||
enable_decision_tracking: bool = False,
|
||||
enable_advanced_analytics: bool = True,
|
||||
enable_kg_algorithms: bool = True,
|
||||
enable_vector_store_features: bool = True,
|
||||
decision_tracking: bool = False,
|
||||
advanced_analytics: bool = True,
|
||||
kg_algorithms: bool = True,
|
||||
vector_store_features: bool = True,
|
||||
**kwargs,
|
||||
):
|
||||
"""
|
||||
@@ -141,14 +142,14 @@ class AgentContext:
|
||||
knowledge_graph: Knowledge graph instance (optional, enables GraphRAG)
|
||||
retention_days: Days to keep memories (default: 30, None=unlimited)
|
||||
max_memories: Maximum number of memories (default: 10000)
|
||||
use_graph_expansion: Enable graph expansion for retrieval (default: True)
|
||||
graph_expansion: Enable graph expansion for retrieval (default: True)
|
||||
max_expansion_hops: Maximum hops for graph expansion (default: 2)
|
||||
hybrid_alpha: Balance between vector (0) and graph (1) retrieval
|
||||
(default: 0.5)
|
||||
enable_decision_tracking: Enable decision tracking features (default: False)
|
||||
enable_advanced_analytics: Enable advanced analytics (default: True)
|
||||
enable_kg_algorithms: Enable KG algorithms integration (default: True)
|
||||
enable_vector_store_features: Enable vector store features (default: True)
|
||||
decision_tracking: Enable decision tracking features (default: False)
|
||||
advanced_analytics: Enable advanced analytics (default: True)
|
||||
kg_algorithms: Enable KG algorithms integration (default: True)
|
||||
vector_store_features: Enable vector store features (default: True)
|
||||
**kwargs: Additional options passed to underlying components
|
||||
|
||||
Raises:
|
||||
@@ -168,11 +169,11 @@ class AgentContext:
|
||||
|
||||
# Store advanced feature flags
|
||||
self.config = {
|
||||
"enable_decision_tracking": enable_decision_tracking,
|
||||
"enable_advanced_analytics": enable_advanced_analytics,
|
||||
"enable_kg_algorithms": enable_kg_algorithms,
|
||||
"enable_vector_store_features": enable_vector_store_features,
|
||||
"use_graph_expansion": use_graph_expansion,
|
||||
"decision_tracking": decision_tracking,
|
||||
"advanced_analytics": advanced_analytics,
|
||||
"kg_algorithms": kg_algorithms,
|
||||
"vector_store_features": vector_store_features,
|
||||
"graph_expansion": graph_expansion,
|
||||
"max_expansion_hops": max_expansion_hops,
|
||||
"hybrid_alpha": hybrid_alpha,
|
||||
**kwargs
|
||||
@@ -195,7 +196,7 @@ class AgentContext:
|
||||
"memory_store": self._memory,
|
||||
"knowledge_graph": knowledge_graph,
|
||||
"vector_store": vector_store,
|
||||
"use_graph_expansion": use_graph_expansion,
|
||||
"use_graph_expansion": graph_expansion,
|
||||
"max_expansion_hops": max_expansion_hops,
|
||||
"hybrid_alpha": hybrid_alpha,
|
||||
**kwargs,
|
||||
@@ -209,62 +210,81 @@ class AgentContext:
|
||||
if knowledge_graph and hasattr(knowledge_graph, "build_from_conversations"):
|
||||
self._graph_builder = knowledge_graph
|
||||
|
||||
# Store config
|
||||
self.config = {
|
||||
self.config.update({
|
||||
"retention_days": retention_days,
|
||||
"max_memories": max_memories,
|
||||
"use_graph_expansion": use_graph_expansion,
|
||||
"max_expansion_hops": max_expansion_hops,
|
||||
"hybrid_alpha": hybrid_alpha,
|
||||
"enable_decision_tracking": enable_decision_tracking,
|
||||
}
|
||||
})
|
||||
|
||||
# Initialize decision tracking components if enabled
|
||||
self._decision_backend = None
|
||||
self._decision_recorder = None
|
||||
self._decision_query = None
|
||||
self._causal_analyzer = None
|
||||
self._policy_engine = None
|
||||
|
||||
if enable_decision_tracking and knowledge_graph:
|
||||
# Validate that knowledge_graph supports required GraphStore interface
|
||||
if not hasattr(knowledge_graph, 'execute_query'):
|
||||
self.logger.error(
|
||||
"Decision tracking requires a GraphStore-compatible knowledge graph with execute_query() method. "
|
||||
"Provided knowledge_graph type does not support Cypher queries. "
|
||||
"Use GraphStore (Neo4j, FalkorDB) or disable decision tracking."
|
||||
)
|
||||
raise ValueError(
|
||||
"Decision tracking requires a GraphStore-compatible knowledge graph. "
|
||||
"The provided knowledge_graph does not have an execute_query() method. "
|
||||
"For decision tracking, use a GraphStore backend (Neo4j, FalkorDB) "
|
||||
"or set enable_decision_tracking=False."
|
||||
)
|
||||
|
||||
# Initialize enhanced decision tracking components
|
||||
try:
|
||||
self._decision_recorder = DecisionRecorder(knowledge_graph)
|
||||
|
||||
# Enhanced DecisionQuery with KG and vector store integration
|
||||
self._decision_query = DecisionQuery(
|
||||
graph_store=knowledge_graph,
|
||||
vector_store=vector_store if enable_vector_store_features else None,
|
||||
enable_advanced_analytics=enable_advanced_analytics,
|
||||
enable_centrality_analysis=enable_kg_algorithms,
|
||||
enable_community_detection=enable_kg_algorithms,
|
||||
enable_node_embeddings=enable_kg_algorithms
|
||||
)
|
||||
|
||||
self._causal_analyzer = CausalChainAnalyzer(knowledge_graph)
|
||||
if decision_tracking and knowledge_graph:
|
||||
if hasattr(knowledge_graph, "execute_query"):
|
||||
self._decision_backend = "graph_store"
|
||||
try:
|
||||
self._decision_recorder = DecisionRecorder(knowledge_graph)
|
||||
self._decision_query = DecisionQuery(
|
||||
graph_store=knowledge_graph,
|
||||
vector_store=vector_store if vector_store_features else None,
|
||||
advanced_analytics=advanced_analytics,
|
||||
centrality_analysis=kg_algorithms,
|
||||
community_detection=kg_algorithms,
|
||||
node_embeddings=kg_algorithms
|
||||
)
|
||||
self._causal_analyzer = CausalChainAnalyzer(knowledge_graph)
|
||||
self._policy_engine = PolicyEngine(knowledge_graph)
|
||||
self.logger.info("Enhanced decision tracking components initialized successfully")
|
||||
except Exception as e:
|
||||
self.logger.warning(
|
||||
f"Failed to initialize enhanced decision tracking ({type(e).__name__})"
|
||||
)
|
||||
self._decision_recorder = DecisionRecorder(knowledge_graph)
|
||||
self._decision_query = DecisionQuery(knowledge_graph)
|
||||
self._causal_analyzer = CausalChainAnalyzer(knowledge_graph)
|
||||
self._policy_engine = PolicyEngine(knowledge_graph)
|
||||
else:
|
||||
self._decision_backend = "context_graph"
|
||||
# Initialize basic decision components for ContextGraph
|
||||
self._policy_engine = PolicyEngine(knowledge_graph)
|
||||
|
||||
self.logger.info("Enhanced decision tracking components initialized successfully")
|
||||
except Exception as e:
|
||||
self.logger.warning(f"Failed to initialize enhanced decision tracking: {e}")
|
||||
# Fallback to basic components
|
||||
self._decision_recorder = DecisionRecorder(knowledge_graph)
|
||||
self._decision_query = DecisionQuery(knowledge_graph)
|
||||
self._causal_analyzer = CausalChainAnalyzer(knowledge_graph)
|
||||
self._policy_engine = PolicyEngine(knowledge_graph)
|
||||
|
||||
# Initialize DecisionQuery for ContextGraph
|
||||
try:
|
||||
self._decision_query = DecisionQuery(
|
||||
graph_store=knowledge_graph,
|
||||
vector_store=vector_store if vector_store_features else None,
|
||||
advanced_analytics=advanced_analytics,
|
||||
centrality_analysis=kg_algorithms,
|
||||
community_detection=kg_algorithms,
|
||||
node_embeddings=kg_algorithms
|
||||
)
|
||||
self.logger.info("ContextGraph decision tracking initialized successfully")
|
||||
except Exception as e:
|
||||
self.logger.warning(
|
||||
f"Failed to initialize DecisionQuery for ContextGraph ({type(e).__name__})"
|
||||
)
|
||||
# Create a minimal DecisionQuery that delegates to ContextGraph
|
||||
self._decision_query = type('MinimalDecisionQuery', (), {
|
||||
'analyze_decision_influence': lambda self, decision_id, max_depth=3:
|
||||
knowledge_graph.analyze_decision_influence(decision_id, max_depth) if hasattr(knowledge_graph, 'analyze_decision_influence') else {},
|
||||
'find_precedents': lambda self, query, category=None, limit=10:
|
||||
knowledge_graph.find_precedents(query, category, limit) if hasattr(knowledge_graph, 'find_precedents') else [],
|
||||
})()
|
||||
|
||||
if vector_store_features and hasattr(self.vector_store, "initialize_decision_pipeline"):
|
||||
try:
|
||||
self.vector_store.initialize_decision_pipeline(
|
||||
graph_store=knowledge_graph if kg_algorithms else None,
|
||||
use_graph_features=kg_algorithms
|
||||
)
|
||||
except Exception as e:
|
||||
self.logger.warning(
|
||||
f"Failed to initialize decision pipeline ({type(e).__name__})"
|
||||
)
|
||||
|
||||
@property
|
||||
def memory(self) -> AgentMemory:
|
||||
@@ -766,7 +786,7 @@ class AgentContext:
|
||||
"edge_count": graph.get("statistics", {}).get("edge_count", 0),
|
||||
}
|
||||
except Exception as e:
|
||||
self.logger.warning(f"Failed to build graph from documents: {e}")
|
||||
self.logger.warning(f"Failed to build graph from documents ({type(e).__name__})")
|
||||
return {"node_count": 0, "edge_count": 0}
|
||||
|
||||
def _context_to_dict(
|
||||
@@ -1467,7 +1487,7 @@ class AgentContext:
|
||||
if memory_id:
|
||||
imported += 1
|
||||
except Exception as e:
|
||||
self.logger.warning(f"Failed to import memory: {e}")
|
||||
self.logger.warning(f"Failed to import memory ({type(e).__name__})")
|
||||
|
||||
return imported
|
||||
|
||||
@@ -1560,7 +1580,7 @@ class AgentContext:
|
||||
Raises:
|
||||
RuntimeError: If decision tracking is not enabled
|
||||
"""
|
||||
if not self._decision_recorder:
|
||||
if not self._decision_backend:
|
||||
raise RuntimeError("Decision tracking is not enabled")
|
||||
|
||||
from .decision_models import Decision
|
||||
@@ -1579,16 +1599,33 @@ class AgentContext:
|
||||
|
||||
entities = entities or []
|
||||
source_documents = [] # Could be enhanced to capture source docs
|
||||
|
||||
decision_id = self._decision_recorder.record_decision(
|
||||
decision, entities, source_documents
|
||||
)
|
||||
|
||||
# Capture cross-system context if provided
|
||||
if cross_system_context:
|
||||
self._decision_recorder.capture_cross_system_context(
|
||||
decision_id, cross_system_context
|
||||
|
||||
if self._decision_backend == "graph_store":
|
||||
decision_id = self._decision_recorder.record_decision(
|
||||
decision, entities, source_documents
|
||||
)
|
||||
|
||||
if cross_system_context:
|
||||
self._decision_recorder.capture_cross_system_context(
|
||||
decision_id, cross_system_context
|
||||
)
|
||||
|
||||
return decision_id
|
||||
|
||||
if not hasattr(self.knowledge_graph, "record_decision"):
|
||||
raise RuntimeError("Decision tracking backend does not support decisions")
|
||||
|
||||
# Delegate to ContextGraph
|
||||
decision_id = self.knowledge_graph.record_decision(
|
||||
category=category,
|
||||
scenario=scenario,
|
||||
reasoning=reasoning,
|
||||
outcome=outcome,
|
||||
confidence=confidence,
|
||||
entities=entities,
|
||||
decision_maker=decision_maker,
|
||||
metadata={"cross_system_context": cross_system_context} if cross_system_context else None
|
||||
)
|
||||
|
||||
return decision_id
|
||||
|
||||
@@ -1618,24 +1655,130 @@ class AgentContext:
|
||||
Raises:
|
||||
RuntimeError: If decision tracking is not enabled
|
||||
"""
|
||||
if not self._decision_query:
|
||||
if not self._decision_backend:
|
||||
raise RuntimeError("Decision tracking is not enabled")
|
||||
|
||||
if use_hybrid_search:
|
||||
|
||||
# Delegate to ContextGraph if available
|
||||
if self._decision_backend == "context_graph" and hasattr(self.knowledge_graph, "find_precedents_by_scenario"):
|
||||
try:
|
||||
return self._decision_query.find_precedents_hybrid(
|
||||
scenario, category, limit
|
||||
precedents = self.knowledge_graph.find_precedents_by_scenario(
|
||||
scenario=scenario,
|
||||
category=category,
|
||||
limit=limit,
|
||||
use_semantic_search=use_hybrid_search
|
||||
)
|
||||
except Exception:
|
||||
# Fallback to basic search if hybrid fails
|
||||
return self._decision_query._find_precedents_basic(scenario, category, limit)
|
||||
else:
|
||||
# Simple category-based search
|
||||
# Convert to Decision objects if needed
|
||||
from .decision_models import Decision
|
||||
decisions = []
|
||||
for precedent in precedents:
|
||||
decision_data = precedent["decision"]
|
||||
metadata = dict(decision_data.get("metadata", {}) or {})
|
||||
if "entities" in decision_data:
|
||||
metadata["entities"] = decision_data.get("entities", [])
|
||||
decision = Decision(
|
||||
decision_id=decision_data["id"],
|
||||
category=decision_data["category"],
|
||||
scenario=decision_data["scenario"],
|
||||
reasoning=decision_data["reasoning"],
|
||||
outcome=decision_data["outcome"],
|
||||
confidence=decision_data["confidence"],
|
||||
timestamp=datetime.fromtimestamp(decision_data["timestamp"]),
|
||||
decision_maker=decision_data.get("decision_maker"),
|
||||
metadata=metadata,
|
||||
)
|
||||
decisions.append(decision)
|
||||
return decisions
|
||||
except Exception as e:
|
||||
self.logger.exception("ContextGraph find_precedents failed")
|
||||
return []
|
||||
|
||||
# Fallback to DecisionQuery for graph_store backend
|
||||
if self._decision_backend == "graph_store":
|
||||
if use_hybrid_search:
|
||||
try:
|
||||
return self._decision_query.find_precedents_hybrid(
|
||||
scenario, category, limit
|
||||
)
|
||||
except Exception:
|
||||
return self._decision_query._find_precedents_basic(scenario, category, limit)
|
||||
if category:
|
||||
return self._decision_query.find_by_category(category, limit)
|
||||
else:
|
||||
# Use basic search
|
||||
return self._decision_query._find_precedents_basic(scenario, category, limit)
|
||||
return self._decision_query._find_precedents_basic(scenario, category, limit)
|
||||
|
||||
results: List[Decision] = []
|
||||
|
||||
def _safe_parse_timestamp(value: Any) -> datetime:
|
||||
if isinstance(value, datetime):
|
||||
return value
|
||||
if not value:
|
||||
return datetime.now()
|
||||
try:
|
||||
return datetime.fromisoformat(str(value))
|
||||
except Exception:
|
||||
return datetime.now()
|
||||
|
||||
if use_hybrid_search and hasattr(self.vector_store, "search_decisions"):
|
||||
filters = {"category": category} if category else None
|
||||
vector_results = self.vector_store.search_decisions(
|
||||
query=scenario,
|
||||
filters=filters,
|
||||
limit=limit,
|
||||
use_hybrid_search=True
|
||||
)
|
||||
for r in vector_results:
|
||||
meta = r.get("metadata") or {}
|
||||
decision_id = meta.get("decision_id") or meta.get("id")
|
||||
if decision_id and hasattr(self.knowledge_graph, "nodes") and decision_id in self.knowledge_graph.nodes:
|
||||
node = self.knowledge_graph.nodes[decision_id]
|
||||
if getattr(node, "node_type", None) == "Decision":
|
||||
data = getattr(node, "properties", {}) or {}
|
||||
decision = Decision(
|
||||
decision_id=decision_id,
|
||||
category=data.get("category", ""),
|
||||
scenario=getattr(node, "content", ""),
|
||||
reasoning=data.get("reasoning", ""),
|
||||
outcome=data.get("outcome", ""),
|
||||
confidence=float(data.get("confidence", 0.0) or 0.0),
|
||||
timestamp=_safe_parse_timestamp(data.get("timestamp")),
|
||||
decision_maker=data.get("decision_maker", "ai_agent"),
|
||||
reasoning_embedding=data.get("reasoning_embedding"),
|
||||
node2vec_embedding=data.get("node2vec_embedding"),
|
||||
metadata={k: v for k, v in data.items() if k not in [
|
||||
"category", "reasoning", "outcome", "confidence",
|
||||
"timestamp", "decision_maker", "reasoning_embedding", "node2vec_embedding"
|
||||
]}
|
||||
)
|
||||
decision.metadata["score"] = r.get("score")
|
||||
results.append(decision)
|
||||
|
||||
if results:
|
||||
return results[:limit]
|
||||
|
||||
if hasattr(self.knowledge_graph, "find_nodes"):
|
||||
for node in self.knowledge_graph.find_nodes(node_type="Decision"):
|
||||
if category and node.get("metadata", {}).get("category") != category:
|
||||
continue
|
||||
data = node.get("metadata", {}) or {}
|
||||
results.append(
|
||||
Decision(
|
||||
decision_id=node.get("id", ""),
|
||||
category=data.get("category", ""),
|
||||
scenario=node.get("content", ""),
|
||||
reasoning=data.get("reasoning", ""),
|
||||
outcome=data.get("outcome", ""),
|
||||
confidence=float(data.get("confidence", 0.0) or 0.0),
|
||||
timestamp=_safe_parse_timestamp(data.get("timestamp")),
|
||||
decision_maker=data.get("decision_maker", "ai_agent"),
|
||||
reasoning_embedding=data.get("reasoning_embedding"),
|
||||
node2vec_embedding=data.get("node2vec_embedding"),
|
||||
metadata={k: v for k, v in data.items() if k not in [
|
||||
"category", "reasoning", "outcome", "confidence",
|
||||
"timestamp", "decision_maker", "reasoning_embedding", "node2vec_embedding"
|
||||
]}
|
||||
)
|
||||
)
|
||||
|
||||
return results[:limit]
|
||||
|
||||
def get_causal_chain(
|
||||
self,
|
||||
@@ -1657,12 +1800,35 @@ class AgentContext:
|
||||
Raises:
|
||||
RuntimeError: If decision tracking is not enabled
|
||||
"""
|
||||
if not self._causal_analyzer:
|
||||
if not self._decision_backend:
|
||||
raise RuntimeError("Decision tracking is not enabled")
|
||||
|
||||
return self._causal_analyzer.get_causal_chain(
|
||||
decision_id, direction, max_depth
|
||||
)
|
||||
|
||||
if self._decision_backend == "graph_store":
|
||||
return self._causal_analyzer.get_causal_chain(
|
||||
decision_id, direction, max_depth
|
||||
)
|
||||
|
||||
if self._decision_backend == "context_graph":
|
||||
# Use ContextGraph's get_causal_chain method
|
||||
if hasattr(self.knowledge_graph, "get_causal_chain"):
|
||||
return self.knowledge_graph.get_causal_chain(
|
||||
decision_id=decision_id,
|
||||
direction=direction,
|
||||
max_depth=max_depth
|
||||
)
|
||||
# Fallback to causal analyzer
|
||||
return self._causal_analyzer.get_causal_chain(
|
||||
decision_id, direction, max_depth
|
||||
)
|
||||
|
||||
if hasattr(self.knowledge_graph, "get_causal_chain"):
|
||||
return self.knowledge_graph.get_causal_chain(
|
||||
decision_id=decision_id,
|
||||
direction=direction,
|
||||
max_depth=max_depth
|
||||
)
|
||||
|
||||
raise RuntimeError("Decision tracking backend does not support causal chains")
|
||||
|
||||
def get_policy_engine(self) -> PolicyEngine:
|
||||
"""
|
||||
@@ -1791,17 +1957,50 @@ class AgentContext:
|
||||
Returns:
|
||||
Cross-system context
|
||||
"""
|
||||
# This is a placeholder for cross-system context capture
|
||||
# In practice, this would integrate with various systems
|
||||
context = {}
|
||||
|
||||
|
||||
for system in systems:
|
||||
context[system] = {
|
||||
captured_at = datetime.now().isoformat()
|
||||
payload: Dict[str, Any] = {
|
||||
"entity_id": entity_id,
|
||||
"system_name": system,
|
||||
"captured_at": datetime.now().isoformat(),
|
||||
"status": "captured"
|
||||
"captured_at": captured_at,
|
||||
}
|
||||
|
||||
try:
|
||||
# GraphStore-backed capture path
|
||||
if self.knowledge_graph and hasattr(self.knowledge_graph, "execute_query"):
|
||||
query = """
|
||||
MATCH (c:CrossSystemContext {system_name: $system_name})
|
||||
WHERE c.context_data IS NOT NULL
|
||||
RETURN c
|
||||
ORDER BY c.created_at DESC
|
||||
LIMIT 5
|
||||
"""
|
||||
result = self.knowledge_graph.execute_query(
|
||||
query, {"system_name": system}
|
||||
)
|
||||
records = result.get("records", []) if isinstance(result, dict) else result
|
||||
payload["status"] = "captured"
|
||||
payload["records_found"] = len(records) if isinstance(records, list) else 0
|
||||
payload["records"] = records if isinstance(records, list) else []
|
||||
else:
|
||||
payload["status"] = "captured_without_backend"
|
||||
payload["records_found"] = 0
|
||||
payload["records"] = []
|
||||
except Exception as e:
|
||||
self.logger.warning(
|
||||
"Cross-system input capture failed for system=%s entity_id=%s: %s",
|
||||
system,
|
||||
entity_id,
|
||||
str(e),
|
||||
)
|
||||
payload["status"] = "capture_failed"
|
||||
payload["error"] = "internal_capture_error"
|
||||
payload["records_found"] = 0
|
||||
payload["records"] = []
|
||||
|
||||
context[system] = payload
|
||||
|
||||
return context
|
||||
|
||||
@@ -1854,7 +2053,7 @@ class AgentContext:
|
||||
Returns:
|
||||
Comprehensive graph analysis results
|
||||
"""
|
||||
if not self._graph_builder or not self.config.get("enable_advanced_analytics", True):
|
||||
if not self._graph_builder or not self.config.get("advanced_analytics", True):
|
||||
return {"error": "Advanced analytics not available"}
|
||||
|
||||
try:
|
||||
@@ -1869,7 +2068,7 @@ class AgentContext:
|
||||
"message": "Basic analysis only - KG features not available"
|
||||
}
|
||||
except Exception as e:
|
||||
self.logger.error(f"Failed to analyze context graph: {e}")
|
||||
self.logger.error(f"Failed to analyze context graph ({type(e).__name__})")
|
||||
return {"error": str(e)}
|
||||
|
||||
def find_similar_entities(
|
||||
@@ -1896,7 +2095,7 @@ class AgentContext:
|
||||
# Fallback to basic content similarity
|
||||
return []
|
||||
except Exception as e:
|
||||
self.logger.error(f"Failed to find similar entities: {e}")
|
||||
self.logger.error(f"Failed to find similar entities ({type(e).__name__})")
|
||||
return []
|
||||
|
||||
def get_entity_centrality(self, entity_id: str) -> Dict[str, float]:
|
||||
@@ -1918,7 +2117,7 @@ class AgentContext:
|
||||
else:
|
||||
return {"error": "Centrality analysis not available"}
|
||||
except Exception as e:
|
||||
self.logger.error(f"Failed to get entity centrality: {e}")
|
||||
self.logger.error(f"Failed to get entity centrality ({type(e).__name__})")
|
||||
return {"error": str(e)}
|
||||
|
||||
def find_precedents_advanced(
|
||||
@@ -1958,7 +2157,7 @@ class AgentContext:
|
||||
# Fallback to basic method
|
||||
return self.find_precedents(scenario, category, limit)
|
||||
except Exception as e:
|
||||
self.logger.error(f"Failed to find advanced precedents: {e}")
|
||||
self.logger.error(f"Failed to find advanced precedents ({type(e).__name__})")
|
||||
return []
|
||||
|
||||
def analyze_decision_influence(self, decision_id: str, max_depth: int = 3) -> Dict[str, Any]:
|
||||
@@ -1975,11 +2174,21 @@ class AgentContext:
|
||||
if not self._decision_query:
|
||||
raise RuntimeError("Decision tracking is not enabled")
|
||||
|
||||
# Delegate to ContextGraph if available
|
||||
if hasattr(self.knowledge_graph, "analyze_decision_influence"):
|
||||
try:
|
||||
return self.knowledge_graph.analyze_decision_influence(decision_id, max_depth)
|
||||
except Exception as e:
|
||||
self.logger.error(f"ContextGraph analyze_decision_influence failed: {e}")
|
||||
# Fallback to DecisionQuery
|
||||
pass
|
||||
|
||||
# Fallback to DecisionQuery
|
||||
try:
|
||||
if hasattr(self._decision_query, 'analyze_decision_influence'):
|
||||
return self._decision_query.analyze_decision_influence(decision_id, max_depth)
|
||||
else:
|
||||
# Fallback to basic causal chain
|
||||
# Basic causal chain fallback
|
||||
return {
|
||||
"decision_id": decision_id,
|
||||
"downstream_decisions": self.get_causal_chain(decision_id, "downstream", max_depth),
|
||||
@@ -1987,7 +2196,7 @@ class AgentContext:
|
||||
"message": "Basic analysis only - KG features not available"
|
||||
}
|
||||
except Exception as e:
|
||||
self.logger.error(f"Failed to analyze decision influence: {e}")
|
||||
self.logger.error(f"Failed to analyze decision influence ({type(e).__name__})")
|
||||
return {"error": str(e)}
|
||||
|
||||
def predict_decision_relationships(self, decision_id: str, top_k: int = 5) -> List[Dict]:
|
||||
@@ -2010,7 +2219,7 @@ class AgentContext:
|
||||
else:
|
||||
return []
|
||||
except Exception as e:
|
||||
self.logger.error(f"Failed to predict decision relationships: {e}")
|
||||
self.logger.error(f"Failed to predict decision relationships ({type(e).__name__})")
|
||||
return []
|
||||
|
||||
def get_context_insights(self) -> Dict[str, Any]:
|
||||
@@ -2023,12 +2232,12 @@ class AgentContext:
|
||||
insights = {
|
||||
"timestamp": datetime.now().isoformat(),
|
||||
"memory_stats": self.stats(),
|
||||
"decision_stats": self.get_decision_statistics() if self.config.get("enable_decision_tracking") and hasattr(self, 'get_decision_statistics') else {},
|
||||
"decision_stats": self.get_decision_statistics() if self.config.get("decision_tracking") and hasattr(self, 'get_decision_statistics') else {},
|
||||
"graph_analysis": self.analyze_context_graph(),
|
||||
"advanced_features": {
|
||||
"kg_algorithms_enabled": self.config.get("enable_kg_algorithms", False),
|
||||
"vector_store_features_enabled": self.config.get("enable_vector_store_features", False),
|
||||
"decision_tracking_enabled": self.config.get("enable_decision_tracking", False)
|
||||
"kg_algorithms_enabled": self.config.get("kg_algorithms", False),
|
||||
"vector_store_features_enabled": self.config.get("vector_store_features", False),
|
||||
"decision_tracking_enabled": self.config.get("decision_tracking", False)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -77,7 +77,7 @@ class CausalChainAnalyzer:
|
||||
using graph traversal.
|
||||
"""
|
||||
|
||||
def __init__(self, graph_store: GraphStore):
|
||||
def __init__(self, graph_store: Any):
|
||||
"""
|
||||
Initialize CausalChainAnalyzer.
|
||||
|
||||
@@ -105,6 +105,13 @@ class CausalChainAnalyzer:
|
||||
List of decisions in causal chain
|
||||
"""
|
||||
try:
|
||||
if hasattr(self.graph_store, "get_causal_chain") and not hasattr(self.graph_store, "execute_query"):
|
||||
return self.graph_store.get_causal_chain(
|
||||
decision_id=decision_id,
|
||||
direction=direction,
|
||||
max_depth=max_depth
|
||||
)
|
||||
|
||||
if direction not in ["upstream", "downstream"]:
|
||||
raise ValueError("Direction must be 'upstream' or 'downstream'")
|
||||
|
||||
@@ -124,10 +131,13 @@ class CausalChainAnalyzer:
|
||||
results = self.graph_store.execute_query(query, {
|
||||
"decision_id": decision_id
|
||||
})
|
||||
results = self._extract_records(results)
|
||||
|
||||
decisions = []
|
||||
for record in results:
|
||||
decision_data = record.get("end", {})
|
||||
decision_data = record.get("end") if isinstance(record, dict) else None
|
||||
if not isinstance(decision_data, dict):
|
||||
decision_data = record if isinstance(record, dict) else {}
|
||||
decision = self._dict_to_decision(decision_data)
|
||||
decision.metadata["causal_distance"] = record.get("distance", 0)
|
||||
decisions.append(decision)
|
||||
@@ -165,10 +175,13 @@ class CausalChainAnalyzer:
|
||||
results = self.graph_store.execute_query(query, {
|
||||
"decision_id": decision_id
|
||||
})
|
||||
results = self._extract_records(results)
|
||||
|
||||
decisions = []
|
||||
for record in results:
|
||||
decision_data = record.get("end", {})
|
||||
decision_data = record.get("end") if isinstance(record, dict) else None
|
||||
if not isinstance(decision_data, dict):
|
||||
decision_data = record if isinstance(record, dict) else {}
|
||||
decision = self._dict_to_decision(decision_data)
|
||||
decision.metadata["influence_depth"] = record.get("influence_depth", 0)
|
||||
decisions.append(decision)
|
||||
@@ -207,10 +220,13 @@ class CausalChainAnalyzer:
|
||||
results = self.graph_store.execute_query(query, {
|
||||
"decision_id": decision_id
|
||||
})
|
||||
results = self._extract_records(results)
|
||||
|
||||
decisions = []
|
||||
for record in results:
|
||||
decision_data = record.get("end", {})
|
||||
decision_data = record.get("end") if isinstance(record, dict) else None
|
||||
if not isinstance(decision_data, dict):
|
||||
decision_data = record if isinstance(record, dict) else {}
|
||||
decision = self._dict_to_decision(decision_data)
|
||||
decision.metadata["precedent_depth"] = record.get("precedent_depth", 0)
|
||||
decision.metadata["relationship_types"] = record.get("relationship_types", [])
|
||||
@@ -243,7 +259,7 @@ class CausalChainAnalyzer:
|
||||
ORDER BY loop_length
|
||||
"""
|
||||
|
||||
results = self.graph_store.execute_query(query)
|
||||
results = self._extract_records(self.graph_store.execute_query(query))
|
||||
|
||||
loops = []
|
||||
for record in results:
|
||||
@@ -316,10 +332,13 @@ class CausalChainAnalyzer:
|
||||
results = self.graph_store.execute_query(query, {
|
||||
"decision_id": decision_id
|
||||
})
|
||||
results = self._extract_records(results)
|
||||
|
||||
root_decisions = []
|
||||
for record in results:
|
||||
decision_data = record.get("root", {})
|
||||
decision_data = record.get("root") if isinstance(record, dict) else None
|
||||
if not isinstance(decision_data, dict):
|
||||
decision_data = record if isinstance(record, dict) else {}
|
||||
decision = self._dict_to_decision(decision_data)
|
||||
decision.metadata["root_distance"] = record.get("root_distance", 0)
|
||||
root_decisions.append(decision)
|
||||
@@ -411,9 +430,13 @@ class CausalChainAnalyzer:
|
||||
# Handle timestamp conversion
|
||||
if isinstance(data.get("timestamp"), str):
|
||||
data["timestamp"] = datetime.fromisoformat(data["timestamp"])
|
||||
|
||||
|
||||
decision_id = data.get("decision_id") or data.get("id")
|
||||
if not decision_id:
|
||||
raise KeyError("decision_id")
|
||||
|
||||
return Decision(
|
||||
decision_id=data.get("decision_id", ""),
|
||||
decision_id=decision_id,
|
||||
category=data.get("category", ""),
|
||||
scenario=data.get("scenario", ""),
|
||||
reasoning=data.get("reasoning", ""),
|
||||
@@ -423,5 +446,14 @@ class CausalChainAnalyzer:
|
||||
decision_maker=data.get("decision_maker", ""),
|
||||
reasoning_embedding=data.get("reasoning_embedding"),
|
||||
node2vec_embedding=data.get("node2vec_embedding"),
|
||||
metadata=data.get("metadata", {})
|
||||
metadata=data.get("metadata", {}),
|
||||
)
|
||||
|
||||
def _extract_records(self, results: Any) -> List[Dict[str, Any]]:
|
||||
"""Normalize execute_query result shapes to a list of record maps."""
|
||||
if isinstance(results, dict):
|
||||
records = results.get("records", [])
|
||||
return records if isinstance(records, list) else []
|
||||
if isinstance(results, list):
|
||||
return results
|
||||
return []
|
||||
|
||||
+1112
-73
File diff suppressed because it is too large
Load Diff
@@ -2011,8 +2011,18 @@ Answer:"""
|
||||
try:
|
||||
# Basic neighbor expansion
|
||||
if hasattr(self.knowledge_graph, 'get_neighbors'):
|
||||
neighbors = self.knowledge_graph.get_neighbors(entity_name)
|
||||
for neighbor in neighbors[:5]: # Limit to prevent explosion
|
||||
if hasattr(self.knowledge_graph, "neighbors"):
|
||||
neighbor_ids = list(self.knowledge_graph.neighbors(entity_name))
|
||||
elif hasattr(self.knowledge_graph, "get_neighbor_ids"):
|
||||
neighbor_ids = self.knowledge_graph.get_neighbor_ids(entity_name)
|
||||
else:
|
||||
neighbor_details = self.knowledge_graph.get_neighbors(entity_name, hops=1)
|
||||
neighbor_ids = [
|
||||
n.get("id") for n in neighbor_details
|
||||
if isinstance(n, dict) and n.get("id")
|
||||
]
|
||||
|
||||
for neighbor in neighbor_ids[:5]: # Limit to prevent explosion
|
||||
expanded_entities.append({
|
||||
"name": neighbor,
|
||||
"type": "related_entity",
|
||||
@@ -2082,8 +2092,17 @@ Answer:"""
|
||||
if hasattr(self.centrality_calculator, 'calculate_degree_centrality'):
|
||||
# Simplified centrality calculation
|
||||
if hasattr(self.knowledge_graph, 'get_neighbors'):
|
||||
neighbors = self.knowledge_graph.get_neighbors(entity_name)
|
||||
centrality_scores[entity_name] = len(neighbors)
|
||||
if hasattr(self.knowledge_graph, "neighbors"):
|
||||
neighbor_ids = list(self.knowledge_graph.neighbors(entity_name))
|
||||
elif hasattr(self.knowledge_graph, "get_neighbor_ids"):
|
||||
neighbor_ids = self.knowledge_graph.get_neighbor_ids(entity_name)
|
||||
else:
|
||||
neighbor_details = self.knowledge_graph.get_neighbors(entity_name, hops=1)
|
||||
neighbor_ids = [
|
||||
n.get("id") for n in neighbor_details
|
||||
if isinstance(n, dict) and n.get("id")
|
||||
]
|
||||
centrality_scores[entity_name] = len(neighbor_ids)
|
||||
else:
|
||||
centrality_scores[entity_name] = 1
|
||||
|
||||
@@ -2136,6 +2155,7 @@ Answer:"""
|
||||
safe_category = category[:20] if category else "unknown"
|
||||
self.logger.warning(f"Failed to find policies for {safe_category}: {type(e).__name__}")
|
||||
return policies
|
||||
return policies
|
||||
|
||||
# Decision Retrieval Methods
|
||||
def find_precedents_hybrid(
|
||||
|
||||
+395
-1165
File diff suppressed because it is too large
Load Diff
@@ -6,6 +6,8 @@ offering simple interfaces for common use cases.
|
||||
"""
|
||||
|
||||
from datetime import datetime
|
||||
import hashlib
|
||||
import json
|
||||
from typing import Any, Dict, List, Optional, Union
|
||||
|
||||
from ..graph_store import GraphStore
|
||||
@@ -215,7 +217,15 @@ def multi_hop_query(
|
||||
|
||||
def capture_decision_trace(
|
||||
decision: Decision,
|
||||
cross_system_context: Dict[str, Any]
|
||||
cross_system_context: Dict[str, Any],
|
||||
graph_store: Optional[GraphStore] = None,
|
||||
entities: Optional[List[str]] = None,
|
||||
source_documents: Optional[List[str]] = None,
|
||||
policy_ids: Optional[Union[str, Dict[str, str], List[Union[str, Dict[str, str]]]]] = None,
|
||||
exceptions: Optional[List[Dict[str, Any]]] = None,
|
||||
approvals: Optional[List[Dict[str, Any]]] = None,
|
||||
precedents: Optional[List[Dict[str, str]]] = None,
|
||||
immutable_audit_log: bool = True,
|
||||
) -> str:
|
||||
"""
|
||||
Complete decision trace capture.
|
||||
@@ -223,6 +233,16 @@ def capture_decision_trace(
|
||||
Args:
|
||||
decision: Decision object to capture
|
||||
cross_system_context: Cross-system context
|
||||
graph_store: Optional graph store used to persist full trace
|
||||
entities: Optional list of linked entities
|
||||
source_documents: Optional list of source documents
|
||||
policy_ids: Optional list of policy refs. Supports:
|
||||
- "policy_id"
|
||||
- {"policy_id": "...", "version": "..."}
|
||||
exceptions: Optional list of exception records
|
||||
approvals: Optional list of approval records
|
||||
precedents: Optional list of precedent links
|
||||
immutable_audit_log: Whether to append immutable hash-chained trace events
|
||||
|
||||
Returns:
|
||||
Decision ID
|
||||
@@ -230,16 +250,324 @@ def capture_decision_trace(
|
||||
logger = get_logger(__name__)
|
||||
|
||||
try:
|
||||
# This would typically use a global or context-specific graph store
|
||||
# For now, return the decision ID as a placeholder
|
||||
logger.info(f"Captured decision trace for: {decision.decision_id}")
|
||||
return decision.decision_id
|
||||
# Backward-compatible behavior: allow legacy call sites without graph_store.
|
||||
if graph_store is None:
|
||||
policy_refs = _normalize_policy_refs(policy_ids)
|
||||
logger.warning(
|
||||
"capture_decision_trace skipped persistence (no graph_store) | "
|
||||
f"decision_id={decision.decision_id} "
|
||||
f"decision_maker={decision.decision_maker} "
|
||||
f"timestamp={decision.timestamp.isoformat() if hasattr(decision.timestamp, 'isoformat') else decision.timestamp} "
|
||||
f"category={decision.category} "
|
||||
f"outcome={decision.outcome} "
|
||||
f"confidence={decision.confidence} "
|
||||
f"cross_system_keys={list((cross_system_context or {}).keys())} "
|
||||
f"policy_refs={policy_refs} "
|
||||
f"exception_count={len(_normalize_record_list(exceptions))} "
|
||||
f"approval_count={len(_normalize_record_list(approvals))} "
|
||||
f"precedent_count={len(_normalize_precedents(precedents))} "
|
||||
"mode=backward_compatible_non_persistent"
|
||||
)
|
||||
return decision.decision_id
|
||||
|
||||
recorder = DecisionRecorder(graph_store)
|
||||
entities = _normalize_string_list(entities)
|
||||
source_documents = _normalize_string_list(source_documents)
|
||||
policy_refs = _normalize_policy_refs(policy_ids)
|
||||
exceptions = _normalize_record_list(exceptions)
|
||||
approvals = _normalize_record_list(approvals)
|
||||
precedents = _normalize_precedents(precedents)
|
||||
|
||||
decision_id = recorder.record_decision(
|
||||
decision=decision,
|
||||
entities=entities,
|
||||
source_documents=source_documents,
|
||||
)
|
||||
|
||||
trace_events: List[Dict[str, Any]] = [
|
||||
{
|
||||
"event_type": "DECISION_RECORDED",
|
||||
"payload": {
|
||||
"decision_id": decision_id,
|
||||
"category": decision.category,
|
||||
"outcome": decision.outcome,
|
||||
"confidence": decision.confidence,
|
||||
"decision_maker": decision.decision_maker,
|
||||
"entities": entities,
|
||||
"source_documents": source_documents,
|
||||
},
|
||||
}
|
||||
]
|
||||
|
||||
if cross_system_context:
|
||||
recorder.capture_cross_system_context(decision_id, cross_system_context)
|
||||
trace_events.append(
|
||||
{
|
||||
"event_type": "CROSS_SYSTEM_CONTEXT_CAPTURED",
|
||||
"payload": {"systems": list(cross_system_context.keys())},
|
||||
}
|
||||
)
|
||||
|
||||
if policy_refs:
|
||||
applied_policies = recorder.apply_policies(decision_id, policy_refs)
|
||||
trace_events.append(
|
||||
{
|
||||
"event_type": "POLICIES_APPLIED",
|
||||
"payload": {
|
||||
"policy_ids": [p.get("policy_id") for p in policy_refs],
|
||||
"applied_policies": applied_policies,
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
if exceptions:
|
||||
recorded_exception_ids: List[str] = []
|
||||
for exception_data in exceptions:
|
||||
exception_id = recorder.record_exception(
|
||||
decision_id=decision_id,
|
||||
policy_id=exception_data.get("policy_id", ""),
|
||||
reason=exception_data.get("reason", ""),
|
||||
approver=exception_data.get("approver", "system"),
|
||||
approval_method=exception_data.get("approval_method", "system"),
|
||||
justification=exception_data.get("justification", ""),
|
||||
)
|
||||
recorded_exception_ids.append(exception_id)
|
||||
if recorded_exception_ids:
|
||||
trace_events.append(
|
||||
{
|
||||
"event_type": "EXCEPTIONS_RECORDED",
|
||||
"payload": {"exception_ids": recorded_exception_ids},
|
||||
}
|
||||
)
|
||||
|
||||
if approvals:
|
||||
approvers = [a.get("approver", "system") for a in approvals]
|
||||
methods = [a.get("approval_method", "system") for a in approvals]
|
||||
contexts = [a.get("approval_context", "") for a in approvals]
|
||||
if approvers:
|
||||
recorder.record_approval_chain(
|
||||
decision_id=decision_id,
|
||||
approvers=approvers,
|
||||
methods=methods,
|
||||
contexts=contexts,
|
||||
)
|
||||
trace_events.append(
|
||||
{
|
||||
"event_type": "APPROVAL_CHAIN_RECORDED",
|
||||
"payload": {"approvers": approvers, "methods": methods},
|
||||
}
|
||||
)
|
||||
|
||||
if precedents:
|
||||
precedent_ids = [p.get("precedent_id", "") for p in precedents if p.get("precedent_id")]
|
||||
relationship_types = [
|
||||
p.get("relationship_type", "similar_scenario")
|
||||
for p in precedents
|
||||
if p.get("precedent_id")
|
||||
]
|
||||
if precedent_ids:
|
||||
recorder.link_precedents(decision_id, precedent_ids, relationship_types)
|
||||
trace_events.append(
|
||||
{
|
||||
"event_type": "PRECEDENTS_LINKED",
|
||||
"payload": {"precedent_ids": precedent_ids},
|
||||
}
|
||||
)
|
||||
|
||||
if immutable_audit_log:
|
||||
_append_immutable_trace_events(graph_store, decision_id, trace_events, logger)
|
||||
|
||||
logger.info(f"Captured decision trace for: {decision_id}")
|
||||
return decision_id
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to capture decision trace: {e}")
|
||||
raise
|
||||
|
||||
|
||||
def _append_immutable_trace_events(
|
||||
graph_store: GraphStore,
|
||||
decision_id: str,
|
||||
events: List[Dict[str, Any]],
|
||||
logger: Any,
|
||||
) -> None:
|
||||
"""Append hash-chained trace events for immutable decision lineage."""
|
||||
if not events:
|
||||
return
|
||||
|
||||
previous_trace_id: Optional[str] = None
|
||||
previous_hash = ""
|
||||
next_index = 1
|
||||
|
||||
try:
|
||||
previous_result = graph_store.execute_query(
|
||||
"""
|
||||
MATCH (d:Decision {decision_id: $decision_id})-[:HAS_TRACE_EVENT]->(t:DecisionTraceEvent)
|
||||
RETURN t.trace_id as trace_id, t.event_index as event_index, t.event_hash as event_hash
|
||||
ORDER BY t.event_index DESC
|
||||
LIMIT 1
|
||||
""",
|
||||
{"decision_id": decision_id},
|
||||
)
|
||||
records = previous_result.get("records", []) if isinstance(previous_result, dict) else previous_result
|
||||
if records:
|
||||
latest = records[0]
|
||||
latest_map = latest.get("t", latest) if isinstance(latest, dict) else {}
|
||||
previous_trace_id = latest_map.get("trace_id")
|
||||
previous_hash = latest_map.get("event_hash", "") or ""
|
||||
next_index = int(latest_map.get("event_index", 0) or 0) + 1
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
"Failed to lookup previous immutable trace event; starting new chain "
|
||||
f"for decision_id={decision_id}: {e}"
|
||||
)
|
||||
# Start a fresh chain if previous trace lookup fails.
|
||||
previous_trace_id = None
|
||||
previous_hash = ""
|
||||
next_index = 1
|
||||
|
||||
for event in events:
|
||||
event_type = event.get("event_type", "TRACE_EVENT")
|
||||
payload = event.get("payload", {})
|
||||
payload_json = json.dumps(payload, sort_keys=True, default=str)
|
||||
event_timestamp = datetime.now().isoformat()
|
||||
trace_id = f"{decision_id}:{next_index}"
|
||||
hash_input = (
|
||||
f"{decision_id}|{next_index}|{event_type}|{event_timestamp}|{payload_json}|{previous_hash}"
|
||||
)
|
||||
event_hash = hashlib.sha256(hash_input.encode("utf-8")).hexdigest()
|
||||
|
||||
graph_store.execute_query(
|
||||
"""
|
||||
MATCH (d:Decision {decision_id: $decision_id})
|
||||
CREATE (t:DecisionTraceEvent {
|
||||
trace_id: $trace_id,
|
||||
decision_id: $decision_id,
|
||||
event_index: $event_index,
|
||||
event_type: $event_type,
|
||||
event_timestamp: $event_timestamp,
|
||||
event_payload: $event_payload,
|
||||
previous_hash: $previous_hash,
|
||||
event_hash: $event_hash
|
||||
})
|
||||
MERGE (d)-[:HAS_TRACE_EVENT]->(t)
|
||||
""",
|
||||
{
|
||||
"decision_id": decision_id,
|
||||
"trace_id": trace_id,
|
||||
"event_index": next_index,
|
||||
"event_type": event_type,
|
||||
"event_timestamp": event_timestamp,
|
||||
"event_payload": payload_json,
|
||||
"previous_hash": previous_hash,
|
||||
"event_hash": event_hash,
|
||||
},
|
||||
)
|
||||
|
||||
if previous_trace_id:
|
||||
graph_store.execute_query(
|
||||
"""
|
||||
MATCH (prev:DecisionTraceEvent {trace_id: $prev_trace_id})
|
||||
MATCH (curr:DecisionTraceEvent {trace_id: $curr_trace_id})
|
||||
MERGE (prev)-[:NEXT_TRACE_EVENT]->(curr)
|
||||
""",
|
||||
{"prev_trace_id": previous_trace_id, "curr_trace_id": trace_id},
|
||||
)
|
||||
|
||||
previous_trace_id = trace_id
|
||||
previous_hash = event_hash
|
||||
next_index += 1
|
||||
|
||||
logger.debug(f"Appended {len(events)} immutable trace events for {decision_id}")
|
||||
|
||||
|
||||
def _normalize_string_list(value: Optional[Union[str, List[str]]]) -> List[str]:
|
||||
"""Normalize optional string/list payloads to a clean list of strings."""
|
||||
if value is None:
|
||||
return []
|
||||
if isinstance(value, str):
|
||||
return [value] if value else []
|
||||
if isinstance(value, list):
|
||||
return [str(item) for item in value if item is not None and str(item)]
|
||||
return []
|
||||
|
||||
|
||||
def _normalize_record_list(
|
||||
value: Optional[Union[Dict[str, Any], List[Dict[str, Any]]]]
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""Normalize optional dict/list payloads to list[dict] for legacy callers."""
|
||||
if value is None:
|
||||
return []
|
||||
if isinstance(value, dict):
|
||||
return [value]
|
||||
if isinstance(value, list):
|
||||
return [item for item in value if isinstance(item, dict)]
|
||||
return []
|
||||
|
||||
|
||||
def _normalize_policy_refs(
|
||||
value: Optional[Union[str, Dict[str, str], List[Union[str, Dict[str, str]]]]]
|
||||
) -> List[Dict[str, str]]:
|
||||
"""Normalize policy refs to [{policy_id, version?}] for version-safe matching."""
|
||||
if value is None:
|
||||
return []
|
||||
|
||||
raw_items: List[Union[str, Dict[str, str]]]
|
||||
if isinstance(value, (str, dict)):
|
||||
raw_items = [value]
|
||||
elif isinstance(value, list):
|
||||
raw_items = value
|
||||
else:
|
||||
return []
|
||||
|
||||
normalized: List[Dict[str, str]] = []
|
||||
for item in raw_items:
|
||||
if isinstance(item, str) and item:
|
||||
normalized.append({"policy_id": item})
|
||||
elif isinstance(item, dict):
|
||||
policy_id = item.get("policy_id")
|
||||
if not policy_id:
|
||||
continue
|
||||
ref: Dict[str, str] = {"policy_id": str(policy_id)}
|
||||
if item.get("version") is not None and str(item.get("version")):
|
||||
ref["version"] = str(item.get("version"))
|
||||
normalized.append(ref)
|
||||
return normalized
|
||||
|
||||
|
||||
def _normalize_precedents(
|
||||
value: Optional[Union[str, Dict[str, str], List[Union[str, Dict[str, str]]]]]
|
||||
) -> List[Dict[str, str]]:
|
||||
"""Normalize precedents payload from legacy forms to structured records."""
|
||||
if value is None:
|
||||
return []
|
||||
|
||||
raw_items: List[Union[str, Dict[str, str]]]
|
||||
if isinstance(value, (str, dict)):
|
||||
raw_items = [value]
|
||||
elif isinstance(value, list):
|
||||
raw_items = value
|
||||
else:
|
||||
return []
|
||||
|
||||
normalized: List[Dict[str, str]] = []
|
||||
for item in raw_items:
|
||||
if isinstance(item, str) and item:
|
||||
normalized.append(
|
||||
{"precedent_id": item, "relationship_type": "similar_scenario"}
|
||||
)
|
||||
elif isinstance(item, dict) and item.get("precedent_id"):
|
||||
normalized.append(
|
||||
{
|
||||
"precedent_id": str(item.get("precedent_id")),
|
||||
"relationship_type": str(
|
||||
item.get("relationship_type", "similar_scenario")
|
||||
),
|
||||
}
|
||||
)
|
||||
return normalized
|
||||
|
||||
|
||||
def find_exception_precedents(
|
||||
graph_store: GraphStore,
|
||||
exception_reason: str,
|
||||
@@ -549,7 +877,7 @@ def enhance_agent_context_with_decisions(agent_context: AgentContext) -> None:
|
||||
logger = get_logger(__name__)
|
||||
|
||||
try:
|
||||
if not agent_context.config.get("enable_decision_tracking"):
|
||||
if not agent_context.config.get("decision_tracking"):
|
||||
logger.warning("Decision tracking not enabled in AgentContext")
|
||||
return
|
||||
|
||||
|
||||
@@ -99,10 +99,12 @@ class Decision:
|
||||
node2vec_embedding: Optional[List[float]] = None
|
||||
metadata: Dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
def __post_init__(self):
|
||||
def __post_init__(self, auto_generate_id: bool = True):
|
||||
"""Validate decision data."""
|
||||
if not self.decision_id:
|
||||
if auto_generate_id and not self.decision_id: # Handle both None and empty string
|
||||
self.decision_id = str(uuid.uuid4())
|
||||
elif not self.decision_id and not auto_generate_id:
|
||||
raise ValueError("decision_id is required when auto_generate_id=False")
|
||||
if not 0 <= self.confidence <= 1:
|
||||
raise ValueError("Confidence must be between 0 and 1")
|
||||
|
||||
@@ -141,10 +143,12 @@ class DecisionContext:
|
||||
cross_system_inputs: Dict[str, Any] = field(default_factory=dict)
|
||||
metadata: Dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
def __post_init__(self):
|
||||
"""Validate context data."""
|
||||
if not self.context_id:
|
||||
def __post_init__(self, auto_generate_id: bool = True):
|
||||
"""Validate decision context data."""
|
||||
if auto_generate_id and not self.context_id: # Handle both None and empty string
|
||||
self.context_id = str(uuid.uuid4())
|
||||
elif not self.context_id and not auto_generate_id:
|
||||
raise ValueError("context_id is required when auto_generate_id=False")
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
"""Convert context to dictionary."""
|
||||
@@ -177,10 +181,12 @@ class Policy:
|
||||
updated_at: datetime
|
||||
metadata: Dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
def __post_init__(self):
|
||||
def __post_init__(self, auto_generate_id: bool = True):
|
||||
"""Validate policy data."""
|
||||
if not self.policy_id:
|
||||
if auto_generate_id and not self.policy_id: # Handle both None and empty string
|
||||
self.policy_id = str(uuid.uuid4())
|
||||
elif not self.policy_id and not auto_generate_id:
|
||||
raise ValueError("policy_id is required when auto_generate_id=False")
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
"""Convert policy to dictionary."""
|
||||
@@ -218,10 +224,12 @@ class PolicyException:
|
||||
justification: str
|
||||
metadata: Dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
def __post_init__(self):
|
||||
"""Validate exception data."""
|
||||
if not self.exception_id:
|
||||
def __post_init__(self, auto_generate_id: bool = True):
|
||||
"""Validate policy exception data."""
|
||||
if auto_generate_id and not self.exception_id: # Handle both None and empty string
|
||||
self.exception_id = str(uuid.uuid4())
|
||||
elif not self.exception_id and not auto_generate_id:
|
||||
raise ValueError("exception_id is required when auto_generate_id=False")
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
"""Convert exception to dictionary."""
|
||||
@@ -254,10 +262,12 @@ class Precedent:
|
||||
relationship_type: str # "similar_scenario", "same_policy", "exception_precedent"
|
||||
metadata: Dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
def __post_init__(self):
|
||||
def __post_init__(self, auto_generate_id: bool = True):
|
||||
"""Validate precedent data."""
|
||||
if not self.precedent_id:
|
||||
if auto_generate_id and not self.precedent_id: # Handle both None and empty string
|
||||
self.precedent_id = str(uuid.uuid4())
|
||||
elif not self.precedent_id and not auto_generate_id:
|
||||
raise ValueError("precedent_id is required when auto_generate_id=False")
|
||||
if not 0 <= self.similarity_score <= 1:
|
||||
raise ValueError("Similarity score must be between 0 and 1")
|
||||
valid_types = ["similar_scenario", "same_policy", "exception_precedent"]
|
||||
@@ -292,10 +302,12 @@ class ApprovalChain:
|
||||
timestamp: datetime
|
||||
metadata: Dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
def __post_init__(self):
|
||||
"""Validate approval data."""
|
||||
if not self.approval_id:
|
||||
def __post_init__(self, auto_generate_id: bool = True):
|
||||
"""Validate approval chain data."""
|
||||
if auto_generate_id and not self.approval_id: # Handle both None and empty string
|
||||
self.approval_id = str(uuid.uuid4())
|
||||
elif not self.approval_id and not auto_generate_id:
|
||||
raise ValueError("approval_id is required when auto_generate_id=False")
|
||||
valid_methods = ["slack_dm", "zoom_call", "email", "system"]
|
||||
if self.approval_method not in valid_methods:
|
||||
raise ValueError(f"Approval method must be one of: {valid_methods}")
|
||||
|
||||
@@ -55,10 +55,10 @@ Search Capabilities:
|
||||
Example Usage:
|
||||
>>> from semantica.context import DecisionQuery
|
||||
>>> query = DecisionQuery(graph_store=kg, vector_store=vs,
|
||||
... enable_advanced_analytics=True,
|
||||
... enable_centrality_analysis=True,
|
||||
... enable_community_detection=True,
|
||||
... enable_node_embeddings=True)
|
||||
... advanced_analytics=True,
|
||||
... centrality_analysis=True,
|
||||
... community_detection=True,
|
||||
... node_embeddings=True)
|
||||
>>> precedents = query.find_precedents_hybrid("Loan application",
|
||||
... category="approval",
|
||||
... limit=10)
|
||||
@@ -111,11 +111,11 @@ class DecisionQuery:
|
||||
graph_store: GraphStore,
|
||||
embedding_generator: Optional[EmbeddingGenerator] = None,
|
||||
vector_store: Optional[Any] = None,
|
||||
enable_advanced_analytics: bool = True,
|
||||
enable_node_embeddings: bool = True,
|
||||
enable_centrality_analysis: bool = True,
|
||||
enable_community_detection: bool = True,
|
||||
enable_link_prediction: bool = True
|
||||
advanced_analytics: bool = True,
|
||||
node_embeddings: bool = True,
|
||||
centrality_analysis: bool = True,
|
||||
community_detection: bool = True,
|
||||
link_prediction: bool = True
|
||||
):
|
||||
"""
|
||||
Initialize DecisionQuery with optional advanced features.
|
||||
@@ -124,11 +124,11 @@ class DecisionQuery:
|
||||
graph_store: Graph database instance
|
||||
embedding_generator: Optional embedding generator for semantic search
|
||||
vector_store: Optional vector store for hybrid search
|
||||
enable_advanced_analytics: Enable advanced graph analytics (requires semantica.kg)
|
||||
enable_node_embeddings: Enable Node2Vec embeddings (requires semantica.kg)
|
||||
enable_centrality_analysis: Enable centrality measures (requires semantica.kg)
|
||||
enable_community_detection: Enable community detection (requires semantica.kg)
|
||||
enable_link_prediction: Enable link prediction (requires semantica.kg)
|
||||
advanced_analytics: Enable advanced graph analytics (requires semantica.kg)
|
||||
node_embeddings: Enable Node2Vec embeddings (requires semantica.kg)
|
||||
centrality_analysis: Enable centrality measures (requires semantica.kg)
|
||||
community_detection: Enable community detection (requires semantica.kg)
|
||||
link_prediction: Enable link prediction (requires semantica.kg)
|
||||
"""
|
||||
self.graph_store = graph_store
|
||||
self.embedding_generator = embedding_generator
|
||||
@@ -139,17 +139,17 @@ class DecisionQuery:
|
||||
self.kg_components = {}
|
||||
self.vector_components = {}
|
||||
|
||||
if KG_AVAILABLE and enable_advanced_analytics:
|
||||
if KG_AVAILABLE and advanced_analytics:
|
||||
try:
|
||||
if enable_centrality_analysis:
|
||||
if centrality_analysis:
|
||||
self.kg_components["centrality_calculator"] = CentralityCalculator()
|
||||
if enable_community_detection:
|
||||
if community_detection:
|
||||
self.kg_components["community_detector"] = CommunityDetector()
|
||||
if enable_node_embeddings:
|
||||
if node_embeddings:
|
||||
self.kg_components["node_embedder"] = NodeEmbedder()
|
||||
self.kg_components["path_finder"] = PathFinder()
|
||||
self.kg_components["similarity_calculator"] = SimilarityCalculator()
|
||||
if enable_link_prediction:
|
||||
if link_prediction:
|
||||
self.kg_components["link_predictor"] = LinkPredictor()
|
||||
|
||||
self.logger.info("Advanced KG components initialized successfully")
|
||||
@@ -344,11 +344,13 @@ class DecisionQuery:
|
||||
query_parts.append("LIMIT $limit")
|
||||
|
||||
query = " ".join(query_parts)
|
||||
results = self.graph_store.execute_query(query, params)
|
||||
|
||||
results = self._extract_records(self.graph_store.execute_query(query, params))
|
||||
|
||||
decisions = []
|
||||
for record in results:
|
||||
decision_data = record.get("d", {})
|
||||
decision_data = record.get("d") if isinstance(record, dict) else None
|
||||
if not isinstance(decision_data, dict):
|
||||
decision_data = record if isinstance(record, dict) else {}
|
||||
decision = self._dict_to_decision(decision_data)
|
||||
|
||||
# Calculate similarity if embedding available
|
||||
@@ -393,10 +395,13 @@ class DecisionQuery:
|
||||
"category": category,
|
||||
"limit": limit
|
||||
})
|
||||
results = self._extract_records(results)
|
||||
|
||||
decisions = []
|
||||
for record in results:
|
||||
decision_data = record.get("d", {})
|
||||
decision_data = record.get("d") if isinstance(record, dict) else None
|
||||
if not isinstance(decision_data, dict):
|
||||
decision_data = record if isinstance(record, dict) else {}
|
||||
decisions.append(self._dict_to_decision(decision_data))
|
||||
|
||||
self.logger.info(f"Found {len(decisions)} decisions in category {category}")
|
||||
@@ -429,10 +434,13 @@ class DecisionQuery:
|
||||
"entity_id": entity_id,
|
||||
"limit": limit
|
||||
})
|
||||
results = self._extract_records(results)
|
||||
|
||||
decisions = []
|
||||
for record in results:
|
||||
decision_data = record.get("d", {})
|
||||
decision_data = record.get("d") if isinstance(record, dict) else None
|
||||
if not isinstance(decision_data, dict):
|
||||
decision_data = record if isinstance(record, dict) else {}
|
||||
decisions.append(self._dict_to_decision(decision_data))
|
||||
|
||||
self.logger.info(f"Found {len(decisions)} decisions about entity {entity_id}")
|
||||
@@ -472,10 +480,13 @@ class DecisionQuery:
|
||||
"end": end,
|
||||
"limit": limit
|
||||
})
|
||||
results = self._extract_records(results)
|
||||
|
||||
decisions = []
|
||||
for record in results:
|
||||
decision_data = record.get("d", {})
|
||||
decision_data = record.get("d") if isinstance(record, dict) else None
|
||||
if not isinstance(decision_data, dict):
|
||||
decision_data = record if isinstance(record, dict) else {}
|
||||
decisions.append(self._dict_to_decision(decision_data))
|
||||
|
||||
self.logger.info(f"Found {len(decisions)} decisions in time range")
|
||||
@@ -518,10 +529,13 @@ class DecisionQuery:
|
||||
results = self.graph_store.execute_query(query, {
|
||||
"start_entity": start_entity
|
||||
})
|
||||
results = self._extract_records(results)
|
||||
|
||||
decisions = []
|
||||
for record in results:
|
||||
decision_data = record.get("d", {})
|
||||
decision_data = record.get("d") if isinstance(record, dict) else None
|
||||
if not isinstance(decision_data, dict):
|
||||
decision_data = record if isinstance(record, dict) else {}
|
||||
decision = self._dict_to_decision(decision_data)
|
||||
decision.metadata["hop_count"] = record.get("hop_count", 0)
|
||||
decisions.append(decision)
|
||||
@@ -562,6 +576,7 @@ class DecisionQuery:
|
||||
results = self.graph_store.execute_query(query, {
|
||||
"decision_id": decision_id
|
||||
})
|
||||
results = self._extract_records(results)
|
||||
|
||||
paths = []
|
||||
for record in results:
|
||||
@@ -606,10 +621,13 @@ class DecisionQuery:
|
||||
LIMIT $limit
|
||||
"""
|
||||
results = self.graph_store.execute_query(query, {"limit": limit})
|
||||
results = self._extract_records(results)
|
||||
|
||||
exceptions = []
|
||||
for record in results:
|
||||
exception_data = record.get("e", {})
|
||||
exception_data = record.get("e") if isinstance(record, dict) else None
|
||||
if not isinstance(exception_data, dict):
|
||||
exception_data = record if isinstance(record, dict) else {}
|
||||
exception = self._dict_to_exception(exception_data)
|
||||
|
||||
# Calculate similarity if embedding available
|
||||
@@ -639,9 +657,13 @@ class DecisionQuery:
|
||||
# Handle timestamp conversion
|
||||
if isinstance(data.get("timestamp"), str):
|
||||
data["timestamp"] = datetime.fromisoformat(data["timestamp"])
|
||||
|
||||
|
||||
decision_id = data.get("decision_id") or data.get("id")
|
||||
if not decision_id:
|
||||
raise KeyError("decision_id")
|
||||
|
||||
return Decision(
|
||||
decision_id=data.get("decision_id", ""),
|
||||
decision_id=decision_id,
|
||||
category=data.get("category", ""),
|
||||
scenario=data.get("scenario", ""),
|
||||
reasoning=data.get("reasoning", ""),
|
||||
@@ -651,7 +673,7 @@ class DecisionQuery:
|
||||
decision_maker=data.get("decision_maker", ""),
|
||||
reasoning_embedding=data.get("reasoning_embedding"),
|
||||
node2vec_embedding=data.get("node2vec_embedding"),
|
||||
metadata=data.get("metadata", {})
|
||||
metadata=data.get("metadata", {}),
|
||||
)
|
||||
|
||||
def _dict_to_exception(self, data: Dict[str, Any]) -> PolicyException:
|
||||
@@ -659,16 +681,22 @@ class DecisionQuery:
|
||||
# Handle timestamp conversion
|
||||
if isinstance(data.get("approval_timestamp"), str):
|
||||
data["approval_timestamp"] = datetime.fromisoformat(data["approval_timestamp"])
|
||||
|
||||
|
||||
exception_id = data.get("exception_id") or data.get("id")
|
||||
decision_id = data.get("decision_id")
|
||||
policy_id = data.get("policy_id")
|
||||
if not exception_id or not decision_id or not policy_id:
|
||||
raise KeyError("exception_id/decision_id/policy_id")
|
||||
|
||||
return PolicyException(
|
||||
exception_id=data.get("exception_id", ""),
|
||||
decision_id=data.get("decision_id", ""),
|
||||
policy_id=data.get("policy_id", ""),
|
||||
exception_id=exception_id,
|
||||
decision_id=decision_id,
|
||||
policy_id=policy_id,
|
||||
reason=data.get("reason", ""),
|
||||
approver=data.get("approver", ""),
|
||||
approval_timestamp=data.get("approval_timestamp", datetime.now()),
|
||||
justification=data.get("justification", ""),
|
||||
metadata=data.get("metadata", {})
|
||||
metadata=data.get("metadata", {}),
|
||||
)
|
||||
|
||||
def _cosine_similarity(self, vec1: List[float], vec2: List[float]) -> float:
|
||||
@@ -805,6 +833,7 @@ class DecisionQuery:
|
||||
results = self.graph_store.execute_query(query, {
|
||||
"decision_id": decision_id
|
||||
})
|
||||
results = self._extract_records(results)
|
||||
|
||||
return {"nodes": results, "max_depth": max_depth}
|
||||
except Exception:
|
||||
@@ -857,10 +886,12 @@ class DecisionQuery:
|
||||
downstream_results = self.graph_store.execute_query(downstream_query, {
|
||||
"decision_id": decision_id
|
||||
})
|
||||
downstream_results = self._extract_records(downstream_results)
|
||||
|
||||
upstream_results = self.graph_store.execute_query(upstream_query, {
|
||||
"decision_id": decision_id
|
||||
})
|
||||
upstream_results = self._extract_records(upstream_results)
|
||||
|
||||
# Process results
|
||||
for record in downstream_results:
|
||||
@@ -954,3 +985,12 @@ class DecisionQuery:
|
||||
except Exception as e:
|
||||
self.logger.error(f"Failed to predict relationships: {e}")
|
||||
return []
|
||||
|
||||
def _extract_records(self, results: Any) -> List[Dict[str, Any]]:
|
||||
"""Normalize execute_query result shapes to a list of record maps."""
|
||||
if isinstance(results, dict):
|
||||
records = results.get("records", [])
|
||||
return records if isinstance(records, list) else []
|
||||
if isinstance(results, list):
|
||||
return results
|
||||
return []
|
||||
|
||||
@@ -149,7 +149,7 @@ class DecisionRecorder:
|
||||
return decision.decision_id
|
||||
|
||||
except Exception as e:
|
||||
self.logger.error(f"Failed to record decision: {e}")
|
||||
self.logger.exception("Failed to record decision")
|
||||
raise
|
||||
|
||||
def link_entities(self, decision_id: str, entities: List[str]) -> None:
|
||||
@@ -176,35 +176,88 @@ class DecisionRecorder:
|
||||
self.logger.info(f"Linked decision {decision_id} to {len(entities)} entities")
|
||||
|
||||
except Exception as e:
|
||||
self.logger.error(f"Failed to link entities: {e}")
|
||||
self.logger.exception("Failed to link entities")
|
||||
raise
|
||||
|
||||
def apply_policies(self, decision_id: str, policy_ids: List[str]) -> None:
|
||||
def apply_policies(
|
||||
self,
|
||||
decision_id: str,
|
||||
policy_ids: List[Union[str, Dict[str, str]]],
|
||||
) -> List[Dict[str, str]]:
|
||||
"""
|
||||
Track policy applications for a decision.
|
||||
|
||||
Args:
|
||||
decision_id: Decision ID
|
||||
policy_ids: List of policy IDs that were applied
|
||||
policy_ids: List of policy IDs or policy refs with explicit version
|
||||
|
||||
Returns:
|
||||
Applied policy references with resolved versions
|
||||
"""
|
||||
try:
|
||||
for policy_id in policy_ids:
|
||||
# Create APPLIED_POLICY relationship
|
||||
applied: List[Dict[str, str]] = []
|
||||
|
||||
for policy_ref in policy_ids:
|
||||
if isinstance(policy_ref, dict):
|
||||
policy_id = str(policy_ref.get("policy_id", ""))
|
||||
policy_version = (
|
||||
str(policy_ref.get("version"))
|
||||
if policy_ref.get("version") is not None
|
||||
else None
|
||||
)
|
||||
else:
|
||||
policy_id = str(policy_ref)
|
||||
policy_version = None
|
||||
|
||||
if not policy_id:
|
||||
continue
|
||||
|
||||
# Resolve exactly one policy node:
|
||||
# - explicit version when provided
|
||||
# - latest available version for legacy callers
|
||||
query = """
|
||||
MATCH (d:Decision {decision_id: $decision_id})
|
||||
MATCH (p:Policy {policy_id: $policy_id})
|
||||
MERGE (d)-[:APPLIED_POLICY]->(p)
|
||||
SET d.applied_at = timestamp()
|
||||
WHERE $policy_version IS NULL OR p.version = $policy_version
|
||||
WITH d, p
|
||||
ORDER BY p.updated_at DESC, p.version DESC
|
||||
LIMIT 1
|
||||
MERGE (d)-[r:APPLIED_POLICY]->(p)
|
||||
SET r.policy_id = $policy_id,
|
||||
r.policy_version = p.version,
|
||||
d.applied_at = timestamp()
|
||||
RETURN p.policy_id as policy_id, p.version as version
|
||||
"""
|
||||
self.graph_store.execute_query(query, {
|
||||
result = self.graph_store.execute_query(query, {
|
||||
"decision_id": decision_id,
|
||||
"policy_id": policy_id
|
||||
"policy_id": policy_id,
|
||||
"policy_version": policy_version,
|
||||
})
|
||||
|
||||
records = (
|
||||
result.get("records", [])
|
||||
if isinstance(result, dict)
|
||||
else (result if isinstance(result, list) else [])
|
||||
)
|
||||
if records:
|
||||
record = records[0]
|
||||
applied.append(
|
||||
{
|
||||
"policy_id": str(record.get("policy_id", policy_id)),
|
||||
"version": str(record.get("version", policy_version or "")),
|
||||
}
|
||||
)
|
||||
else:
|
||||
self.logger.warning(
|
||||
f"No policy match found for {policy_id}"
|
||||
+ (f" version {policy_version}" if policy_version else "")
|
||||
)
|
||||
|
||||
self.logger.info(f"Applied {len(policy_ids)} policies to decision {decision_id}")
|
||||
self.logger.info(f"Applied {len(applied)} policies to decision {decision_id}")
|
||||
return applied
|
||||
|
||||
except Exception as e:
|
||||
self.logger.error(f"Failed to apply policies: {e}")
|
||||
self.logger.exception("Failed to apply policies")
|
||||
raise
|
||||
|
||||
def record_exception(
|
||||
@@ -231,7 +284,7 @@ class DecisionRecorder:
|
||||
Exception ID
|
||||
"""
|
||||
try:
|
||||
exception = Exception(
|
||||
exception = PolicyException(
|
||||
exception_id=str(uuid.uuid4()),
|
||||
decision_id=decision_id,
|
||||
policy_id=policy_id,
|
||||
@@ -262,7 +315,7 @@ class DecisionRecorder:
|
||||
return exception.exception_id
|
||||
|
||||
except Exception as e:
|
||||
self.logger.error(f"Failed to record exception: {e}")
|
||||
self.logger.exception("Failed to record exception")
|
||||
raise
|
||||
|
||||
def capture_cross_system_context(
|
||||
@@ -302,7 +355,7 @@ class DecisionRecorder:
|
||||
self.logger.info(f"Captured cross-system context for decision {decision_id}")
|
||||
|
||||
except Exception as e:
|
||||
self.logger.error(f"Failed to capture cross-system context: {e}")
|
||||
self.logger.exception("Failed to capture cross-system context")
|
||||
raise
|
||||
|
||||
def record_approval_chain(
|
||||
@@ -352,7 +405,7 @@ class DecisionRecorder:
|
||||
self.logger.info(f"Recorded approval chain with {len(approvers)} approvers")
|
||||
|
||||
except Exception as e:
|
||||
self.logger.error(f"Failed to record approval chain: {e}")
|
||||
self.logger.exception("Failed to record approval chain")
|
||||
raise
|
||||
|
||||
def link_precedents(
|
||||
@@ -389,7 +442,7 @@ class DecisionRecorder:
|
||||
self.logger.info(f"Linked {len(precedent_ids)} precedents to decision {decision_id}")
|
||||
|
||||
except Exception as e:
|
||||
self.logger.error(f"Failed to link precedents: {e}")
|
||||
self.logger.exception("Failed to link precedents")
|
||||
raise
|
||||
|
||||
def _store_decision_node(self, decision: Decision) -> None:
|
||||
@@ -423,7 +476,7 @@ class DecisionRecorder:
|
||||
"metadata": decision.metadata
|
||||
})
|
||||
|
||||
def _store_exception_node(self, exception: Exception) -> None:
|
||||
def _store_exception_node(self, exception: PolicyException) -> None:
|
||||
"""Store exception node in graph database."""
|
||||
query = """
|
||||
CREATE (e:Exception {
|
||||
@@ -502,4 +555,4 @@ class DecisionRecorder:
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
self.logger.warning(f"Failed to track provenance: {e}")
|
||||
self.logger.exception("Failed to track provenance")
|
||||
|
||||
@@ -5,6 +5,7 @@ This module provides schema setup utilities for decision tracking,
|
||||
including node labels, relationship types, and indexes for graph databases.
|
||||
"""
|
||||
|
||||
import json
|
||||
from typing import Dict, Any, List
|
||||
|
||||
from ..graph_store import GraphStore
|
||||
@@ -44,22 +45,51 @@ def create_decision_constraints(graph_store: GraphStore) -> None:
|
||||
constraints = [
|
||||
# Decision nodes
|
||||
"CREATE CONSTRAINT decision_id_unique IF NOT EXISTS FOR (d:Decision) REQUIRE d.decision_id IS UNIQUE",
|
||||
|
||||
# Policy nodes
|
||||
"CREATE CONSTRAINT policy_id_unique IF NOT EXISTS FOR (p:Policy) REQUIRE p.policy_id IS UNIQUE",
|
||||
|
||||
|
||||
# Exception nodes
|
||||
"CREATE CONSTRAINT exception_id_unique IF NOT EXISTS FOR (e:Exception) REQUIRE e.exception_id IS UNIQUE",
|
||||
|
||||
|
||||
# ApprovalChain nodes
|
||||
"CREATE CONSTRAINT approval_id_unique IF NOT EXISTS FOR (a:ApprovalChain) REQUIRE a.approval_id IS UNIQUE",
|
||||
|
||||
|
||||
# DecisionContext nodes
|
||||
"CREATE CONSTRAINT context_id_unique IF NOT EXISTS FOR (c:DecisionContext) REQUIRE c.context_id IS UNIQUE",
|
||||
|
||||
|
||||
# Precedent nodes
|
||||
"CREATE CONSTRAINT precedent_id_unique IF NOT EXISTS FOR (pr:Precedent) REQUIRE pr.precedent_id IS UNIQUE"
|
||||
"CREATE CONSTRAINT precedent_id_unique IF NOT EXISTS FOR (pr:Precedent) REQUIRE pr.precedent_id IS UNIQUE",
|
||||
|
||||
# Immutable trace nodes
|
||||
"CREATE CONSTRAINT decision_trace_id_unique IF NOT EXISTS FOR (t:DecisionTraceEvent) REQUIRE t.trace_id IS UNIQUE",
|
||||
]
|
||||
|
||||
# Policy versioning needs (policy_id, version) identity. Keep legacy fallback for old backends.
|
||||
try:
|
||||
# Drop legacy constraint when possible so versioned policies can coexist.
|
||||
graph_store.execute_query("DROP CONSTRAINT policy_id_unique IF EXISTS")
|
||||
except Exception as e:
|
||||
get_logger(__name__).warning(
|
||||
"Failed to drop legacy policy_id_unique constraint before policy "
|
||||
f"versioning migration: {e}"
|
||||
)
|
||||
|
||||
try:
|
||||
graph_store.execute_query(
|
||||
"CREATE CONSTRAINT policy_identity_unique IF NOT EXISTS "
|
||||
"FOR (p:Policy) REQUIRE (p.policy_id, p.version) IS UNIQUE"
|
||||
)
|
||||
except Exception as e:
|
||||
get_logger(__name__).warning(
|
||||
"Composite policy constraint not supported; falling back to legacy "
|
||||
"policy_id uniqueness (policy versioning may be limited)"
|
||||
)
|
||||
try:
|
||||
graph_store.execute_query(
|
||||
"CREATE CONSTRAINT policy_id_unique IF NOT EXISTS FOR (p:Policy) REQUIRE p.policy_id IS UNIQUE"
|
||||
)
|
||||
except Exception as fallback_error:
|
||||
get_logger(__name__).debug(
|
||||
f"Policy constraint creation failed (may already exist): {fallback_error}"
|
||||
)
|
||||
|
||||
for constraint in constraints:
|
||||
try:
|
||||
@@ -77,6 +107,15 @@ def create_decision_indexes(graph_store: GraphStore) -> None:
|
||||
graph_store: Graph database instance
|
||||
"""
|
||||
indexes = [
|
||||
# Explicit identity indexes (helps verification and non-constraint lookups)
|
||||
"CREATE INDEX decision_id_index IF NOT EXISTS FOR (d:Decision) ON (d.decision_id)",
|
||||
"CREATE INDEX policy_id_index IF NOT EXISTS FOR (p:Policy) ON (p.policy_id)",
|
||||
"CREATE INDEX exception_id_index IF NOT EXISTS FOR (e:Exception) ON (e.exception_id)",
|
||||
"CREATE INDEX approval_id_index IF NOT EXISTS FOR (a:ApprovalChain) ON (a.approval_id)",
|
||||
"CREATE INDEX context_id_index IF NOT EXISTS FOR (c:DecisionContext) ON (c.context_id)",
|
||||
"CREATE INDEX precedent_id_index IF NOT EXISTS FOR (pr:Precedent) ON (pr.precedent_id)",
|
||||
"CREATE INDEX decision_trace_id_index IF NOT EXISTS FOR (t:DecisionTraceEvent) ON (t.trace_id)",
|
||||
|
||||
# Decision indexes
|
||||
"CREATE INDEX decision_category_index IF NOT EXISTS FOR (d:Decision) ON (d.category)",
|
||||
"CREATE INDEX decision_timestamp_index IF NOT EXISTS FOR (d:Decision) ON (d.timestamp)",
|
||||
@@ -111,6 +150,11 @@ def create_decision_indexes(graph_store: GraphStore) -> None:
|
||||
# Cross-system context indexes
|
||||
"CREATE INDEX cross_system_name_index IF NOT EXISTS FOR (c:CrossSystemContext) ON (c.system_name)",
|
||||
"CREATE INDEX cross_system_created_at_index IF NOT EXISTS FOR (c:CrossSystemContext) ON (c.created_at)",
|
||||
|
||||
# Decision trace indexes
|
||||
"CREATE INDEX decision_trace_event_index IF NOT EXISTS FOR (t:DecisionTraceEvent) ON (t.event_index)",
|
||||
"CREATE INDEX decision_trace_type_index IF NOT EXISTS FOR (t:DecisionTraceEvent) ON (t.event_type)",
|
||||
"CREATE INDEX decision_trace_timestamp_index IF NOT EXISTS FOR (t:DecisionTraceEvent) ON (t.event_timestamp)",
|
||||
|
||||
# Entity type indexes for general graph operations
|
||||
"CREATE INDEX entity_type_index IF NOT EXISTS FOR (n) ON (n.type)",
|
||||
@@ -153,7 +197,11 @@ def verify_schema(graph_store: GraphStore) -> bool:
|
||||
"policy_id_index",
|
||||
"policy_category_index",
|
||||
"exception_id_index",
|
||||
"approval_id_index"
|
||||
"approval_id_index",
|
||||
"decision_trace_id_index",
|
||||
"decision_trace_event_index",
|
||||
"decision_trace_type_index",
|
||||
"decision_trace_timestamp_index",
|
||||
]
|
||||
|
||||
for index_name in index_checks:
|
||||
@@ -178,6 +226,25 @@ def verify_schema(graph_store: GraphStore) -> bool:
|
||||
logger.warning(f"Schema verification failed for {index_name}: {e}")
|
||||
return False
|
||||
|
||||
# Verify policy constraint compatibility:
|
||||
# prefer composite (policy_id, version), allow legacy policy_id uniqueness.
|
||||
try:
|
||||
constraints_result = graph_store.execute_query("SHOW CONSTRAINTS")
|
||||
constraint_records = (
|
||||
constraints_result.get("records", [])
|
||||
if isinstance(constraints_result, dict)
|
||||
else constraints_result
|
||||
)
|
||||
constraint_text = json.dumps(constraint_records, default=str).lower()
|
||||
has_composite = "policy_identity_unique" in constraint_text
|
||||
has_legacy = "policy_id_unique" in constraint_text
|
||||
if not (has_composite or has_legacy):
|
||||
logger.warning("Missing policy identity constraint (composite or legacy)")
|
||||
return False
|
||||
except Exception:
|
||||
# Backend may not support SHOW CONSTRAINTS; skip hard failure here.
|
||||
pass
|
||||
|
||||
# Check for node labels
|
||||
label_checks = [
|
||||
"Decision",
|
||||
@@ -185,7 +252,8 @@ def verify_schema(graph_store: GraphStore) -> bool:
|
||||
"Exception",
|
||||
"ApprovalChain",
|
||||
"DecisionContext",
|
||||
"Precedent"
|
||||
"Precedent",
|
||||
"DecisionTraceEvent",
|
||||
]
|
||||
|
||||
for label in label_checks:
|
||||
@@ -227,7 +295,7 @@ def get_schema_info() -> Dict[str, Any]:
|
||||
"policy_id", "name", "description", "rules", "category",
|
||||
"version", "created_at", "updated_at", "metadata"
|
||||
],
|
||||
"constraints": ["policy_id_unique"],
|
||||
"constraints": ["policy_identity_unique"],
|
||||
"indexes": ["policy_id_index", "policy_category_index", "policy_version_index"]
|
||||
},
|
||||
"Exception": {
|
||||
@@ -267,6 +335,19 @@ def get_schema_info() -> Dict[str, Any]:
|
||||
"context_id", "system_name", "context_data", "created_at"
|
||||
],
|
||||
"indexes": ["cross_system_name_index", "cross_system_created_at_index"]
|
||||
},
|
||||
"DecisionTraceEvent": {
|
||||
"properties": [
|
||||
"trace_id", "decision_id", "event_index", "event_type",
|
||||
"event_timestamp", "event_payload", "previous_hash", "event_hash"
|
||||
],
|
||||
"constraints": ["decision_trace_id_unique"],
|
||||
"indexes": [
|
||||
"decision_trace_id_index",
|
||||
"decision_trace_event_index",
|
||||
"decision_trace_type_index",
|
||||
"decision_trace_timestamp_index"
|
||||
]
|
||||
}
|
||||
},
|
||||
"relationship_types": {
|
||||
@@ -288,6 +369,9 @@ def get_schema_info() -> Dict[str, Any]:
|
||||
"Provenance relationships": [
|
||||
"DERIVED_FROM", "INFLUENCED_BY", "BASED_ON"
|
||||
],
|
||||
"Decision trace relationships": [
|
||||
"HAS_TRACE_EVENT", "NEXT_TRACE_EVENT"
|
||||
],
|
||||
"Entity and context relationships": [
|
||||
"REPORTED_BY", "RELATES_TO", "ESCALATED_TO", "SIMILAR_TO"
|
||||
],
|
||||
@@ -302,13 +386,15 @@ def get_schema_info() -> Dict[str, Any]:
|
||||
"approval_id_index", "approval_method_index", "approval_approver_index",
|
||||
"context_id_index", "context_decision_id_index",
|
||||
"precedent_id_index", "precedent_source_index", "precedent_similarity_index",
|
||||
"decision_trace_id_index", "decision_trace_event_index", "decision_trace_type_index", "decision_trace_timestamp_index",
|
||||
"cross_system_name_index", "cross_system_created_at_index",
|
||||
"entity_type_index", "entity_id_index", "relationship_strength_index",
|
||||
"temporal_before_index", "temporal_after_index", "temporal_during_index"
|
||||
],
|
||||
"constraints": [
|
||||
"decision_id_unique", "policy_id_unique", "exception_id_unique",
|
||||
"approval_id_unique", "context_id_unique", "precedent_id_unique"
|
||||
"decision_id_unique", "policy_identity_unique", "exception_id_unique",
|
||||
"approval_id_unique", "context_id_unique", "precedent_id_unique",
|
||||
"decision_trace_id_unique"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -383,11 +469,13 @@ def drop_decision_schema(graph_store: GraphStore) -> None:
|
||||
# Drop constraints
|
||||
constraints = [
|
||||
"DROP CONSTRAINT decision_id_unique IF EXISTS",
|
||||
"DROP CONSTRAINT policy_id_unique IF EXISTS",
|
||||
"DROP CONSTRAINT policy_identity_unique IF EXISTS",
|
||||
"DROP CONSTRAINT policy_id_unique IF EXISTS",
|
||||
"DROP CONSTRAINT exception_id_unique IF EXISTS",
|
||||
"DROP CONSTRAINT approval_id_unique IF EXISTS",
|
||||
"DROP CONSTRAINT context_id_unique IF EXISTS",
|
||||
"DROP CONSTRAINT precedent_id_unique IF EXISTS"
|
||||
"DROP CONSTRAINT precedent_id_unique IF EXISTS",
|
||||
"DROP CONSTRAINT decision_trace_id_unique IF EXISTS",
|
||||
]
|
||||
|
||||
for constraint in constraints:
|
||||
@@ -398,12 +486,22 @@ def drop_decision_schema(graph_store: GraphStore) -> None:
|
||||
|
||||
# Drop indexes
|
||||
indexes = [
|
||||
"DROP INDEX decision_id_index IF EXISTS",
|
||||
"DROP INDEX decision_category_index IF EXISTS",
|
||||
"DROP INDEX decision_timestamp_index IF EXISTS",
|
||||
"DROP INDEX policy_id_index IF EXISTS",
|
||||
"DROP INDEX policy_category_index IF EXISTS",
|
||||
"DROP INDEX policy_version_index IF EXISTS",
|
||||
"DROP INDEX exception_id_index IF EXISTS",
|
||||
"DROP INDEX exception_reason_index IF EXISTS",
|
||||
"DROP INDEX approval_method_index IF EXISTS"
|
||||
"DROP INDEX approval_id_index IF EXISTS",
|
||||
"DROP INDEX approval_method_index IF EXISTS",
|
||||
"DROP INDEX context_id_index IF EXISTS",
|
||||
"DROP INDEX precedent_id_index IF EXISTS",
|
||||
"DROP INDEX decision_trace_id_index IF EXISTS",
|
||||
"DROP INDEX decision_trace_event_index IF EXISTS",
|
||||
"DROP INDEX decision_trace_type_index IF EXISTS",
|
||||
"DROP INDEX decision_trace_timestamp_index IF EXISTS"
|
||||
]
|
||||
|
||||
for index in indexes:
|
||||
@@ -416,6 +514,7 @@ def drop_decision_schema(graph_store: GraphStore) -> None:
|
||||
cleanup_query = """
|
||||
MATCH (n) WHERE n:Decision OR n:Policy OR n:Exception OR n:ApprovalChain
|
||||
OR n:DecisionContext OR n:Precedent OR n:CrossSystemContext
|
||||
OR n:DecisionTraceEvent
|
||||
DETACH DELETE n
|
||||
"""
|
||||
graph_store.execute_query(cleanup_query)
|
||||
|
||||
+431
-179
@@ -84,7 +84,7 @@ class PolicyEngine:
|
||||
records exceptions, and analyzes policy impact.
|
||||
"""
|
||||
|
||||
def __init__(self, graph_store: GraphStore):
|
||||
def __init__(self, graph_store: Any):
|
||||
"""
|
||||
Initialize PolicyEngine.
|
||||
|
||||
@@ -93,6 +93,7 @@ class PolicyEngine:
|
||||
"""
|
||||
self.graph_store = graph_store
|
||||
self.logger = get_logger(__name__)
|
||||
self._supports_cypher = hasattr(graph_store, "execute_query")
|
||||
|
||||
def add_policy(self, policy: Policy) -> str:
|
||||
"""
|
||||
@@ -105,37 +106,56 @@ class PolicyEngine:
|
||||
Policy ID
|
||||
"""
|
||||
try:
|
||||
# Store policy node
|
||||
query = """
|
||||
CREATE (p:Policy {
|
||||
policy_id: $policy_id,
|
||||
name: $name,
|
||||
description: $description,
|
||||
rules: $rules,
|
||||
category: $category,
|
||||
version: $version,
|
||||
created_at: $created_at,
|
||||
updated_at: $updated_at,
|
||||
metadata: $metadata
|
||||
})
|
||||
"""
|
||||
self.graph_store.execute_query(query, {
|
||||
"policy_id": policy.policy_id,
|
||||
"name": policy.name,
|
||||
"description": policy.description,
|
||||
"rules": policy.rules,
|
||||
"category": policy.category,
|
||||
"version": policy.version,
|
||||
"created_at": policy.created_at,
|
||||
"updated_at": policy.updated_at,
|
||||
"metadata": policy.metadata
|
||||
})
|
||||
|
||||
if self._supports_cypher:
|
||||
query = """
|
||||
CREATE (p:Policy {
|
||||
policy_id: $policy_id,
|
||||
name: $name,
|
||||
description: $description,
|
||||
rules: $rules,
|
||||
category: $category,
|
||||
version: $version,
|
||||
created_at: $created_at,
|
||||
updated_at: $updated_at,
|
||||
metadata: $metadata
|
||||
})
|
||||
"""
|
||||
self.graph_store.execute_query(query, {
|
||||
"policy_id": policy.policy_id,
|
||||
"name": policy.name,
|
||||
"description": policy.description,
|
||||
"rules": policy.rules,
|
||||
"category": policy.category,
|
||||
"version": policy.version,
|
||||
"created_at": policy.created_at,
|
||||
"updated_at": policy.updated_at,
|
||||
"metadata": policy.metadata
|
||||
})
|
||||
self.logger.info(f"Added policy: {policy.policy_id} version {policy.version}")
|
||||
return policy.policy_id
|
||||
|
||||
if not hasattr(self.graph_store, "add_node"):
|
||||
raise RuntimeError("Graph backend does not support policy storage")
|
||||
|
||||
node_id = f"{policy.policy_id}:{policy.version}"
|
||||
self.graph_store.add_node(
|
||||
node_id=node_id,
|
||||
node_type="Policy",
|
||||
content=policy.name or policy.policy_id,
|
||||
policy_id=policy.policy_id,
|
||||
name=policy.name,
|
||||
description=policy.description,
|
||||
rules=policy.rules,
|
||||
category=policy.category,
|
||||
version=policy.version,
|
||||
created_at=policy.created_at.isoformat() if hasattr(policy.created_at, "isoformat") else str(policy.created_at),
|
||||
updated_at=policy.updated_at.isoformat() if hasattr(policy.updated_at, "isoformat") else str(policy.updated_at),
|
||||
metadata=policy.metadata or {}
|
||||
)
|
||||
self.logger.info(f"Added policy: {policy.policy_id} version {policy.version}")
|
||||
return policy.policy_id
|
||||
|
||||
except Exception as e:
|
||||
self.logger.error(f"Failed to add policy: {e}")
|
||||
self.logger.exception("Failed to add policy")
|
||||
raise
|
||||
|
||||
def update_policy(
|
||||
@@ -187,23 +207,32 @@ class PolicyEngine:
|
||||
# Store new version
|
||||
self.add_policy(updated_policy)
|
||||
|
||||
# Link versions
|
||||
query = """
|
||||
MATCH (old:Policy {policy_id: $policy_id, version: $old_version})
|
||||
MATCH (new:Policy {policy_id: $policy_id, version: $new_version})
|
||||
MERGE (old)-[:VERSION_OF]->(new)
|
||||
"""
|
||||
self.graph_store.execute_query(query, {
|
||||
"policy_id": policy_id,
|
||||
"old_version": current_policy.version,
|
||||
"new_version": new_version
|
||||
})
|
||||
if self._supports_cypher:
|
||||
query = """
|
||||
MATCH (old:Policy {policy_id: $policy_id, version: $old_version})
|
||||
MATCH (new:Policy {policy_id: $policy_id, version: $new_version})
|
||||
MERGE (old)-[:VERSION_OF]->(new)
|
||||
"""
|
||||
self.graph_store.execute_query(query, {
|
||||
"policy_id": policy_id,
|
||||
"old_version": current_policy.version,
|
||||
"new_version": new_version
|
||||
})
|
||||
else:
|
||||
if hasattr(self.graph_store, "add_edge"):
|
||||
self.graph_store.add_edge(
|
||||
f"{policy_id}:{current_policy.version}",
|
||||
f"{policy_id}:{new_version}",
|
||||
edge_type="VERSION_OF",
|
||||
changed_at=datetime.now().isoformat(),
|
||||
change_reason=change_reason
|
||||
)
|
||||
|
||||
self.logger.info(f"Updated policy {policy_id} to version {new_version}")
|
||||
return new_version
|
||||
|
||||
except Exception as e:
|
||||
self.logger.error(f"Failed to update policy: {e}")
|
||||
self.logger.exception("Failed to update policy")
|
||||
raise
|
||||
|
||||
def get_applicable_policies(
|
||||
@@ -222,32 +251,123 @@ class PolicyEngine:
|
||||
List of applicable policies (latest versions)
|
||||
"""
|
||||
try:
|
||||
if self._supports_cypher:
|
||||
# Get latest policies for category
|
||||
query = """
|
||||
MATCH (p:Policy {category: $category})
|
||||
WHERE NOT (p)-[:VERSION_OF]->(:Policy)
|
||||
RETURN p
|
||||
ORDER BY p.updated_at DESC
|
||||
"""
|
||||
results = self.graph_store.execute_query(query, {"category": category})
|
||||
|
||||
policies = []
|
||||
for record in results:
|
||||
policy_data = record.get("p", {})
|
||||
policies.append(self._dict_to_policy(policy_data))
|
||||
|
||||
# Filter by entities if specified
|
||||
if entities:
|
||||
# This would require entity-specific policy relationships
|
||||
# For now, return all category policies
|
||||
pass
|
||||
|
||||
query = """
|
||||
MATCH (p:Policy {category: $category})
|
||||
WHERE NOT (p)-[:VERSION_OF]->(:Policy)
|
||||
RETURN p
|
||||
ORDER BY p.updated_at DESC
|
||||
"""
|
||||
results = self.graph_store.execute_query(query, {"category": category})
|
||||
records = self._extract_records(results)
|
||||
|
||||
policies = []
|
||||
for record in records:
|
||||
policy_data = record.get("p") if isinstance(record, dict) else None
|
||||
if not isinstance(policy_data, dict):
|
||||
policy_data = record if isinstance(record, dict) else {}
|
||||
if not isinstance(policy_data, dict) or not policy_data.get("policy_id"):
|
||||
self.logger.debug(
|
||||
"Skipping malformed policy record in get_applicable_policies: "
|
||||
f"{record}"
|
||||
)
|
||||
continue
|
||||
policy = self._dict_to_policy(policy_data)
|
||||
if self._policy_matches_entities(policy, entities):
|
||||
policies.append(policy)
|
||||
|
||||
self.logger.info(f"Found {len(policies)} applicable policies for category {category}")
|
||||
return policies
|
||||
|
||||
if not hasattr(self.graph_store, "find_nodes"):
|
||||
return []
|
||||
|
||||
latest_by_policy_id: Dict[str, Dict[str, Any]] = {}
|
||||
for node in self.graph_store.find_nodes(node_type="Policy"):
|
||||
data = node.get("metadata", {}) or {}
|
||||
if data.get("category") != category:
|
||||
continue
|
||||
pid = data.get("policy_id")
|
||||
if not pid:
|
||||
continue
|
||||
updated_at = data.get("updated_at") or ""
|
||||
prev = latest_by_policy_id.get(pid)
|
||||
if not prev:
|
||||
latest_by_policy_id[pid] = data
|
||||
else:
|
||||
if str(updated_at) > str(prev.get("updated_at") or ""):
|
||||
latest_by_policy_id[pid] = data
|
||||
|
||||
policies: List[Policy] = []
|
||||
for data in latest_by_policy_id.values():
|
||||
policy = self._dict_to_policy({
|
||||
"policy_id": data.get("policy_id"),
|
||||
"name": data.get("name"),
|
||||
"description": data.get("description"),
|
||||
"rules": data.get("rules", {}),
|
||||
"category": data.get("category"),
|
||||
"version": data.get("version"),
|
||||
"created_at": data.get("created_at"),
|
||||
"updated_at": data.get("updated_at"),
|
||||
"metadata": data.get("metadata", {})
|
||||
})
|
||||
if self._policy_matches_entities(policy, entities):
|
||||
policies.append(policy)
|
||||
|
||||
self.logger.info(f"Found {len(policies)} applicable policies for category {category}")
|
||||
return policies
|
||||
|
||||
except Exception as e:
|
||||
self.logger.error(f"Failed to get applicable policies: {e}")
|
||||
self.logger.exception("Failed to get applicable policies")
|
||||
raise
|
||||
|
||||
def _extract_records(self, results: Any) -> List[Dict[str, Any]]:
|
||||
"""Normalize execute_query result shapes to record lists."""
|
||||
if isinstance(results, dict):
|
||||
records = results.get("records", [])
|
||||
if not isinstance(records, list):
|
||||
return []
|
||||
|
||||
# FalkorDB shape: {"records": [[...], ...], "header": ["col1", ...]}
|
||||
header = results.get("header")
|
||||
if (
|
||||
isinstance(header, list)
|
||||
and records
|
||||
and all(isinstance(row, list) for row in records)
|
||||
):
|
||||
normalized: List[Dict[str, Any]] = []
|
||||
for row in records:
|
||||
row_map: Dict[str, Any] = dict(zip(header, row))
|
||||
normalized.append(row_map)
|
||||
return normalized
|
||||
|
||||
return records
|
||||
if isinstance(results, list):
|
||||
return results
|
||||
return []
|
||||
|
||||
def _policy_matches_entities(
|
||||
self, policy: Policy, entities: Optional[List[str]]
|
||||
) -> bool:
|
||||
"""
|
||||
Entity scoping for policies.
|
||||
If no entity scope is defined on the policy, it is globally applicable.
|
||||
"""
|
||||
if not entities:
|
||||
return True
|
||||
|
||||
metadata = policy.metadata or {}
|
||||
scoped_entities = (
|
||||
metadata.get("entities")
|
||||
or metadata.get("entity_ids")
|
||||
or metadata.get("applies_to_entities")
|
||||
or []
|
||||
)
|
||||
if not scoped_entities:
|
||||
return True
|
||||
|
||||
return bool(set(str(e) for e in scoped_entities).intersection(set(entities)))
|
||||
|
||||
def check_compliance(self, decision: Decision, policy_id: str) -> bool:
|
||||
"""
|
||||
@@ -285,7 +405,7 @@ class PolicyEngine:
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
self.logger.error(f"Failed to check compliance: {e}")
|
||||
self.logger.exception("Failed to check compliance")
|
||||
return False
|
||||
|
||||
def record_policy_application(
|
||||
@@ -303,22 +423,35 @@ class PolicyEngine:
|
||||
version: Policy version that was applied
|
||||
"""
|
||||
try:
|
||||
query = """
|
||||
MATCH (d:Decision {decision_id: $decision_id})
|
||||
MATCH (p:Policy {policy_id: $policy_id, version: $version})
|
||||
MERGE (d)-[:APPLIED_POLICY]->(p)
|
||||
SET d.policy_applied_at = timestamp()
|
||||
"""
|
||||
self.graph_store.execute_query(query, {
|
||||
"decision_id": decision_id,
|
||||
"policy_id": policy_id,
|
||||
"version": version
|
||||
})
|
||||
|
||||
if self._supports_cypher:
|
||||
query = """
|
||||
MATCH (d:Decision {decision_id: $decision_id})
|
||||
MATCH (p:Policy {policy_id: $policy_id, version: $version})
|
||||
MERGE (d)-[:APPLIED_POLICY]->(p)
|
||||
SET d.policy_applied_at = timestamp()
|
||||
"""
|
||||
self.graph_store.execute_query(query, {
|
||||
"decision_id": decision_id,
|
||||
"policy_id": policy_id,
|
||||
"version": version
|
||||
})
|
||||
self.logger.info(f"Recorded policy application: {policy_id} v{version} to decision {decision_id}")
|
||||
return
|
||||
|
||||
if not hasattr(self.graph_store, "add_edge"):
|
||||
raise RuntimeError("Graph backend does not support relationships")
|
||||
policy_node_id = f"{policy_id}:{version}"
|
||||
self.graph_store.add_edge(
|
||||
decision_id,
|
||||
policy_node_id,
|
||||
edge_type="APPLIED_POLICY",
|
||||
applied_at=datetime.now().isoformat(),
|
||||
policy_id=policy_id,
|
||||
version=version
|
||||
)
|
||||
self.logger.info(f"Recorded policy application: {policy_id} v{version} to decision {decision_id}")
|
||||
|
||||
except Exception as e:
|
||||
self.logger.error(f"Failed to record policy application: {e}")
|
||||
self.logger.exception("Failed to record policy application")
|
||||
raise
|
||||
|
||||
def record_exception(
|
||||
@@ -340,42 +473,63 @@ class PolicyEngine:
|
||||
"""
|
||||
try:
|
||||
exception_id = str(uuid.uuid4())
|
||||
|
||||
query = """
|
||||
CREATE (e:Exception {
|
||||
exception_id: $exception_id,
|
||||
decision_id: $decision_id,
|
||||
policy_id: $policy_id,
|
||||
reason: $reason,
|
||||
created_at: datetime()
|
||||
})
|
||||
"""
|
||||
self.graph_store.execute_query(query, {
|
||||
"exception_id": exception_id,
|
||||
"decision_id": decision_id,
|
||||
"policy_id": policy_id,
|
||||
"reason": reason
|
||||
})
|
||||
|
||||
# Link to decision and policy
|
||||
query = """
|
||||
MATCH (d:Decision {decision_id: $decision_id})
|
||||
MATCH (p:Policy {policy_id: $policy_id})
|
||||
MATCH (e:Exception {exception_id: $exception_id})
|
||||
MERGE (d)-[:GRANTED_EXCEPTION]->(e)
|
||||
MERGE (e)-[:OVERRIDDEN_POLICY]->(p)
|
||||
"""
|
||||
self.graph_store.execute_query(query, {
|
||||
"decision_id": decision_id,
|
||||
"policy_id": policy_id,
|
||||
"exception_id": exception_id
|
||||
})
|
||||
|
||||
|
||||
if self._supports_cypher:
|
||||
query = """
|
||||
CREATE (e:Exception {
|
||||
exception_id: $exception_id,
|
||||
decision_id: $decision_id,
|
||||
policy_id: $policy_id,
|
||||
reason: $reason,
|
||||
created_at: datetime()
|
||||
})
|
||||
"""
|
||||
self.graph_store.execute_query(query, {
|
||||
"exception_id": exception_id,
|
||||
"decision_id": decision_id,
|
||||
"policy_id": policy_id,
|
||||
"reason": reason
|
||||
})
|
||||
|
||||
query = """
|
||||
MATCH (d:Decision {decision_id: $decision_id})
|
||||
MATCH (p:Policy {policy_id: $policy_id})
|
||||
MATCH (e:Exception {exception_id: $exception_id})
|
||||
MERGE (d)-[:GRANTED_EXCEPTION]->(e)
|
||||
MERGE (e)-[:OVERRIDDEN_POLICY]->(p)
|
||||
"""
|
||||
self.graph_store.execute_query(query, {
|
||||
"decision_id": decision_id,
|
||||
"policy_id": policy_id,
|
||||
"exception_id": exception_id
|
||||
})
|
||||
|
||||
self.logger.info(f"Recorded policy exception: {exception_id}")
|
||||
return exception_id
|
||||
|
||||
if not hasattr(self.graph_store, "add_node") or not hasattr(self.graph_store, "add_edge"):
|
||||
raise RuntimeError("Graph backend does not support exceptions")
|
||||
|
||||
self.graph_store.add_node(
|
||||
node_id=exception_id,
|
||||
node_type="Exception",
|
||||
content=reason,
|
||||
exception_id=exception_id,
|
||||
decision_id=decision_id,
|
||||
policy_id=policy_id,
|
||||
reason=reason,
|
||||
created_at=datetime.now().isoformat()
|
||||
)
|
||||
self.graph_store.add_edge(decision_id, exception_id, edge_type="GRANTED_EXCEPTION")
|
||||
|
||||
policy = self.get_policy(policy_id)
|
||||
if policy:
|
||||
self.graph_store.add_edge(exception_id, f"{policy_id}:{policy.version}", edge_type="OVERRIDDEN_POLICY")
|
||||
|
||||
self.logger.info(f"Recorded policy exception: {exception_id}")
|
||||
return exception_id
|
||||
|
||||
except Exception as e:
|
||||
self.logger.error(f"Failed to record exception: {e}")
|
||||
self.logger.exception("Failed to record exception")
|
||||
raise
|
||||
|
||||
def get_policy_history(self, policy_id: str) -> List[Policy]:
|
||||
@@ -389,26 +543,48 @@ class PolicyEngine:
|
||||
List of policy versions
|
||||
"""
|
||||
try:
|
||||
query = """
|
||||
MATCH (p:Policy {policy_id: $policy_id})
|
||||
OPTIONAL MATCH (p)-[:VERSION_OF*]->(future:Policy)
|
||||
WITH collect(p) + collect(future) as all_versions
|
||||
UNWIND all_versions as version
|
||||
RETURN DISTINCT version
|
||||
ORDER BY version.updated_at
|
||||
"""
|
||||
results = self.graph_store.execute_query(query, {"policy_id": policy_id})
|
||||
if self._supports_cypher:
|
||||
query = """
|
||||
MATCH (p:Policy {policy_id: $policy_id})
|
||||
OPTIONAL MATCH (p)-[:VERSION_OF*]->(future:Policy)
|
||||
WITH collect(p) + collect(future) as all_versions
|
||||
UNWIND all_versions as version
|
||||
RETURN DISTINCT version
|
||||
ORDER BY version.updated_at
|
||||
"""
|
||||
results = self.graph_store.execute_query(query, {"policy_id": policy_id})
|
||||
|
||||
policies = []
|
||||
for record in results:
|
||||
policy_data = record.get("version", {})
|
||||
policies.append(self._dict_to_policy(policy_data))
|
||||
policies = []
|
||||
for record in results:
|
||||
policy_data = record.get("version", {})
|
||||
policies.append(self._dict_to_policy(policy_data))
|
||||
|
||||
self.logger.info(f"Found {len(policies)} versions for policy {policy_id}")
|
||||
return policies
|
||||
self.logger.info(f"Found {len(policies)} versions for policy {policy_id}")
|
||||
return policies
|
||||
|
||||
if not hasattr(self.graph_store, "find_nodes"):
|
||||
return []
|
||||
versions: List[Policy] = []
|
||||
for node in self.graph_store.find_nodes(node_type="Policy"):
|
||||
data = node.get("metadata", {}) or {}
|
||||
if data.get("policy_id") != policy_id:
|
||||
continue
|
||||
versions.append(self._dict_to_policy({
|
||||
"policy_id": data.get("policy_id"),
|
||||
"name": data.get("name"),
|
||||
"description": data.get("description"),
|
||||
"rules": data.get("rules", {}),
|
||||
"category": data.get("category"),
|
||||
"version": data.get("version"),
|
||||
"created_at": data.get("created_at"),
|
||||
"updated_at": data.get("updated_at"),
|
||||
"metadata": data.get("metadata", {})
|
||||
}))
|
||||
versions.sort(key=lambda p: str(p.updated_at))
|
||||
return versions
|
||||
|
||||
except Exception as e:
|
||||
self.logger.error(f"Failed to get policy history: {e}")
|
||||
self.logger.exception("Failed to get policy history")
|
||||
raise
|
||||
|
||||
def get_affected_decisions(
|
||||
@@ -429,27 +605,37 @@ class PolicyEngine:
|
||||
List of affected decision IDs
|
||||
"""
|
||||
try:
|
||||
query = """
|
||||
MATCH (d:Decision)-[:APPLIED_POLICY]->(p:Policy {
|
||||
policy_id: $policy_id,
|
||||
version: $from_version
|
||||
})
|
||||
RETURN d.decision_id as decision_id
|
||||
"""
|
||||
results = self.graph_store.execute_query(query, {
|
||||
"policy_id": policy_id,
|
||||
"from_version": from_version
|
||||
})
|
||||
if self._supports_cypher:
|
||||
query = """
|
||||
MATCH (d:Decision)-[:APPLIED_POLICY]->(p:Policy {
|
||||
policy_id: $policy_id,
|
||||
version: $from_version
|
||||
})
|
||||
RETURN d.decision_id as decision_id
|
||||
"""
|
||||
results = self.graph_store.execute_query(query, {
|
||||
"policy_id": policy_id,
|
||||
"from_version": from_version
|
||||
})
|
||||
|
||||
decision_ids = []
|
||||
for record in results:
|
||||
decision_ids.append(record.get("decision_id", ""))
|
||||
decision_ids = []
|
||||
for record in results:
|
||||
decision_ids.append(record.get("decision_id", ""))
|
||||
|
||||
self.logger.info(f"Found {len(decision_ids)} decisions affected by policy change")
|
||||
self.logger.info(f"Found {len(decision_ids)} decisions affected by policy change")
|
||||
return decision_ids
|
||||
|
||||
if not hasattr(self.graph_store, "find_edges"):
|
||||
return []
|
||||
policy_node_id = f"{policy_id}:{from_version}"
|
||||
decision_ids: List[str] = []
|
||||
for edge in self.graph_store.find_edges(edge_type="APPLIED_POLICY"):
|
||||
if edge.get("target") == policy_node_id:
|
||||
decision_ids.append(edge.get("source"))
|
||||
return decision_ids
|
||||
|
||||
except Exception as e:
|
||||
self.logger.error(f"Failed to get affected decisions: {e}")
|
||||
self.logger.exception("Failed to get affected decisions")
|
||||
raise
|
||||
|
||||
def analyze_policy_impact(
|
||||
@@ -471,16 +657,38 @@ class PolicyEngine:
|
||||
current_policy = self.get_policy(policy_id)
|
||||
if not current_policy:
|
||||
raise ValueError(f"Policy {policy_id} not found")
|
||||
|
||||
# Get decisions that used this policy
|
||||
query = """
|
||||
MATCH (d:Decision)-[:APPLIED_POLICY]->(p:Policy {policy_id: $policy_id})
|
||||
RETURN d.decision_id as decision_id, d.confidence as confidence,
|
||||
d.outcome as outcome, d.category as category
|
||||
"""
|
||||
results = self.graph_store.execute_query(query, {"policy_id": policy_id})
|
||||
|
||||
# Analyze impact
|
||||
|
||||
results: List[Dict[str, Any]] = []
|
||||
if self._supports_cypher:
|
||||
query = """
|
||||
MATCH (d:Decision)-[:APPLIED_POLICY]->(p:Policy {policy_id: $policy_id})
|
||||
RETURN d.decision_id as decision_id, d.confidence as confidence,
|
||||
d.outcome as outcome, d.category as category
|
||||
"""
|
||||
results = self.graph_store.execute_query(query, {"policy_id": policy_id})
|
||||
else:
|
||||
if hasattr(self.graph_store, "find_edges") and hasattr(self.graph_store, "nodes"):
|
||||
for edge in self.graph_store.find_edges(edge_type="APPLIED_POLICY"):
|
||||
target = edge.get("target")
|
||||
if not target or not isinstance(target, str):
|
||||
continue
|
||||
policy_node = self.graph_store.nodes.get(target)
|
||||
if not policy_node:
|
||||
continue
|
||||
props = getattr(policy_node, "properties", {}) or {}
|
||||
if props.get("policy_id") != policy_id:
|
||||
continue
|
||||
decision_node = self.graph_store.nodes.get(edge.get("source"))
|
||||
if not decision_node:
|
||||
continue
|
||||
dprops = getattr(decision_node, "properties", {}) or {}
|
||||
results.append({
|
||||
"decision_id": edge.get("source"),
|
||||
"confidence": dprops.get("confidence", 0.0),
|
||||
"outcome": dprops.get("outcome", ""),
|
||||
"category": dprops.get("category", "")
|
||||
})
|
||||
|
||||
impact_analysis = {
|
||||
"total_decisions": len(results),
|
||||
"affected_decisions": 0,
|
||||
@@ -488,19 +696,16 @@ class PolicyEngine:
|
||||
"risk_assessment": "low",
|
||||
"recommendations": []
|
||||
}
|
||||
|
||||
|
||||
for record in results:
|
||||
decision_data = {
|
||||
"confidence": record.get("confidence", 0.0),
|
||||
"outcome": record.get("outcome", ""),
|
||||
"category": record.get("category", "")
|
||||
}
|
||||
|
||||
# Check if decision would still comply with new rules
|
||||
would_comply = self._check_compliance_with_rules(
|
||||
decision_data, proposed_rules
|
||||
)
|
||||
|
||||
if not would_comply:
|
||||
impact_analysis["affected_decisions"] += 1
|
||||
|
||||
@@ -531,7 +736,7 @@ class PolicyEngine:
|
||||
return impact_analysis
|
||||
|
||||
except Exception as e:
|
||||
self.logger.error(f"Failed to analyze policy impact: {e}")
|
||||
self.logger.exception("Failed to analyze policy impact")
|
||||
raise
|
||||
|
||||
def get_policy(self, policy_id: str, version: Optional[str] = None) -> Optional[Policy]:
|
||||
@@ -546,31 +751,78 @@ class PolicyEngine:
|
||||
Policy object or None
|
||||
"""
|
||||
try:
|
||||
if self._supports_cypher:
|
||||
if version:
|
||||
query = """
|
||||
MATCH (p:Policy {policy_id: $policy_id, version: $version})
|
||||
RETURN p
|
||||
"""
|
||||
params = {"policy_id": policy_id, "version": version}
|
||||
else:
|
||||
query = """
|
||||
MATCH (p:Policy {policy_id: $policy_id})
|
||||
WHERE NOT (p)-[:VERSION_OF]->(:Policy)
|
||||
RETURN p
|
||||
"""
|
||||
params = {"policy_id": policy_id}
|
||||
|
||||
results = self.graph_store.execute_query(query, params)
|
||||
|
||||
if results:
|
||||
policy_data = results[0].get("p", {})
|
||||
return self._dict_to_policy(policy_data)
|
||||
return None
|
||||
|
||||
if not hasattr(self.graph_store, "find_nodes"):
|
||||
return None
|
||||
|
||||
candidates: List[Dict[str, Any]] = []
|
||||
for node in self.graph_store.find_nodes(node_type="Policy"):
|
||||
data = node.get("metadata", {}) or {}
|
||||
if data.get("policy_id") != policy_id:
|
||||
continue
|
||||
if version and data.get("version") != version:
|
||||
continue
|
||||
candidates.append(data)
|
||||
|
||||
if not candidates:
|
||||
return None
|
||||
|
||||
if version:
|
||||
query = """
|
||||
MATCH (p:Policy {policy_id: $policy_id, version: $version})
|
||||
RETURN p
|
||||
"""
|
||||
params = {"policy_id": policy_id, "version": version}
|
||||
data = candidates[0]
|
||||
else:
|
||||
# Get latest version
|
||||
query = """
|
||||
MATCH (p:Policy {policy_id: $policy_id})
|
||||
WHERE NOT (p)-[:VERSION_OF]->(:Policy)
|
||||
RETURN p
|
||||
"""
|
||||
params = {"policy_id": policy_id}
|
||||
|
||||
results = self.graph_store.execute_query(query, params)
|
||||
|
||||
if results:
|
||||
policy_data = results[0].get("p", {})
|
||||
return self._dict_to_policy(policy_data)
|
||||
|
||||
return None
|
||||
|
||||
# Prefer highest semantic version if available, fallback to updated_at
|
||||
def _version_key(v: str) -> tuple:
|
||||
try:
|
||||
parts = [int(p) for p in str(v).split(".")]
|
||||
# Normalize length for comparison
|
||||
while len(parts) < 3:
|
||||
parts.append(-1)
|
||||
return tuple(parts[:3])
|
||||
except Exception:
|
||||
return (-1, -1, -1)
|
||||
|
||||
try:
|
||||
data = max(
|
||||
candidates,
|
||||
key=lambda d: (_version_key(d.get("version")), str(d.get("updated_at") or "")),
|
||||
)
|
||||
except Exception:
|
||||
data = max(candidates, key=lambda d: str(d.get("updated_at") or ""))
|
||||
|
||||
return self._dict_to_policy({
|
||||
"policy_id": data.get("policy_id"),
|
||||
"name": data.get("name"),
|
||||
"description": data.get("description"),
|
||||
"rules": data.get("rules", {}),
|
||||
"category": data.get("category"),
|
||||
"version": data.get("version"),
|
||||
"created_at": data.get("created_at"),
|
||||
"updated_at": data.get("updated_at"),
|
||||
"metadata": data.get("metadata", {})
|
||||
})
|
||||
except Exception as e:
|
||||
self.logger.error(f"Failed to get policy: {e}")
|
||||
self.logger.exception("Failed to get policy")
|
||||
return None
|
||||
|
||||
def _generate_next_version(self, current_version: str) -> str:
|
||||
@@ -619,7 +871,7 @@ class PolicyEngine:
|
||||
data[field] = datetime.fromisoformat(data[field])
|
||||
|
||||
return Policy(
|
||||
policy_id=data.get("policy_id", ""),
|
||||
policy_id=data["policy_id"], # Required field
|
||||
name=data.get("name", ""),
|
||||
description=data.get("description", ""),
|
||||
rules=data.get("rules", {}),
|
||||
|
||||
@@ -122,6 +122,7 @@ Author: Semantica Contributors
|
||||
License: MIT
|
||||
"""
|
||||
|
||||
from .age_store import ApacheAgeStore
|
||||
from .amazon_neptune import (
|
||||
AmazonNeptuneStore,
|
||||
NeptuneAuthTokenManager,
|
||||
@@ -172,6 +173,8 @@ __all__ = [
|
||||
"Neo4jStore",
|
||||
"Neo4jDriver",
|
||||
"Neo4jTransaction",
|
||||
# Apache AGE
|
||||
"ApacheAgeStore",
|
||||
# Amazon Neptune
|
||||
"AmazonNeptuneStore",
|
||||
"NeptuneAuthTokenManager",
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -138,6 +138,9 @@ class GraphStoreConfig:
|
||||
"AWS_ACCESS_KEY_ID": "neptune_access_key",
|
||||
"AWS_SECRET_ACCESS_KEY": "neptune_secret_key",
|
||||
"AWS_SESSION_TOKEN": "neptune_session_token",
|
||||
# Apache AGE settings
|
||||
"GRAPH_STORE_AGE_CONNECTION_STRING": "age_connection_string",
|
||||
"GRAPH_STORE_AGE_GRAPH_NAME": "age_graph_name",
|
||||
}
|
||||
|
||||
for env_var, config_key in env_mappings.items():
|
||||
@@ -199,6 +202,9 @@ class GraphStoreConfig:
|
||||
"neptune_access_key": None,
|
||||
"neptune_secret_key": None,
|
||||
"neptune_session_token": None,
|
||||
# Apache AGE defaults
|
||||
"age_connection_string": "host=localhost dbname=agedb user=postgres password=postgres",
|
||||
"age_graph_name": "semantica",
|
||||
}
|
||||
|
||||
for key, default_value in defaults.items():
|
||||
@@ -315,6 +321,18 @@ class GraphStoreConfig:
|
||||
"session_token": self._config.get("neptune_session_token"),
|
||||
}
|
||||
|
||||
def get_age_config(self) -> Dict[str, Any]:
|
||||
"""
|
||||
Get Apache AGE-specific configuration.
|
||||
|
||||
Returns:
|
||||
Apache AGE configuration dictionary
|
||||
"""
|
||||
return {
|
||||
"connection_string": self._config.get("age_connection_string"),
|
||||
"graph_name": self._config.get("age_graph_name"),
|
||||
}
|
||||
|
||||
def reset(self) -> None:
|
||||
"""Reset configuration to defaults."""
|
||||
self._config.clear()
|
||||
|
||||
@@ -583,6 +583,13 @@ class GraphStore:
|
||||
neptune_config.update(self.config)
|
||||
self._store_backend = AmazonNeptuneStore(**neptune_config)
|
||||
|
||||
elif self.backend == "age" or self.backend == "apache_age":
|
||||
from .age_store import ApacheAgeStore
|
||||
|
||||
age_config = graph_store_config.get_age_config()
|
||||
age_config.update(self.config)
|
||||
self._store_backend = ApacheAgeStore(**age_config)
|
||||
|
||||
else:
|
||||
raise ValidationError(f"Unknown backend: {self.backend}")
|
||||
|
||||
|
||||
@@ -739,6 +739,11 @@ class CentralityCalculator:
|
||||
neighbors = list(graph.neighbors(node))
|
||||
elif hasattr(graph, 'get_neighbors'):
|
||||
neighbors = graph.get_neighbors(node)
|
||||
if neighbors and isinstance(neighbors[0], dict):
|
||||
neighbors = [
|
||||
n.get("id") for n in neighbors
|
||||
if isinstance(n, dict) and n.get("id")
|
||||
]
|
||||
else:
|
||||
neighbors = []
|
||||
|
||||
|
||||
@@ -891,6 +891,12 @@ class CommunityDetector:
|
||||
all_neighbors = graph.get_neighbors(node)
|
||||
else:
|
||||
all_neighbors = []
|
||||
|
||||
if all_neighbors and isinstance(all_neighbors[0], dict):
|
||||
all_neighbors = [
|
||||
n.get("id") for n in all_neighbors
|
||||
if isinstance(n, dict) and n.get("id")
|
||||
]
|
||||
|
||||
# Filter by relationship types if specified
|
||||
if relationship_types is not None and hasattr(graph, 'get_edge_data'):
|
||||
|
||||
@@ -455,6 +455,9 @@ class LinkPredictor:
|
||||
if hasattr(graph_store, 'neighbors'):
|
||||
return list(graph_store.neighbors(node_id))
|
||||
elif hasattr(graph_store, 'get_neighbors'):
|
||||
return graph_store.get_neighbors(node_id)
|
||||
neighbors = graph_store.get_neighbors(node_id)
|
||||
if neighbors and isinstance(neighbors[0], dict):
|
||||
return [n.get("id") for n in neighbors if isinstance(n, dict) and n.get("id")]
|
||||
return neighbors
|
||||
else:
|
||||
return []
|
||||
|
||||
@@ -350,13 +350,25 @@ class NodeEmbedder:
|
||||
|
||||
# Build adjacency
|
||||
for node in nodes:
|
||||
if hasattr(graph_store, 'get_neighbors'):
|
||||
neighbors = graph_store.get_neighbors(node, relationship_types)
|
||||
adjacency[node] = neighbors
|
||||
else:
|
||||
# Fallback for NetworkX
|
||||
if hasattr(graph_store, 'neighbors'):
|
||||
adjacency[node] = list(graph_store.neighbors(node))
|
||||
if hasattr(graph_store, 'neighbors'):
|
||||
adjacency[node] = list(graph_store.neighbors(node))
|
||||
elif hasattr(graph_store, 'get_neighbor_ids'):
|
||||
adjacency[node] = graph_store.get_neighbor_ids(node, relationship_types)
|
||||
elif hasattr(graph_store, 'get_neighbors'):
|
||||
try:
|
||||
neighbor_details = graph_store.get_neighbors(node, hops=1, relationship_types=relationship_types)
|
||||
adjacency[node] = [
|
||||
n.get("id") for n in neighbor_details
|
||||
if isinstance(n, dict) and n.get("id")
|
||||
]
|
||||
except TypeError:
|
||||
neighbor_details = graph_store.get_neighbors(node)
|
||||
adjacency[node] = [
|
||||
n.get("id") for n in neighbor_details
|
||||
if isinstance(n, dict) and n.get("id")
|
||||
]
|
||||
elif hasattr(graph_store, 'nodes') and hasattr(graph_store, 'edges'):
|
||||
adjacency[node] = []
|
||||
|
||||
return dict(adjacency)
|
||||
|
||||
|
||||
@@ -38,7 +38,7 @@ class TestAgentContextDecisions:
|
||||
return AgentContext(
|
||||
vector_store=mock_vector_store,
|
||||
knowledge_graph=mock_knowledge_graph,
|
||||
enable_decision_tracking=True
|
||||
decision_tracking=True
|
||||
)
|
||||
|
||||
@pytest.fixture
|
||||
@@ -47,7 +47,7 @@ class TestAgentContextDecisions:
|
||||
return AgentContext(
|
||||
vector_store=mock_vector_store,
|
||||
knowledge_graph=mock_knowledge_graph,
|
||||
enable_decision_tracking=False
|
||||
decision_tracking=False
|
||||
)
|
||||
|
||||
def test_agent_context_initialization_with_decisions(self, mock_vector_store, mock_knowledge_graph):
|
||||
@@ -55,10 +55,10 @@ class TestAgentContextDecisions:
|
||||
context = AgentContext(
|
||||
vector_store=mock_vector_store,
|
||||
knowledge_graph=mock_knowledge_graph,
|
||||
enable_decision_tracking=True
|
||||
decision_tracking=True
|
||||
)
|
||||
|
||||
assert context.config["enable_decision_tracking"] is True
|
||||
assert context.config["decision_tracking"] is True
|
||||
assert context._decision_recorder is not None
|
||||
assert context._decision_query is not None
|
||||
assert context._causal_analyzer is not None
|
||||
@@ -69,10 +69,10 @@ class TestAgentContextDecisions:
|
||||
context = AgentContext(
|
||||
vector_store=mock_vector_store,
|
||||
knowledge_graph=mock_knowledge_graph,
|
||||
enable_decision_tracking=False
|
||||
decision_tracking=False
|
||||
)
|
||||
|
||||
assert context.config["enable_decision_tracking"] is False
|
||||
assert context.config["decision_tracking"] is False
|
||||
assert context._decision_recorder is None
|
||||
assert context._decision_query is None
|
||||
assert context._causal_analyzer is None
|
||||
@@ -246,6 +246,22 @@ class TestAgentContextDecisions:
|
||||
assert context[system]["system_name"] == system
|
||||
assert "captured_at" in context[system]
|
||||
assert context[system]["status"] == "captured"
|
||||
|
||||
def test_capture_cross_system_inputs_sanitizes_errors(
|
||||
self, agent_context_with_decisions, mock_knowledge_graph
|
||||
):
|
||||
"""Test capture errors are sanitized in returned payload."""
|
||||
mock_knowledge_graph.execute_query.side_effect = RuntimeError(
|
||||
"backend connection failed: sensitive details"
|
||||
)
|
||||
|
||||
context = agent_context_with_decisions.capture_cross_system_inputs(
|
||||
["salesforce"], "customer_001"
|
||||
)
|
||||
|
||||
assert context["salesforce"]["status"] == "capture_failed"
|
||||
assert context["salesforce"]["error"] == "internal_capture_error"
|
||||
assert "sensitive" not in context["salesforce"]["error"]
|
||||
|
||||
def test_backward_compatibility(self, mock_vector_store, mock_knowledge_graph):
|
||||
"""Test backward compatibility when decision tracking is not explicitly set."""
|
||||
@@ -255,7 +271,7 @@ class TestAgentContextDecisions:
|
||||
knowledge_graph=mock_knowledge_graph
|
||||
)
|
||||
|
||||
assert context.config["enable_decision_tracking"] is False
|
||||
assert context.config["decision_tracking"] is False
|
||||
assert context._decision_recorder is None
|
||||
|
||||
def test_error_handling(self, agent_context_with_decisions):
|
||||
@@ -279,11 +295,11 @@ class TestAgentContextDecisions:
|
||||
context = AgentContext(
|
||||
vector_store=mock_vector_store,
|
||||
knowledge_graph=None,
|
||||
enable_decision_tracking=True
|
||||
decision_tracking=True
|
||||
)
|
||||
|
||||
# Should initialize but warn about missing knowledge graph
|
||||
assert context.config["enable_decision_tracking"] is True
|
||||
assert context.config["decision_tracking"] is True
|
||||
|
||||
def test_error_handling(self, agent_context_with_decisions):
|
||||
"""Test error handling in decision tracking."""
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
import pytest
|
||||
from semantica.context import AgentContext, ContextGraph
|
||||
from semantica.context.decision_models import Policy
|
||||
from semantica.vector_store import VectorStore
|
||||
from datetime import datetime
|
||||
|
||||
|
||||
def test_agent_context_minimal_decisions_and_chain():
|
||||
vs = VectorStore(backend="inmemory", dimension=64)
|
||||
graph = ContextGraph()
|
||||
ctx = AgentContext(
|
||||
vector_store=vs,
|
||||
knowledge_graph=graph,
|
||||
decision_tracking=True,
|
||||
kg_algorithms=False,
|
||||
vector_store_features=False,
|
||||
)
|
||||
d1 = ctx.record_decision(
|
||||
category="credit_approval",
|
||||
scenario="s1",
|
||||
reasoning="r1",
|
||||
outcome="rejected",
|
||||
confidence=0.8,
|
||||
entities=["e1"],
|
||||
decision_maker="tester",
|
||||
)
|
||||
d2 = ctx.record_decision(
|
||||
category="credit_approval",
|
||||
scenario="s2",
|
||||
reasoning="r2",
|
||||
outcome="rejected",
|
||||
confidence=0.85,
|
||||
entities=["e1"],
|
||||
decision_maker="tester",
|
||||
)
|
||||
graph.add_causal_relationship(d1, d2, "INFLUENCED")
|
||||
chain = ctx.get_causal_chain(d2, direction="upstream", max_depth=5)
|
||||
assert isinstance(chain, list)
|
||||
assert len(chain) >= 1
|
||||
|
||||
|
||||
def test_agent_context_policy_engine_with_graph_backend():
|
||||
vs = VectorStore(backend="inmemory", dimension=64)
|
||||
graph = ContextGraph()
|
||||
ctx = AgentContext(
|
||||
vector_store=vs,
|
||||
knowledge_graph=graph,
|
||||
decision_tracking=True,
|
||||
kg_algorithms=False,
|
||||
vector_store_features=False,
|
||||
)
|
||||
pe = ctx.get_policy_engine()
|
||||
pol = Policy(
|
||||
policy_id="cp",
|
||||
name="Credit Policy",
|
||||
description="d",
|
||||
rules={"min_confidence": 0.8, "allowed_outcomes": ["approved", "rejected"]},
|
||||
category="credit_approval",
|
||||
version="1.0.0",
|
||||
created_at=datetime.now(),
|
||||
updated_at=datetime.now(),
|
||||
metadata={},
|
||||
)
|
||||
pe.add_policy(pol)
|
||||
found = pe.get_policy("cp")
|
||||
assert found is not None
|
||||
@@ -52,10 +52,10 @@ class TestBankingDecisionSystem:
|
||||
return 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
|
||||
decision_tracking=True,
|
||||
advanced_analytics=True,
|
||||
kg_algorithms=True,
|
||||
vector_store_features=True
|
||||
)
|
||||
|
||||
def test_banking_decision_lifecycle(self, banking_context):
|
||||
@@ -216,10 +216,10 @@ class TestBankingDecisionSystem:
|
||||
enhanced_query = DecisionQuery(
|
||||
graph_store=mock_knowledge_graph,
|
||||
vector_store=mock_vector_store,
|
||||
enable_advanced_analytics=True,
|
||||
enable_centrality_analysis=True,
|
||||
enable_community_detection=True,
|
||||
enable_node_embeddings=True
|
||||
advanced_analytics=True,
|
||||
centrality_analysis=True,
|
||||
community_detection=True,
|
||||
node_embeddings=True
|
||||
)
|
||||
|
||||
print(f"[OK] Enhanced DecisionQuery with {len(enhanced_query.kg_components)} KG components")
|
||||
@@ -240,10 +240,10 @@ class TestBankingDecisionSystem:
|
||||
|
||||
# Test enhanced ContextGraph
|
||||
enhanced_graph = ContextGraph(
|
||||
enable_advanced_analytics=True,
|
||||
enable_centrality_analysis=True,
|
||||
enable_community_detection=True,
|
||||
enable_node_embeddings=True
|
||||
advanced_analytics=True,
|
||||
centrality_analysis=True,
|
||||
community_detection=True,
|
||||
node_embeddings=True
|
||||
)
|
||||
|
||||
print(f"[OK] Enhanced ContextGraph with {len(enhanced_graph.kg_components)} KG components")
|
||||
|
||||
@@ -594,11 +594,12 @@ class TestContextGraphDecisionsEdgeCases:
|
||||
decision_maker="test_agent"
|
||||
)
|
||||
|
||||
# Should still add the decision
|
||||
# Should still add the decision (auto-generates UUID for empty string)
|
||||
context_graph.add_decision(decision)
|
||||
|
||||
# Should be accessible with empty string key
|
||||
assert "" in context_graph.nodes
|
||||
# Should have generated UUID for empty string (not preserve empty string)
|
||||
assert len(context_graph.nodes) == 1
|
||||
assert "" not in context_graph.nodes # Empty string should be replaced with UUID
|
||||
|
||||
def test_decision_with_null_fields(self, context_graph):
|
||||
"""Test adding decision with null fields."""
|
||||
|
||||
@@ -0,0 +1,303 @@
|
||||
"""Consolidated end-to-end coverage for context-graph critical features."""
|
||||
|
||||
from datetime import datetime
|
||||
from unittest.mock import Mock
|
||||
|
||||
from semantica.context.agent_context import AgentContext
|
||||
from semantica.context.causal_analyzer import CausalChainAnalyzer
|
||||
from semantica.context.decision_methods import capture_decision_trace
|
||||
from semantica.context.decision_models import Decision
|
||||
from semantica.context.decision_query import DecisionQuery
|
||||
from semantica.context.graph_schema import get_schema_info
|
||||
from semantica.context.policy_engine import PolicyEngine
|
||||
|
||||
|
||||
def _decision() -> Decision:
|
||||
return Decision(
|
||||
decision_id="e2e_decision_001",
|
||||
category="renewal_pricing",
|
||||
scenario="Renewal discount exception",
|
||||
reasoning="SEV-1 history + churn risk",
|
||||
outcome="approved",
|
||||
confidence=0.93,
|
||||
timestamp=datetime.now(),
|
||||
decision_maker="agent_renewal",
|
||||
)
|
||||
|
||||
|
||||
def _realistic_cross_system_context():
|
||||
"""Representative enterprise sources used by agentic workflows."""
|
||||
return {
|
||||
"salesforce": {
|
||||
"account_id": "001A",
|
||||
"arr": 120000,
|
||||
"customer_tier": "enterprise",
|
||||
"renewal_date": "2026-03-15",
|
||||
},
|
||||
"zendesk": {
|
||||
"open_escalations": 2,
|
||||
"sev1_tickets_last_90d": 3,
|
||||
"latest_ticket_id": "ZD-9912",
|
||||
},
|
||||
"pagerduty": {
|
||||
"sev1_incidents_last_90d": 3,
|
||||
"latest_incident": "PD-4421",
|
||||
"service": "api-gateway",
|
||||
},
|
||||
"slack": {
|
||||
"risk_channel": "#renewal-risk",
|
||||
"churn_flag": True,
|
||||
"latest_thread_ref": "ts-1739202.1234",
|
||||
},
|
||||
"stripe": {
|
||||
"invoice_status": "past_due",
|
||||
"last_payment_attempt": "2026-01-28",
|
||||
"days_past_due": 14,
|
||||
},
|
||||
"product_telemetry": {
|
||||
"weekly_active_users": 284,
|
||||
"api_error_rate": 0.038,
|
||||
"feature_adoption_score": 0.72,
|
||||
},
|
||||
"confluence": {
|
||||
"playbook_version": "renewals-v4.2",
|
||||
"policy_page_id": "CONF-778",
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def test_e2e_decision_trace_capture_with_immutable_lineage():
|
||||
graph_store = Mock()
|
||||
|
||||
def _execute_query(query, params=None, *args, **kwargs):
|
||||
if "RETURN t.trace_id as trace_id" in query:
|
||||
return {
|
||||
"records": [
|
||||
{
|
||||
"trace_id": "e2e_decision_001:2",
|
||||
"event_index": 2,
|
||||
"event_hash": "prev_hash",
|
||||
}
|
||||
]
|
||||
}
|
||||
if "RETURN p.policy_id as policy_id, p.version as version" in query:
|
||||
return {"records": [{"policy_id": "renewal_policy", "version": "3.2"}]}
|
||||
return {"records": []}
|
||||
|
||||
graph_store.execute_query = Mock(side_effect=_execute_query)
|
||||
|
||||
decision_id = capture_decision_trace(
|
||||
decision=_decision(),
|
||||
cross_system_context=_realistic_cross_system_context(),
|
||||
graph_store=graph_store,
|
||||
entities=["customer_123"],
|
||||
source_documents=["note_001"],
|
||||
policy_ids=[{"policy_id": "renewal_policy", "version": "3.2"}],
|
||||
exceptions=[{"policy_id": "renewal_policy", "reason": "service-impact"}],
|
||||
approvals=[{"approver": "vp_finance", "approval_method": "slack_dm"}],
|
||||
precedents=[{"precedent_id": "decision_old_001"}],
|
||||
immutable_audit_log=True,
|
||||
)
|
||||
|
||||
assert decision_id == "e2e_decision_001"
|
||||
queries = [c[0][0] for c in graph_store.execute_query.call_args_list]
|
||||
assert any("CREATE (t:DecisionTraceEvent" in q for q in queries)
|
||||
assert any("MERGE (d)-[:HAS_TRACE_EVENT]->(t)" in q for q in queries)
|
||||
assert any("MERGE (prev)-[:NEXT_TRACE_EVENT]->(curr)" in q for q in queries)
|
||||
|
||||
|
||||
def test_e2e_schema_info_contains_trace_and_policy_versioning():
|
||||
schema = get_schema_info()
|
||||
assert "DecisionTraceEvent" in schema["node_labels"]
|
||||
assert "HAS_TRACE_EVENT" in schema["relationship_types"]["Decision trace relationships"]
|
||||
assert "NEXT_TRACE_EVENT" in schema["relationship_types"]["Decision trace relationships"]
|
||||
assert "policy_identity_unique" in schema["constraints"]
|
||||
assert "decision_trace_timestamp_index" in schema["indexes"]
|
||||
|
||||
|
||||
def test_e2e_policy_applicability_for_wrapped_and_falkordb_shapes():
|
||||
# Wrapped records shape
|
||||
wrapped_store = Mock()
|
||||
wrapped_store.execute_query = Mock(
|
||||
return_value={
|
||||
"records": [
|
||||
{
|
||||
"p": {
|
||||
"policy_id": "p_wrapped",
|
||||
"name": "Wrapped",
|
||||
"description": "wrapped",
|
||||
"rules": {},
|
||||
"category": "renewal_pricing",
|
||||
"version": "1.0",
|
||||
"created_at": datetime.now().isoformat(),
|
||||
"updated_at": datetime.now().isoformat(),
|
||||
"metadata": {},
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
)
|
||||
wrapped_engine = PolicyEngine(graph_store=wrapped_store)
|
||||
wrapped = wrapped_engine.get_applicable_policies("renewal_pricing", None)
|
||||
assert len(wrapped) == 1
|
||||
assert wrapped[0].policy_id == "p_wrapped"
|
||||
|
||||
# FalkorDB row+header shape
|
||||
falkor_store = Mock()
|
||||
falkor_store.execute_query = Mock(
|
||||
return_value={
|
||||
"records": [
|
||||
[
|
||||
{
|
||||
"policy_id": "p_falkor",
|
||||
"name": "Falkor",
|
||||
"description": "row shape",
|
||||
"rules": {},
|
||||
"category": "renewal_pricing",
|
||||
"version": "1.0",
|
||||
"created_at": datetime.now().isoformat(),
|
||||
"updated_at": datetime.now().isoformat(),
|
||||
"metadata": {},
|
||||
}
|
||||
]
|
||||
],
|
||||
"header": ["p"],
|
||||
}
|
||||
)
|
||||
falkor_engine = PolicyEngine(graph_store=falkor_store)
|
||||
falkor = falkor_engine.get_applicable_policies("renewal_pricing", None)
|
||||
assert len(falkor) == 1
|
||||
assert falkor[0].policy_id == "p_falkor"
|
||||
|
||||
|
||||
def test_e2e_policy_applicability_context_graph_fallback_respects_entities():
|
||||
class _ContextGraphLike:
|
||||
def find_nodes(self, node_type=None):
|
||||
return [
|
||||
{
|
||||
"metadata": {
|
||||
"policy_id": "p_match",
|
||||
"name": "match",
|
||||
"description": "match",
|
||||
"rules": {},
|
||||
"category": "renewal_pricing",
|
||||
"version": "1.0",
|
||||
"created_at": datetime.now().isoformat(),
|
||||
"updated_at": datetime.now().isoformat(),
|
||||
"metadata": {"entities": ["customer:123"]},
|
||||
}
|
||||
},
|
||||
{
|
||||
"metadata": {
|
||||
"policy_id": "p_other",
|
||||
"name": "other",
|
||||
"description": "other",
|
||||
"rules": {},
|
||||
"category": "renewal_pricing",
|
||||
"version": "1.0",
|
||||
"created_at": datetime.now().isoformat(),
|
||||
"updated_at": datetime.now().isoformat(),
|
||||
"metadata": {"entities": ["customer:999"]},
|
||||
}
|
||||
},
|
||||
]
|
||||
|
||||
engine = PolicyEngine(graph_store=_ContextGraphLike())
|
||||
policies = engine.get_applicable_policies("renewal_pricing", ["customer:123"])
|
||||
assert len(policies) == 1
|
||||
assert policies[0].policy_id == "p_match"
|
||||
|
||||
|
||||
def test_e2e_decision_query_and_causal_analyzer_handle_wrapped_results():
|
||||
graph_store = Mock()
|
||||
graph_store.execute_query = Mock(
|
||||
return_value={
|
||||
"records": [
|
||||
{
|
||||
"d": {
|
||||
"decision_id": "d1",
|
||||
"category": "renewal_pricing",
|
||||
"scenario": "Renewal case",
|
||||
"reasoning": "Reasoning",
|
||||
"outcome": "approved",
|
||||
"confidence": 0.9,
|
||||
"timestamp": datetime.now().isoformat(),
|
||||
"decision_maker": "agent",
|
||||
},
|
||||
"end": {
|
||||
"decision_id": "d2",
|
||||
"category": "renewal_pricing",
|
||||
"scenario": "Downstream case",
|
||||
"reasoning": "Reasoning",
|
||||
"outcome": "approved",
|
||||
"confidence": 0.8,
|
||||
"timestamp": datetime.now().isoformat(),
|
||||
"decision_maker": "agent",
|
||||
},
|
||||
"distance": 1,
|
||||
}
|
||||
]
|
||||
}
|
||||
)
|
||||
|
||||
query = DecisionQuery(graph_store=graph_store)
|
||||
precedents = query.find_precedents_hybrid("Renewal case", "renewal_pricing", 5)
|
||||
assert len(precedents) == 1
|
||||
assert precedents[0].decision_id == "d1"
|
||||
|
||||
analyzer = CausalChainAnalyzer(graph_store=graph_store)
|
||||
chain = analyzer.get_causal_chain("d1", "downstream", 3)
|
||||
assert len(chain) == 1
|
||||
assert chain[0].decision_id == "d2"
|
||||
|
||||
|
||||
def test_e2e_cross_system_capture_sanitizes_internal_errors():
|
||||
vector_store = Mock()
|
||||
knowledge_graph = Mock()
|
||||
knowledge_graph.execute_query = Mock(
|
||||
side_effect=RuntimeError("secret backend details")
|
||||
)
|
||||
|
||||
ctx = AgentContext(
|
||||
vector_store=vector_store, knowledge_graph=knowledge_graph, decision_tracking=True
|
||||
)
|
||||
data = ctx.capture_cross_system_inputs(["salesforce"], "customer_123")
|
||||
|
||||
assert data["salesforce"]["status"] == "capture_failed"
|
||||
assert data["salesforce"]["error"] == "internal_capture_error"
|
||||
assert "secret" not in data["salesforce"]["error"]
|
||||
|
||||
|
||||
def test_e2e_cross_system_capture_with_real_source_mix():
|
||||
vector_store = Mock()
|
||||
knowledge_graph = Mock()
|
||||
|
||||
# Simulate backend records for all systems with wrapper shape.
|
||||
knowledge_graph.execute_query = Mock(
|
||||
return_value={
|
||||
"records": [
|
||||
{
|
||||
"c": {
|
||||
"context_id": "ctx_001",
|
||||
"system_name": "source",
|
||||
"context_data": {"sample": True},
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
)
|
||||
|
||||
ctx = AgentContext(
|
||||
vector_store=vector_store, knowledge_graph=knowledge_graph, decision_tracking=True
|
||||
)
|
||||
systems = list(_realistic_cross_system_context().keys())
|
||||
data = ctx.capture_cross_system_inputs(systems, "customer_123")
|
||||
|
||||
assert set(data.keys()) == set(systems)
|
||||
for system in systems:
|
||||
assert data[system]["system_name"] == system
|
||||
assert data[system]["entity_id"] == "customer_123"
|
||||
assert data[system]["status"] == "captured"
|
||||
assert "captured_at" in data[system]
|
||||
assert "records_found" in data[system]
|
||||
assert "records" in data[system]
|
||||
@@ -0,0 +1,522 @@
|
||||
#!/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(
|
||||
advanced_analytics=True,
|
||||
centrality_analysis=True,
|
||||
community_detection=True,
|
||||
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,
|
||||
decision_tracking=True,
|
||||
advanced_analytics=True,
|
||||
kg_algorithms=True,
|
||||
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,
|
||||
decision_tracking=True,
|
||||
advanced_analytics=True,
|
||||
kg_algorithms=True,
|
||||
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,
|
||||
decision_tracking=True,
|
||||
advanced_analytics=True,
|
||||
kg_algorithms=True,
|
||||
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 - will be auto-generated
|
||||
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 len(graph.nodes) == 1 # Should have generated UUID for empty string
|
||||
assert "" not in graph.nodes # Empty string should not be preserved
|
||||
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,
|
||||
decision_tracking=True,
|
||||
advanced_analytics=True,
|
||||
kg_algorithms=True,
|
||||
vector_store_features=True,
|
||||
graph_expansion=True,
|
||||
max_expansion_hops=3,
|
||||
hybrid_alpha=0.7
|
||||
)
|
||||
|
||||
# Verify configuration
|
||||
assert context.config["decision_tracking"] is True
|
||||
assert context.config["advanced_analytics"] is True
|
||||
assert context.config["kg_algorithms"] is True
|
||||
assert context.config["vector_store_features"] is True
|
||||
assert context.config["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"])
|
||||
@@ -0,0 +1,180 @@
|
||||
"""Tests for decision trace capture convenience API."""
|
||||
|
||||
from datetime import datetime
|
||||
import logging
|
||||
from unittest.mock import Mock
|
||||
|
||||
from semantica.context.decision_methods import (
|
||||
_append_immutable_trace_events,
|
||||
capture_decision_trace,
|
||||
)
|
||||
from semantica.context.decision_models import Decision
|
||||
|
||||
|
||||
def _sample_decision() -> Decision:
|
||||
return Decision(
|
||||
decision_id="decision_trace_test_001",
|
||||
category="credit_approval",
|
||||
scenario="Credit line increase for long-term customer",
|
||||
reasoning="Strong payment history and low utilization",
|
||||
outcome="approved",
|
||||
confidence=0.92,
|
||||
timestamp=datetime.now(),
|
||||
decision_maker="ai_agent",
|
||||
)
|
||||
|
||||
|
||||
def test_capture_decision_trace_without_graph_store_is_backward_compatible(caplog):
|
||||
decision = _sample_decision()
|
||||
with caplog.at_level(logging.WARNING):
|
||||
decision_id = capture_decision_trace(
|
||||
decision,
|
||||
cross_system_context={"crm": {"arr": 120000}},
|
||||
policy_ids=[{"policy_id": "renewal_discount_policy", "version": "3.2"}],
|
||||
)
|
||||
assert decision_id == decision.decision_id
|
||||
assert "capture_decision_trace skipped persistence (no graph_store)" in caplog.text
|
||||
assert f"decision_id={decision.decision_id}" in caplog.text
|
||||
assert f"decision_maker={decision.decision_maker}" in caplog.text
|
||||
assert f"outcome={decision.outcome}" in caplog.text
|
||||
|
||||
|
||||
def test_capture_decision_trace_with_graph_store_records_trace_events():
|
||||
decision = _sample_decision()
|
||||
graph_store = Mock()
|
||||
|
||||
def _execute_query(query, params=None, *args, **kwargs):
|
||||
if "RETURN t.trace_id as trace_id" in query:
|
||||
# Simulate existing chain head so NEXT_TRACE_EVENT is exercised.
|
||||
return {
|
||||
"records": [
|
||||
{
|
||||
"trace_id": "decision_trace_test_001:3",
|
||||
"event_index": 3,
|
||||
"event_hash": "prev_hash_123",
|
||||
}
|
||||
]
|
||||
}
|
||||
if "RETURN p.policy_id as policy_id, p.version as version" in query:
|
||||
return {
|
||||
"records": [
|
||||
{"policy_id": "renewal_discount_policy_v3_2", "version": "3.2"}
|
||||
]
|
||||
}
|
||||
return {"records": []}
|
||||
|
||||
graph_store.execute_query = Mock(side_effect=_execute_query)
|
||||
|
||||
decision_id = capture_decision_trace(
|
||||
decision=decision,
|
||||
cross_system_context={"crm": {"arr": 120000}},
|
||||
graph_store=graph_store,
|
||||
entities=["customer_123"],
|
||||
source_documents=["renewal_note_001"],
|
||||
policy_ids=["renewal_discount_policy_v3_2"],
|
||||
approvals=[
|
||||
{
|
||||
"approver": "vp_finance",
|
||||
"approval_method": "slack_dm",
|
||||
"approval_context": "Approved due to SEV-1 impact history",
|
||||
}
|
||||
],
|
||||
precedents=[
|
||||
{
|
||||
"precedent_id": "decision_legacy_001",
|
||||
"relationship_type": "similar_scenario",
|
||||
}
|
||||
],
|
||||
immutable_audit_log=True,
|
||||
)
|
||||
|
||||
assert decision_id == decision.decision_id
|
||||
calls = graph_store.execute_query.call_args_list
|
||||
queries = [c[0][0] for c in calls]
|
||||
params_list = [c[0][1] if len(c[0]) > 1 else {} for c in calls]
|
||||
|
||||
assert any("CREATE (t:DecisionTraceEvent" in q for q in queries)
|
||||
assert any("MERGE (d)-[:HAS_TRACE_EVENT]->(t)" in q for q in queries)
|
||||
assert any("MERGE (prev)-[:NEXT_TRACE_EVENT]->(curr)" in q for q in queries)
|
||||
|
||||
event_types = [
|
||||
p.get("event_type")
|
||||
for p in params_list
|
||||
if isinstance(p, dict) and "event_type" in p
|
||||
]
|
||||
assert "DECISION_RECORDED" in event_types
|
||||
assert "CROSS_SYSTEM_CONTEXT_CAPTURED" in event_types
|
||||
assert "POLICIES_APPLIED" in event_types
|
||||
assert "APPROVAL_CHAIN_RECORDED" in event_types
|
||||
assert "PRECEDENTS_LINKED" in event_types
|
||||
|
||||
|
||||
def test_capture_decision_trace_accepts_legacy_payload_shapes():
|
||||
decision = _sample_decision()
|
||||
graph_store = Mock()
|
||||
graph_store.execute_query = Mock(return_value=[{"t": {"trace_id": "decision_trace_test_001:4", "event_index": 4, "event_hash": "abc"}}])
|
||||
|
||||
decision_id = capture_decision_trace(
|
||||
decision=decision,
|
||||
cross_system_context={"crm": {"arr": 120000}},
|
||||
graph_store=graph_store,
|
||||
entities="customer_legacy_001",
|
||||
source_documents="doc_legacy_001",
|
||||
policy_ids="policy_legacy_v1",
|
||||
exceptions={"policy_id": "policy_legacy_v1", "reason": "legacy exception"},
|
||||
approvals={"approver": "vp_ops", "approval_method": "email"},
|
||||
precedents=["decision_legacy_001"],
|
||||
immutable_audit_log=True,
|
||||
)
|
||||
|
||||
assert decision_id == decision.decision_id
|
||||
assert graph_store.execute_query.call_count > 0
|
||||
|
||||
|
||||
def test_capture_decision_trace_accepts_versioned_policy_refs():
|
||||
decision = _sample_decision()
|
||||
graph_store = Mock()
|
||||
graph_store.execute_query = Mock(
|
||||
return_value={"records": [{"policy_id": "renewal_discount_policy", "version": "3.2"}]}
|
||||
)
|
||||
|
||||
decision_id = capture_decision_trace(
|
||||
decision=decision,
|
||||
cross_system_context={"crm": {"arr": 120000}},
|
||||
graph_store=graph_store,
|
||||
policy_ids=[{"policy_id": "renewal_discount_policy", "version": "3.2"}],
|
||||
immutable_audit_log=False,
|
||||
)
|
||||
|
||||
assert decision_id == decision.decision_id
|
||||
policy_calls = [
|
||||
c for c in graph_store.execute_query.call_args_list
|
||||
if "policy_version" in c[0][1]
|
||||
]
|
||||
assert policy_calls
|
||||
assert policy_calls[0][0][1]["policy_version"] == "3.2"
|
||||
|
||||
|
||||
def test_append_immutable_trace_events_logs_lookup_failure_and_continues(caplog):
|
||||
graph_store = Mock()
|
||||
calls = {"n": 0}
|
||||
|
||||
def _execute_query(*args, **kwargs):
|
||||
calls["n"] += 1
|
||||
if calls["n"] == 1:
|
||||
raise RuntimeError("lookup failed")
|
||||
return {"records": []}
|
||||
|
||||
graph_store.execute_query = Mock(side_effect=_execute_query)
|
||||
|
||||
with caplog.at_level(logging.WARNING):
|
||||
_append_immutable_trace_events(
|
||||
graph_store=graph_store,
|
||||
decision_id="decision_trace_test_001",
|
||||
events=[{"event_type": "DECISION_RECORDED", "payload": {"ok": True}}],
|
||||
logger=logging.getLogger("test_logger"),
|
||||
)
|
||||
|
||||
assert "Failed to lookup previous immutable trace event" in caplog.text
|
||||
assert "decision_id=decision_trace_test_001" in caplog.text
|
||||
assert graph_store.execute_query.call_count >= 2
|
||||
@@ -10,7 +10,7 @@ from datetime import datetime
|
||||
from typing import List, Dict, Any
|
||||
|
||||
from semantica.context.decision_models import (
|
||||
Decision, DecisionContext, Policy, Exception, Precedent, ApprovalChain,
|
||||
Decision, DecisionContext, Policy, PolicyException, Precedent, ApprovalChain,
|
||||
validate_decision, validate_policy, serialize_decision, deserialize_decision,
|
||||
serialize_policy, deserialize_policy
|
||||
)
|
||||
@@ -213,12 +213,12 @@ class TestPolicy:
|
||||
assert len(policy.policy_id) > 0
|
||||
|
||||
|
||||
class TestException:
|
||||
"""Test Exception data model."""
|
||||
class TestPolicyException:
|
||||
"""Test PolicyException data model."""
|
||||
|
||||
def test_exception_creation(self):
|
||||
"""Test basic exception creation."""
|
||||
exception = Exception(
|
||||
def test_policy_exception_creation(self):
|
||||
"""Test basic policy exception creation."""
|
||||
policy_exception = PolicyException(
|
||||
exception_id="exc_001",
|
||||
decision_id="decision_001",
|
||||
policy_id="policy_001",
|
||||
@@ -229,17 +229,17 @@ class TestException:
|
||||
metadata={"override_type": "vip_exception"}
|
||||
)
|
||||
|
||||
assert exception.exception_id == "exc_001"
|
||||
assert exception.decision_id == "decision_001"
|
||||
assert exception.policy_id == "policy_001"
|
||||
assert exception.reason == "Customer is VIP with special arrangements"
|
||||
assert exception.approver == "manager_001"
|
||||
assert exception.justification == "Long-term customer with excellent history"
|
||||
assert exception.metadata["override_type"] == "vip_exception"
|
||||
assert policy_exception.exception_id == "exc_001"
|
||||
assert policy_exception.decision_id == "decision_001"
|
||||
assert policy_exception.policy_id == "policy_001"
|
||||
assert policy_exception.reason == "Customer is VIP with special arrangements"
|
||||
assert policy_exception.approver == "manager_001"
|
||||
assert policy_exception.justification == "Long-term customer with excellent history"
|
||||
assert policy_exception.metadata["override_type"] == "vip_exception"
|
||||
|
||||
def test_exception_auto_id(self):
|
||||
def test_policy_exception_auto_id(self):
|
||||
"""Test automatic ID generation."""
|
||||
exception = Exception(
|
||||
policy_exception = PolicyException(
|
||||
exception_id="",
|
||||
decision_id="decision_001",
|
||||
policy_id="policy_001",
|
||||
@@ -249,8 +249,8 @@ class TestException:
|
||||
justification="test justification"
|
||||
)
|
||||
|
||||
assert exception.exception_id != ""
|
||||
assert len(exception.exception_id) > 0
|
||||
assert policy_exception.exception_id != ""
|
||||
assert len(policy_exception.exception_id) > 0
|
||||
|
||||
|
||||
class TestPrecedent:
|
||||
|
||||
@@ -10,7 +10,7 @@ from datetime import datetime, timedelta
|
||||
from unittest.mock import Mock, patch
|
||||
from typing import List, Dict, Any
|
||||
|
||||
from semantica.context.decision_models import Decision, Policy, Exception
|
||||
from semantica.context.decision_models import Decision, Policy, PolicyException
|
||||
from semantica.context.decision_query import DecisionQuery
|
||||
|
||||
|
||||
|
||||
@@ -139,11 +139,29 @@ class TestDecisionRecorder:
|
||||
"""Test applying policies to decision."""
|
||||
decision_id = "decision_001"
|
||||
policy_ids = ["policy_001", "policy_002"]
|
||||
mock_graph_store.execute_query.return_value = {"records": [{"policy_id": "policy_001", "version": "2.0"}]}
|
||||
|
||||
decision_recorder.apply_policies(decision_id, policy_ids)
|
||||
applied = decision_recorder.apply_policies(decision_id, policy_ids)
|
||||
|
||||
# Verify graph store was called for each policy
|
||||
assert mock_graph_store.execute_query.call_count == len(policy_ids)
|
||||
assert isinstance(applied, list)
|
||||
|
||||
def test_apply_policies_with_explicit_version(self, decision_recorder, mock_graph_store):
|
||||
"""Test applying a specific policy version to avoid ambiguous linking."""
|
||||
decision_id = "decision_001"
|
||||
policy_refs = [{"policy_id": "policy_001", "version": "3.2"}]
|
||||
mock_graph_store.execute_query.return_value = {
|
||||
"records": [{"policy_id": "policy_001", "version": "3.2"}]
|
||||
}
|
||||
|
||||
applied = decision_recorder.apply_policies(decision_id, policy_refs)
|
||||
|
||||
assert len(applied) == 1
|
||||
assert applied[0]["policy_id"] == "policy_001"
|
||||
assert applied[0]["version"] == "3.2"
|
||||
call = mock_graph_store.execute_query.call_args_list[0]
|
||||
assert call[0][1]["policy_version"] == "3.2"
|
||||
|
||||
def test_record_exception(self, decision_recorder, mock_graph_store):
|
||||
"""Test recording policy exception."""
|
||||
@@ -239,9 +257,9 @@ class TestDecisionRecorder:
|
||||
|
||||
def test_store_exception_node(self, decision_recorder, mock_graph_store):
|
||||
"""Test storing exception node in graph."""
|
||||
from semantica.context.decision_models import Exception
|
||||
from semantica.context.decision_models import PolicyException
|
||||
|
||||
exception = Exception(
|
||||
exception = PolicyException(
|
||||
exception_id="exc_001",
|
||||
decision_id="decision_001",
|
||||
policy_id="policy_001",
|
||||
|
||||
@@ -118,7 +118,7 @@ class TestEndToEndContextIntegration:
|
||||
results = retriever.retrieve(
|
||||
query="Credit limit increase for business expansion",
|
||||
max_results=10,
|
||||
use_graph_expansion=True
|
||||
graph_expansion=True
|
||||
)
|
||||
print(f"✅ Retrieved {len(results)} context items")
|
||||
|
||||
@@ -286,10 +286,10 @@ class TestEndToEndContextIntegration:
|
||||
|
||||
# Test different search configurations
|
||||
search_configs = [
|
||||
{"use_graph_expansion": False, "max_results": 10},
|
||||
{"use_graph_expansion": True, "max_results": 10},
|
||||
{"use_graph_expansion": True, "max_results": 20},
|
||||
{"use_graph_expansion": False, "max_results": 20},
|
||||
{"graph_expansion": False, "max_results": 10},
|
||||
{"graph_expansion": True, "max_results": 10},
|
||||
{"graph_expansion": True, "max_results": 20},
|
||||
{"graph_expansion": False, "max_results": 20},
|
||||
]
|
||||
|
||||
for i, config in enumerate(search_configs):
|
||||
@@ -399,7 +399,7 @@ class TestEndToEndContextIntegration:
|
||||
)
|
||||
|
||||
# Should handle KG errors gracefully
|
||||
results = retriever_broken.retrieve("Test query", max_results=5, use_graph_expansion=True)
|
||||
results = retriever_broken.retrieve("Test query", max_results=5, graph_expansion=True)
|
||||
assert len(results) > 0, "Should handle KG errors gracefully"
|
||||
print("✅ Handles KG errors gracefully")
|
||||
|
||||
@@ -572,7 +572,7 @@ class TestRealWorldContextScenarios:
|
||||
context_results = retriever.retrieve(
|
||||
query="Premium customer investment and fraud assessment",
|
||||
max_results=15,
|
||||
use_graph_expansion=True
|
||||
graph_expansion=True
|
||||
)
|
||||
|
||||
print(f"✅ Retrieved {len(context_results)} context items")
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
"""Regression tests for graph schema migration logging behavior."""
|
||||
|
||||
import logging
|
||||
from unittest.mock import Mock
|
||||
|
||||
from semantica.context.graph_schema import create_decision_constraints
|
||||
|
||||
|
||||
def test_create_decision_constraints_logs_legacy_drop_failure(caplog):
|
||||
graph_store = Mock()
|
||||
|
||||
def _execute_query(query, *args, **kwargs):
|
||||
if "DROP CONSTRAINT policy_id_unique IF EXISTS" in query:
|
||||
raise RuntimeError("drop failed")
|
||||
return {"records": []}
|
||||
|
||||
graph_store.execute_query = Mock(side_effect=_execute_query)
|
||||
|
||||
with caplog.at_level(logging.WARNING):
|
||||
create_decision_constraints(graph_store)
|
||||
|
||||
assert "Failed to drop legacy policy_id_unique constraint" in caplog.text
|
||||
@@ -52,10 +52,10 @@ class TestHealthcareDecisionSystem:
|
||||
return 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
|
||||
decision_tracking=True,
|
||||
advanced_analytics=True,
|
||||
kg_algorithms=True,
|
||||
vector_store_features=True
|
||||
)
|
||||
|
||||
def test_healthcare_decision_workflow(self, healthcare_context):
|
||||
|
||||
@@ -10,7 +10,7 @@ from datetime import datetime, timedelta
|
||||
from unittest.mock import Mock, patch
|
||||
from typing import List, Dict, Any
|
||||
|
||||
from semantica.context.decision_models import Policy, Exception
|
||||
from semantica.context.decision_models import Policy, PolicyException
|
||||
from semantica.context.policy_engine import PolicyEngine
|
||||
|
||||
|
||||
@@ -187,6 +187,86 @@ class TestPolicyEngine:
|
||||
|
||||
assert len(policies) == 1
|
||||
assert policies[0].category == category
|
||||
|
||||
def test_get_applicable_policies_falkordb_row_shape(self, policy_engine, mock_graph_store):
|
||||
"""Test policy parsing when backend returns FalkorDB list rows + header."""
|
||||
category = "credit_approval"
|
||||
mock_graph_store.execute_query.return_value = {
|
||||
"records": [
|
||||
[
|
||||
{
|
||||
"policy_id": "policy_001",
|
||||
"name": "Credit Approval Policy",
|
||||
"description": "Standard credit approval rules",
|
||||
"rules": {"min_score": 650},
|
||||
"category": "credit_approval",
|
||||
"version": "1.0",
|
||||
"created_at": datetime.now().isoformat(),
|
||||
"updated_at": datetime.now().isoformat(),
|
||||
"metadata": {},
|
||||
}
|
||||
]
|
||||
],
|
||||
"header": ["p"],
|
||||
}
|
||||
|
||||
policies = policy_engine.get_applicable_policies(category, None)
|
||||
|
||||
assert len(policies) == 1
|
||||
assert policies[0].policy_id == "policy_001"
|
||||
|
||||
def test_get_applicable_policies_skips_malformed_record(self, policy_engine, mock_graph_store):
|
||||
"""Test malformed policy records are skipped instead of crashing."""
|
||||
category = "credit_approval"
|
||||
mock_graph_store.execute_query.return_value = [{"unexpected": "shape"}]
|
||||
|
||||
policies = policy_engine.get_applicable_policies(category, None)
|
||||
|
||||
assert policies == []
|
||||
|
||||
def test_get_applicable_policies_context_graph_fallback_respects_entities(self):
|
||||
"""Test entity scoping is applied in find_nodes() fallback path."""
|
||||
category = "credit_approval"
|
||||
entities = ["customer:target"]
|
||||
|
||||
class _ContextGraphLike:
|
||||
def find_nodes(self, node_type=None):
|
||||
if node_type != "Policy":
|
||||
return []
|
||||
return [
|
||||
{
|
||||
"metadata": {
|
||||
"policy_id": "policy_match",
|
||||
"name": "Scoped policy",
|
||||
"description": "Applies to target customer",
|
||||
"rules": {},
|
||||
"category": "credit_approval",
|
||||
"version": "1.0",
|
||||
"created_at": datetime.now().isoformat(),
|
||||
"updated_at": datetime.now().isoformat(),
|
||||
"metadata": {"entities": ["customer:target"]},
|
||||
}
|
||||
},
|
||||
{
|
||||
"metadata": {
|
||||
"policy_id": "policy_other",
|
||||
"name": "Other scoped policy",
|
||||
"description": "Applies elsewhere",
|
||||
"rules": {},
|
||||
"category": "credit_approval",
|
||||
"version": "1.0",
|
||||
"created_at": datetime.now().isoformat(),
|
||||
"updated_at": datetime.now().isoformat(),
|
||||
"metadata": {"entities": ["customer:other"]},
|
||||
}
|
||||
},
|
||||
]
|
||||
|
||||
engine = PolicyEngine(graph_store=_ContextGraphLike())
|
||||
policies = engine.get_applicable_policies(category, entities)
|
||||
|
||||
assert len(policies) == 1
|
||||
assert policies[0].policy_id == "policy_match"
|
||||
|
||||
def test_check_compliance_success(self, policy_engine, mock_graph_store):
|
||||
"""Test successful compliance checking."""
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
from datetime import datetime
|
||||
|
||||
from semantica.context import ContextGraph, PolicyEngine
|
||||
from semantica.context.decision_models import Policy, Decision
|
||||
|
||||
|
||||
def _make_policy(pid="p1", version="1.0.0", min_conf=0.8):
|
||||
return Policy(
|
||||
policy_id=pid,
|
||||
name="Test Policy",
|
||||
description="desc",
|
||||
rules={"min_confidence": min_conf, "allowed_outcomes": ["approved", "rejected"]},
|
||||
category="test",
|
||||
version=version,
|
||||
created_at=datetime.now(),
|
||||
updated_at=datetime.now(),
|
||||
metadata={},
|
||||
)
|
||||
|
||||
|
||||
def _make_decision(decision_id, conf=0.9, outcome="approved"):
|
||||
return Decision(
|
||||
decision_id=decision_id,
|
||||
category="test",
|
||||
scenario="s",
|
||||
reasoning="r",
|
||||
outcome=outcome,
|
||||
confidence=conf,
|
||||
timestamp=datetime.now(),
|
||||
decision_maker="tester",
|
||||
metadata={},
|
||||
)
|
||||
|
||||
|
||||
def test_policy_engine_add_get_update_with_context_graph():
|
||||
graph = ContextGraph()
|
||||
engine = PolicyEngine(graph)
|
||||
policy = _make_policy()
|
||||
engine.add_policy(policy)
|
||||
latest = engine.get_policy("p1")
|
||||
assert latest is not None
|
||||
assert latest.version == "1.0.0"
|
||||
new_ver = engine.update_policy(
|
||||
"p1",
|
||||
{"min_confidence": 0.85, "allowed_outcomes": ["approved", "rejected"]},
|
||||
"raise min",
|
||||
)
|
||||
assert isinstance(new_ver, str)
|
||||
latest2 = engine.get_policy("p1")
|
||||
assert latest2 is not None
|
||||
assert latest2.version != "1.0.0"
|
||||
|
||||
|
||||
def test_policy_engine_compliance_and_application_edges():
|
||||
graph = ContextGraph()
|
||||
engine = PolicyEngine(graph)
|
||||
policy = _make_policy()
|
||||
engine.add_policy(policy)
|
||||
decision = _make_decision("d1", conf=0.9, outcome="approved")
|
||||
graph.add_decision(decision)
|
||||
ok = engine.check_compliance(decision, "p1")
|
||||
assert ok is True
|
||||
engine.record_policy_application("d1", "p1", "1.0.0")
|
||||
edges = graph.find_edges(edge_type="APPLIED_POLICY")
|
||||
assert any(
|
||||
e.get("source") == "d1"
|
||||
and isinstance(e.get("target"), str)
|
||||
and e.get("target").startswith("p1:")
|
||||
for e in edges
|
||||
)
|
||||
@@ -0,0 +1,67 @@
|
||||
"""Regression tests for execute_query wrapper result handling."""
|
||||
|
||||
from datetime import datetime
|
||||
from unittest.mock import Mock
|
||||
|
||||
from semantica.context.causal_analyzer import CausalChainAnalyzer
|
||||
from semantica.context.decision_query import DecisionQuery
|
||||
|
||||
|
||||
def test_decision_query_unwraps_execute_query_records_wrapper():
|
||||
graph_store = Mock()
|
||||
graph_store.execute_query.return_value = {
|
||||
"success": True,
|
||||
"records": [
|
||||
{
|
||||
"d": {
|
||||
"decision_id": "decision_001",
|
||||
"category": "credit_approval",
|
||||
"scenario": "Credit increase",
|
||||
"reasoning": "Strong history",
|
||||
"outcome": "approved",
|
||||
"confidence": 0.9,
|
||||
"timestamp": datetime.now().isoformat(),
|
||||
"decision_maker": "agent",
|
||||
}
|
||||
}
|
||||
],
|
||||
}
|
||||
|
||||
query = DecisionQuery(graph_store=graph_store)
|
||||
results = query.find_precedents_hybrid(
|
||||
scenario="credit increase", category="credit_approval", limit=10
|
||||
)
|
||||
|
||||
assert len(results) == 1
|
||||
assert results[0].decision_id == "decision_001"
|
||||
|
||||
|
||||
def test_causal_analyzer_unwraps_execute_query_records_wrapper():
|
||||
graph_store = Mock()
|
||||
graph_store.execute_query.return_value = {
|
||||
"success": True,
|
||||
"records": [
|
||||
{
|
||||
"end": {
|
||||
"decision_id": "decision_002",
|
||||
"category": "credit_approval",
|
||||
"scenario": "Escalation",
|
||||
"reasoning": "Policy exception",
|
||||
"outcome": "approved",
|
||||
"confidence": 0.8,
|
||||
"timestamp": datetime.now().isoformat(),
|
||||
"decision_maker": "agent",
|
||||
},
|
||||
"distance": 1,
|
||||
}
|
||||
],
|
||||
}
|
||||
|
||||
analyzer = CausalChainAnalyzer(graph_store=graph_store)
|
||||
results = analyzer.get_causal_chain(
|
||||
decision_id="decision_001", direction="downstream", max_depth=3
|
||||
)
|
||||
|
||||
assert len(results) == 1
|
||||
assert results[0].decision_id == "decision_002"
|
||||
assert results[0].metadata.get("causal_distance") == 1
|
||||
@@ -0,0 +1,772 @@
|
||||
"""
|
||||
Tests for Apache AGE Store Module
|
||||
|
||||
Tests cover:
|
||||
- Node CRUD (create, read, update, delete)
|
||||
- Relationship CRUD
|
||||
- Query execution
|
||||
- Graph traversal (get_neighbors, shortest_path)
|
||||
- Transaction rollback on error
|
||||
- Multi-label handling
|
||||
- ID separation (AGE internal vs semantic)
|
||||
- Input validation / sanitisation
|
||||
- Stats retrieval
|
||||
- Index creation
|
||||
|
||||
The psycopg2 database layer is fully mocked to enable offline testing.
|
||||
"""
|
||||
|
||||
import json
|
||||
import unittest
|
||||
from typing import Any, Dict, List, Optional
|
||||
from unittest.mock import MagicMock, call, patch
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Mock psycopg2 before importing the module under test so that ``PSYCOPG2_AVAILABLE``
|
||||
# is ``True`` inside age_store.
|
||||
# ---------------------------------------------------------------------------
|
||||
import sys
|
||||
|
||||
_mock_psycopg2 = MagicMock()
|
||||
_mock_psycopg2_extras = MagicMock()
|
||||
sys.modules["psycopg2"] = _mock_psycopg2
|
||||
sys.modules["psycopg2.extras"] = _mock_psycopg2_extras
|
||||
|
||||
from semantica.graph_store.age_store import (
|
||||
ApacheAgeStore,
|
||||
_edge_to_rel_dict,
|
||||
_parse_agtype,
|
||||
_props_to_cypher_literal,
|
||||
_sanitize_label,
|
||||
_sanitize_rel_type,
|
||||
_value_to_cypher_literal,
|
||||
_vertex_to_node_dict,
|
||||
)
|
||||
from semantica.utils.exceptions import ProcessingError, ValidationError
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helper fixtures
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _make_vertex_agtype(vid: int, label: str, props: Dict[str, Any]) -> str:
|
||||
"""Return a string mimicking AGE agtype vertex output."""
|
||||
obj = {"id": vid, "label": label, "properties": props}
|
||||
return json.dumps(obj) + "::vertex"
|
||||
|
||||
|
||||
def _make_edge_agtype(
|
||||
eid: int, label: str, start_id: int, end_id: int, props: Dict[str, Any]
|
||||
) -> str:
|
||||
"""Return a string mimicking AGE agtype edge output."""
|
||||
obj = {
|
||||
"id": eid,
|
||||
"label": label,
|
||||
"start_id": start_id,
|
||||
"end_id": end_id,
|
||||
"properties": props,
|
||||
}
|
||||
return json.dumps(obj) + "::edge"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Unit tests — helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestHelpers(unittest.TestCase):
|
||||
"""Tests for module-level helper functions."""
|
||||
|
||||
# -- _sanitize_label --------------------------------------------------
|
||||
|
||||
def test_sanitize_label_valid(self):
|
||||
self.assertEqual(_sanitize_label("Person"), "Person")
|
||||
self.assertEqual(_sanitize_label("_hidden"), "_hidden")
|
||||
self.assertEqual(_sanitize_label("Rel_Type2"), "Rel_Type2")
|
||||
|
||||
def test_sanitize_label_invalid(self):
|
||||
with self.assertRaises(ValidationError):
|
||||
_sanitize_label("123bad")
|
||||
with self.assertRaises(ValidationError):
|
||||
_sanitize_label("no spaces")
|
||||
with self.assertRaises(ValidationError):
|
||||
_sanitize_label("no-dashes")
|
||||
|
||||
# -- _sanitize_rel_type -----------------------------------------------
|
||||
|
||||
def test_sanitize_rel_type_valid(self):
|
||||
self.assertEqual(_sanitize_rel_type("KNOWS"), "KNOWS")
|
||||
|
||||
def test_sanitize_rel_type_invalid(self):
|
||||
with self.assertRaises(ValidationError):
|
||||
_sanitize_rel_type("bad type!")
|
||||
|
||||
# -- _value_to_cypher_literal -----------------------------------------
|
||||
|
||||
def test_literal_none(self):
|
||||
self.assertEqual(_value_to_cypher_literal(None), "null")
|
||||
|
||||
def test_literal_bool(self):
|
||||
self.assertEqual(_value_to_cypher_literal(True), "true")
|
||||
self.assertEqual(_value_to_cypher_literal(False), "false")
|
||||
|
||||
def test_literal_int(self):
|
||||
self.assertEqual(_value_to_cypher_literal(42), "42")
|
||||
|
||||
def test_literal_float(self):
|
||||
self.assertIn("3.14", _value_to_cypher_literal(3.14))
|
||||
|
||||
def test_literal_string(self):
|
||||
self.assertEqual(_value_to_cypher_literal("hello"), "'hello'")
|
||||
|
||||
def test_literal_string_escape(self):
|
||||
result = _value_to_cypher_literal("it's a \"test\"")
|
||||
self.assertIn("\\'", result)
|
||||
|
||||
def test_literal_list(self):
|
||||
result = _value_to_cypher_literal([1, "a"])
|
||||
self.assertEqual(result, "[1, 'a']")
|
||||
|
||||
def test_literal_dict(self):
|
||||
result = _value_to_cypher_literal({"x": 1})
|
||||
self.assertEqual(result, "{x: 1}")
|
||||
|
||||
# -- _props_to_cypher_literal -----------------------------------------
|
||||
|
||||
def test_props_empty(self):
|
||||
self.assertEqual(_props_to_cypher_literal({}), "{}")
|
||||
|
||||
def test_props_simple(self):
|
||||
result = _props_to_cypher_literal({"name": "Alice", "age": 30})
|
||||
self.assertIn("name: 'Alice'", result)
|
||||
self.assertIn("age: 30", result)
|
||||
|
||||
def test_props_invalid_key(self):
|
||||
with self.assertRaises(ValidationError):
|
||||
_props_to_cypher_literal({"bad key!": 1})
|
||||
|
||||
# -- _parse_agtype ----------------------------------------------------
|
||||
|
||||
def test_parse_agtype_none(self):
|
||||
self.assertIsNone(_parse_agtype(None))
|
||||
|
||||
def test_parse_agtype_vertex(self):
|
||||
text = '{"id": 1, "label": "Person", "properties": {"name": "Alice"}}::vertex'
|
||||
result = _parse_agtype(text)
|
||||
self.assertEqual(result["id"], 1)
|
||||
self.assertEqual(result["label"], "Person")
|
||||
|
||||
def test_parse_agtype_edge(self):
|
||||
text = '{"id": 10, "label": "KNOWS", "start_id": 1, "end_id": 2, "properties": {}}::edge'
|
||||
result = _parse_agtype(text)
|
||||
self.assertEqual(result["id"], 10)
|
||||
self.assertEqual(result["start_id"], 1)
|
||||
|
||||
def test_parse_agtype_numeric(self):
|
||||
self.assertEqual(_parse_agtype("42::numeric"), 42)
|
||||
self.assertEqual(_parse_agtype("3.14::float"), 3.14)
|
||||
|
||||
def test_parse_agtype_boolean(self):
|
||||
self.assertTrue(_parse_agtype("true::boolean"))
|
||||
self.assertFalse(_parse_agtype("false::boolean"))
|
||||
|
||||
def test_parse_agtype_plain_json(self):
|
||||
self.assertEqual(_parse_agtype('{"a": 1}'), {"a": 1})
|
||||
|
||||
def test_parse_agtype_non_string(self):
|
||||
self.assertEqual(_parse_agtype(99), 99)
|
||||
|
||||
# -- _vertex_to_node_dict ---------------------------------------------
|
||||
|
||||
def test_vertex_to_node_dict_basic(self):
|
||||
vertex = {"id": 5, "label": "Person", "properties": {"name": "Alice"}}
|
||||
result = _vertex_to_node_dict(vertex)
|
||||
self.assertEqual(result["id"], 5)
|
||||
self.assertEqual(result["labels"], ["Person"])
|
||||
self.assertEqual(result["properties"]["name"], "Alice")
|
||||
|
||||
def test_vertex_to_node_dict_extra_labels(self):
|
||||
vertex = {
|
||||
"id": 7,
|
||||
"label": "Person",
|
||||
"properties": {"name": "Bob", "labels": ["Employee", "Admin"]},
|
||||
}
|
||||
result = _vertex_to_node_dict(vertex)
|
||||
self.assertEqual(result["labels"], ["Person", "Employee", "Admin"])
|
||||
# 'labels' property should be removed from properties
|
||||
self.assertNotIn("labels", result["properties"])
|
||||
|
||||
def test_vertex_to_node_dict_non_dict(self):
|
||||
result = _vertex_to_node_dict("not a dict")
|
||||
self.assertIsNone(result["id"])
|
||||
|
||||
# -- _edge_to_rel_dict ------------------------------------------------
|
||||
|
||||
def test_edge_to_rel_dict_basic(self):
|
||||
edge = {
|
||||
"id": 10,
|
||||
"label": "KNOWS",
|
||||
"start_id": 1,
|
||||
"end_id": 2,
|
||||
"properties": {"since": 2020},
|
||||
}
|
||||
result = _edge_to_rel_dict(edge)
|
||||
self.assertEqual(result["id"], 10)
|
||||
self.assertEqual(result["type"], "KNOWS")
|
||||
self.assertEqual(result["start_node_id"], 1)
|
||||
self.assertEqual(result["end_node_id"], 2)
|
||||
self.assertEqual(result["properties"]["since"], 2020)
|
||||
|
||||
def test_edge_to_rel_dict_non_dict(self):
|
||||
result = _edge_to_rel_dict(42)
|
||||
self.assertIsNone(result["id"])
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Unit tests — ApacheAgeStore with mocked DB
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestApacheAgeStore(unittest.TestCase):
|
||||
"""Tests for ApacheAgeStore with a fully mocked psycopg2 connection."""
|
||||
|
||||
def setUp(self):
|
||||
"""Set up a store with a mocked PostgreSQL connection."""
|
||||
self.mock_conn = MagicMock()
|
||||
self.mock_conn.closed = False
|
||||
self.mock_cursor = MagicMock()
|
||||
self.mock_conn.cursor.return_value.__enter__ = MagicMock(
|
||||
return_value=self.mock_cursor
|
||||
)
|
||||
self.mock_conn.cursor.return_value.__exit__ = MagicMock(return_value=False)
|
||||
|
||||
# Patch psycopg2.connect to return the mock connection
|
||||
_mock_psycopg2.connect.return_value = self.mock_conn
|
||||
|
||||
self.store = ApacheAgeStore(
|
||||
connection_string="host=localhost dbname=testdb user=test password=test",
|
||||
graph_name="test_graph",
|
||||
)
|
||||
# Simulate a successful connect (graph already exists)
|
||||
self.mock_cursor.fetchone.return_value = (1,) # graph exists
|
||||
self.store.connect()
|
||||
# Reset mock call history after connect
|
||||
self.mock_cursor.reset_mock()
|
||||
self.mock_conn.reset_mock()
|
||||
self.mock_conn.closed = False
|
||||
|
||||
def tearDown(self):
|
||||
self.store.close()
|
||||
|
||||
# -- connect ----------------------------------------------------------
|
||||
|
||||
def test_connect_creates_extension_and_graph(self):
|
||||
"""connect() should run idempotent setup commands."""
|
||||
store = ApacheAgeStore(
|
||||
connection_string="host=localhost dbname=agedb user=test",
|
||||
graph_name="new_graph",
|
||||
)
|
||||
# Graph does NOT exist yet
|
||||
self.mock_cursor.fetchone.return_value = (0,)
|
||||
|
||||
result = store.connect()
|
||||
self.assertTrue(result)
|
||||
|
||||
# Verify setup SQL was executed
|
||||
executed = [
|
||||
str(c) for c in self.mock_cursor.execute.call_args_list
|
||||
]
|
||||
setup_text = " ".join(executed)
|
||||
self.assertIn("CREATE EXTENSION IF NOT EXISTS age", setup_text)
|
||||
self.assertIn("LOAD 'age'", setup_text)
|
||||
self.assertIn("search_path", setup_text)
|
||||
self.assertIn("create_graph", setup_text)
|
||||
|
||||
def test_connect_idempotent_existing_graph(self):
|
||||
"""connect() should skip create_graph if graph already exists."""
|
||||
store = ApacheAgeStore(
|
||||
connection_string="host=localhost dbname=agedb user=test",
|
||||
graph_name="existing_graph",
|
||||
)
|
||||
self.mock_cursor.fetchone.return_value = (1,) # graph exists
|
||||
|
||||
result = store.connect()
|
||||
self.assertTrue(result)
|
||||
|
||||
executed = [
|
||||
str(c) for c in self.mock_cursor.execute.call_args_list
|
||||
]
|
||||
setup_text = " ".join(executed)
|
||||
# create_graph should NOT be called (graph count=1)
|
||||
self.assertNotIn("create_graph", setup_text.split("ag_graph")[1] if "ag_graph" in setup_text else "")
|
||||
|
||||
# -- create_node ------------------------------------------------------
|
||||
|
||||
def test_create_node_single_label(self):
|
||||
"""create_node with one label should use it as AGE label."""
|
||||
vertex_str = _make_vertex_agtype(100, "Person", {"name": "Alice"})
|
||||
self.mock_cursor.fetchall.return_value = [(vertex_str,)]
|
||||
|
||||
node = self.store.create_node(
|
||||
labels=["Person"], properties={"name": "Alice"}
|
||||
)
|
||||
|
||||
self.assertEqual(node["id"], 100)
|
||||
self.assertEqual(node["labels"], ["Person"])
|
||||
self.assertEqual(node["properties"]["name"], "Alice")
|
||||
|
||||
def test_create_node_multiple_labels(self):
|
||||
"""Additional labels beyond the first are stored as property array."""
|
||||
vertex_str = _make_vertex_agtype(
|
||||
101, "Person", {"name": "Bob", "labels": ["Employee", "Admin"]}
|
||||
)
|
||||
self.mock_cursor.fetchall.return_value = [(vertex_str,)]
|
||||
|
||||
node = self.store.create_node(
|
||||
labels=["Person", "Employee", "Admin"],
|
||||
properties={"name": "Bob"},
|
||||
)
|
||||
|
||||
self.assertEqual(node["id"], 101)
|
||||
self.assertIn("Person", node["labels"])
|
||||
self.assertIn("Employee", node["labels"])
|
||||
self.assertIn("Admin", node["labels"])
|
||||
# 'labels' property should be moved out of properties
|
||||
self.assertNotIn("labels", node["properties"])
|
||||
|
||||
def test_create_node_with_semantica_id(self):
|
||||
"""semantica_id in properties should be preserved."""
|
||||
vertex_str = _make_vertex_agtype(
|
||||
102, "Entity", {"semantica_id": "abc-123", "value": "test"}
|
||||
)
|
||||
self.mock_cursor.fetchall.return_value = [(vertex_str,)]
|
||||
|
||||
node = self.store.create_node(
|
||||
labels=["Entity"],
|
||||
properties={"semantica_id": "abc-123", "value": "test"},
|
||||
)
|
||||
|
||||
self.assertEqual(node["id"], 102) # AGE internal ID
|
||||
self.assertEqual(node["properties"]["semantica_id"], "abc-123")
|
||||
|
||||
def test_create_node_empty_labels_raises(self):
|
||||
"""create_node with empty labels should raise ValidationError."""
|
||||
with self.assertRaises(ValidationError):
|
||||
self.store.create_node(labels=[], properties={"name": "X"})
|
||||
|
||||
def test_create_node_invalid_label_raises(self):
|
||||
"""create_node with invalid label should raise ValidationError."""
|
||||
with self.assertRaises(ValidationError):
|
||||
self.store.create_node(labels=["bad label!"], properties={})
|
||||
|
||||
# -- create_nodes -----------------------------------------------------
|
||||
|
||||
def test_create_nodes_batch(self):
|
||||
"""create_nodes should create multiple nodes."""
|
||||
responses = [
|
||||
[(_make_vertex_agtype(200, "Person", {"name": "A"}),)],
|
||||
[(_make_vertex_agtype(201, "Person", {"name": "B"}),)],
|
||||
]
|
||||
self.mock_cursor.fetchall.side_effect = responses
|
||||
|
||||
nodes_data = [
|
||||
{"labels": ["Person"], "properties": {"name": "A"}},
|
||||
{"labels": ["Person"], "properties": {"name": "B"}},
|
||||
]
|
||||
result = self.store.create_nodes(nodes_data)
|
||||
|
||||
self.assertEqual(len(result), 2)
|
||||
self.assertEqual(result[0]["id"], 200)
|
||||
self.assertEqual(result[1]["id"], 201)
|
||||
|
||||
# -- get_node ---------------------------------------------------------
|
||||
|
||||
def test_get_node_found(self):
|
||||
vertex_str = _make_vertex_agtype(100, "Person", {"name": "Alice"})
|
||||
self.mock_cursor.fetchall.return_value = [(vertex_str,)]
|
||||
|
||||
node = self.store.get_node(100)
|
||||
|
||||
self.assertIsNotNone(node)
|
||||
self.assertEqual(node["id"], 100)
|
||||
self.assertEqual(node["properties"]["name"], "Alice")
|
||||
|
||||
def test_get_node_not_found(self):
|
||||
self.mock_cursor.fetchall.return_value = []
|
||||
|
||||
node = self.store.get_node(999)
|
||||
self.assertIsNone(node)
|
||||
|
||||
# -- get_nodes --------------------------------------------------------
|
||||
|
||||
def test_get_nodes_with_label_filter(self):
|
||||
vertex_str = _make_vertex_agtype(100, "Person", {"name": "Alice"})
|
||||
self.mock_cursor.fetchall.return_value = [(vertex_str,)]
|
||||
|
||||
nodes = self.store.get_nodes(labels=["Person"])
|
||||
|
||||
self.assertEqual(len(nodes), 1)
|
||||
self.assertEqual(nodes[0]["labels"], ["Person"])
|
||||
|
||||
def test_get_nodes_with_property_filter(self):
|
||||
vertex_str = _make_vertex_agtype(100, "Person", {"name": "Alice"})
|
||||
self.mock_cursor.fetchall.return_value = [(vertex_str,)]
|
||||
|
||||
nodes = self.store.get_nodes(properties={"name": "Alice"})
|
||||
self.assertEqual(len(nodes), 1)
|
||||
|
||||
def test_get_nodes_empty(self):
|
||||
self.mock_cursor.fetchall.return_value = []
|
||||
nodes = self.store.get_nodes()
|
||||
self.assertEqual(nodes, [])
|
||||
|
||||
# -- update_node ------------------------------------------------------
|
||||
|
||||
def test_update_node_merge(self):
|
||||
vertex_str = _make_vertex_agtype(100, "Person", {"name": "Alice", "age": 31})
|
||||
self.mock_cursor.fetchall.return_value = [(vertex_str,)]
|
||||
|
||||
node = self.store.update_node(100, {"age": 31}, merge=True)
|
||||
|
||||
self.assertEqual(node["id"], 100)
|
||||
self.assertEqual(node["properties"]["age"], 31)
|
||||
self.assertEqual(node["properties"]["name"], "Alice")
|
||||
|
||||
# Verify the Cypher used += for merge
|
||||
executed_sql = self.mock_cursor.execute.call_args[0][0]
|
||||
self.assertIn("+=", executed_sql)
|
||||
|
||||
def test_update_node_replace(self):
|
||||
vertex_str = _make_vertex_agtype(100, "Person", {"age": 31})
|
||||
self.mock_cursor.fetchall.return_value = [(vertex_str,)]
|
||||
|
||||
node = self.store.update_node(100, {"age": 31}, merge=False)
|
||||
self.assertEqual(node["properties"]["age"], 31)
|
||||
|
||||
# Verify SET n = (not +=) for replace
|
||||
executed_sql = self.mock_cursor.execute.call_args[0][0]
|
||||
self.assertIn("SET n =", executed_sql)
|
||||
self.assertNotIn("+=", executed_sql)
|
||||
|
||||
def test_update_node_not_found(self):
|
||||
self.mock_cursor.fetchall.return_value = []
|
||||
|
||||
with self.assertRaises(ProcessingError):
|
||||
self.store.update_node(999, {"age": 31})
|
||||
|
||||
# -- delete_node ------------------------------------------------------
|
||||
|
||||
def test_delete_node_detach(self):
|
||||
self.mock_cursor.fetchall.return_value = []
|
||||
|
||||
result = self.store.delete_node(100, detach=True)
|
||||
self.assertTrue(result)
|
||||
|
||||
executed_sql = self.mock_cursor.execute.call_args[0][0]
|
||||
self.assertIn("DETACH DELETE", executed_sql)
|
||||
|
||||
def test_delete_node_no_detach(self):
|
||||
self.mock_cursor.fetchall.return_value = []
|
||||
|
||||
result = self.store.delete_node(100, detach=False)
|
||||
self.assertTrue(result)
|
||||
|
||||
executed_sql = self.mock_cursor.execute.call_args[0][0]
|
||||
self.assertIn("DELETE", executed_sql)
|
||||
self.assertNotIn("DETACH", executed_sql)
|
||||
|
||||
# -- create_relationship ----------------------------------------------
|
||||
|
||||
def test_create_relationship(self):
|
||||
edge_str = _make_edge_agtype(500, "KNOWS", 100, 200, {"since": 2023})
|
||||
self.mock_cursor.fetchall.return_value = [(edge_str,)]
|
||||
|
||||
rel = self.store.create_relationship(100, 200, "KNOWS", {"since": 2023})
|
||||
|
||||
self.assertEqual(rel["id"], 500)
|
||||
self.assertEqual(rel["type"], "KNOWS")
|
||||
self.assertEqual(rel["start_node_id"], 100)
|
||||
self.assertEqual(rel["end_node_id"], 200)
|
||||
self.assertEqual(rel["properties"]["since"], 2023)
|
||||
|
||||
def test_create_relationship_no_properties(self):
|
||||
edge_str = _make_edge_agtype(501, "FOLLOWS", 100, 200, {})
|
||||
self.mock_cursor.fetchall.return_value = [(edge_str,)]
|
||||
|
||||
rel = self.store.create_relationship(100, 200, "FOLLOWS")
|
||||
self.assertEqual(rel["type"], "FOLLOWS")
|
||||
self.assertEqual(rel["properties"], {})
|
||||
|
||||
def test_create_relationship_invalid_type_raises(self):
|
||||
with self.assertRaises(ValidationError):
|
||||
self.store.create_relationship(100, 200, "BAD TYPE!")
|
||||
|
||||
# -- get_relationships ------------------------------------------------
|
||||
|
||||
def test_get_relationships_outgoing(self):
|
||||
edge_str = _make_edge_agtype(500, "KNOWS", 100, 200, {})
|
||||
self.mock_cursor.fetchall.return_value = [(edge_str,)]
|
||||
|
||||
rels = self.store.get_relationships(
|
||||
node_id=100, rel_type="KNOWS", direction="out"
|
||||
)
|
||||
|
||||
self.assertEqual(len(rels), 1)
|
||||
self.assertEqual(rels[0]["type"], "KNOWS")
|
||||
|
||||
def test_get_relationships_incoming(self):
|
||||
edge_str = _make_edge_agtype(501, "KNOWS", 200, 100, {})
|
||||
self.mock_cursor.fetchall.return_value = [(edge_str,)]
|
||||
|
||||
rels = self.store.get_relationships(
|
||||
node_id=100, direction="in"
|
||||
)
|
||||
self.assertEqual(len(rels), 1)
|
||||
|
||||
def test_get_relationships_all(self):
|
||||
self.mock_cursor.fetchall.return_value = []
|
||||
rels = self.store.get_relationships()
|
||||
self.assertEqual(rels, [])
|
||||
|
||||
# -- delete_relationship ----------------------------------------------
|
||||
|
||||
def test_delete_relationship(self):
|
||||
self.mock_cursor.fetchall.return_value = []
|
||||
|
||||
result = self.store.delete_relationship(500)
|
||||
self.assertTrue(result)
|
||||
|
||||
# -- execute_query ----------------------------------------------------
|
||||
|
||||
def test_execute_query_basic(self):
|
||||
"""execute_query should return Neo4jStore-compatible result dict."""
|
||||
self.mock_cursor.description = [("n",)]
|
||||
vertex_str = _make_vertex_agtype(100, "Person", {"name": "Alice"})
|
||||
self.mock_cursor.fetchall.return_value = [(vertex_str,)]
|
||||
|
||||
result = self.store.execute_query(
|
||||
"MATCH (n:Person) RETURN n", cols="n agtype"
|
||||
)
|
||||
|
||||
self.assertTrue(result["success"])
|
||||
self.assertEqual(len(result["records"]), 1)
|
||||
self.assertIn("keys", result)
|
||||
self.assertIn("metadata", result)
|
||||
self.assertEqual(result["metadata"]["query"], "MATCH (n:Person) RETURN n")
|
||||
|
||||
def test_execute_query_with_parameters(self):
|
||||
"""Parameters should be substituted as safe literals."""
|
||||
self.mock_cursor.description = [("count",)]
|
||||
self.mock_cursor.fetchall.return_value = [("5::numeric",)]
|
||||
|
||||
result = self.store.execute_query(
|
||||
"MATCH (n) WHERE n.age > $min_age RETURN count(n) AS count",
|
||||
parameters={"min_age": 25},
|
||||
cols="count agtype",
|
||||
)
|
||||
|
||||
self.assertTrue(result["success"])
|
||||
# Check the SQL that was executed contained the literal, not $min_age
|
||||
executed_sql = self.mock_cursor.execute.call_args[0][0]
|
||||
self.assertIn("25", executed_sql)
|
||||
self.assertNotIn("$min_age", executed_sql)
|
||||
|
||||
def test_execute_query_empty_result(self):
|
||||
self.mock_cursor.description = []
|
||||
self.mock_cursor.fetchall.return_value = []
|
||||
|
||||
result = self.store.execute_query("MATCH (n) RETURN n", cols="n agtype")
|
||||
self.assertTrue(result["success"])
|
||||
self.assertEqual(result["records"], [])
|
||||
|
||||
# -- get_neighbors ----------------------------------------------------
|
||||
|
||||
def test_get_neighbors_out(self):
|
||||
vertex_str = _make_vertex_agtype(200, "Person", {"name": "Bob"})
|
||||
self.mock_cursor.fetchall.return_value = [(vertex_str,)]
|
||||
|
||||
neighbors = self.store.get_neighbors(
|
||||
node_id=100, rel_type="KNOWS", direction="out", depth=2
|
||||
)
|
||||
|
||||
self.assertEqual(len(neighbors), 1)
|
||||
self.assertEqual(neighbors[0]["id"], 200)
|
||||
|
||||
def test_get_neighbors_both(self):
|
||||
self.mock_cursor.fetchall.return_value = []
|
||||
neighbors = self.store.get_neighbors(node_id=100)
|
||||
self.assertEqual(neighbors, [])
|
||||
|
||||
# -- shortest_path ----------------------------------------------------
|
||||
|
||||
def test_shortest_path_found(self):
|
||||
"""When a path is found, it should be returned as dict with nodes/relationships."""
|
||||
path_list = [
|
||||
{"id": 1, "label": "Person", "properties": {"name": "A"}},
|
||||
{"id": 10, "label": "KNOWS", "start_id": 1, "end_id": 2, "properties": {}},
|
||||
{"id": 2, "label": "Person", "properties": {"name": "B"}},
|
||||
]
|
||||
path_str = json.dumps(path_list) + "::path"
|
||||
self.mock_cursor.fetchall.return_value = [(path_str,)]
|
||||
|
||||
result = self.store.shortest_path(1, 2)
|
||||
|
||||
self.assertIsNotNone(result)
|
||||
self.assertEqual(result["length"], 1)
|
||||
self.assertEqual(len(result["nodes"]), 2)
|
||||
self.assertEqual(len(result["relationships"]), 1)
|
||||
|
||||
def test_shortest_path_not_found(self):
|
||||
self.mock_cursor.fetchall.return_value = []
|
||||
|
||||
result = self.store.shortest_path(1, 999)
|
||||
self.assertIsNone(result)
|
||||
|
||||
# -- create_index -----------------------------------------------------
|
||||
|
||||
def test_create_index(self):
|
||||
result = self.store.create_index("Person", "name", "btree")
|
||||
self.assertTrue(result)
|
||||
|
||||
executed_sql = self.mock_cursor.execute.call_args[0][0]
|
||||
self.assertIn("CREATE INDEX IF NOT EXISTS", executed_sql)
|
||||
self.assertIn("Person", executed_sql)
|
||||
self.assertIn("name", executed_sql)
|
||||
|
||||
def test_create_index_invalid_property_raises(self):
|
||||
with self.assertRaises(ValidationError):
|
||||
self.store.create_index("Person", "bad name!", "btree")
|
||||
|
||||
# -- get_stats --------------------------------------------------------
|
||||
|
||||
def test_get_stats(self):
|
||||
"""get_stats should return structured dict."""
|
||||
# Mock call sequence:
|
||||
# 1. node count cypher → fetchall
|
||||
# 2. relationship count cypher → fetchall
|
||||
# 3. label catalog query → fetchall (pg cursor)
|
||||
# then per-label cypher → fetchall
|
||||
# 4. edge type catalog query → fetchall (pg cursor)
|
||||
# then per-type cypher → fetchall
|
||||
|
||||
call_count = [0]
|
||||
fetch_responses = [
|
||||
[("42::numeric",)], # node count
|
||||
[("10::numeric",)], # relationship count
|
||||
[("5::numeric",)], # label count for Person
|
||||
[("10::numeric",)], # edge count for KNOWS
|
||||
]
|
||||
cursor_fetch_responses = [
|
||||
(1,), # ensure_connection: ag_graph check (not used here)
|
||||
[("Person",)], # label catalog
|
||||
[("KNOWS",)], # edge type catalog
|
||||
]
|
||||
|
||||
def mock_fetchall():
|
||||
idx = call_count[0]
|
||||
call_count[0] += 1
|
||||
if idx < len(fetch_responses):
|
||||
return fetch_responses[idx]
|
||||
return []
|
||||
|
||||
def mock_cursor_fetchall():
|
||||
# Returns for the catalog queries
|
||||
if not hasattr(mock_cursor_fetchall, "_idx"):
|
||||
mock_cursor_fetchall._idx = 0
|
||||
idx = mock_cursor_fetchall._idx
|
||||
mock_cursor_fetchall._idx += 1
|
||||
if idx < len(cursor_fetch_responses):
|
||||
return cursor_fetch_responses[idx]
|
||||
return []
|
||||
|
||||
self.mock_cursor.fetchall.side_effect = mock_fetchall
|
||||
self.mock_cursor.fetchone.return_value = (1,)
|
||||
|
||||
stats = self.store.get_stats()
|
||||
|
||||
self.assertIn("node_count", stats)
|
||||
self.assertIn("relationship_count", stats)
|
||||
self.assertIn("label_counts", stats)
|
||||
self.assertIn("relationship_type_counts", stats)
|
||||
|
||||
# -- Transaction rollback ---------------------------------------------
|
||||
|
||||
def test_cypher_execution_rollback_on_error(self):
|
||||
"""If a query fails, the connection should be rolled back."""
|
||||
self.mock_cursor.execute.side_effect = Exception("SQL error")
|
||||
|
||||
with self.assertRaises(ProcessingError):
|
||||
self.store.get_node(100)
|
||||
|
||||
self.mock_conn.rollback.assert_called()
|
||||
|
||||
def test_create_node_db_error_raises(self):
|
||||
"""Database errors during create_node should raise ProcessingError."""
|
||||
self.mock_cursor.execute.side_effect = Exception("Disk full")
|
||||
|
||||
with self.assertRaises(ProcessingError):
|
||||
self.store.create_node(["Test"], {"key": "val"})
|
||||
|
||||
# -- close ------------------------------------------------------------
|
||||
|
||||
def test_close(self):
|
||||
self.store.close()
|
||||
self.assertIsNone(self.store._conn)
|
||||
|
||||
def test_close_idempotent(self):
|
||||
"""Calling close() twice should not raise."""
|
||||
self.store.close()
|
||||
self.store.close() # Should not raise
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Integration-style test with GraphStore facade
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestGraphStoreFacadeAge(unittest.TestCase):
|
||||
"""Test that GraphStore(backend='age') initialises ApacheAgeStore."""
|
||||
|
||||
@patch("semantica.graph_store.age_store.ApacheAgeStore", autospec=True)
|
||||
def test_age_backend_initialisation(self, MockAgeStore):
|
||||
"""GraphStore should instantiate ApacheAgeStore for 'age' backend."""
|
||||
from semantica.graph_store.graph_store import GraphStore
|
||||
|
||||
mock_instance = MagicMock()
|
||||
MockAgeStore.return_value = mock_instance
|
||||
|
||||
store = GraphStore(backend="age")
|
||||
self.assertIs(store._store_backend, mock_instance)
|
||||
|
||||
@patch("semantica.graph_store.age_store.ApacheAgeStore", autospec=True)
|
||||
def test_apache_age_backend_alias(self, MockAgeStore):
|
||||
"""GraphStore should accept 'apache_age' as backend alias."""
|
||||
from semantica.graph_store.graph_store import GraphStore
|
||||
|
||||
mock_instance = MagicMock()
|
||||
MockAgeStore.return_value = mock_instance
|
||||
|
||||
store = GraphStore(backend="apache_age")
|
||||
self.assertIs(store._store_backend, mock_instance)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Return format conformance tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestReturnFormatConformance(unittest.TestCase):
|
||||
"""Verify that returned dicts match Neo4jStore structure exactly."""
|
||||
|
||||
def test_node_return_keys(self):
|
||||
vertex = {"id": 1, "label": "X", "properties": {"a": 1}}
|
||||
result = _vertex_to_node_dict(vertex)
|
||||
self.assertSetEqual(set(result.keys()), {"id", "labels", "properties"})
|
||||
|
||||
def test_relationship_return_keys(self):
|
||||
edge = {"id": 1, "label": "R", "start_id": 2, "end_id": 3, "properties": {}}
|
||||
result = _edge_to_rel_dict(edge)
|
||||
self.assertSetEqual(
|
||||
set(result.keys()),
|
||||
{"id", "type", "start_node_id", "end_node_id", "properties"},
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Reference in New Issue
Block a user