Merge main into docs and resolve README Discord badge conflict

This commit is contained in:
KaifAhmad1
2026-02-18 16:36:23 +05:30
44 changed files with 8658 additions and 2659 deletions
-17
View File
@@ -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": "[![All Contributors](https://img.shields.io/badge/all_contributors-<%= contributors.length %>-orange.svg?style=flat-square)](#contributors)",
"skipCi": true
}
+1116
View File
File diff suppressed because it is too large Load Diff
+311 -96
View File
@@ -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
View File
@@ -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
)
+243
View File
@@ -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
View File
File diff suppressed because it is too large Load Diff
+22 -6
View File
@@ -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
View File
@@ -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)
}
}
+41 -9
View File
@@ -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 []
File diff suppressed because it is too large Load Diff
+24 -4
View File
@@ -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(
File diff suppressed because it is too large Load Diff
+334 -6
View File
@@ -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
+27 -15
View File
@@ -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}")
+75 -35
View File
@@ -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 []
+72 -19
View File
@@ -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")
+115 -16
View File
@@ -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
View File
@@ -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", {}),
+3
View File
@@ -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
+18
View File
@@ -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()
+7
View File
@@ -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}")
+5
View File
@@ -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 = []
+6
View File
@@ -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'):
+4 -1
View File
@@ -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 []
+19 -7
View File
@@ -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)
+25 -9
View File
@@ -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."""
+66
View File
@@ -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
+17 -17
View File
@@ -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:
+1 -1
View File
@@ -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
+21 -3
View File
@@ -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):
+81 -1
View File
@@ -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
)
+67
View File
@@ -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
View File
+772
View File
@@ -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()