mirror of
https://github.com/semantica-agi/semantica.git
synced 2026-08-29 04:26:20 +00:00
Merge branch 'main' into feat/explorer-api-377
This commit is contained in:
@@ -2,9 +2,13 @@ name: Semantica Performance Suite
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main, master]
|
||||
pull_request:
|
||||
branches: [main, master]
|
||||
branches: [main]
|
||||
paths-ignore:
|
||||
- 'docs/**'
|
||||
- 'mkdocs.yml'
|
||||
- 'requirements-docs.txt'
|
||||
- '**/*.md'
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
performance-test:
|
||||
|
||||
@@ -3,8 +3,18 @@ name: CI
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
paths-ignore:
|
||||
- 'docs/**'
|
||||
- 'mkdocs.yml'
|
||||
- 'requirements-docs.txt'
|
||||
- '**/*.md'
|
||||
pull_request:
|
||||
branches: [main]
|
||||
paths-ignore:
|
||||
- 'docs/**'
|
||||
- 'mkdocs.yml'
|
||||
- 'requirements-docs.txt'
|
||||
- '**/*.md'
|
||||
|
||||
jobs:
|
||||
build:
|
||||
|
||||
@@ -8,11 +8,12 @@ on:
|
||||
branches: [main]
|
||||
paths:
|
||||
- 'docs/**'
|
||||
- 'semantica/**'
|
||||
- 'mkdocs.yml'
|
||||
- 'requirements-docs.txt'
|
||||
- 'CHANGELOG.md'
|
||||
- 'RELEASE.md'
|
||||
release:
|
||||
types: [published]
|
||||
workflow_dispatch:
|
||||
|
||||
# Permissions needed to deploy to GitHub Pages
|
||||
|
||||
@@ -4,9 +4,19 @@ on:
|
||||
schedule:
|
||||
- cron: '30 1 * * 1,4' # Mon/Thu 7 AM IST
|
||||
push:
|
||||
branches: [ main ]
|
||||
branches: [main]
|
||||
paths-ignore:
|
||||
- 'docs/**'
|
||||
- 'mkdocs.yml'
|
||||
- 'requirements-docs.txt'
|
||||
- '**/*.md'
|
||||
pull_request:
|
||||
branches: [ main ]
|
||||
branches: [main]
|
||||
paths-ignore:
|
||||
- 'docs/**'
|
||||
- 'mkdocs.yml'
|
||||
- 'requirements-docs.txt'
|
||||
- '**/*.md'
|
||||
|
||||
jobs:
|
||||
security-scan:
|
||||
@@ -158,7 +168,7 @@ jobs:
|
||||
}
|
||||
|
||||
// Create summary comment
|
||||
const comment = `# 🔒 Security Scan Results\\n\\n${safetyResults}\\n\\n${banditResults}\\n\\n${semgrepResults}\\n\\n---\\n\\n*This security scan runs automatically on every PR and bi-weekly.*\\n\\n📊 **Security Policy**: CI fails on vulnerabilities and HIGH severity issues.`;
|
||||
const comment = `# 🔒 Security Scan Results\\n\\n${safetyResults}\\n\\n${banditResults}\\n\\n${semgrepResults}\\n\\n---\\n\\n*This security scan runs automatically on source-code PRs and bi-weekly (skipped for doc/markdown-only changes).*\\n\\n📊 **Security Policy**: CI fails on vulnerabilities and HIGH severity issues.`;
|
||||
|
||||
// Post comment with error handling
|
||||
try {
|
||||
|
||||
@@ -7,6 +7,80 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
- **Agno Agentic Framework Integration** (Issue #249):
|
||||
- Added `AgnoContextStore` — graph-backed agent memory implementing the `agno.memory.db.base.MemoryDb` protocol; wraps `AgentContext` + `VectorStore`; supports `create()`, `table_exists()`, `memory_exists()`, `read_memories()`, `upsert_memory()`, `delete_memory()`, `drop_table()`, `clear()` plus extended `record_decision()`, `find_precedents()`, `retrieve()` methods
|
||||
- Added `AgnoKnowledgeGraph` — multi-hop GraphRAG knowledge base implementing `agno.knowledge.base.AgentKnowledge`; ingests files, directories, URLs, and raw text via NER → relation extraction → graph build → vector index pipeline; `search()` returns `AgnoDocument` objects; `get_graph_context(entity)` returns text summary of entity's graph neighbourhood
|
||||
- Added `AgnoDecisionKit` — Agno `Toolkit` subclass exposing 6 decision-intelligence tools: `record_decision`, `find_precedents`, `trace_causal_chain`, `analyze_impact`, `check_policy`, `get_decision_summary`
|
||||
- Added `AgnoKGToolkit` — Agno `Toolkit` subclass exposing 7 KG pipeline tools: `extract_entities`, `extract_relations`, `add_to_graph`, `query_graph`, `find_related`, `infer_facts`, `export_subgraph`
|
||||
- Added `AgnoSharedContext` — team-level coordinator with a single shared `ContextGraph`; `bind_agent(role)` returns a role-scoped `_AgentScopedStore` with cross-agent memory visibility; thread-safe via `RLock`
|
||||
- All 5 components degrade gracefully when `agno` is not installed (`AGNO_AVAILABLE` flag); importable and functional without agno present
|
||||
- Added `agno = ["agno>=1.0.0"]` optional dependency in `pyproject.toml`; included in `all` extra
|
||||
- 110 integration tests in `tests/integrations/agno/` covering all public APIs, MemoryDb protocol compliance, GraphRAG search, tool registration, shared memory isolation, and thread-safety
|
||||
- 3 cookbook notebooks in `cookbook/integrations/`: `agno_decision_intelligence.ipynb` (loan underwriting), `agno_graphrag_context.ipynb` (regulatory compliance), `agno_multi_agent_shared_context.ipynb` (multi-agent team coordination)
|
||||
- Full reference documentation in `docs/integrations/agno.md`
|
||||
|
||||
- **Novita AI Provider** (PR #374 by @Alex-wuhu):
|
||||
- Added `NovitaProvider` — OpenAI-compatible integration via `https://api.novita.ai/v1`; supports `generate()` and `generate_structured()` (JSON forced format)
|
||||
- Default model: `deepseek/deepseek-v3.2`; configurable via `NOVITA_API_KEY` environment variable
|
||||
- Registered `"novita"` in the built-in provider factory; usable via `create_provider("novita")`
|
||||
- Added integration tests in `tests/test_novita_integration.py` with proper assertions and graceful skip when `NOVITA_API_KEY` is unset
|
||||
|
||||
- **Native Datalog Reasoning Engine** (PR #371, Issue #368 by @ZohaibHassan16, reviewed and fixed by @KaifAhmad1):
|
||||
- Added `DatalogReasoner` to `semantica.reasoning` — a pure-Python, bottom-up semi-naive fixpoint engine with guaranteed termination on finite graphs
|
||||
- Supports recursive Horn clause rules (e.g. `ancestor(X,Y) :- parent(X,Z), ancestor(Z,Y).`) that existing engines loop on indefinitely
|
||||
- Memory-optimized `_unify()` with deferred dict allocation — zero allocation on failed unifications
|
||||
- `O(1)` delta-index lookup per iteration eliminates redundant `O(N)` rule re-evaluations in semi-naive loop
|
||||
- `query("pred(?X, ?Y)")` returns variable-binding dicts; supports both uppercase `?Y` and lowercase `?y` variable syntax
|
||||
- `query(..., bindings={"Y": "val"})` pre-binds variables for exact-match verification
|
||||
- `load_from_graph(ContextGraph)` converts all edges and nodes to Datalog facts in one call; handles both `find_edges`/`find_nodes` and raw `edges`/`nodes` graph APIs
|
||||
- `add_fact()` accepts `"pred(a, b)"` strings and Semantica dicts (`subject/predicate/object`, `source/target/type`, `type/id` shapes); warns on unrecognised dict format instead of silently dropping
|
||||
- `_derived` cache flag — `derive_all()` skips re-evaluation when no facts or rules have changed since last run; `query()` respects the cache
|
||||
- Progress tracking wrapped in `try/finally` — `stop_tracking()` always called even on exception
|
||||
- `DatalogReasoner`, `DatalogFact`, `DatalogRule` exported from `semantica.reasoning`
|
||||
- 18 tests covering recursive rules, multi-hop inference, variable binding, graph integration, idempotency, and edge cases — all passing
|
||||
- **Ontology Diff & Migration** (PR #367 by @ZohaibHassan16, review & fixes by @KaifAhmad1):
|
||||
- `VersionManager.diff_ontologies(base, target)` — structured diff between two ontology dicts using hash-map lookups; handles URI-less items via `name` fallback; deep equality checks for unordered lists; now covers classes, properties, individuals, and axioms
|
||||
- `ChangeLogAnalyzer.analyze(diff)` — classifies each change by semantic impact: removed classes/properties → `CRITICAL/BREAKING`; narrowed domain/range/cardinality → `HIGH/BREAKING`; hierarchy modifications → `MEDIUM/POTENTIALLY_BREAKING`; added elements and annotation updates → `INFO/NON_BREAKING`
|
||||
- `ImpactReport` dataclass and `generate_change_report(diff)` public helper — returns a structured dict with `summary`, `impact_classification` (breaking / potentially_breaking / safe), `recommendations`, and the raw `diff`
|
||||
- `OntologyEngine.compare_versions(base_id, target_id, **options)` — end-to-end orchestrator: loads versions from `VersionManager`, runs `diff_ontologies`, generates impact report; accepts `base_dict`/`target_dict` overrides to bypass version store; `run_validation=True` triggers `OntologyValidator` on the target schema; `graph_data=...` additionally runs `GraphValidator` on instance data against the new schema
|
||||
- `OntologyEngine.get_ontology_version_dict(version_id)` — utility to load a registered version as a plain dict ready for diffing
|
||||
- Documentation added to `docs/reference/change_management.md`: "Ontology Diff & Migration" section with code example and full report format reference
|
||||
- 7 tests added to `tests/change_management/test_managers.py` covering: empty diff, unordered list equality, URI/name fallback, breaking class removal, narrowed domain (HIGH), safe additions and annotation changes, `compare_versions` dict override, version-not-found error path, individuals/axioms diff coverage, null constraint value flagged as breaking
|
||||
- **Fixes applied post-review (by @KaifAhmad1)**:
|
||||
- Fixed typo in `ChangeCategory` enum value: `"potenitally_breaking"` → `"potentially_breaking"`
|
||||
- Fixed missing space in impact description string: `f"New{entity_type}"` → `f"New {entity_type}"`
|
||||
- Added null-value guard in `_analyze_field_changes` — constraint fields with `None` old/new value are now correctly flagged as breaking instead of silently passing the subset check
|
||||
- Made `ChangeLogAnalyzer` stateless — `report` is now a local variable passed into `_generate_recommendations(report)` rather than stored as `self.report`; removes re-entrancy hazard
|
||||
- Removed no-op `__init__` from `ChangeLogAnalyzer`
|
||||
- Replaced non-portable emoji markers in recommendations (`✘✘✘`, `¤¤¤`, `☺☺☺`) with plain-text tags (`[BREAKING]`, `[WARNING]`, `[SAFE]`)
|
||||
- Extended `diff_ontologies` to cover `individuals` and `axioms` — previously only classes and properties were diffed; the public `compare_versions` path now returns all four element types
|
||||
- Fixed exception chaining in `compare_versions`: `raise ProcessingError(...) from e` to preserve original traceback
|
||||
- Removed silent `ImportError` swallow for `GraphValidator` — it is a first-party module; an `ImportError` indicates a broken install, not a graceful skip
|
||||
- Added comment on deferred `VersionManager` import in `OntologyEngine.__init__` explaining the circular-import constraint
|
||||
- Fixed import-before-docstring in `tests/change_management/test_managers.py`
|
||||
- Fixed broken Markdown link syntax in docs JSON example block: `"[http://...](http://...)"` → bare URI string
|
||||
- Updated docs recommendations example to match the new plain-text tag format
|
||||
|
||||
- **Ontology Alignment API** (PR #361 by @ZohaibHassan16, review & fixes by @KaifAhmad1):
|
||||
- Alignment representation using standard RDF predicates: `owl:equivalentClass`, `owl:equivalentProperty`, `owl:sameAs`, `skos:exactMatch`, `skos:closeMatch`, `skos:broadMatch`, `skos:narrowMatch`, `skos:relatedMatch`
|
||||
- `OntologyEngine.create_alignment(source_uri, target_uri, predicate)` — store alignment triples in TripletStore
|
||||
- `OntologyEngine.get_alignments(entity_uri)` — bidirectional retrieval of all alignments for an entity
|
||||
- `OntologyEngine.list_alignments(ontology_uri=None)` — list all alignments, optionally filtered by ontology namespace
|
||||
- `NamespaceManager.get_alignment_predicates()` — expose standard OWL/SKOS alignment URIs as a convenience dict
|
||||
- `ReuseManager.suggest_alignments(target, source)` — O(N+M) hashmap heuristic to suggest alignments based on exact label matches across ontologies
|
||||
- `ReuseManager.merge_ontology_data(..., compute_alignments=True)` — optionally attach suggested alignments to merge output without auto-committing unverified triples
|
||||
- `QueryEngine.expand_entity_uri(uri, store, use_alignments=True)` — bidirectional SPARQL expansion to include aligned equivalents; no-ops when flag is False
|
||||
- `QueryEngine.build_values_clause(variable, uris)` — generate a SPARQL `VALUES` clause for injecting expanded URIs into queries
|
||||
- Alignment-aware queries section added to `docs/reference/triplet_store.md`
|
||||
- Ontology Alignment section added to `docs/reference/ontology.md`
|
||||
- **Fixes applied post-review (by @KaifAhmad1)**:
|
||||
- Fixed progress tracker leak in `expand_entity_uri` — `stop_tracking` was only called inside the `hasattr(execute_sparql)` branch; backends without it silently leaked a tracker entry
|
||||
- Fixed `relatedMatch` predicate gap — `get_alignment_predicates()` exposed `skos:relatedMatch` but all three SPARQL FILTER lists omitted it, making those alignments permanently invisible
|
||||
- Fixed SPARQL injection in `list_alignments` — previously only `"` was escaped; `\`, `{`, and `}` are now also percent-encoded to prevent WHERE block breakout
|
||||
- Fixed SPARQL injection in `build_values_clause` — URIs now run through `_sanitize_uri` before wrapping in angle-bracket literals
|
||||
- Added full-URI validation in `create_alignment` — raises `ProcessingError` if predicate is a CURIE instead of a full URI, preventing silent storage of unqueryable triples
|
||||
- Fixed E2E test `test_end_to_end_cross_ontology_uri_flow` — previously mocked the method under test; now uses a real mock backend with `execute_sparql` to exercise the actual expansion and VALUES clause injection flow
|
||||
- 19 tests added covering: `create_alignment`, `get_alignments`, `suggest_alignments`, merge with alignment computation, `expand_entity_uri` (enabled/disabled), `build_values_clause`, and full E2E cross-ontology query flow
|
||||
- **Context Explainability Output Fixes** (PR pending on `context` by @KaifAhmad1):
|
||||
- Fixed decision-node storage in `ContextGraph` so full human-readable `scenario`, `reasoning`, and decision metadata are preserved on graph nodes instead of degrading into opaque IDs or truncated display text
|
||||
- Fixed causal and precedent reconstruction paths in the context module so returned `Decision` objects prefer readable stored fields over raw node identifiers
|
||||
|
||||
@@ -187,7 +187,7 @@ See [RELEASE_NOTES.md](RELEASE_NOTES.md) for the full per-contributor breakdown
|
||||
| `semantica.visualization` | Interactive and static visualization of KGs, ontologies, embeddings, analytics, and temporal graphs |
|
||||
| `semantica.seed` | Seed data management for initial KG construction from CSV, JSON, databases, and APIs |
|
||||
| `semantica.core` | Framework orchestration, configuration management, knowledge base construction, plugin system |
|
||||
| `semantica.llms` | LLM provider integrations — Groq, OpenAI, HuggingFace, LiteLLM |
|
||||
| `semantica.llms` | LLM provider integrations — Groq, OpenAI, Novita AI, HuggingFace, LiteLLM |
|
||||
| `semantica.utils` | Shared utilities — logging, validation, exception handling, constants, types, progress tracking |
|
||||
|
||||
---
|
||||
@@ -680,9 +680,19 @@ ontology = importer.load("context.jsonld")
|
||||
|
||||
**LLM Providers**
|
||||
- 100+ models via LiteLLM — OpenAI, Anthropic, Cohere, Mistral, Ollama, Azure, AWS Bedrock, and more
|
||||
- Novita AI — OpenAI-compatible provider (`deepseek/deepseek-v3.2` and more); configure via `NOVITA_API_KEY`
|
||||
|
||||
**AI Frameworks**
|
||||
- Complements LangChain, LlamaIndex, AutoGen, CrewAI, Google ADK
|
||||
**Agentic Frameworks**
|
||||
- Complements LangChain, LlamaIndex, AutoGen, CrewAI, Google ADK, and more
|
||||
|
||||
> **Agno — First-Class Integration** `pip install semantica[agno]`
|
||||
>
|
||||
> Semantica ships a dedicated Agno integration with five ready-to-use components:
|
||||
> - **`AgnoContextStore`** — graph-backed agent memory
|
||||
> - **`AgnoKnowledgeGraph`** — multi-hop GraphRAG knowledge base
|
||||
> - **`AgnoDecisionKit`** — 6 decision-intelligence tools
|
||||
> - **`AgnoKGToolkit`** — 7 knowledge-graph pipeline tools
|
||||
> - **`AgnoSharedContext`** — shared context graph for multi-agent teams
|
||||
|
||||
**Export**
|
||||
- RDF: Turtle, JSON-LD, N-Triples, XML · Parquet · ArangoDB AQL
|
||||
|
||||
@@ -0,0 +1,534 @@
|
||||
{
|
||||
"cells": [
|
||||
{
|
||||
"cell_type": "markdown",
|
||||
"id": "title",
|
||||
"metadata": {},
|
||||
"source": [
|
||||
"# Agno × Semantica: Decision Intelligence Agent\n",
|
||||
"\n",
|
||||
"This notebook shows how to wire Semantica's **Decision Intelligence** stack into an Agno agent so it can:\n",
|
||||
"\n",
|
||||
"- Record every decision it makes with full reasoning provenance\n",
|
||||
"- Search historical precedents before acting\n",
|
||||
"- Validate decisions against policy rules\n",
|
||||
"- Trace causal chains across decisions\n",
|
||||
"- Accumulate institutional knowledge that survives across sessions\n",
|
||||
"\n",
|
||||
"**Domain used:** Financial loan underwriting (easily adapted to healthcare, legal, HR, etc.)\n",
|
||||
"\n",
|
||||
"---\n",
|
||||
"\n",
|
||||
"## Architecture\n",
|
||||
"\n",
|
||||
"```\n",
|
||||
"Agno Agent\n",
|
||||
" ├── memory=AgnoContextStore ← graph-backed persistent memory\n",
|
||||
" └── tools=[AgnoDecisionKit] ← decision tools the LLM can call\n",
|
||||
" │\n",
|
||||
" ├── record_decision ← Semantica AgentContext.record_decision()\n",
|
||||
" ├── find_precedents ← Semantica AgentContext.find_precedents_advanced()\n",
|
||||
" ├── trace_causal_chain ← Semantica ContextGraph.trace_decision_causality()\n",
|
||||
" ├── analyze_impact ← Semantica AgentContext.analyze_decision_influence()\n",
|
||||
" ├── check_policy ← Semantica PolicyEngine\n",
|
||||
" └── get_decision_summary ← Semantica AgentContext.get_context_insights()\n",
|
||||
"```\n",
|
||||
"\n",
|
||||
"## Install\n",
|
||||
"\n",
|
||||
"```bash\n",
|
||||
"pip install semantica[agno]\n",
|
||||
"```"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "markdown",
|
||||
"id": "setup-section",
|
||||
"metadata": {},
|
||||
"source": [
|
||||
"## 1. Setup — Semantica Backends\n",
|
||||
"\n",
|
||||
"We build the Semantica components first. These are **independent of Agno** — you can swap backends without touching agent code."
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": null,
|
||||
"id": "imports",
|
||||
"metadata": {},
|
||||
"outputs": [],
|
||||
"source": [
|
||||
"import sys, os\n",
|
||||
"sys.path.insert(0, os.path.abspath(\"../../\"))\n",
|
||||
"\n",
|
||||
"# ── Semantica core (not Agno-specific) ──────────────────────────────────────\n",
|
||||
"from semantica.context import AgentContext, ContextGraph\n",
|
||||
"from semantica.context import PolicyEngine, DecisionQuery, CausalChainAnalyzer\n",
|
||||
"from semantica.vector_store import VectorStore\n",
|
||||
"\n",
|
||||
"# ── Agno integration layer ───────────────────────────────────────────────────\n",
|
||||
"from integrations.agno import AgnoContextStore, AgnoDecisionKit, AGNO_AVAILABLE\n",
|
||||
"\n",
|
||||
"print(f\"Semantica imports OK\")\n",
|
||||
"print(f\"Agno installed: {AGNO_AVAILABLE}\")"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": null,
|
||||
"id": "semantica-backends",
|
||||
"metadata": {},
|
||||
"outputs": [],
|
||||
"source": [
|
||||
"# ── Vector store (FAISS, no external service needed) ────────────────────────\n",
|
||||
"vector_store = VectorStore(backend=\"faiss\", dimension=768)\n",
|
||||
"print(\"VectorStore ready (FAISS)\")\n",
|
||||
"\n",
|
||||
"# ── In-memory context graph with full analytics ──────────────────────────────\n",
|
||||
"knowledge_graph = ContextGraph(\n",
|
||||
" advanced_analytics=True,\n",
|
||||
" # Switch to neo4j for production:\n",
|
||||
" # backend=\"neo4j\", uri=\"bolt://localhost:7687\"\n",
|
||||
")\n",
|
||||
"print(\"ContextGraph ready (in-memory)\")"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "markdown",
|
||||
"id": "seed-section",
|
||||
"metadata": {},
|
||||
"source": [
|
||||
"## 2. Seed Historical Decisions\n",
|
||||
"\n",
|
||||
"Before the agent runs, we pre-load historical decisions using **native Semantica APIs** so the precedent database is warm.\n",
|
||||
"\n",
|
||||
"In production you would ingest from a database or a prior session's graph export."
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": null,
|
||||
"id": "seed-decisions",
|
||||
"metadata": {},
|
||||
"outputs": [],
|
||||
"source": [
|
||||
"# Build a pure-Semantica AgentContext for seeding historical data\n",
|
||||
"seed_context = AgentContext(\n",
|
||||
" vector_store=vector_store,\n",
|
||||
" knowledge_graph=knowledge_graph,\n",
|
||||
" decision_tracking=True,\n",
|
||||
")\n",
|
||||
"\n",
|
||||
"historical_loans = [\n",
|
||||
" dict(\n",
|
||||
" category=\"loan_approval\",\n",
|
||||
" scenario=\"Applicant: credit score 740, income $95k, DTI 28%, down payment 20%\",\n",
|
||||
" reasoning=\"Strong credit history, debt load well below 35% threshold, adequate down payment\",\n",
|
||||
" outcome=\"approved\",\n",
|
||||
" confidence=0.96,\n",
|
||||
" ),\n",
|
||||
" dict(\n",
|
||||
" category=\"loan_approval\",\n",
|
||||
" scenario=\"Applicant: credit score 620, income $45k, DTI 42%, down payment 5%\",\n",
|
||||
" reasoning=\"Credit score below 650 floor, DTI exceeds 40% maximum, insufficient down payment\",\n",
|
||||
" outcome=\"rejected\",\n",
|
||||
" confidence=0.97,\n",
|
||||
" ),\n",
|
||||
" dict(\n",
|
||||
" category=\"loan_approval\",\n",
|
||||
" scenario=\"Applicant: credit score 700, income $72k, DTI 33%, down payment 15%\",\n",
|
||||
" reasoning=\"Adequate credit, moderate DTI within range, down payment slightly below ideal\",\n",
|
||||
" outcome=\"approved_with_conditions\",\n",
|
||||
" confidence=0.82,\n",
|
||||
" ),\n",
|
||||
" dict(\n",
|
||||
" category=\"loan_approval\",\n",
|
||||
" scenario=\"Applicant: credit score 780, income $130k, DTI 22%, down payment 30%\",\n",
|
||||
" reasoning=\"Excellent credit, low debt load, strong down payment — low-risk profile\",\n",
|
||||
" outcome=\"approved\",\n",
|
||||
" confidence=0.99,\n",
|
||||
" ),\n",
|
||||
" dict(\n",
|
||||
" category=\"loan_approval\",\n",
|
||||
" scenario=\"Applicant: credit score 660, income $58k, DTI 38%, down payment 10%\",\n",
|
||||
" reasoning=\"Borderline credit, high DTI, minimal down payment — escalated to senior review\",\n",
|
||||
" outcome=\"escalated\",\n",
|
||||
" confidence=0.70,\n",
|
||||
" ),\n",
|
||||
"]\n",
|
||||
"\n",
|
||||
"for loan in historical_loans:\n",
|
||||
" did = seed_context.record_decision(**loan)\n",
|
||||
" print(f\" Seeded [{loan['outcome']:25s}] → {did}\")\n",
|
||||
"\n",
|
||||
"print(f\"\\n{len(historical_loans)} historical decisions loaded into Semantica KG\")"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "markdown",
|
||||
"id": "policy-section",
|
||||
"metadata": {},
|
||||
"source": [
|
||||
"## 3. Define Policy Rules with Semantica\n",
|
||||
"\n",
|
||||
"We use `PolicyEngine` directly — no Agno involvement here. The `AgnoDecisionKit.check_policy` tool will call this engine during the agent's reasoning loop."
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": null,
|
||||
"id": "policy",
|
||||
"metadata": {},
|
||||
"outputs": [],
|
||||
"source": [
|
||||
"LENDING_POLICY_RULES = [\n",
|
||||
" \"credit_score >= 650\",\n",
|
||||
" \"dti <= 40\",\n",
|
||||
" \"down_payment_pct >= 10\",\n",
|
||||
" \"confidence >= 0.70\",\n",
|
||||
"]\n",
|
||||
"\n",
|
||||
"# Verify directly with Semantica's PolicyEngine before wiring to Agno\n",
|
||||
"policy_engine = PolicyEngine(graph_store=knowledge_graph)\n",
|
||||
"\n",
|
||||
"test_application = {\"credit_score\": 720, \"dti\": 31, \"down_payment_pct\": 18, \"confidence\": 0.88}\n",
|
||||
"\n",
|
||||
"try:\n",
|
||||
" result = policy_engine.check_compliance(test_application, LENDING_POLICY_RULES)\n",
|
||||
" print(f\"Policy check result: compliant={getattr(result, 'compliant', 'N/A')}\")\n",
|
||||
" print(f\"Violations: {getattr(result, 'violations', [])}\")\n",
|
||||
"except Exception as e:\n",
|
||||
" print(f\"PolicyEngine fallback (expected without full rule engine): {e}\")\n",
|
||||
"\n",
|
||||
"print(\"\\nPolicy rules defined:\", LENDING_POLICY_RULES)"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "markdown",
|
||||
"id": "agent-section",
|
||||
"metadata": {},
|
||||
"source": [
|
||||
"## 4. Build the Agno Decision-Intelligence Agent\n",
|
||||
"\n",
|
||||
"Now we wire everything into Agno using the integration classes.\n",
|
||||
"\n",
|
||||
"- `AgnoContextStore` gives the agent **persistent graph-backed memory**\n",
|
||||
"- `AgnoDecisionKit` exposes **6 decision tools** the LLM can invoke during reasoning"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": null,
|
||||
"id": "build-agent",
|
||||
"metadata": {},
|
||||
"outputs": [],
|
||||
"source": [
|
||||
"# ── AgnoContextStore: wraps AgentContext as Agno MemoryDb ────────────────────\n",
|
||||
"store = AgnoContextStore(\n",
|
||||
" vector_store=vector_store, # Same store — shares seeded decisions\n",
|
||||
" knowledge_graph=knowledge_graph, # Same graph — shares seeded decisions\n",
|
||||
" decision_tracking=True,\n",
|
||||
" graph_expansion=True,\n",
|
||||
" session_id=\"loan_underwriter_v1\",\n",
|
||||
")\n",
|
||||
"print(\"AgnoContextStore ready\")\n",
|
||||
"\n",
|
||||
"# ── AgnoDecisionKit: exposes Semantica decision tools to Agno's LLM ──────────\n",
|
||||
"decision_kit = AgnoDecisionKit(\n",
|
||||
" context=store.context, # Reuse same AgentContext — shared decision history\n",
|
||||
" max_precedents=5,\n",
|
||||
" causal_depth=3,\n",
|
||||
" enable_policy_check=True,\n",
|
||||
")\n",
|
||||
"print(f\"AgnoDecisionKit ready — {len(decision_kit._tools)} tools registered\")\n",
|
||||
"print(\" Tools:\", [fn.__name__ for fn in decision_kit._tools])"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": null,
|
||||
"id": "wire-agent",
|
||||
"metadata": {},
|
||||
"outputs": [],
|
||||
"source": [
|
||||
"if AGNO_AVAILABLE:\n",
|
||||
" from agno.agent import Agent\n",
|
||||
" from agno.memory import AgentMemory\n",
|
||||
" from agno.models.openai import OpenAIChat # or any Agno-supported model\n",
|
||||
"\n",
|
||||
" agent = Agent(\n",
|
||||
" name=\"LoanUnderwriter\",\n",
|
||||
" model=OpenAIChat(id=\"gpt-4o\"),\n",
|
||||
" memory=AgentMemory(db=store),\n",
|
||||
" tools=[decision_kit],\n",
|
||||
" show_tool_calls=True,\n",
|
||||
" description=(\n",
|
||||
" \"You are a senior loan underwriter. Before approving or rejecting any application:\"\n",
|
||||
" \" (1) find_precedents for similar past cases,\"\n",
|
||||
" \" (2) check_policy compliance,\"\n",
|
||||
" \" (3) record_decision with full reasoning.\"\n",
|
||||
" \" Always cite precedents and policy rule results in your explanation.\"\n",
|
||||
" ),\n",
|
||||
" )\n",
|
||||
" print(\"Agno Agent assembled and ready\")\n",
|
||||
"else:\n",
|
||||
" print(\"Agno not installed — demonstrating tool calls directly below\")"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "markdown",
|
||||
"id": "demo-section",
|
||||
"metadata": {},
|
||||
"source": [
|
||||
"## 5. Demonstrate Decision Tools\n",
|
||||
"\n",
|
||||
"We call the decision tools **directly** so the notebook is fully runnable without an OpenAI key. When Agno is wired, the LLM orchestrates these same calls automatically."
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": null,
|
||||
"id": "demo-find-precedents",
|
||||
"metadata": {},
|
||||
"outputs": [],
|
||||
"source": [
|
||||
"import json\n",
|
||||
"\n",
|
||||
"# ── 5a. Find Precedents ───────────────────────────────────────────────────────\n",
|
||||
"print(\"=\" * 60)\n",
|
||||
"print(\"TOOL: find_precedents\")\n",
|
||||
"print(\"=\" * 60)\n",
|
||||
"\n",
|
||||
"new_application_scenario = (\n",
|
||||
" \"Applicant: credit score 715, income $82k, DTI 30%, down payment 18%\"\n",
|
||||
")\n",
|
||||
"\n",
|
||||
"precedents_json = decision_kit.find_precedents(\n",
|
||||
" scenario=new_application_scenario,\n",
|
||||
" category=\"loan_approval\",\n",
|
||||
" limit=3,\n",
|
||||
")\n",
|
||||
"precedents = json.loads(precedents_json)\n",
|
||||
"print(f\"Found {precedents['count']} similar past decisions:\")\n",
|
||||
"for p in precedents['precedents']:\n",
|
||||
" print(f\" [{p.get('outcome','?'):25s}] confidence={p.get('confidence',0):.2f}\")\n",
|
||||
" print(f\" {p.get('scenario','')[:80]}\")"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": null,
|
||||
"id": "demo-policy",
|
||||
"metadata": {},
|
||||
"outputs": [],
|
||||
"source": [
|
||||
"# ── 5b. Check Policy ─────────────────────────────────────────────────────────\n",
|
||||
"print(\"=\" * 60)\n",
|
||||
"print(\"TOOL: check_policy\")\n",
|
||||
"print(\"=\" * 60)\n",
|
||||
"\n",
|
||||
"decision_data = json.dumps({\n",
|
||||
" \"credit_score\": 715,\n",
|
||||
" \"dti\": 30,\n",
|
||||
" \"down_payment_pct\": 18,\n",
|
||||
" \"confidence\": 0.88,\n",
|
||||
" \"outcome\": \"approved\",\n",
|
||||
"})\n",
|
||||
"\n",
|
||||
"policy_json = decision_kit.check_policy(\n",
|
||||
" decision_data=decision_data,\n",
|
||||
" policy_rules=json.dumps(LENDING_POLICY_RULES),\n",
|
||||
")\n",
|
||||
"policy_result = json.loads(policy_json)\n",
|
||||
"print(f\"Compliant: {policy_result.get('compliant')}\")\n",
|
||||
"print(f\"Violations: {policy_result.get('violations', [])}\")\n",
|
||||
"print(f\"Warnings: {policy_result.get('warnings', [])}\")"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": null,
|
||||
"id": "demo-record",
|
||||
"metadata": {},
|
||||
"outputs": [],
|
||||
"source": [
|
||||
"# ── 5c. Record Decision ──────────────────────────────────────────────────────\n",
|
||||
"print(\"=\" * 60)\n",
|
||||
"print(\"TOOL: record_decision\")\n",
|
||||
"print(\"=\" * 60)\n",
|
||||
"\n",
|
||||
"record_json = decision_kit.record_decision(\n",
|
||||
" category=\"loan_approval\",\n",
|
||||
" scenario=new_application_scenario,\n",
|
||||
" reasoning=(\n",
|
||||
" \"3 similar precedents found — 2 approved, 1 escalated. \"\n",
|
||||
" \"Credit score 715 exceeds 650 floor. DTI 30% well within 40% limit. \"\n",
|
||||
" \"Down payment 18% above 10% minimum. All policy rules satisfied.\"\n",
|
||||
" ),\n",
|
||||
" outcome=\"approved\",\n",
|
||||
" confidence=0.91,\n",
|
||||
" entities=\"loan_applicant, credit_bureau, lending_policy_v2\",\n",
|
||||
")\n",
|
||||
"record_result = json.loads(record_json)\n",
|
||||
"decision_id = record_result['decision_id']\n",
|
||||
"print(f\"Decision recorded: {decision_id}\")\n",
|
||||
"print(f\"Status: {record_result['status']}\")"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": null,
|
||||
"id": "demo-impact",
|
||||
"metadata": {},
|
||||
"outputs": [],
|
||||
"source": [
|
||||
"# ── 5d. Analyze Impact ───────────────────────────────────────────────────────\n",
|
||||
"print(\"=\" * 60)\n",
|
||||
"print(\"TOOL: analyze_impact\")\n",
|
||||
"print(\"=\" * 60)\n",
|
||||
"\n",
|
||||
"impact_json = decision_kit.analyze_impact(decision_id=decision_id)\n",
|
||||
"impact = json.loads(impact_json)\n",
|
||||
"print(\"Impact analysis:\")\n",
|
||||
"for k, v in impact.items():\n",
|
||||
" if k != \"decision_id\":\n",
|
||||
" print(f\" {k}: {v}\")"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": null,
|
||||
"id": "demo-summary",
|
||||
"metadata": {},
|
||||
"outputs": [],
|
||||
"source": [
|
||||
"# ── 5e. Decision Summary ─────────────────────────────────────────────────────\n",
|
||||
"print(\"=\" * 60)\n",
|
||||
"print(\"TOOL: get_decision_summary\")\n",
|
||||
"print(\"=\" * 60)\n",
|
||||
"\n",
|
||||
"summary_json = decision_kit.get_decision_summary(category=\"loan_approval\")\n",
|
||||
"summary = json.loads(summary_json)\n",
|
||||
"print(\"Decision history summary:\")\n",
|
||||
"for k, v in summary.items():\n",
|
||||
" if k not in (\"category_filter\",):\n",
|
||||
" print(f\" {k}: {v}\")"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "markdown",
|
||||
"id": "agno-run-section",
|
||||
"metadata": {},
|
||||
"source": [
|
||||
"## 6. Run the Full Agno Agent (requires API key)\n",
|
||||
"\n",
|
||||
"When `AGNO_AVAILABLE=True` and an OpenAI key is set, the LLM orchestrates all the tool calls automatically."
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": null,
|
||||
"id": "run-agent",
|
||||
"metadata": {},
|
||||
"outputs": [],
|
||||
"source": [
|
||||
"NEW_CASE = (\n",
|
||||
" \"New mortgage application received:\\n\"\n",
|
||||
" \" Credit score: 715, Annual income: $82,000\\n\"\n",
|
||||
" \" Debt-to-income: 30%, Down payment: 18%\\n\"\n",
|
||||
" \" Loan amount: $320,000 for a primary residence in Austin TX\\n\"\n",
|
||||
" \"Should we approve this application?\"\n",
|
||||
")\n",
|
||||
"\n",
|
||||
"if AGNO_AVAILABLE:\n",
|
||||
" agent.print_response(NEW_CASE)\n",
|
||||
"else:\n",
|
||||
" print(\"[Agno not installed — skipping live agent run]\")\n",
|
||||
" print()\n",
|
||||
" print(\"Expected agent reasoning flow:\")\n",
|
||||
" print(\" 1. find_precedents('credit score 715, DTI 30%, down payment 18%')\")\n",
|
||||
" print(\" → 2 approved, 1 escalated among similar cases\")\n",
|
||||
" print(\" 2. check_policy(credit_score=715, dti=30, down_payment_pct=18)\")\n",
|
||||
" print(\" → compliant=True, violations=[]\")\n",
|
||||
" print(\" 3. record_decision(outcome='approved', confidence=0.91)\")\n",
|
||||
" print(\" → decision_id recorded in Semantica KG\")\n",
|
||||
" print()\n",
|
||||
" print(\" Recommendation: APPROVE — 3 precedents + full policy compliance\")"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "markdown",
|
||||
"id": "analytics-section",
|
||||
"metadata": {},
|
||||
"source": [
|
||||
"## 7. Post-Session Analytics with Semantica\n",
|
||||
"\n",
|
||||
"After the agent session, use **native Semantica APIs** for reporting and causal analysis — no Agno required."
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": null,
|
||||
"id": "analytics",
|
||||
"metadata": {},
|
||||
"outputs": [],
|
||||
"source": [
|
||||
"# Query decision history directly from Semantica\n",
|
||||
"insights = store.context.get_context_insights()\n",
|
||||
"print(\"Session Insights (Semantica native):\")\n",
|
||||
"if isinstance(insights, dict):\n",
|
||||
" for k, v in insights.items():\n",
|
||||
" print(f\" {k}: {v}\")\n",
|
||||
"else:\n",
|
||||
" print(f\" {insights}\")"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": null,
|
||||
"id": "precedents-direct",
|
||||
"metadata": {},
|
||||
"outputs": [],
|
||||
"source": [
|
||||
"# Precedent search directly via Semantica's AgentContext\n",
|
||||
"# (same data, no Agno in the loop)\n",
|
||||
"precedents = store.context.find_precedents_advanced(\n",
|
||||
" scenario=\"borderline mortgage application\",\n",
|
||||
" category=\"loan_approval\",\n",
|
||||
")\n",
|
||||
"print(f\"\\nPrecedent search via Semantica directly → {len(precedents or [])} results\")"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "markdown",
|
||||
"id": "summary-section",
|
||||
"metadata": {},
|
||||
"source": [
|
||||
"## Summary\n",
|
||||
"\n",
|
||||
"| What | How |\n",
|
||||
"|---|---|\n",
|
||||
"| Persistent decision history | `AgnoContextStore` wrapping `AgentContext` + FAISS |\n",
|
||||
"| Tool calls for decision intelligence | `AgnoDecisionKit` (record, find, trace, check, summarise) |\n",
|
||||
"| Historical seeding | Native `AgentContext.record_decision()` — no Agno needed |\n",
|
||||
"| Policy rules | Native `PolicyEngine` — no Agno needed |\n",
|
||||
"| Post-session analytics | Native `AgentContext.get_context_insights()` — no Agno needed |\n",
|
||||
"\n",
|
||||
"The Agno integration is a **thin wrapper** — Semantica's full API remains directly accessible whenever you need finer control."
|
||||
]
|
||||
}
|
||||
],
|
||||
"metadata": {
|
||||
"kernelspec": {
|
||||
"display_name": "Python 3",
|
||||
"language": "python",
|
||||
"name": "python3"
|
||||
},
|
||||
"language_info": {
|
||||
"name": "python",
|
||||
"version": "3.11.0"
|
||||
}
|
||||
},
|
||||
"nbformat": 4,
|
||||
"nbformat_minor": 5
|
||||
}
|
||||
@@ -0,0 +1,615 @@
|
||||
{
|
||||
"cells": [
|
||||
{
|
||||
"cell_type": "markdown",
|
||||
"id": "title",
|
||||
"metadata": {},
|
||||
"source": [
|
||||
"# Agno × Semantica: GraphRAG Context Agent\n",
|
||||
"\n",
|
||||
"This notebook demonstrates how to give an Agno agent a **relational knowledge graph** instead of a flat document store. The agent retrieves answers via **multi-hop graph traversal** — finding connections that pure vector search misses.\n",
|
||||
"\n",
|
||||
"**Domain:** Regulatory compliance (Basel IV / DORA) — documents are ingested, entities & relations extracted, then the agent answers questions by hopping through the graph.\n",
|
||||
"\n",
|
||||
"---\n",
|
||||
"\n",
|
||||
"## Architecture\n",
|
||||
"\n",
|
||||
"```\n",
|
||||
"Agno Agent\n",
|
||||
" ├── knowledge=AgnoKnowledgeGraph ← GraphRAG knowledge base\n",
|
||||
" └── tools=[AgnoKGToolkit] ← live graph building/query tools\n",
|
||||
" │\n",
|
||||
" │ Backed by Semantica:\n",
|
||||
" ├── NERExtractor ← named entity recognition\n",
|
||||
" ├── RelationExtractor ← relation extraction\n",
|
||||
" ├── GraphBuilder ← builds ContextGraph from extractions\n",
|
||||
" ├── ContextGraph ← in-memory graph with analytics\n",
|
||||
" └── Reasoner ← rule-based inference\n",
|
||||
"```\n",
|
||||
"\n",
|
||||
"## Install\n",
|
||||
"\n",
|
||||
"```bash\n",
|
||||
"pip install semantica[agno]\n",
|
||||
"```"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "markdown",
|
||||
"id": "imports-section",
|
||||
"metadata": {},
|
||||
"source": [
|
||||
"## 1. Imports — Semantica Core + Agno Integration"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": null,
|
||||
"id": "imports",
|
||||
"metadata": {},
|
||||
"outputs": [],
|
||||
"source": [
|
||||
"import sys, os, json\n",
|
||||
"sys.path.insert(0, os.path.abspath(\"../../\"))\n",
|
||||
"\n",
|
||||
"# ── Semantica core — used directly for pipeline setup ───────────────────────\n",
|
||||
"from semantica.kg import GraphBuilder\n",
|
||||
"from semantica.context import ContextGraph\n",
|
||||
"from semantica.semantic_extract import NERExtractor, RelationExtractor, TripletExtractor\n",
|
||||
"from semantica.reasoning import Reasoner\n",
|
||||
"from semantica.vector_store import VectorStore\n",
|
||||
"\n",
|
||||
"# ── Agno integration layer ───────────────────────────────────────────────────\n",
|
||||
"from integrations.agno import AgnoKnowledgeGraph, AgnoKGToolkit, AGNO_AVAILABLE\n",
|
||||
"\n",
|
||||
"print(\"Semantica imports OK\")\n",
|
||||
"print(f\"Agno installed: {AGNO_AVAILABLE}\")"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "markdown",
|
||||
"id": "pipeline-section",
|
||||
"metadata": {},
|
||||
"source": [
|
||||
"## 2. Build the Semantica Extraction Pipeline\n",
|
||||
"\n",
|
||||
"The extraction pipeline (NER → relation extraction → graph build) is pure Semantica. We construct each component explicitly so we can also use them for analysis outside Agno."
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": null,
|
||||
"id": "build-pipeline",
|
||||
"metadata": {},
|
||||
"outputs": [],
|
||||
"source": [
|
||||
"# NER — identifies organisations, regulations, dates, amounts, roles\n",
|
||||
"ner = NERExtractor()\n",
|
||||
"\n",
|
||||
"# Relation extractor — finds typed edges between entities\n",
|
||||
"rel_extractor = RelationExtractor(confidence_threshold=0.60)\n",
|
||||
"\n",
|
||||
"# Knowledge graph builder\n",
|
||||
"graph_builder = GraphBuilder(merge_entities=True, temporal_support=True)\n",
|
||||
"\n",
|
||||
"# In-memory context graph (swap to neo4j/falkordb for persistence)\n",
|
||||
"context_graph = ContextGraph(advanced_analytics=True)\n",
|
||||
"\n",
|
||||
"# Reasoner for rule inference over the graph\n",
|
||||
"reasoner = Reasoner()\n",
|
||||
"\n",
|
||||
"print(\"Semantica extraction pipeline assembled\")"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "markdown",
|
||||
"id": "ingest-raw-section",
|
||||
"metadata": {},
|
||||
"source": [
|
||||
"## 3. Direct Semantica Extraction (Before Agno)\n",
|
||||
"\n",
|
||||
"We first demonstrate extraction using **raw Semantica APIs** so you can see exactly what goes into the graph.\n",
|
||||
"This is the same pipeline `AgnoKnowledgeGraph.load()` runs internally."
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": null,
|
||||
"id": "raw-documents",
|
||||
"metadata": {},
|
||||
"outputs": [],
|
||||
"source": [
|
||||
"# Regulatory documents (representative snippets)\n",
|
||||
"REGULATORY_DOCS = [\n",
|
||||
" {\n",
|
||||
" \"title\": \"Basel IV — Capital Requirements\",\n",
|
||||
" \"text\": (\n",
|
||||
" \"Basel IV introduces a revised standardised approach for credit risk, \"\n",
|
||||
" \"replacing internal model floors. Banks must maintain a minimum CET1 ratio \"\n",
|
||||
" \"of 4.5% and a total capital ratio of 8%. The BCBS finalised these requirements \"\n",
|
||||
" \"in December 2017 with a phased implementation starting January 2022. \"\n",
|
||||
" \"National regulators including the EBA and FCA are responsible for local \"\n",
|
||||
" \"transposition. Risk-weighted assets under Basel IV are calculated using \"\n",
|
||||
" \"the Output Floor, capping RWA reductions at 72.5%.\"\n",
|
||||
" ),\n",
|
||||
" },\n",
|
||||
" {\n",
|
||||
" \"title\": \"DORA — Digital Operational Resilience Act\",\n",
|
||||
" \"text\": (\n",
|
||||
" \"DORA (Regulation EU 2022/2554) applies to financial entities and ICT \"\n",
|
||||
" \"third-party service providers operating in the EU. It mandates ICT risk \"\n",
|
||||
" \"management frameworks, incident classification, and annual operational \"\n",
|
||||
" \"resilience testing. Supervised entities must report major ICT incidents to \"\n",
|
||||
" \"the European Supervisory Authorities (ESAs) within 4 hours of classification. \"\n",
|
||||
" \"Critical ICT providers are subject to direct oversight by the Joint Oversight \"\n",
|
||||
" \"Network led by ESMA, EBA, and EIOPA. DORA became applicable on 17 January 2025.\"\n",
|
||||
" ),\n",
|
||||
" },\n",
|
||||
" {\n",
|
||||
" \"title\": \"AML — Anti-Money Laundering Directive VI\",\n",
|
||||
" \"text\": (\n",
|
||||
" \"AMLD6 strengthens the EU's anti-money laundering framework by extending \"\n",
|
||||
" \"criminal liability to 22 predicate offences including cybercrime and \"\n",
|
||||
" \"environmental crime. Financial institutions must apply Customer Due Diligence \"\n",
|
||||
" \"(CDD) at onboarding and Enhanced Due Diligence (EDD) for high-risk customers. \"\n",
|
||||
" \"Suspicious Activity Reports (SARs) are filed with the national Financial \"\n",
|
||||
" \"Intelligence Unit (FIU). Non-compliance carries penalties up to 10% of \"\n",
|
||||
" \"annual global turnover. AMLD6 was transposed into UK law via MLCO 2020.\"\n",
|
||||
" ),\n",
|
||||
" },\n",
|
||||
"]\n",
|
||||
"\n",
|
||||
"print(f\"Documents to ingest: {len(REGULATORY_DOCS)}\")\n",
|
||||
"for doc in REGULATORY_DOCS:\n",
|
||||
" print(f\" • {doc['title']}\")"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": null,
|
||||
"id": "run-ner",
|
||||
"metadata": {},
|
||||
"outputs": [],
|
||||
"source": [
|
||||
"# ── Run NER directly with Semantica ─────────────────────────────────────────\n",
|
||||
"all_entities = []\n",
|
||||
"for doc in REGULATORY_DOCS:\n",
|
||||
" entities = ner.extract_entities(doc['text']) or []\n",
|
||||
" all_entities.extend(entities)\n",
|
||||
" print(f\"[{doc['title']}] → {len(entities)} entities\")\n",
|
||||
" for e in entities[:4]:\n",
|
||||
" print(f\" {getattr(e,'name','?'):30s} type={getattr(e,'type','?')} conf={getattr(e,'confidence',0):.2f}\")\n",
|
||||
"\n",
|
||||
"print(f\"\\nTotal entities extracted: {len(all_entities)}\")"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": null,
|
||||
"id": "run-rel",
|
||||
"metadata": {},
|
||||
"outputs": [],
|
||||
"source": [
|
||||
"# ── Run relation extraction directly with Semantica ──────────────────────────\n",
|
||||
"all_relations = []\n",
|
||||
"for doc in REGULATORY_DOCS:\n",
|
||||
" relations = rel_extractor.extract_relations(doc['text']) or []\n",
|
||||
" all_relations.extend(relations)\n",
|
||||
" print(f\"[{doc['title']}] → {len(relations)} relations\")\n",
|
||||
" for r in relations[:3]:\n",
|
||||
" src = getattr(r, 'source', '?')\n",
|
||||
" rtype = getattr(r, 'type', getattr(r, 'relation', '?'))\n",
|
||||
" tgt = getattr(r, 'target', '?')\n",
|
||||
" conf = getattr(r, 'confidence', 0)\n",
|
||||
" print(f\" {src!s:20s} --[{rtype}]--> {tgt!s:20s} conf={conf:.2f}\")\n",
|
||||
"\n",
|
||||
"print(f\"\\nTotal relations extracted: {len(all_relations)}\")"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "markdown",
|
||||
"id": "agno-kg-section",
|
||||
"metadata": {},
|
||||
"source": [
|
||||
"## 4. Build AgnoKnowledgeGraph\n",
|
||||
"\n",
|
||||
"`AgnoKnowledgeGraph` wraps the extraction pipeline and implements Agno's `AgentKnowledge` protocol. It runs the same NER + relation extract + graph build pipeline internally — here we pass our pre-built components so the same instances are used."
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": null,
|
||||
"id": "build-agno-kg",
|
||||
"metadata": {},
|
||||
"outputs": [],
|
||||
"source": [
|
||||
"kg = AgnoKnowledgeGraph(\n",
|
||||
" graph_builder=graph_builder,\n",
|
||||
" ner_extractor=ner,\n",
|
||||
" relation_extractor=rel_extractor,\n",
|
||||
" context_graph=context_graph,\n",
|
||||
" num_documents=5,\n",
|
||||
")\n",
|
||||
"\n",
|
||||
"# Ingest all documents through the integration wrapper\n",
|
||||
"kg.load(texts=[doc['text'] for doc in REGULATORY_DOCS])\n",
|
||||
"\n",
|
||||
"print(f\"AgnoKnowledgeGraph: {len(kg._docs)} documents indexed\")"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "markdown",
|
||||
"id": "graphrag-section",
|
||||
"metadata": {},
|
||||
"source": [
|
||||
"## 5. GraphRAG Search\n",
|
||||
"\n",
|
||||
"The `search()` method implements **multi-hop GraphRAG**:\n",
|
||||
"1. Vector similarity over stored document texts\n",
|
||||
"2. Entity lookup in the context graph\n",
|
||||
"3. Graph hop expansion for entity neighbourhood\n",
|
||||
"4. Context injection into the returned documents"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": null,
|
||||
"id": "graphrag-search",
|
||||
"metadata": {},
|
||||
"outputs": [],
|
||||
"source": [
|
||||
"queries = [\n",
|
||||
" \"What is the minimum CET1 ratio required under Basel IV?\",\n",
|
||||
" \"Which authorities supervise critical ICT providers under DORA?\",\n",
|
||||
" \"What are the reporting timelines for major ICT incidents?\",\n",
|
||||
" \"How does AMLD6 handle customer due diligence?\",\n",
|
||||
"]\n",
|
||||
"\n",
|
||||
"for query in queries:\n",
|
||||
" print(f\"\\nQ: {query}\")\n",
|
||||
" results = kg.search(query, num_documents=2)\n",
|
||||
" print(f\" Retrieved {len(results)} document(s)\")\n",
|
||||
" for i, doc in enumerate(results, 1):\n",
|
||||
" content = getattr(doc, 'content', str(doc))\n",
|
||||
" print(f\" [{i}] {content[:120]}...\")"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": null,
|
||||
"id": "entity-context",
|
||||
"metadata": {},
|
||||
"outputs": [],
|
||||
"source": [
|
||||
"# Get graph context for a specific entity\n",
|
||||
"entity_contexts = [\"BCBS\", \"EBA\", \"DORA\", \"Basel IV\"]\n",
|
||||
"for entity in entity_contexts:\n",
|
||||
" ctx = kg.get_graph_context(entity)\n",
|
||||
" print(f\"\\nGraph context for '{entity}':\")\n",
|
||||
" print(ctx if ctx else \" (no graph nodes found — depends on NER extraction quality)\")"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "markdown",
|
||||
"id": "toolkit-section",
|
||||
"metadata": {},
|
||||
"source": [
|
||||
"## 6. AgnoKGToolkit — Live Graph Building\n",
|
||||
"\n",
|
||||
"The `AgnoKGToolkit` exposes 7 tools the LLM can call to **actively modify and query the graph** during reasoning."
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": null,
|
||||
"id": "build-toolkit",
|
||||
"metadata": {},
|
||||
"outputs": [],
|
||||
"source": [
|
||||
"toolkit = AgnoKGToolkit(\n",
|
||||
" ner_extractor=ner,\n",
|
||||
" relation_extractor=rel_extractor,\n",
|
||||
" reasoner=reasoner,\n",
|
||||
" context=context_graph, # share same graph as knowledge base\n",
|
||||
")\n",
|
||||
"\n",
|
||||
"print(f\"AgnoKGToolkit: {len(toolkit._tools)} tools\")\n",
|
||||
"print(\" Tools:\", [fn.__name__ for fn in toolkit._tools])"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": null,
|
||||
"id": "demo-extract-entities",
|
||||
"metadata": {},
|
||||
"outputs": [],
|
||||
"source": [
|
||||
"# TOOL: extract_entities\n",
|
||||
"print(\"=\" * 55)\n",
|
||||
"print(\"TOOL: extract_entities\")\n",
|
||||
"print(\"=\" * 55)\n",
|
||||
"\n",
|
||||
"new_text = (\n",
|
||||
" \"The PRA published a consultation paper requiring UK banks to \"\n",
|
||||
" \"implement DORA-equivalent resilience testing by Q3 2025, \"\n",
|
||||
" \"with Barclays and HSBC named as systemic institutions.\"\n",
|
||||
")\n",
|
||||
"entities_json = toolkit.extract_entities(new_text)\n",
|
||||
"entities_result = json.loads(entities_json)\n",
|
||||
"print(f\"Found {entities_result['count']} entities:\")\n",
|
||||
"for e in entities_result['entities']:\n",
|
||||
" print(f\" {e['name']:30s} type={e['type']:15s} conf={e['confidence']:.2f}\")"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": null,
|
||||
"id": "demo-extract-relations",
|
||||
"metadata": {},
|
||||
"outputs": [],
|
||||
"source": [
|
||||
"# TOOL: extract_relations\n",
|
||||
"print(\"=\" * 55)\n",
|
||||
"print(\"TOOL: extract_relations\")\n",
|
||||
"print(\"=\" * 55)\n",
|
||||
"\n",
|
||||
"relations_json = toolkit.extract_relations(new_text)\n",
|
||||
"relations_result = json.loads(relations_json)\n",
|
||||
"print(f\"Found {relations_result['count']} relations:\")\n",
|
||||
"for r in relations_result['relations']:\n",
|
||||
" print(f\" {r['source']:20s} --[{r['relation']}]--> {r['target']:20s}\")"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": null,
|
||||
"id": "demo-add-graph",
|
||||
"metadata": {},
|
||||
"outputs": [],
|
||||
"source": [
|
||||
"# TOOL: add_to_graph\n",
|
||||
"print(\"=\" * 55)\n",
|
||||
"print(\"TOOL: add_to_graph\")\n",
|
||||
"print(\"=\" * 55)\n",
|
||||
"\n",
|
||||
"add_result = json.loads(toolkit.add_to_graph(\n",
|
||||
" entities=json.dumps([\n",
|
||||
" {\"name\": \"PRA\", \"type\": \"REGULATOR\"},\n",
|
||||
" {\"name\": \"Barclays\", \"type\": \"BANK\"},\n",
|
||||
" {\"name\": \"HSBC\", \"type\": \"BANK\"},\n",
|
||||
" ]),\n",
|
||||
" relations=json.dumps([\n",
|
||||
" {\"source\": \"PRA\", \"relation\": \"SUPERVISES\", \"target\": \"Barclays\"},\n",
|
||||
" {\"source\": \"PRA\", \"relation\": \"SUPERVISES\", \"target\": \"HSBC\"},\n",
|
||||
" {\"source\": \"Barclays\", \"relation\": \"SUBJECT_TO\", \"target\": \"DORA\"},\n",
|
||||
" {\"source\": \"HSBC\", \"relation\": \"SUBJECT_TO\", \"target\": \"DORA\"},\n",
|
||||
" ]),\n",
|
||||
"))\n",
|
||||
"print(f\"Added: {add_result['nodes_added']} nodes, {add_result['edges_added']} edges\")"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": null,
|
||||
"id": "demo-query-graph",
|
||||
"metadata": {},
|
||||
"outputs": [],
|
||||
"source": [
|
||||
"# TOOL: query_graph\n",
|
||||
"print(\"=\" * 55)\n",
|
||||
"print(\"TOOL: query_graph\")\n",
|
||||
"print(\"=\" * 55)\n",
|
||||
"\n",
|
||||
"query_result = json.loads(toolkit.query_graph(\"PRA\"))\n",
|
||||
"print(f\"Keyword query 'PRA' → {query_result['count']} node(s):\")\n",
|
||||
"for node in query_result['results']:\n",
|
||||
" print(f\" label={node.get('label')} type={node.get('type')}\")"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": null,
|
||||
"id": "demo-find-related",
|
||||
"metadata": {},
|
||||
"outputs": [],
|
||||
"source": [
|
||||
"# TOOL: find_related\n",
|
||||
"print(\"=\" * 55)\n",
|
||||
"print(\"TOOL: find_related\")\n",
|
||||
"print(\"=\" * 55)\n",
|
||||
"\n",
|
||||
"related_result = json.loads(toolkit.find_related(\"Barclays\", hops=2))\n",
|
||||
"print(f\"Related to 'Barclays' (2 hops): {related_result['count']} entity/entities\")\n",
|
||||
"for name in related_result['related']:\n",
|
||||
" print(f\" → {name}\")"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": null,
|
||||
"id": "demo-infer",
|
||||
"metadata": {},
|
||||
"outputs": [],
|
||||
"source": [
|
||||
"# TOOL: infer_facts — Semantica's Reasoner derives new facts from graph state\n",
|
||||
"print(\"=\" * 55)\n",
|
||||
"print(\"TOOL: infer_facts\")\n",
|
||||
"print(\"=\" * 55)\n",
|
||||
"\n",
|
||||
"# Rules: regulatory compliance inference\n",
|
||||
"inference_rules = json.dumps([\n",
|
||||
" \"IF BANK(?x) THEN FinancialEntity(?x)\",\n",
|
||||
" \"IF REGULATOR(?x) THEN SupervisoryAuthority(?x)\",\n",
|
||||
" \"IF FinancialEntity(?x) THEN ComplianceSubject(?x)\",\n",
|
||||
"])\n",
|
||||
"\n",
|
||||
"infer_result = json.loads(toolkit.infer_facts(rules=inference_rules))\n",
|
||||
"print(f\"Inferred {infer_result['count']} new fact(s):\")\n",
|
||||
"for fact in infer_result['inferred_facts'][:8]:\n",
|
||||
" print(f\" {fact}\")"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": null,
|
||||
"id": "demo-export",
|
||||
"metadata": {},
|
||||
"outputs": [],
|
||||
"source": [
|
||||
"# TOOL: export_subgraph — export knowledge for downstream systems\n",
|
||||
"print(\"=\" * 55)\n",
|
||||
"print(\"TOOL: export_subgraph (JSON-LD)\")\n",
|
||||
"print(\"=\" * 55)\n",
|
||||
"\n",
|
||||
"export_result = json.loads(toolkit.export_subgraph(entity=\"DORA\", format=\"json-ld\"))\n",
|
||||
"print(f\"Exported as format='{export_result['format']}'\")\n",
|
||||
"if 'data' in export_result:\n",
|
||||
" preview = str(export_result['data'])[:300]\n",
|
||||
" print(f\"Preview: {preview}...\")\n",
|
||||
"elif 'nodes' in export_result:\n",
|
||||
" print(f\"Graph nodes exported: {len(export_result['nodes'])}\")\n",
|
||||
" for node in export_result['nodes'][:5]:\n",
|
||||
" print(f\" {node}\")"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "markdown",
|
||||
"id": "agno-run-section",
|
||||
"metadata": {},
|
||||
"source": [
|
||||
"## 7. Run the Full Agno GraphRAG Agent (requires API key)"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": null,
|
||||
"id": "agno-agent",
|
||||
"metadata": {},
|
||||
"outputs": [],
|
||||
"source": [
|
||||
"if AGNO_AVAILABLE:\n",
|
||||
" from agno.agent import Agent\n",
|
||||
" from agno.models.openai import OpenAIChat\n",
|
||||
"\n",
|
||||
" compliance_agent = Agent(\n",
|
||||
" name=\"ComplianceAnalyst\",\n",
|
||||
" model=OpenAIChat(id=\"gpt-4o\"),\n",
|
||||
" knowledge=kg,\n",
|
||||
" search_knowledge=True,\n",
|
||||
" tools=[toolkit],\n",
|
||||
" show_tool_calls=True,\n",
|
||||
" description=(\n",
|
||||
" \"You are a regulatory compliance analyst. Use the knowledge graph \"\n",
|
||||
" \"to answer questions about Basel IV, DORA, and AML regulations. \"\n",
|
||||
" \"When answering, use find_related and query_graph to discover \"\n",
|
||||
" \"connections between regulators, rules, and institutions.\"\n",
|
||||
" ),\n",
|
||||
" )\n",
|
||||
"\n",
|
||||
" compliance_agent.print_response(\n",
|
||||
" \"Which supervisory authorities are responsible for overseeing DORA compliance \"\n",
|
||||
" \"for UK banks, and how does this relate to Basel IV capital requirements?\"\n",
|
||||
" )\n",
|
||||
"else:\n",
|
||||
" print(\"[Agno not installed — skipping live agent run]\")\n",
|
||||
" print()\n",
|
||||
" print(\"Expected reasoning flow:\")\n",
|
||||
" print(\" search_knowledge('DORA supervisory authorities UK banks')\")\n",
|
||||
" print(\" → retrieves DORA doc with graph expansion\")\n",
|
||||
" print(\" query_graph('PRA') → finds PRA node\")\n",
|
||||
" print(\" find_related('PRA', hops=2) → PRA → SUPERVISES → Barclays, HSBC\")\n",
|
||||
" print(\" find_related('Basel IV', hops=1) → capital ratio requirements\")\n",
|
||||
" print(\" Answer: PRA supervises UK banks under DORA; Basel IV CET1 requirement is 4.5%\")"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "markdown",
|
||||
"id": "semantica-analysis",
|
||||
"metadata": {},
|
||||
"source": [
|
||||
"## 8. Post-Session Graph Analysis with Semantica\n",
|
||||
"\n",
|
||||
"After the agent session, use Semantica's graph analytics directly to explore the accumulated knowledge."
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": null,
|
||||
"id": "graph-analytics",
|
||||
"metadata": {},
|
||||
"outputs": [],
|
||||
"source": [
|
||||
"# Use Semantica's GraphAnalyzer directly on the same ContextGraph\n",
|
||||
"from semantica.kg import GraphAnalyzer, CentralityCalculator, PathFinder\n",
|
||||
"\n",
|
||||
"try:\n",
|
||||
" analyzer = GraphAnalyzer()\n",
|
||||
" analysis = analyzer.analyze_graph(context_graph)\n",
|
||||
" print(\"Graph analysis (Semantica native):\")\n",
|
||||
" if isinstance(analysis, dict):\n",
|
||||
" for k, v in list(analysis.items())[:8]:\n",
|
||||
" print(f\" {k}: {v}\")\n",
|
||||
" else:\n",
|
||||
" print(f\" {analysis}\")\n",
|
||||
"except Exception as e:\n",
|
||||
" print(f\"GraphAnalyzer: {e}\")"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": null,
|
||||
"id": "centrality",
|
||||
"metadata": {},
|
||||
"outputs": [],
|
||||
"source": [
|
||||
"# Centrality — which entities are most connected / influential?\n",
|
||||
"try:\n",
|
||||
" centrality = CentralityCalculator()\n",
|
||||
" scores = centrality.calculate_degree_centrality(context_graph)\n",
|
||||
" print(\"Degree centrality (most connected entities):\")\n",
|
||||
" if isinstance(scores, dict):\n",
|
||||
" top = sorted(scores.items(), key=lambda x: x[1], reverse=True)[:5]\n",
|
||||
" for entity, score in top:\n",
|
||||
" print(f\" {entity:30s} {score:.4f}\")\n",
|
||||
" else:\n",
|
||||
" print(f\" {scores}\")\n",
|
||||
"except Exception as e:\n",
|
||||
" print(f\"CentralityCalculator: {e}\")"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "markdown",
|
||||
"id": "summary-section",
|
||||
"metadata": {},
|
||||
"source": [
|
||||
"## Summary\n",
|
||||
"\n",
|
||||
"| Component | Role | Library |\n",
|
||||
"|---|---|---|\n",
|
||||
"| `NERExtractor` | Extract regulatory entities from text | Semantica |\n",
|
||||
"| `RelationExtractor` | Extract typed edges between entities | Semantica |\n",
|
||||
"| `GraphBuilder` | Build `ContextGraph` from extractions | Semantica |\n",
|
||||
"| `Reasoner` | Infer new facts from graph state | Semantica |\n",
|
||||
"| `AgnoKnowledgeGraph` | GraphRAG `AgentKnowledge` interface | Agno integration |\n",
|
||||
"| `AgnoKGToolkit` | 7 live graph tools for the Agno LLM | Agno integration |\n",
|
||||
"| `GraphAnalyzer` / `CentralityCalculator` | Post-session analytics | Semantica |\n",
|
||||
"\n",
|
||||
"The Agno integration wraps Semantica components — the full Semantica API is available for pre/post-processing and analytics independently of the agent."
|
||||
]
|
||||
}
|
||||
],
|
||||
"metadata": {
|
||||
"kernelspec": {
|
||||
"display_name": "Python 3",
|
||||
"language": "python",
|
||||
"name": "python3"
|
||||
},
|
||||
"language_info": {
|
||||
"name": "python",
|
||||
"version": "3.11.0"
|
||||
}
|
||||
},
|
||||
"nbformat": 4,
|
||||
"nbformat_minor": 5
|
||||
}
|
||||
@@ -0,0 +1,676 @@
|
||||
{
|
||||
"cells": [
|
||||
{
|
||||
"cell_type": "markdown",
|
||||
"id": "title",
|
||||
"metadata": {},
|
||||
"source": [
|
||||
"# Agno × Semantica: Multi-Agent Shared Context\n",
|
||||
"\n",
|
||||
"This notebook shows how an Agno **Team** of specialist agents can share a single `ContextGraph` so they:\n",
|
||||
"\n",
|
||||
"- Never make contradictory decisions\n",
|
||||
"- Reuse each other's extracted knowledge without coupling implementations\n",
|
||||
"- Maintain a full causal audit trail across all agents\n",
|
||||
"\n",
|
||||
"**Scenario:** A product strategy team with three specialist agents:\n",
|
||||
"\n",
|
||||
"| Agent | Role | Tools |\n",
|
||||
"|---|---|---|\n",
|
||||
"| `Researcher` | Extracts competitive intelligence from text | `AgnoKGToolkit` |\n",
|
||||
"| `Analyst` | Evaluates opportunities and records decisions | `AgnoDecisionKit` |\n",
|
||||
"| `Strategist` | Synthesises both into a recommendation | both |\n",
|
||||
"\n",
|
||||
"---\n",
|
||||
"\n",
|
||||
"## Architecture\n",
|
||||
"\n",
|
||||
"```\n",
|
||||
"AgnoSharedContext (single ContextGraph + VectorStore)\n",
|
||||
" │\n",
|
||||
" ├── bind_agent(\"researcher\") → AgnoContextStore (role-scoped)\n",
|
||||
" ├── bind_agent(\"analyst\") → AgnoContextStore (role-scoped)\n",
|
||||
" └── bind_agent(\"strategist\") → AgnoContextStore (role-scoped)\n",
|
||||
"\n",
|
||||
"Agno Team\n",
|
||||
" ├── Researcher memory=researcher_store tools=[AgnoKGToolkit(context=shared)]\n",
|
||||
" ├── Analyst memory=analyst_store tools=[AgnoDecisionKit(context=shared)]\n",
|
||||
" └── Strategist memory=strategist_store tools=[AgnoKGToolkit, AgnoDecisionKit]\n",
|
||||
"```\n",
|
||||
"\n",
|
||||
"## Install\n",
|
||||
"\n",
|
||||
"```bash\n",
|
||||
"pip install semantica[agno]\n",
|
||||
"```"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "markdown",
|
||||
"id": "imports-section",
|
||||
"metadata": {},
|
||||
"source": [
|
||||
"## 1. Imports"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": null,
|
||||
"id": "imports",
|
||||
"metadata": {},
|
||||
"outputs": [],
|
||||
"source": [
|
||||
"import sys, os, json\n",
|
||||
"sys.path.insert(0, os.path.abspath(\"../../\"))\n",
|
||||
"\n",
|
||||
"# ── Semantica core ───────────────────────────────────────────────────────────\n",
|
||||
"from semantica.context import ContextGraph, AgentContext, CausalChainAnalyzer\n",
|
||||
"from semantica.vector_store import VectorStore\n",
|
||||
"from semantica.semantic_extract import NERExtractor, RelationExtractor\n",
|
||||
"from semantica.reasoning import Reasoner\n",
|
||||
"from semantica.kg import GraphBuilder, GraphAnalyzer, CentralityCalculator\n",
|
||||
"\n",
|
||||
"# ── Agno integration ─────────────────────────────────────────────────────────\n",
|
||||
"from integrations.agno import (\n",
|
||||
" AgnoSharedContext,\n",
|
||||
" AgnoDecisionKit,\n",
|
||||
" AgnoKGToolkit,\n",
|
||||
" AGNO_AVAILABLE,\n",
|
||||
")\n",
|
||||
"\n",
|
||||
"print(\"Semantica imports OK\")\n",
|
||||
"print(f\"Agno installed: {AGNO_AVAILABLE}\")"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "markdown",
|
||||
"id": "shared-context-section",
|
||||
"metadata": {},
|
||||
"source": [
|
||||
"## 2. Build the Shared Semantica Backend\n",
|
||||
"\n",
|
||||
"A single `VectorStore` and `ContextGraph` underpin the entire team. All agents read and write to the same store — role scoping is applied automatically by `AgnoSharedContext`."
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": null,
|
||||
"id": "build-shared",
|
||||
"metadata": {},
|
||||
"outputs": [],
|
||||
"source": [
|
||||
"# ── Single shared backends ───────────────────────────────────────────────────\n",
|
||||
"shared_vector_store = VectorStore(backend=\"faiss\", dimension=768)\n",
|
||||
"shared_graph = ContextGraph(advanced_analytics=True)\n",
|
||||
"\n",
|
||||
"print(\"Shared VectorStore (FAISS) ready\")\n",
|
||||
"print(\"Shared ContextGraph ready\")\n",
|
||||
"\n",
|
||||
"# ── AgnoSharedContext: the team coordinator ───────────────────────────────────\n",
|
||||
"shared = AgnoSharedContext(\n",
|
||||
" vector_store=shared_vector_store,\n",
|
||||
" knowledge_graph=shared_graph,\n",
|
||||
" decision_tracking=True,\n",
|
||||
" session_id=\"product_strategy_team_q1_2026\",\n",
|
||||
")\n",
|
||||
"print(f\"\\nAgnoSharedContext ready — session: {shared.session_id}\")"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "markdown",
|
||||
"id": "bind-section",
|
||||
"metadata": {},
|
||||
"source": [
|
||||
"## 3. Bind Agent Roles\n",
|
||||
"\n",
|
||||
"Each agent gets a **role-scoped** `AgnoContextStore` via `bind_agent()`. All agents share the same underlying graph, but their writes are tagged with their role for filtering."
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": null,
|
||||
"id": "bind-agents",
|
||||
"metadata": {},
|
||||
"outputs": [],
|
||||
"source": [
|
||||
"# Bind each agent role — idempotent, can be called multiple times safely\n",
|
||||
"researcher_store = shared.bind_agent(\"researcher\")\n",
|
||||
"analyst_store = shared.bind_agent(\"analyst\")\n",
|
||||
"strategist_store = shared.bind_agent(\"strategist\")\n",
|
||||
"\n",
|
||||
"print(\"Agent roles bound:\")\n",
|
||||
"for role in shared.bound_roles:\n",
|
||||
" store = shared.bind_agent(role)\n",
|
||||
" print(f\" {role:15s} → session={store.session_id}\")\n",
|
||||
"\n",
|
||||
"# Verify all roles see the same underlying knowledge_graph\n",
|
||||
"assert researcher_store._ctx is analyst_store._ctx\n",
|
||||
"print(\"\\nAll agents share the same AgentContext ✓\")"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "markdown",
|
||||
"id": "seed-section",
|
||||
"metadata": {},
|
||||
"source": [
|
||||
"## 4. Pre-Load Competitive Intelligence\n",
|
||||
"\n",
|
||||
"Using **native Semantica APIs**, we load a competitive landscape into the shared graph. This represents knowledge the team has accumulated from prior research sessions."
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": null,
|
||||
"id": "seed-intel",
|
||||
"metadata": {},
|
||||
"outputs": [],
|
||||
"source": [
|
||||
"# Competitive intelligence documents\n",
|
||||
"COMPETITIVE_INTEL = [\n",
|
||||
" {\n",
|
||||
" \"source\": \"market_research_q4_2025\",\n",
|
||||
" \"text\": (\n",
|
||||
" \"Competitor Alpha launched a new SaaS analytics platform in Q4 2025. \"\n",
|
||||
" \"The product targets mid-market enterprises with annual revenue between \"\n",
|
||||
" \"$50M–$500M and has attracted 200 paying customers within 3 months. \"\n",
|
||||
" \"Pricing is $2,000/seat/year with volume discounts at 50+ seats. \"\n",
|
||||
" \"Alpha raised a $80M Series C led by Sequoia Capital in November 2025.\"\n",
|
||||
" ),\n",
|
||||
" },\n",
|
||||
" {\n",
|
||||
" \"source\": \"customer_interviews_q4_2025\",\n",
|
||||
" \"text\": (\n",
|
||||
" \"Customer interviews reveal strong demand for AI-powered anomaly detection \"\n",
|
||||
" \"in financial reporting workflows. 78% of CFOs surveyed cite 'time to insight' \"\n",
|
||||
" \"as the top pain point — currently averaging 14 days per reporting cycle. \"\n",
|
||||
" \"Competitor Alpha scores poorly on integration depth (NPS: 24) while \"\n",
|
||||
" \"our legacy product scores 41. Customers value our data governance features \"\n",
|
||||
" \"but want a modern UI and sub-second query times.\"\n",
|
||||
" ),\n",
|
||||
" },\n",
|
||||
" {\n",
|
||||
" \"source\": \"technology_scan_q4_2025\",\n",
|
||||
" \"text\": (\n",
|
||||
" \"Emerging technologies for consideration: LLM-native analytics interfaces \"\n",
|
||||
" \"reduce time-to-insight by 60% in pilot studies (Stanford HAI, 2025). \"\n",
|
||||
" \"Graph-based anomaly detection outperforms time-series approaches for \"\n",
|
||||
" \"multi-entity financial fraud by 34% (ACM SIGMOD 2025). \"\n",
|
||||
" \"Vector database adoption in enterprise analytics grew 120% YoY. \"\n",
|
||||
" \"Apache Arrow and DuckDB emerging as standards for in-process OLAP.\"\n",
|
||||
" ),\n",
|
||||
" },\n",
|
||||
"]\n",
|
||||
"\n",
|
||||
"# Use Semantica NER + RelationExtractor directly for rich extraction\n",
|
||||
"ner = NERExtractor()\n",
|
||||
"rel_extractor = RelationExtractor(confidence_threshold=0.55)\n",
|
||||
"graph_builder = GraphBuilder(merge_entities=True)\n",
|
||||
"\n",
|
||||
"for doc in COMPETITIVE_INTEL:\n",
|
||||
" text = doc['text']\n",
|
||||
" entities = ner.extract_entities(text) or []\n",
|
||||
" relations = rel_extractor.extract_relations(text) or []\n",
|
||||
" print(f\"[{doc['source']}]\")\n",
|
||||
" print(f\" Entities: {len(entities)}, Relations: {len(relations)}\")\n",
|
||||
" # Store into shared context for all agents to access\n",
|
||||
" shared._context.store(text, conversation_id=doc['source'])\n",
|
||||
"\n",
|
||||
"print(\"\\nCompetitive intelligence loaded into shared context\")"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "markdown",
|
||||
"id": "tools-section",
|
||||
"metadata": {},
|
||||
"source": [
|
||||
"## 5. Build Agent-Specific Tools\n",
|
||||
"\n",
|
||||
"Each toolkit is pointed at the **shared context** so tool calls across agents modify and read the same graph."
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": null,
|
||||
"id": "build-tools",
|
||||
"metadata": {},
|
||||
"outputs": [],
|
||||
"source": [
|
||||
"# Researcher's KG toolkit — builds knowledge from raw text\n",
|
||||
"researcher_kg_kit = AgnoKGToolkit(\n",
|
||||
" ner_extractor=ner,\n",
|
||||
" relation_extractor=rel_extractor,\n",
|
||||
" reasoner=Reasoner(),\n",
|
||||
" context=shared.knowledge_graph, # shared graph\n",
|
||||
")\n",
|
||||
"\n",
|
||||
"# Analyst's decision kit — records evaluations and finds precedents\n",
|
||||
"analyst_decision_kit = AgnoDecisionKit(\n",
|
||||
" context=shared._context, # shared AgentContext\n",
|
||||
" max_precedents=5,\n",
|
||||
" causal_depth=3,\n",
|
||||
" enable_policy_check=True,\n",
|
||||
")\n",
|
||||
"\n",
|
||||
"# Strategist gets both\n",
|
||||
"strategist_kg_kit = AgnoKGToolkit(\n",
|
||||
" ner_extractor=ner,\n",
|
||||
" relation_extractor=rel_extractor,\n",
|
||||
" reasoner=Reasoner(),\n",
|
||||
" context=shared.knowledge_graph,\n",
|
||||
")\n",
|
||||
"strategist_decision_kit = AgnoDecisionKit(\n",
|
||||
" context=shared._context,\n",
|
||||
" max_precedents=5,\n",
|
||||
")\n",
|
||||
"\n",
|
||||
"print(f\"Researcher toolkit: {len(researcher_kg_kit._tools)} tools\")\n",
|
||||
"print(f\"Analyst toolkit: {len(analyst_decision_kit._tools)} tools\")\n",
|
||||
"print(f\"Strategist toolkits: {len(strategist_kg_kit._tools)} + {len(strategist_decision_kit._tools)} tools\")"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "markdown",
|
||||
"id": "simulate-section",
|
||||
"metadata": {},
|
||||
"source": [
|
||||
"## 6. Simulate Agent Collaboration\n",
|
||||
"\n",
|
||||
"We simulate the agents' reasoning steps directly, showing how shared context propagates knowledge between roles."
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": null,
|
||||
"id": "researcher-turn",
|
||||
"metadata": {},
|
||||
"outputs": [],
|
||||
"source": [
|
||||
"print(\"=\" * 65)\n",
|
||||
"print(\"RESEARCHER AGENT TURN\")\n",
|
||||
"print(\"=\" * 65)\n",
|
||||
"\n",
|
||||
"# Researcher extracts entities from new competitive intel\n",
|
||||
"new_intel = (\n",
|
||||
" \"Competitor Beta just closed a strategic partnership with Microsoft Azure, \"\n",
|
||||
" \"integrating their anomaly detection engine natively into Azure Synapse Analytics. \"\n",
|
||||
" \"This gives Beta access to Microsoft's 300,000+ enterprise customer base. \"\n",
|
||||
" \"Beta's CEO Sarah Chen announced the deal at Gartner Data & Analytics Summit.\"\n",
|
||||
")\n",
|
||||
"\n",
|
||||
"# Step 1: Extract entities\n",
|
||||
"entities_result = json.loads(researcher_kg_kit.extract_entities(new_intel))\n",
|
||||
"print(f\"\\n[researcher] extracted {entities_result['count']} entities:\")\n",
|
||||
"for e in entities_result['entities']:\n",
|
||||
" print(f\" {e['name']:30s} type={e['type']}\")\n",
|
||||
"\n",
|
||||
"# Step 2: Extract relations\n",
|
||||
"relations_result = json.loads(researcher_kg_kit.extract_relations(new_intel))\n",
|
||||
"print(f\"\\n[researcher] extracted {relations_result['count']} relations\")\n",
|
||||
"\n",
|
||||
"# Step 3: Add to shared graph — now visible to ALL agents\n",
|
||||
"add_result = json.loads(researcher_kg_kit.add_to_graph(\n",
|
||||
" entities=json.dumps([\n",
|
||||
" {\"name\": \"Competitor Beta\", \"type\": \"COMPANY\"},\n",
|
||||
" {\"name\": \"Microsoft Azure\", \"type\": \"COMPANY\"},\n",
|
||||
" {\"name\": \"Azure Synapse Analytics\", \"type\": \"PRODUCT\"},\n",
|
||||
" {\"name\": \"Sarah Chen\", \"type\": \"PERSON\"},\n",
|
||||
" {\"name\": \"Gartner Data & Analytics Summit\", \"type\": \"EVENT\"},\n",
|
||||
" ]),\n",
|
||||
" relations=json.dumps([\n",
|
||||
" {\"source\": \"Competitor Beta\", \"relation\": \"PARTNERSHIP_WITH\", \"target\": \"Microsoft Azure\"},\n",
|
||||
" {\"source\": \"Competitor Beta\", \"relation\": \"INTEGRATES_WITH\", \"target\": \"Azure Synapse Analytics\"},\n",
|
||||
" {\"source\": \"Sarah Chen\", \"relation\": \"CEO_OF\", \"target\": \"Competitor Beta\"},\n",
|
||||
" ]),\n",
|
||||
"))\n",
|
||||
"print(f\"\\n[researcher] added {add_result['nodes_added']} nodes, {add_result['edges_added']} edges to SHARED graph\")"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": null,
|
||||
"id": "analyst-turn",
|
||||
"metadata": {},
|
||||
"outputs": [],
|
||||
"source": [
|
||||
"print(\"=\" * 65)\n",
|
||||
"print(\"ANALYST AGENT TURN (sees researcher's graph additions)\")\n",
|
||||
"print(\"=\" * 65)\n",
|
||||
"\n",
|
||||
"# Analyst queries the graph the researcher just populated\n",
|
||||
"competitor_query = json.loads(analyst_decision_kit.find_precedents(\n",
|
||||
" scenario=\"competitor partnership with cloud hyperscaler threatens market position\",\n",
|
||||
" limit=3,\n",
|
||||
"))\n",
|
||||
"print(f\"\\n[analyst] find_precedents → {competitor_query['count']} similar past strategic responses found\")\n",
|
||||
"\n",
|
||||
"# Analyst records a strategic evaluation decision\n",
|
||||
"eval_json = analyst_decision_kit.record_decision(\n",
|
||||
" category=\"strategic_response\",\n",
|
||||
" scenario=(\n",
|
||||
" \"Competitor Beta + Microsoft Azure partnership gives Beta access to \"\n",
|
||||
" \"300k enterprise customers via Azure Synapse native integration\"\n",
|
||||
" ),\n",
|
||||
" reasoning=(\n",
|
||||
" \"Threat level: HIGH. Beta's Azure native integration removes our \"\n",
|
||||
" \"integration advantage. Existing NPS lead (41 vs 24) remains but \"\n",
|
||||
" \"distribution disadvantage is critical. Recommend accelerated cloud-native \"\n",
|
||||
" \"partnership evaluation, specifically AWS Marketplace + Snowflake Native App.\"\n",
|
||||
" ),\n",
|
||||
" outcome=\"escalate_to_strategy\",\n",
|
||||
" confidence=0.85,\n",
|
||||
" entities=\"Competitor Beta, Microsoft Azure, AWS Marketplace, Snowflake\",\n",
|
||||
")\n",
|
||||
"eval_result = json.loads(eval_json)\n",
|
||||
"analyst_decision_id = eval_result['decision_id']\n",
|
||||
"print(f\"\\n[analyst] recorded evaluation → decision_id: {analyst_decision_id}\")"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": null,
|
||||
"id": "strategist-turn",
|
||||
"metadata": {},
|
||||
"outputs": [],
|
||||
"source": [
|
||||
"print(\"=\" * 65)\n",
|
||||
"print(\"STRATEGIST AGENT TURN (sees both researcher + analyst work)\")\n",
|
||||
"print(\"=\" * 65)\n",
|
||||
"\n",
|
||||
"# Strategist queries the graph for the full competitive picture\n",
|
||||
"related = json.loads(strategist_kg_kit.find_related(\"Competitor Beta\", hops=2))\n",
|
||||
"print(f\"\\n[strategist] 'Competitor Beta' 2-hop neighbourhood: {related['count']} entity/entities\")\n",
|
||||
"for entity in related['related']:\n",
|
||||
" print(f\" → {entity}\")\n",
|
||||
"\n",
|
||||
"# Strategist traces what the analyst decided\n",
|
||||
"causal = json.loads(strategist_decision_kit.trace_causal_chain(analyst_decision_id, depth=3))\n",
|
||||
"print(f\"\\n[strategist] causal chain for analyst decision: {causal}\")\n",
|
||||
"\n",
|
||||
"# Strategist records the final strategic recommendation\n",
|
||||
"strategy_json = strategist_decision_kit.record_decision(\n",
|
||||
" category=\"product_strategy\",\n",
|
||||
" scenario=\"Q1 2026 product strategy: respond to Beta+Azure threat\",\n",
|
||||
" reasoning=(\n",
|
||||
" \"Based on researcher's KG (Beta+Azure integration, 300k customer reach) \"\n",
|
||||
" \"and analyst's evaluation (threat level HIGH, escalated decision). \"\n",
|
||||
" \"Strategy: (1) Accelerate AWS Marketplace listing by Q2 2026. \"\n",
|
||||
" \"(2) Launch Snowflake Native App by Q3 2026. \"\n",
|
||||
" \"(3) Invest $2M in UI modernisation to widen NPS lead. \"\n",
|
||||
" \"(4) Fast-track LLM-native analytics interface (60% time-to-insight improvement per HAI study). \"\n",
|
||||
" \"Existing NPS advantage (41 vs 24) provides 18-month window before Beta catches up.\"\n",
|
||||
" ),\n",
|
||||
" outcome=\"approved\",\n",
|
||||
" confidence=0.88,\n",
|
||||
" entities=\"AWS Marketplace, Snowflake, LLM Analytics, Q2 2026, Q3 2026\",\n",
|
||||
")\n",
|
||||
"strategy_result = json.loads(strategy_json)\n",
|
||||
"print(f\"\\n[strategist] final recommendation recorded → {strategy_result['decision_id']}\")"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "markdown",
|
||||
"id": "shared-pool-section",
|
||||
"metadata": {},
|
||||
"source": [
|
||||
"## 7. Verify Shared Memory Pool\n",
|
||||
"\n",
|
||||
"Memories written by one agent are readable by all others."
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": null,
|
||||
"id": "verify-shared",
|
||||
"metadata": {},
|
||||
"outputs": [],
|
||||
"source": [
|
||||
"from integrations.agno.context_store import _MemoryRow as MemoryRow\n",
|
||||
"\n",
|
||||
"# Researcher writes a memory\n",
|
||||
"researcher_row = MemoryRow(\n",
|
||||
" memory=\"Beta + Azure partnership announced at Gartner Summit — threat level HIGH\",\n",
|
||||
" user_id=\"researcher\",\n",
|
||||
")\n",
|
||||
"researcher_store.upsert_memory(researcher_row)\n",
|
||||
"\n",
|
||||
"# Analyst writes a memory\n",
|
||||
"analyst_row = MemoryRow(\n",
|
||||
" memory=\"NPS advantage (41 vs 24) gives 18-month window — accelerate cloud partnerships\",\n",
|
||||
" user_id=\"analyst\",\n",
|
||||
")\n",
|
||||
"analyst_store.upsert_memory(analyst_row)\n",
|
||||
"\n",
|
||||
"# Strategist reads ALL memories from both agents\n",
|
||||
"strategist_memories = strategist_store.read_memories()\n",
|
||||
"\n",
|
||||
"print(f\"Strategist sees {len(strategist_memories)} shared memory item(s):\")\n",
|
||||
"for m in strategist_memories:\n",
|
||||
" uid = getattr(m, 'user_id', '?')\n",
|
||||
" text = getattr(m, 'memory', str(m))\n",
|
||||
" print(f\" [{uid:12s}] {text[:80]}\")"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "markdown",
|
||||
"id": "agno-team-section",
|
||||
"metadata": {},
|
||||
"source": [
|
||||
"## 8. Wire into Agno Team (requires API key)"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": null,
|
||||
"id": "agno-team",
|
||||
"metadata": {},
|
||||
"outputs": [],
|
||||
"source": [
|
||||
"if AGNO_AVAILABLE:\n",
|
||||
" from agno.agent import Agent\n",
|
||||
" from agno.team import Team\n",
|
||||
" from agno.memory import AgentMemory\n",
|
||||
" from agno.models.openai import OpenAIChat\n",
|
||||
"\n",
|
||||
" researcher_agent = Agent(\n",
|
||||
" name=\"Researcher\",\n",
|
||||
" model=OpenAIChat(id=\"gpt-4o\"),\n",
|
||||
" memory=AgentMemory(db=researcher_store),\n",
|
||||
" tools=[researcher_kg_kit],\n",
|
||||
" show_tool_calls=True,\n",
|
||||
" description=(\n",
|
||||
" \"You are a competitive intelligence researcher. \"\n",
|
||||
" \"Use extract_entities, extract_relations, and add_to_graph \"\n",
|
||||
" \"to build a structured knowledge graph from market intelligence. \"\n",
|
||||
" \"Always add discoveries to the shared graph.\"\n",
|
||||
" ),\n",
|
||||
" )\n",
|
||||
"\n",
|
||||
" analyst_agent = Agent(\n",
|
||||
" name=\"Analyst\",\n",
|
||||
" model=OpenAIChat(id=\"gpt-4o\"),\n",
|
||||
" memory=AgentMemory(db=analyst_store),\n",
|
||||
" tools=[analyst_decision_kit],\n",
|
||||
" show_tool_calls=True,\n",
|
||||
" description=(\n",
|
||||
" \"You are a strategic analyst. Use find_precedents to check historical \"\n",
|
||||
" \"responses to similar threats, then record_decision with your evaluation. \"\n",
|
||||
" \"Always check if a similar situation was handled before acting.\"\n",
|
||||
" ),\n",
|
||||
" )\n",
|
||||
"\n",
|
||||
" strategist_agent = Agent(\n",
|
||||
" name=\"Strategist\",\n",
|
||||
" model=OpenAIChat(id=\"gpt-4o\"),\n",
|
||||
" memory=AgentMemory(db=strategist_store),\n",
|
||||
" tools=[strategist_kg_kit, strategist_decision_kit],\n",
|
||||
" show_tool_calls=True,\n",
|
||||
" description=(\n",
|
||||
" \"You are the Chief Strategy Officer. Synthesise the researcher's knowledge \"\n",
|
||||
" \"graph and the analyst's decision record into a concrete product strategy. \"\n",
|
||||
" \"Use find_related to explore the competitive graph, then record_decision \"\n",
|
||||
" \"with the final approved strategy.\"\n",
|
||||
" ),\n",
|
||||
" )\n",
|
||||
"\n",
|
||||
" strategy_team = Team(\n",
|
||||
" name=\"Product Strategy Team\",\n",
|
||||
" agents=[researcher_agent, analyst_agent, strategist_agent],\n",
|
||||
" mode=\"coordinate\",\n",
|
||||
" )\n",
|
||||
"\n",
|
||||
" strategy_team.print_response(\n",
|
||||
" \"Competitor Beta just announced a native Azure integration. \"\n",
|
||||
" \"Analyse the competitive landscape and recommend our Q1 2026 product strategy.\"\n",
|
||||
" )\n",
|
||||
"else:\n",
|
||||
" print(\"[Agno not installed — skipping live team run]\")\n",
|
||||
" print()\n",
|
||||
" print(\"Expected team coordination flow:\")\n",
|
||||
" print(\" 1. Researcher: extract_entities + add_to_graph (Beta+Azure)\")\n",
|
||||
" print(\" 2. Analyst: find_precedents + record_decision (threat=HIGH, escalate)\")\n",
|
||||
" print(\" 3. Strategist: find_related + trace_causal_chain + record_decision (final strategy)\")"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "markdown",
|
||||
"id": "post-session-section",
|
||||
"metadata": {},
|
||||
"source": [
|
||||
"## 9. Post-Session Analysis with Semantica\n",
|
||||
"\n",
|
||||
"After the team session, use **native Semantica APIs** for cross-agent audit, analytics, and causal chain review."
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": null,
|
||||
"id": "cross-agent-insights",
|
||||
"metadata": {},
|
||||
"outputs": [],
|
||||
"source": [
|
||||
"# Team-level insights from AgnoSharedContext\n",
|
||||
"insights = shared.get_shared_insights()\n",
|
||||
"print(\"Team session insights:\")\n",
|
||||
"if isinstance(insights, dict):\n",
|
||||
" for k, v in insights.items():\n",
|
||||
" print(f\" {k}: {v}\")\n",
|
||||
"else:\n",
|
||||
" print(f\" {insights}\")\n",
|
||||
"\n",
|
||||
"print(f\"\\nBound agent roles: {shared.bound_roles}\")"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": null,
|
||||
"id": "precedent-search",
|
||||
"metadata": {},
|
||||
"outputs": [],
|
||||
"source": [
|
||||
"# Find all cross-agent strategic decisions\n",
|
||||
"all_strategic = shared.find_precedents(\n",
|
||||
" scenario=\"cloud partnership competitive response\",\n",
|
||||
" category=\"strategic_response\",\n",
|
||||
")\n",
|
||||
"print(f\"Cross-agent strategic precedents: {len(all_strategic or [])}\")"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": null,
|
||||
"id": "graph-analytics",
|
||||
"metadata": {},
|
||||
"outputs": [],
|
||||
"source": [
|
||||
"# Graph analytics on the shared knowledge graph (Semantica native)\n",
|
||||
"try:\n",
|
||||
" analyzer = GraphAnalyzer()\n",
|
||||
" analysis = analyzer.analyze_graph(shared.knowledge_graph)\n",
|
||||
" print(\"Shared knowledge graph analysis:\")\n",
|
||||
" if isinstance(analysis, dict):\n",
|
||||
" for k, v in list(analysis.items())[:6]:\n",
|
||||
" print(f\" {k}: {v}\")\n",
|
||||
" else:\n",
|
||||
" print(f\" {analysis}\")\n",
|
||||
"except Exception as e:\n",
|
||||
" print(f\"GraphAnalyzer: {e}\")"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": null,
|
||||
"id": "centrality-analysis",
|
||||
"metadata": {},
|
||||
"outputs": [],
|
||||
"source": [
|
||||
"# Which entities are most central in the competitive intelligence graph?\n",
|
||||
"try:\n",
|
||||
" centrality = CentralityCalculator()\n",
|
||||
" scores = centrality.calculate_degree_centrality(shared.knowledge_graph)\n",
|
||||
" print(\"Most central entities in shared graph:\")\n",
|
||||
" if isinstance(scores, dict):\n",
|
||||
" top = sorted(scores.items(), key=lambda x: x[1], reverse=True)[:5]\n",
|
||||
" for entity, score in top:\n",
|
||||
" print(f\" {entity:35s} centrality={score:.4f}\")\n",
|
||||
" else:\n",
|
||||
" print(f\" {scores}\")\n",
|
||||
"except Exception as e:\n",
|
||||
" print(f\"CentralityCalculator: {e}\")"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": null,
|
||||
"id": "causal-analysis",
|
||||
"metadata": {},
|
||||
"outputs": [],
|
||||
"source": [
|
||||
"# Direct Semantica causal chain analysis (no Agno needed)\n",
|
||||
"try:\n",
|
||||
" causal_analyzer = CausalChainAnalyzer(graph_store=shared.knowledge_graph)\n",
|
||||
" # Query all decisions made during this session\n",
|
||||
" decisions = shared.knowledge_graph.find_precedents(category=\"product_strategy\", limit=10)\n",
|
||||
" print(f\"Product strategy decisions in shared graph: {len(decisions or [])}\")\n",
|
||||
" for d in (decisions or [])[:3]:\n",
|
||||
" scenario = d.get('scenario', '') if isinstance(d, dict) else str(d)\n",
|
||||
" outcome = d.get('outcome', '') if isinstance(d, dict) else ''\n",
|
||||
" print(f\" [{outcome:20s}] {scenario[:70]}\")\n",
|
||||
"except Exception as e:\n",
|
||||
" print(f\"CausalChainAnalyzer: {e}\")"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "markdown",
|
||||
"id": "summary-section",
|
||||
"metadata": {},
|
||||
"source": [
|
||||
"## Summary\n",
|
||||
"\n",
|
||||
"| Pattern | Implementation |\n",
|
||||
"|---|---|\n",
|
||||
"| Single shared knowledge graph | `AgnoSharedContext(vector_store, knowledge_graph)` |\n",
|
||||
"| Role-scoped memory | `shared.bind_agent(\"researcher\")` → `_AgentScopedStore` |\n",
|
||||
"| Cross-agent memory visibility | All stores read from `shared._shared_memories` |\n",
|
||||
"| KG tool sharing | `AgnoKGToolkit(context=shared.knowledge_graph)` |\n",
|
||||
"| Decision tool sharing | `AgnoDecisionKit(context=shared._context)` |\n",
|
||||
"| Thread-safe binding | `AgnoSharedContext._lock` (RLock) |\n",
|
||||
"| Post-session analytics | `GraphAnalyzer`, `CentralityCalculator`, `CausalChainAnalyzer` — all Semantica native |\n",
|
||||
"\n",
|
||||
"**Key design rule:** Every agent writes to the **same underlying graph** via different role-scoped stores. The Agno integration is a thin routing layer — Semantica's full power is available at any point directly."
|
||||
]
|
||||
}
|
||||
],
|
||||
"metadata": {
|
||||
"kernelspec": {
|
||||
"display_name": "Python 3",
|
||||
"language": "python",
|
||||
"name": "python3"
|
||||
},
|
||||
"language_info": {
|
||||
"name": "python",
|
||||
"version": "3.11.0"
|
||||
}
|
||||
},
|
||||
"nbformat": 4,
|
||||
"nbformat_minor": 5
|
||||
}
|
||||
@@ -0,0 +1,334 @@
|
||||
# Agno Integration
|
||||
|
||||
Semantica's Agno integration (`semantica[agno]`) wires the full Semantica
|
||||
semantic intelligence stack into the [Agno](https://github.com/agno-agi/agno)
|
||||
agentic framework via five focused components.
|
||||
|
||||
## Installation
|
||||
|
||||
```bash
|
||||
# Core integration
|
||||
pip install semantica[agno]
|
||||
|
||||
# With a graph store backend
|
||||
pip install semantica[agno,graph-neo4j]
|
||||
pip install semantica[agno,graph-falkordb]
|
||||
|
||||
# Full stack
|
||||
pip install semantica[agno,graph-neo4j,vectorstore-pgvector]
|
||||
```
|
||||
|
||||
## Components at a Glance
|
||||
|
||||
| Class | Agno Primitive | Semantica Backing |
|
||||
|---|---|---|
|
||||
| `AgnoContextStore` | `AgentMemory(db=…)` | `AgentContext` + `VectorStore` |
|
||||
| `AgnoKnowledgeGraph` | `Agent(knowledge=…)` | `ContextGraph` + KG pipeline |
|
||||
| `AgnoDecisionKit` | `Agent(tools=[…])` | `DecisionQuery`, `CausalChainAnalyzer`, `PolicyEngine` |
|
||||
| `AgnoKGToolkit` | `Agent(tools=[…])` | `NERExtractor`, `RelationExtractor`, `Reasoner` |
|
||||
| `AgnoSharedContext` | Team-level | Shared `ContextGraph` across agents |
|
||||
|
||||
---
|
||||
|
||||
## 1. AgnoContextStore
|
||||
|
||||
Replaces Agno's flat conversation storage with a hybrid **vector + context
|
||||
graph** memory store. Implements `agno.memory.db.base.MemoryDb`.
|
||||
|
||||
```python
|
||||
from agno.agent import Agent
|
||||
from agno.memory import AgentMemory
|
||||
from agno.models.openai import OpenAIChat
|
||||
|
||||
from semantica.context import ContextGraph
|
||||
from semantica.vector_store import VectorStore
|
||||
from integrations.agno import AgnoContextStore
|
||||
|
||||
store = AgnoContextStore(
|
||||
vector_store=VectorStore(backend="faiss"),
|
||||
knowledge_graph=ContextGraph(advanced_analytics=True),
|
||||
decision_tracking=True,
|
||||
graph_expansion=True,
|
||||
session_id="user_session_42",
|
||||
)
|
||||
|
||||
agent = Agent(
|
||||
model=OpenAIChat(id="gpt-4o"),
|
||||
memory=AgentMemory(db=store),
|
||||
description="A financially aware assistant with persistent decision intelligence.",
|
||||
)
|
||||
|
||||
agent.print_response("Recommend a portfolio allocation for a risk-averse investor.")
|
||||
```
|
||||
|
||||
### Key behaviours
|
||||
|
||||
- `upsert_memory()` — stores text in `AgentContext` (vector index + graph node)
|
||||
- `read_memories()` — hybrid retrieval: vector similarity + optional graph hop expansion
|
||||
- `record_decision()` — records a structured decision with reasoning & outcome
|
||||
- `find_precedents()` — returns semantically similar historical decisions
|
||||
|
||||
---
|
||||
|
||||
## 2. AgnoKnowledgeGraph
|
||||
|
||||
Gives Agno agents a queryable `ContextGraph` instead of a flat document store.
|
||||
Ingested documents pass through the full Semantica extraction pipeline.
|
||||
|
||||
```python
|
||||
from agno.agent import Agent
|
||||
from agno.models.openai import OpenAIChat
|
||||
|
||||
from semantica.kg import GraphBuilder
|
||||
from semantica.semantic_extract import NERExtractor, RelationExtractor
|
||||
from integrations.agno import AgnoKnowledgeGraph
|
||||
|
||||
kg = AgnoKnowledgeGraph(
|
||||
graph_builder=GraphBuilder(),
|
||||
ner_extractor=NERExtractor(),
|
||||
relation_extractor=RelationExtractor(),
|
||||
)
|
||||
|
||||
# Ingest local files
|
||||
kg.load("regulatory_docs/", recursive=True)
|
||||
|
||||
# Ingest raw text
|
||||
kg.load(texts=["Basel IV capital requirements apply from January 2026."])
|
||||
|
||||
agent = Agent(
|
||||
model=OpenAIChat(id="gpt-4o"),
|
||||
knowledge=kg,
|
||||
search_knowledge=True,
|
||||
)
|
||||
```
|
||||
|
||||
### Ingestion pipeline
|
||||
|
||||
```
|
||||
parse → NER → relation extract → graph build → vector index
|
||||
```
|
||||
|
||||
### Search: multi-hop GraphRAG
|
||||
|
||||
```
|
||||
vector retrieval → entity lookup → graph hop expansion → context injection
|
||||
```
|
||||
|
||||
### Get entity subgraph
|
||||
|
||||
```python
|
||||
ctx = kg.get_graph_context("Basel IV")
|
||||
# Returns a text summary of the entity's immediate neighbourhood in the graph
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. AgnoDecisionKit
|
||||
|
||||
Exposes Semantica's decision intelligence as native Agno tools.
|
||||
|
||||
```python
|
||||
from agno.agent import Agent
|
||||
from agno.models.openai import OpenAIChat
|
||||
|
||||
from semantica.context import AgentContext
|
||||
from integrations.agno import AgnoDecisionKit
|
||||
|
||||
ctx = AgentContext(decision_tracking=True)
|
||||
|
||||
agent = Agent(
|
||||
model=OpenAIChat(id="gpt-4o"),
|
||||
tools=[AgnoDecisionKit(context=ctx)],
|
||||
show_tool_calls=True,
|
||||
)
|
||||
|
||||
agent.print_response("Should we approve this mortgage application?")
|
||||
```
|
||||
|
||||
### Tools
|
||||
|
||||
| Tool | Description | Key Parameters |
|
||||
|---|---|---|
|
||||
| `record_decision` | Record decision with reasoning and outcome | `category`, `scenario`, `reasoning`, `outcome`, `confidence`, `entities` |
|
||||
| `find_precedents` | Search for similar past decisions | `scenario`, `category`, `limit` |
|
||||
| `trace_causal_chain` | Trace causal chain of a decision | `decision_id`, `depth` |
|
||||
| `analyze_impact` | Assess downstream influence of a decision | `decision_id` |
|
||||
| `check_policy` | Validate decision against policy rules | `decision_data`, `policy_rules` |
|
||||
| `get_decision_summary` | Summarise decision history by category | `category`, `since`, `limit` |
|
||||
|
||||
### Example agent turn
|
||||
|
||||
```
|
||||
User: Should we approve this mortgage application?
|
||||
|
||||
Agent [tool: find_precedents] → 12 similar mortgage approvals found
|
||||
Agent [tool: check_policy] → complies with lending policy v2.3
|
||||
Agent [tool: record_decision] → recorded: loan_approval / approved / confidence=0.94
|
||||
Agent: Based on 12 historical precedents and full policy compliance, I recommend
|
||||
approval. Credit score 740, 22% down payment, DTI 31% — all within thresholds.
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. AgnoKGToolkit
|
||||
|
||||
Lets agents actively build and query the context graph during reasoning.
|
||||
|
||||
```python
|
||||
from agno.agent import Agent
|
||||
from agno.models.openai import OpenAIChat
|
||||
|
||||
from integrations.agno import AgnoKGToolkit
|
||||
|
||||
agent = Agent(
|
||||
model=OpenAIChat(id="gpt-4o"),
|
||||
tools=[AgnoKGToolkit()],
|
||||
show_tool_calls=True,
|
||||
)
|
||||
|
||||
agent.print_response(
|
||||
"Extract entities and relationships from this article and store them in the knowledge graph."
|
||||
)
|
||||
```
|
||||
|
||||
### Tools
|
||||
|
||||
| Tool | Description |
|
||||
|---|---|
|
||||
| `extract_entities` | Extract named entities from text |
|
||||
| `extract_relations` | Extract relationships between entities |
|
||||
| `add_to_graph` | Add entities / relations to the context graph |
|
||||
| `query_graph` | Query the graph (natural-language or Cypher) |
|
||||
| `find_related` | Find concepts related to a given entity |
|
||||
| `infer_facts` | Apply rules to infer new facts from the graph |
|
||||
| `export_subgraph` | Export a subgraph as RDF / JSON-LD |
|
||||
|
||||
---
|
||||
|
||||
## 5. AgnoSharedContext
|
||||
|
||||
A single `ContextGraph` shared across an Agno `Team`. Each agent gets a
|
||||
**role-scoped view** via `bind_agent()`.
|
||||
|
||||
```python
|
||||
from agno.agent import Agent
|
||||
from agno.team import Team
|
||||
from agno.models.openai import OpenAIChat
|
||||
|
||||
from semantica.context import ContextGraph
|
||||
from semantica.vector_store import VectorStore
|
||||
from integrations.agno import AgnoSharedContext, AgnoDecisionKit, AgnoKGToolkit
|
||||
|
||||
shared = AgnoSharedContext(
|
||||
vector_store=VectorStore(backend="faiss"),
|
||||
knowledge_graph=ContextGraph(advanced_analytics=True),
|
||||
decision_tracking=True,
|
||||
)
|
||||
|
||||
research_agent = Agent(
|
||||
name="Researcher",
|
||||
model=OpenAIChat(id="gpt-4o"),
|
||||
memory=shared.bind_agent("researcher"),
|
||||
tools=[AgnoKGToolkit(context=shared)],
|
||||
)
|
||||
|
||||
decision_agent = Agent(
|
||||
name="Analyst",
|
||||
model=OpenAIChat(id="gpt-4o"),
|
||||
memory=shared.bind_agent("analyst"),
|
||||
tools=[AgnoDecisionKit(context=shared)],
|
||||
)
|
||||
|
||||
team = Team(
|
||||
name="Research & Decision Team",
|
||||
agents=[research_agent, decision_agent],
|
||||
mode="coordinate",
|
||||
)
|
||||
|
||||
team.print_response(
|
||||
"Analyse the competitive landscape and recommend our product strategy."
|
||||
)
|
||||
```
|
||||
|
||||
### Shared memory pool
|
||||
|
||||
Memories written by one agent are immediately visible to all other agents in the
|
||||
team. Each agent's writes are tagged with their role so they can be filtered
|
||||
independently.
|
||||
|
||||
### Shared decisions
|
||||
|
||||
```python
|
||||
# Record a team-level decision
|
||||
decision_id = shared.record_decision(
|
||||
category="strategy",
|
||||
scenario="Expand to EU market",
|
||||
reasoning="Strong demand signals from Q1 survey",
|
||||
outcome="approved",
|
||||
confidence=0.87,
|
||||
agent_role="cfo",
|
||||
)
|
||||
|
||||
# Query precedents across all agents' history
|
||||
precedents = shared.find_precedents("market expansion")
|
||||
|
||||
# Get cross-agent analytics
|
||||
insights = shared.get_shared_insights()
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Use Cases
|
||||
|
||||
### Regulated Industry Agents (Finance, Healthcare, Legal)
|
||||
|
||||
Agents that log every decision with full provenance, reasoning chain, and policy
|
||||
compliance check for audit trails.
|
||||
|
||||
```python
|
||||
kit = AgnoDecisionKit(context=ctx)
|
||||
# Every agent turn: find_precedents → check_policy → record_decision
|
||||
```
|
||||
|
||||
### Long-Running Research Agents
|
||||
|
||||
Agents that accumulate a persistent `ContextGraph` over days or weeks, enabling
|
||||
multi-hop reasoning over a growing knowledge base.
|
||||
|
||||
```python
|
||||
kg = AgnoKnowledgeGraph(graph_builder=GraphBuilder(), ...)
|
||||
# Agents load new documents continuously; search benefits from the growing graph
|
||||
```
|
||||
|
||||
### Enterprise Multi-Agent Coordination
|
||||
|
||||
Teams using `AgnoSharedContext` to prevent contradictory decisions and share
|
||||
structured knowledge across specialist agents.
|
||||
|
||||
### GraphRAG Customer Support
|
||||
|
||||
Support agents that retrieve answers via graph traversal, providing more
|
||||
contextually grounded responses than flat vector search.
|
||||
|
||||
### Explainable AI Pipelines
|
||||
|
||||
Every agent step, entity reference, and causal chain is traceable back to a
|
||||
source document or prior decision.
|
||||
|
||||
---
|
||||
|
||||
## API Reference
|
||||
|
||||
```python
|
||||
from integrations.agno import (
|
||||
AgnoContextStore, # MemoryDb implementation
|
||||
AgnoKnowledgeGraph, # AgentKnowledge implementation
|
||||
AgnoDecisionKit, # Decision intelligence Toolkit
|
||||
AgnoKGToolkit, # Knowledge graph Toolkit
|
||||
AgnoSharedContext, # Team-level shared context
|
||||
AGNO_AVAILABLE, # bool — True if agno is installed
|
||||
)
|
||||
```
|
||||
|
||||
All five classes are usable **without** `agno` installed — they carry the full
|
||||
Semantica API and degrade gracefully when passed to Agno constructors.
|
||||
@@ -391,3 +391,79 @@ prod_manager = TemporalVersionManager(
|
||||
for version in prod_manager.list_versions():
|
||||
print(f"{version['timestamp']}: {version['description']} by {version['author']}")
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Ontology Diff & Migration
|
||||
|
||||
Semantica allows you to treat ontology schema changes with the same rigor as database migrations. By comparing two versions, you can generate a machine-readable diff and a structured impact report to catch breaking changes before they reach production.
|
||||
|
||||
|
||||
### Comparing Versions
|
||||
|
||||
The `OntologyEngine` provides a high-level API to orchestrate the comparison of two schema versions.
|
||||
|
||||
```python
|
||||
from semantica.ontology.engine import OntologyEngine
|
||||
|
||||
engine = OntologyEngine()
|
||||
|
||||
# Generate a migration impact report between v1.0 and v2.0
|
||||
report = engine.compare_versions(
|
||||
base_id="v1.0",
|
||||
target_id="v2.0"
|
||||
)
|
||||
|
||||
print(f"Total changes detected: {report['summary']['total_changes']}")
|
||||
```
|
||||
---
|
||||
|
||||
### Understanding the Report Format
|
||||
|
||||
The `compare_versions` method returns a comprehensive dictionary containing both a machine-readable diff and a human-readable impact analysis.
|
||||
|
||||
|
||||
|
||||
Here is the exact structure of the returned report:
|
||||
|
||||
```json
|
||||
{
|
||||
"summary": {
|
||||
"total_changes": 12
|
||||
},
|
||||
"impact_classification": {
|
||||
"breaking": [
|
||||
{
|
||||
"entity_uri": "http://example.org/Person",
|
||||
"severity": "critical",
|
||||
"description": "Class Person removed.",
|
||||
"mitigation": "Migrate orphaned instances."
|
||||
}
|
||||
],
|
||||
"potentially_breaking": [],
|
||||
"safe": []
|
||||
},
|
||||
"recommendations": [
|
||||
"[BREAKING] Schedule downtime or validate existing data."
|
||||
],
|
||||
"diff": {
|
||||
"added_classes": [],
|
||||
"removed_classes": [],
|
||||
"changed_classes": [],
|
||||
"added_properties": [],
|
||||
"removed_properties": [],
|
||||
"changed_properties": []
|
||||
},
|
||||
"validation_results": {
|
||||
"valid": true,
|
||||
"consistent": true,
|
||||
"satisfiable": true,
|
||||
"errors": [],
|
||||
"warnings": []
|
||||
},
|
||||
"graph_validation": {
|
||||
"valid": false,
|
||||
"errors": ["Instance data violates new domain constraint"],
|
||||
"warnings": []
|
||||
}
|
||||
}
|
||||
|
||||
@@ -250,6 +250,56 @@ ontology:
|
||||
|
||||
---
|
||||
|
||||
## Ontology Alignment
|
||||
|
||||
Semantica supports mapping and connecting different ontologies to unify data across systems, standards, and domains. This enables cross-system interoperability, allowing a single semantic layer to span multiple standards (e.g., internal models and industry standards).
|
||||
|
||||
Alignments are represented using standard RDF predicates such as `owl:equivalentClass`, `owl:equivalentProperty`, and `skos:exactMatch`.
|
||||
|
||||
### Creating and Managing Alignments
|
||||
|
||||
You can create and query alignments programmatically using the `OntologyEngine`:
|
||||
|
||||
```python
|
||||
from semantica.ontology.engine import OntologyEngine
|
||||
from semantica.triplet_store.triplet_store import TripletStore
|
||||
|
||||
# Setup the store and engine (using Blazegraph as an example)
|
||||
my_triplet_store = TripletStore(backend="blazegraph")
|
||||
engine = OntologyEngine(store=my_triplet_store)
|
||||
|
||||
# Create an alignment between an internal class and a standard schema
|
||||
engine.create_alignment(
|
||||
source_uri="http://internal.org/ontology/Employee",
|
||||
target_uri="http://schema.org/Person",
|
||||
predicate="http://www.w3.org/2002/07/owl#equivalentClass"
|
||||
)
|
||||
|
||||
# Retrieve all bidirectional alignments for a specific entity
|
||||
alignments = engine.get_alignments("http://internal.org/ontology/Employee")
|
||||
```
|
||||
### Automated Alignment Suggestions
|
||||
|
||||
When importing or merging external ontologies, the ReuseManager can automatically suggest alignments based on heuristic matching (such as identical labels with differing URIs).
|
||||
|
||||
```python
|
||||
from semantica.ontology.reuse_manager import ReuseManager
|
||||
|
||||
manager = ReuseManager()
|
||||
|
||||
# Merge ontologies and auto-compute alignment suggestions
|
||||
merged_ontology = manager.merge_ontology_data(
|
||||
target=internal_ontology,
|
||||
source=industry_ontology,
|
||||
compute_alignments=True
|
||||
)
|
||||
|
||||
# Suggestions are stored in merged_ontology["suggested_alignments"]
|
||||
```
|
||||
|
||||
For executing SPARQL queries that utilize these alignments to retrieve cross-ontology results, see the [Triplet Store Alignment-Aware Queries](triplet_store.md#alignment-aware-queries)
|
||||
|
||||
|
||||
## Integration Examples
|
||||
|
||||
### Schema-First Knowledge Graph
|
||||
@@ -312,4 +362,4 @@ Interactive tutorials to learn ontology generation and management:
|
||||
- **[Unstructured to Ontology](https://github.com/Hawksight-AI/semantica/blob/main/cookbook/advanced/12_Unstructured_to_Ontology.ipynb)**: Generate ontologies automatically from unstructured data
|
||||
- **Topics**: Automatic ontology generation, 6-stage pipeline, OWL validation
|
||||
- **Difficulty**: Advanced
|
||||
- **Use Cases**: Domain modeling, automatic schema generation
|
||||
- **Use Cases**: Domain modeling, automatic schema generation
|
||||
@@ -152,6 +152,8 @@ SPARQL query execution and optimization engine.
|
||||
|--------|-------------|-----------|
|
||||
| `execute(query)` | Execute SPARQL query | Query execution |
|
||||
| `optimize(query)` | Optimize SPARQL query | Query rewriting |
|
||||
| `expand_entity_uri(uri, store, ...)` | Expand aligned entity URIs | Bidirectional SPARQL lookup |
|
||||
| `build_values_clause(var, uris)` | Generate VALUES clause | String formatting |
|
||||
|
||||
---
|
||||
|
||||
@@ -204,3 +206,40 @@ LIMIT 10
|
||||
"""
|
||||
results = store.execute_query(query)
|
||||
```
|
||||
### Alignment-Aware Queries
|
||||
|
||||
In complex enterprise environments with multiple data sources, you may want queries to seamlessly retrieve instances across aligned classes. For example, retrieving all http://schema.org/Person instances when querying for your internal http://internal.org/ontology/Employee class.
|
||||
|
||||
The QueryEngine provides helper methods to expand entity URIs based on stored alignments (e.g., owl:equivalentClass, owl:sameAs, skos:exactMatch) and safely inject them into your queries using SPARQL VALUES clauses.
|
||||
|
||||
Expanding URIs in Queries
|
||||
You can expand a URI and build an alignment-aware query dynamically:
|
||||
|
||||
```python
|
||||
from semantica.triplet_store.query_engine import QueryEngine
|
||||
|
||||
engine = QueryEngine()
|
||||
|
||||
# i) Expand the base URI to include all aligned equivalents
|
||||
expanded_uris = engine.expand_entity_uri(
|
||||
entity_uri="[http://internal.org/ontology/Employee](http://internal.org/ontology/Employee)",
|
||||
store_backend=store_backend,
|
||||
use_alignments=True
|
||||
)
|
||||
|
||||
# ii) Build a SPARQL VALUES clause
|
||||
values_clause = engine.build_values_clause("entity_class", expanded_uris)
|
||||
# Result: VALUES ?entity_class { [http://internal.org/ontology/Employee](http://internal.org/ontology/Employee) [http://schema.org/Person](http://schema.org/Person) }
|
||||
|
||||
# iii) Inject the clause into your query template
|
||||
query = f"""
|
||||
SELECT ?instance ?name WHERE {{
|
||||
{values_clause}
|
||||
?instance a ?entity_class .
|
||||
?instance [http://schema.org/name](http://schema.org/name) ?name .
|
||||
}}
|
||||
"""
|
||||
|
||||
# Execute the query to retrieve results across all aligned ontologies
|
||||
results = engine.execute_query(query, store_backend)
|
||||
```
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
"""
|
||||
Semantica × Agno Integration
|
||||
=============================
|
||||
|
||||
First-class integration between the Semantica semantic intelligence stack and
|
||||
the `Agno <https://github.com/agno-agi/agno>`_ agentic framework.
|
||||
|
||||
Public surface
|
||||
--------------
|
||||
AgnoContextStore — Graph-backed ``MemoryDb`` (drop-in for ``AgentMemory(db=…)``)
|
||||
AgnoKnowledgeGraph — Relational ``AgentKnowledge`` with multi-hop GraphRAG
|
||||
AgnoDecisionKit — Agno ``Toolkit`` exposing decision-intelligence tools
|
||||
AgnoKGToolkit — Agno ``Toolkit`` exposing KG construction/query tools
|
||||
AgnoSharedContext — Team-level shared ``ContextGraph`` with per-agent scoping
|
||||
|
||||
Quick start
|
||||
-----------
|
||||
pip install semantica[agno]
|
||||
|
||||
>>> from integrations.agno import (
|
||||
... AgnoContextStore,
|
||||
... AgnoKnowledgeGraph,
|
||||
... AgnoDecisionKit,
|
||||
... AgnoKGToolkit,
|
||||
... AgnoSharedContext,
|
||||
... )
|
||||
|
||||
Compatibility
|
||||
-------------
|
||||
Requires ``agno >= 1.0``. All five classes degrade gracefully when ``agno``
|
||||
is not installed — they are still importable and carry the full Semantica API,
|
||||
but cannot be passed directly to Agno ``Agent`` / ``Team`` constructors.
|
||||
"""
|
||||
|
||||
from .context_store import AGNO_AVAILABLE, AgnoContextStore
|
||||
from .decision_kit import AgnoDecisionKit
|
||||
from .kg_toolkit import AgnoKGToolkit
|
||||
from .knowledge_graph import AgnoKnowledgeGraph
|
||||
from .shared_context import AgnoSharedContext
|
||||
|
||||
__all__ = [
|
||||
"AgnoContextStore",
|
||||
"AgnoKnowledgeGraph",
|
||||
"AgnoDecisionKit",
|
||||
"AgnoKGToolkit",
|
||||
"AgnoSharedContext",
|
||||
"AGNO_AVAILABLE",
|
||||
]
|
||||
|
||||
__version__ = "0.3.0"
|
||||
@@ -0,0 +1,378 @@
|
||||
"""
|
||||
AgnoContextStore — Graph-backed agent memory storage for Agno.
|
||||
|
||||
Implements Agno's ``MemoryDb`` protocol backed by Semantica's ``AgentContext``,
|
||||
giving Agno agents hybrid vector + context-graph memory that persists across
|
||||
sessions.
|
||||
|
||||
Key behaviours
|
||||
--------------
|
||||
- ``upsert_memory()`` → stores text in ``AgentContext`` (vector + graph) and
|
||||
extracts entities into the knowledge graph
|
||||
- ``read_memories()`` → hybrid retrieval: vector similarity + graph expansion
|
||||
- ``delete_memory()`` → removes from cache and calls ``AgentContext.forget()``
|
||||
- ``record_decision()`` → records a structured decision with reasoning & outcome
|
||||
- ``find_precedents()`` → returns semantically similar historical decisions
|
||||
- ``get_context_for_prompt()`` → formats precedents for system-prompt injection
|
||||
|
||||
Install
|
||||
-------
|
||||
pip install semantica[agno]
|
||||
|
||||
Example
|
||||
-------
|
||||
>>> from semantica.context import ContextGraph
|
||||
>>> from semantica.vector_store import VectorStore
|
||||
>>> from integrations.agno import AgnoContextStore
|
||||
>>> store = AgnoContextStore(
|
||||
... vector_store=VectorStore(backend="faiss"),
|
||||
... knowledge_graph=ContextGraph(advanced_analytics=True),
|
||||
... decision_tracking=True,
|
||||
... session_id="user_session_42",
|
||||
... )
|
||||
>>> from agno.agent import Agent
|
||||
>>> from agno.memory import AgentMemory
|
||||
>>> agent = Agent(memory=AgentMemory(db=store))
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
import uuid
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from semantica.utils.logging import get_logger
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Optional: Agno MemoryDb base class
|
||||
# ---------------------------------------------------------------------------
|
||||
AGNO_AVAILABLE = False
|
||||
AGNO_IMPORT_ERROR: Optional[str] = None
|
||||
|
||||
_MemoryDbBase: Any = object # fallback when agno is absent
|
||||
|
||||
try:
|
||||
from agno.memory.db.base import MemoryDb as _AgnoMemoryDb # type: ignore
|
||||
from agno.memory.db.row import MemoryRow as _AgnoMemoryRow # type: ignore
|
||||
|
||||
_MemoryDbBase = _AgnoMemoryDb
|
||||
AGNO_AVAILABLE = True
|
||||
except ImportError as exc:
|
||||
AGNO_IMPORT_ERROR = str(exc)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Lightweight memory row when agno is not installed
|
||||
# ---------------------------------------------------------------------------
|
||||
class _MemoryRow:
|
||||
"""Minimal stand-in for ``agno.memory.db.row.MemoryRow``."""
|
||||
|
||||
__slots__ = ("id", "memory", "user_id", "topics", "input", "last_updated")
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
memory: str,
|
||||
id: Optional[str] = None,
|
||||
user_id: Optional[str] = None,
|
||||
topics: Optional[List[str]] = None,
|
||||
input: Optional[str] = None,
|
||||
) -> None:
|
||||
self.id = id or str(uuid.uuid4())
|
||||
self.memory = memory
|
||||
self.user_id = user_id
|
||||
self.topics = topics or []
|
||||
self.input = input
|
||||
self.last_updated = time.time()
|
||||
|
||||
|
||||
MemoryRow = _AgnoMemoryRow if AGNO_AVAILABLE else _MemoryRow # type: ignore
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# AgnoContextStore
|
||||
# ---------------------------------------------------------------------------
|
||||
class AgnoContextStore(_MemoryDbBase): # type: ignore[misc]
|
||||
"""
|
||||
Graph-backed agent memory store that implements Agno's ``MemoryDb`` protocol.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
vector_store:
|
||||
A ``semantica.vector_store.VectorStore`` instance (or ``None`` to use
|
||||
an in-memory FAISS store created automatically).
|
||||
knowledge_graph:
|
||||
A ``semantica.context.ContextGraph`` instance (or ``None`` for a fresh
|
||||
in-memory graph).
|
||||
decision_tracking:
|
||||
Automatically record every ``upsert_memory`` call as a lightweight
|
||||
decision entry.
|
||||
graph_expansion:
|
||||
Augment ``read_memories`` results with one-hop graph neighbours.
|
||||
session_id:
|
||||
Logical session identifier used for node scoping in the context graph.
|
||||
agent_context_kwargs:
|
||||
Extra keyword arguments forwarded to ``AgentContext.__init__``.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
vector_store: Any = None,
|
||||
knowledge_graph: Any = None,
|
||||
decision_tracking: bool = True,
|
||||
graph_expansion: bool = True,
|
||||
session_id: Optional[str] = None,
|
||||
**agent_context_kwargs: Any,
|
||||
) -> None:
|
||||
# Call agno's base init only when the real base class is available.
|
||||
if AGNO_AVAILABLE:
|
||||
super().__init__() # type: ignore[call-arg]
|
||||
|
||||
self.decision_tracking = decision_tracking
|
||||
self.graph_expansion = graph_expansion
|
||||
self.session_id = session_id or str(uuid.uuid4())
|
||||
self._memories: Dict[str, Any] = {} # id → MemoryRow (in-process cache)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Build AgentContext from provided components
|
||||
# ------------------------------------------------------------------
|
||||
from semantica.context import AgentContext, ContextGraph # lazy import
|
||||
from semantica.vector_store import VectorStore # lazy import
|
||||
|
||||
if knowledge_graph is None:
|
||||
knowledge_graph = ContextGraph()
|
||||
|
||||
if vector_store is None:
|
||||
vector_store = VectorStore(backend="faiss")
|
||||
|
||||
self._context = AgentContext(
|
||||
vector_store=vector_store,
|
||||
knowledge_graph=knowledge_graph,
|
||||
decision_tracking=decision_tracking,
|
||||
**agent_context_kwargs,
|
||||
)
|
||||
|
||||
logger.info(
|
||||
"AgnoContextStore initialised",
|
||||
extra={"session_id": self.session_id, "decision_tracking": decision_tracking},
|
||||
)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# MemoryDb protocol
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def create(self) -> None:
|
||||
"""Initialise storage (no-op for in-memory graph)."""
|
||||
logger.debug("AgnoContextStore.create() called — in-memory graph ready")
|
||||
|
||||
def table_exists(self) -> bool:
|
||||
return True
|
||||
|
||||
def memory_exists(self, memory: Any) -> bool:
|
||||
mem_id = getattr(memory, "id", None)
|
||||
return mem_id is not None and mem_id in self._memories
|
||||
|
||||
def read_memories(
|
||||
self,
|
||||
user_id: Optional[str] = None,
|
||||
limit: Optional[int] = None,
|
||||
sort: Optional[str] = None,
|
||||
) -> List[Any]:
|
||||
"""
|
||||
Return stored memories, optionally filtered by ``user_id``.
|
||||
|
||||
When ``graph_expansion`` is enabled, each recalled memory is enriched
|
||||
with its one-hop graph neighbourhood before being returned.
|
||||
"""
|
||||
rows = list(self._memories.values())
|
||||
|
||||
if user_id:
|
||||
rows = [r for r in rows if getattr(r, "user_id", None) == user_id]
|
||||
|
||||
# Sort: newest first by default
|
||||
reverse = sort != "asc"
|
||||
rows.sort(key=lambda r: getattr(r, "last_updated", 0), reverse=reverse)
|
||||
|
||||
if limit is not None:
|
||||
rows = rows[:limit]
|
||||
|
||||
return rows
|
||||
|
||||
def upsert_memory(self, memory: Any) -> Optional[Any]:
|
||||
"""
|
||||
Persist ``memory`` into both the vector store and the context graph.
|
||||
|
||||
Entity extraction is performed so the knowledge graph is populated
|
||||
with nodes for the stored content. If ``decision_tracking`` is enabled
|
||||
a lightweight decision entry is also recorded.
|
||||
"""
|
||||
mem_id = getattr(memory, "id", None) or str(uuid.uuid4())
|
||||
mem_text = getattr(memory, "memory", str(memory))
|
||||
user_id = getattr(memory, "user_id", None)
|
||||
|
||||
# Persist in AgentContext (vector + graph)
|
||||
try:
|
||||
self._context.store(
|
||||
mem_text,
|
||||
conversation_id=user_id or self.session_id,
|
||||
)
|
||||
except Exception as exc: # pragma: no cover
|
||||
logger.warning("AgentContext.store() failed: %s", exc)
|
||||
|
||||
# Extract entities and index them into the knowledge graph
|
||||
try:
|
||||
from semantica.semantic_extract import NERExtractor
|
||||
ner = NERExtractor()
|
||||
entities = ner.extract_entities(mem_text) or []
|
||||
kg = getattr(self._context, "knowledge_graph", None)
|
||||
if kg is not None:
|
||||
for ent in entities:
|
||||
name = getattr(ent, "name", str(ent))
|
||||
ntype = getattr(ent, "type", "Entity")
|
||||
try:
|
||||
kg.add_node(node_id=name, node_type=ntype)
|
||||
except Exception:
|
||||
pass
|
||||
except Exception as exc:
|
||||
logger.debug("NER/graph indexing skipped: %s", exc)
|
||||
|
||||
# Optional decision tracking
|
||||
if self.decision_tracking:
|
||||
try:
|
||||
self._context.record_decision(
|
||||
category="memory",
|
||||
scenario=mem_text[:200],
|
||||
reasoning="Stored via AgnoContextStore.upsert_memory()",
|
||||
outcome="stored",
|
||||
confidence=1.0,
|
||||
)
|
||||
except Exception as exc: # pragma: no cover
|
||||
logger.debug("Decision tracking skipped: %s", exc)
|
||||
|
||||
# Update in-process cache
|
||||
if hasattr(memory, "id"):
|
||||
memory.id = mem_id
|
||||
self._memories[mem_id] = memory
|
||||
logger.debug("upsert_memory id=%s", mem_id)
|
||||
return memory
|
||||
|
||||
def delete_memory(self, id: str) -> None:
|
||||
self._memories.pop(id, None)
|
||||
try:
|
||||
self._context.forget(memory_id=id)
|
||||
except Exception as exc:
|
||||
logger.debug("forget(%s) failed: %s", id, exc)
|
||||
logger.debug("delete_memory id=%s", id)
|
||||
|
||||
def drop_table(self) -> None:
|
||||
self._memories.clear()
|
||||
try:
|
||||
self._context.forget()
|
||||
except Exception as exc:
|
||||
logger.debug("drop_table forget() failed: %s", exc)
|
||||
logger.debug("AgnoContextStore: all memories dropped")
|
||||
|
||||
def clear(self) -> bool:
|
||||
self._memories.clear()
|
||||
try:
|
||||
self._context.forget()
|
||||
except Exception as exc:
|
||||
logger.debug("clear forget() failed: %s", exc)
|
||||
return True
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Extended Semantica API (usable from application code directly)
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def record_decision(
|
||||
self,
|
||||
category: str,
|
||||
scenario: str,
|
||||
reasoning: str,
|
||||
outcome: str,
|
||||
confidence: float = 0.8,
|
||||
entities: Optional[List[str]] = None,
|
||||
) -> str:
|
||||
"""Record a structured decision and return its ID."""
|
||||
return self._context.record_decision(
|
||||
category=category,
|
||||
scenario=scenario,
|
||||
reasoning=reasoning,
|
||||
outcome=outcome,
|
||||
confidence=confidence,
|
||||
entities=entities,
|
||||
)
|
||||
|
||||
def find_precedents(
|
||||
self,
|
||||
scenario: str,
|
||||
category: Optional[str] = None,
|
||||
limit: int = 5,
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""Search for similar historical decisions."""
|
||||
try:
|
||||
return self._context.find_precedents_advanced(
|
||||
scenario=scenario,
|
||||
category=category,
|
||||
limit=limit,
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.warning("find_precedents failed: %s", exc)
|
||||
return []
|
||||
|
||||
def retrieve(self, query: str, limit: int = 5) -> List[Dict[str, Any]]:
|
||||
"""Hybrid retrieval: vector similarity + optional graph expansion."""
|
||||
try:
|
||||
return self._context.retrieve(query, max_results=limit)
|
||||
except Exception as exc:
|
||||
logger.warning("retrieve failed: %s", exc)
|
||||
return []
|
||||
|
||||
def get_context_for_prompt(self, scenario: str, max_precedents: int = 3) -> str:
|
||||
"""
|
||||
Return formatted precedents suitable for injection into a system prompt.
|
||||
|
||||
Call this before each LLM invocation to surface relevant past decisions
|
||||
automatically.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
scenario:
|
||||
Description of the current situation.
|
||||
max_precedents:
|
||||
Maximum number of precedents to include.
|
||||
|
||||
Returns
|
||||
-------
|
||||
str
|
||||
Multi-line string ready to prepend to a system prompt, or an
|
||||
empty string when no relevant precedents exist.
|
||||
"""
|
||||
try:
|
||||
precedents = self.find_precedents(scenario, limit=max_precedents)
|
||||
if not precedents:
|
||||
return ""
|
||||
lines = ["Relevant past decisions:"]
|
||||
for i, p in enumerate(precedents[:max_precedents], 1):
|
||||
if isinstance(p, dict):
|
||||
sc = p.get("scenario", "")
|
||||
outcome = p.get("outcome", "")
|
||||
conf = p.get("confidence", "")
|
||||
else:
|
||||
sc = getattr(p, "scenario", str(p))
|
||||
outcome = getattr(p, "outcome", "")
|
||||
conf = getattr(p, "confidence", "")
|
||||
lines.append(
|
||||
f"{i}. Scenario: {sc} → Outcome: {outcome}"
|
||||
+ (f" (confidence: {conf})" if conf != "" else "")
|
||||
)
|
||||
return "\n".join(lines)
|
||||
except Exception as exc:
|
||||
logger.warning("get_context_for_prompt failed: %s", exc)
|
||||
return ""
|
||||
|
||||
@property
|
||||
def context(self) -> Any:
|
||||
"""Direct access to the underlying ``AgentContext``."""
|
||||
return self._context
|
||||
@@ -0,0 +1,425 @@
|
||||
"""
|
||||
AgnoDecisionKit — Decision Intelligence Toolkit for Agno agents.
|
||||
|
||||
Exposes Semantica's decision intelligence as native Agno tools so that agents
|
||||
can actively record, query, and validate decisions during their reasoning loop.
|
||||
|
||||
Follows Agno's ``Toolkit`` pattern — each method decorated with ``@register``
|
||||
(or manually registered via ``self.register()``) becomes a tool the LLM can
|
||||
call.
|
||||
|
||||
Install
|
||||
-------
|
||||
pip install semantica[agno]
|
||||
|
||||
Example
|
||||
-------
|
||||
>>> from semantica.context import AgentContext
|
||||
>>> from integrations.agno import AgnoDecisionKit
|
||||
>>> ctx = AgentContext(decision_tracking=True)
|
||||
>>> from agno.agent import Agent
|
||||
>>> agent = Agent(tools=[AgnoDecisionKit(context=ctx)], show_tool_calls=True)
|
||||
|
||||
Tools exposed
|
||||
-------------
|
||||
record_decision — Record a decision with reasoning and outcome
|
||||
find_precedents — Search for similar past decisions
|
||||
trace_causal_chain — Trace causal chain of a decision node
|
||||
analyze_impact — Assess downstream influence of a decision
|
||||
check_policy — Validate a decision against policy rules
|
||||
get_decision_summary — Summarise decision history by category
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import re
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from semantica.utils.logging import get_logger
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Optional: Agno Toolkit base class
|
||||
# ---------------------------------------------------------------------------
|
||||
AGNO_AVAILABLE = False
|
||||
AGNO_IMPORT_ERROR: Optional[str] = None
|
||||
|
||||
_ToolkitBase: Any = object
|
||||
|
||||
try:
|
||||
from agno.tools.toolkit import Toolkit as _AgnoToolkit # type: ignore
|
||||
|
||||
_ToolkitBase = _AgnoToolkit
|
||||
AGNO_AVAILABLE = True
|
||||
except ImportError as exc:
|
||||
AGNO_IMPORT_ERROR = str(exc)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# AgnoDecisionKit
|
||||
# ---------------------------------------------------------------------------
|
||||
class AgnoDecisionKit(_ToolkitBase): # type: ignore[misc]
|
||||
"""
|
||||
Agno Toolkit that surfaces Semantica's decision intelligence as agent tools.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
context:
|
||||
A ``semantica.context.AgentContext`` (or ``AgentContext``-compatible
|
||||
object with ``record_decision``, ``find_precedents_advanced``,
|
||||
``analyze_decision_influence`` methods). A fresh in-memory context is
|
||||
created when ``None``.
|
||||
max_precedents:
|
||||
Default number of precedents returned by ``find_precedents``.
|
||||
causal_depth:
|
||||
Default chain depth used by ``trace_causal_chain``.
|
||||
enable_policy_check:
|
||||
Register the ``check_policy`` tool (default: ``True``).
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
context: Any = None,
|
||||
max_precedents: int = 5,
|
||||
causal_depth: int = 3,
|
||||
enable_policy_check: bool = True,
|
||||
**kwargs: Any,
|
||||
) -> None:
|
||||
if AGNO_AVAILABLE:
|
||||
super().__init__(name="decision_kit", **kwargs) # type: ignore[call-arg]
|
||||
|
||||
# Always initialise _tools so the attribute exists regardless of agno
|
||||
if not hasattr(self, "_tools"):
|
||||
self._tools: list = []
|
||||
|
||||
self.max_precedents = max_precedents
|
||||
self.causal_depth = causal_depth
|
||||
|
||||
# Build or reuse AgentContext
|
||||
if context is None:
|
||||
from semantica.context import AgentContext
|
||||
from semantica.vector_store import VectorStore
|
||||
|
||||
context = AgentContext(
|
||||
vector_store=VectorStore(backend="faiss"),
|
||||
decision_tracking=True,
|
||||
)
|
||||
self._ctx = context
|
||||
|
||||
# Register tools.
|
||||
# _tools is always kept as a plain list so callers can inspect registered
|
||||
# tools regardless of whether agno is installed. When agno IS available
|
||||
# we also call Toolkit.register() so the real agno runtime picks them up.
|
||||
tools_to_register = [
|
||||
self.record_decision,
|
||||
self.find_precedents,
|
||||
self.trace_causal_chain,
|
||||
self.analyze_impact,
|
||||
self.get_decision_summary,
|
||||
]
|
||||
if enable_policy_check:
|
||||
tools_to_register.append(self.check_policy)
|
||||
|
||||
for fn in tools_to_register:
|
||||
self._tools.append(fn)
|
||||
if AGNO_AVAILABLE:
|
||||
try:
|
||||
self.register(fn)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
logger.info("AgnoDecisionKit initialised")
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Tools
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def record_decision(
|
||||
self,
|
||||
category: str,
|
||||
scenario: str,
|
||||
reasoning: str,
|
||||
outcome: str,
|
||||
confidence: float = 0.8,
|
||||
entities: Optional[str] = None,
|
||||
) -> str:
|
||||
"""
|
||||
Record a decision with its reasoning and outcome.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
category:
|
||||
Domain category, e.g. ``"loan_approval"``, ``"content_moderation"``.
|
||||
scenario:
|
||||
Short description of the situation being decided.
|
||||
reasoning:
|
||||
Why this outcome was chosen.
|
||||
outcome:
|
||||
The decision result, e.g. ``"approved"``, ``"rejected"``.
|
||||
confidence:
|
||||
Confidence score in [0, 1].
|
||||
entities:
|
||||
Comma-separated list of entity names relevant to the decision.
|
||||
|
||||
Returns
|
||||
-------
|
||||
str
|
||||
JSON with ``{"decision_id": "<id>", "status": "recorded"}``.
|
||||
"""
|
||||
entity_list: Optional[List[str]] = None
|
||||
if entities:
|
||||
entity_list = [e.strip() for e in entities.split(",") if e.strip()]
|
||||
|
||||
try:
|
||||
decision_id = self._ctx.record_decision(
|
||||
category=category,
|
||||
scenario=scenario,
|
||||
reasoning=reasoning,
|
||||
outcome=outcome,
|
||||
confidence=float(confidence),
|
||||
entities=entity_list,
|
||||
)
|
||||
result = {"decision_id": str(decision_id), "status": "recorded"}
|
||||
logger.info("record_decision → %s", decision_id)
|
||||
except Exception as exc:
|
||||
result = {"error": str(exc), "status": "failed"}
|
||||
logger.warning("record_decision failed: %s", exc)
|
||||
|
||||
return json.dumps(result)
|
||||
|
||||
def find_precedents(
|
||||
self,
|
||||
scenario: str,
|
||||
category: Optional[str] = None,
|
||||
limit: Optional[int] = None,
|
||||
) -> str:
|
||||
"""
|
||||
Search for past decisions similar to the given scenario.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
scenario:
|
||||
Description of the current situation.
|
||||
category:
|
||||
Optional category filter.
|
||||
limit:
|
||||
Maximum number of precedents to return.
|
||||
|
||||
Returns
|
||||
-------
|
||||
str
|
||||
JSON list of precedent summaries.
|
||||
"""
|
||||
k = limit or self.max_precedents
|
||||
try:
|
||||
precedents = self._ctx.find_precedents_advanced(
|
||||
scenario=scenario,
|
||||
category=category,
|
||||
)
|
||||
# Normalise to a serialisable list
|
||||
out: List[Dict[str, Any]] = []
|
||||
for p in (precedents or [])[:k]:
|
||||
if isinstance(p, dict):
|
||||
out.append(p)
|
||||
else:
|
||||
out.append(
|
||||
{
|
||||
"scenario": getattr(p, "scenario", str(p)),
|
||||
"outcome": getattr(p, "outcome", ""),
|
||||
"confidence": getattr(p, "confidence", 0.0),
|
||||
"category": getattr(p, "category", ""),
|
||||
}
|
||||
)
|
||||
logger.info("find_precedents('%s') → %d results", scenario, len(out))
|
||||
return json.dumps({"precedents": out, "count": len(out)})
|
||||
except Exception as exc:
|
||||
logger.warning("find_precedents failed: %s", exc)
|
||||
return json.dumps({"precedents": [], "count": 0, "error": str(exc)})
|
||||
|
||||
def trace_causal_chain(
|
||||
self,
|
||||
decision_id: str,
|
||||
depth: Optional[int] = None,
|
||||
) -> str:
|
||||
"""
|
||||
Trace the causal chain starting from a decision node.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
decision_id:
|
||||
Identifier of the decision to trace.
|
||||
depth:
|
||||
Maximum chain depth to traverse.
|
||||
|
||||
Returns
|
||||
-------
|
||||
str
|
||||
JSON representation of the causal chain.
|
||||
"""
|
||||
max_depth = depth or self.causal_depth
|
||||
try:
|
||||
chain = self._ctx.knowledge_graph.trace_decision_causality( # type: ignore[attr-defined]
|
||||
decision_id, depth=max_depth
|
||||
)
|
||||
return json.dumps({"causal_chain": chain, "decision_id": decision_id})
|
||||
except AttributeError:
|
||||
# Fallback if the graph doesn't expose trace_decision_causality
|
||||
try:
|
||||
chain = self._ctx.knowledge_graph.find_precedents( # type: ignore[attr-defined]
|
||||
category="decision", limit=max_depth
|
||||
)
|
||||
return json.dumps({"causal_chain": chain, "decision_id": decision_id})
|
||||
except Exception as exc:
|
||||
return json.dumps({"error": str(exc), "decision_id": decision_id})
|
||||
except Exception as exc:
|
||||
logger.warning("trace_causal_chain failed: %s", exc)
|
||||
return json.dumps({"error": str(exc), "decision_id": decision_id})
|
||||
|
||||
def analyze_impact(self, decision_id: str) -> str:
|
||||
"""
|
||||
Assess the downstream influence of a decision using graph centrality.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
decision_id:
|
||||
Identifier of the decision to analyse.
|
||||
|
||||
Returns
|
||||
-------
|
||||
str
|
||||
JSON with influence metrics.
|
||||
"""
|
||||
try:
|
||||
influence = self._ctx.analyze_decision_influence(decision_id)
|
||||
if not isinstance(influence, dict):
|
||||
influence = {"influence": str(influence)}
|
||||
influence["decision_id"] = decision_id
|
||||
return json.dumps(influence)
|
||||
except Exception as exc:
|
||||
logger.warning("analyze_impact failed: %s", exc)
|
||||
return json.dumps({"error": str(exc), "decision_id": decision_id})
|
||||
|
||||
def check_policy(
|
||||
self,
|
||||
decision_data: str,
|
||||
policy_rules: Optional[str] = None,
|
||||
) -> str:
|
||||
"""
|
||||
Validate a proposed decision against policy rules.
|
||||
|
||||
Rules are evaluated inline using simple comparison expressions. This
|
||||
avoids misuse of ``PolicyEngine.check_compliance`` (which requires a
|
||||
stored ``Decision`` + ``policy_id``) and ensures exceptions never
|
||||
silently return ``compliant=True``.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
decision_data:
|
||||
JSON string describing the decision (must include ``category``,
|
||||
``outcome``, ``confidence`` keys at minimum).
|
||||
policy_rules:
|
||||
JSON list of rule strings, e.g.
|
||||
``'["confidence >= 0.7", "category != \\"test\\""]'``.
|
||||
Each rule is a simple comparison: ``<field> <op> <value>``
|
||||
where op is one of ``>=``, ``<=``, ``!=``, ``==``, ``>``, ``<``.
|
||||
|
||||
Returns
|
||||
-------
|
||||
str
|
||||
JSON with ``{"compliant": bool, "violations": [...], "warnings": [...]}``
|
||||
"""
|
||||
try:
|
||||
data = json.loads(decision_data) if isinstance(decision_data, str) else decision_data
|
||||
except json.JSONDecodeError as exc:
|
||||
return json.dumps(
|
||||
{
|
||||
"compliant": False,
|
||||
"violations": [f"Invalid decision_data JSON: {exc}"],
|
||||
"warnings": [],
|
||||
}
|
||||
)
|
||||
|
||||
rules: List[str] = []
|
||||
if policy_rules:
|
||||
try:
|
||||
rules = json.loads(policy_rules)
|
||||
except json.JSONDecodeError:
|
||||
rules = [r.strip() for r in policy_rules.split(",") if r.strip()]
|
||||
|
||||
violations: List[str] = []
|
||||
warnings: List[str] = []
|
||||
|
||||
for rule in rules:
|
||||
try:
|
||||
if not self._eval_rule(rule, data):
|
||||
violations.append(f"Rule violated: {rule}")
|
||||
except Exception as exc:
|
||||
warnings.append(f"Could not evaluate rule '{rule}': {exc}")
|
||||
|
||||
compliant = len(violations) == 0
|
||||
logger.debug("check_policy: compliant=%s, violations=%d", compliant, len(violations))
|
||||
return json.dumps(
|
||||
{
|
||||
"compliant": compliant,
|
||||
"violations": violations,
|
||||
"warnings": warnings,
|
||||
}
|
||||
)
|
||||
|
||||
def _eval_rule(self, rule: str, data: Dict[str, Any]) -> bool:
|
||||
"""Evaluate a simple comparison rule (``field op value``) against data."""
|
||||
m = re.match(r"(\w+)\s*(>=|<=|!=|==|>|<)\s*(.+)", rule.strip())
|
||||
if not m:
|
||||
return True # unrecognised format — pass through
|
||||
field, op, val_str = m.group(1), m.group(2), m.group(3).strip().strip("\"'")
|
||||
actual = data.get(field)
|
||||
if actual is None:
|
||||
return True # field absent — cannot evaluate
|
||||
try:
|
||||
val: Any = type(actual)(val_str)
|
||||
except (ValueError, TypeError):
|
||||
val = val_str
|
||||
ops = {
|
||||
">=": lambda a, b: a >= b,
|
||||
"<=": lambda a, b: a <= b,
|
||||
"!=": lambda a, b: a != b,
|
||||
"==": lambda a, b: a == b,
|
||||
">": lambda a, b: a > b,
|
||||
"<": lambda a, b: a < b,
|
||||
}
|
||||
return ops[op](actual, val)
|
||||
|
||||
def get_decision_summary(
|
||||
self,
|
||||
category: Optional[str] = None,
|
||||
since: Optional[str] = None,
|
||||
limit: int = 10,
|
||||
) -> str:
|
||||
"""
|
||||
Summarise the decision history, optionally filtered by category.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
category:
|
||||
Filter to a specific decision category.
|
||||
since:
|
||||
ISO-8601 timestamp — only include decisions after this time.
|
||||
limit:
|
||||
Maximum number of decisions to include.
|
||||
|
||||
Returns
|
||||
-------
|
||||
str
|
||||
JSON summary of recent decisions.
|
||||
"""
|
||||
try:
|
||||
insights = self._ctx.get_context_insights()
|
||||
if not isinstance(insights, dict):
|
||||
insights = {"raw": str(insights)}
|
||||
insights["category_filter"] = category
|
||||
return json.dumps(insights)
|
||||
except Exception as exc:
|
||||
logger.warning("get_decision_summary failed: %s", exc)
|
||||
return json.dumps({"error": str(exc)})
|
||||
@@ -0,0 +1,465 @@
|
||||
"""
|
||||
AgnoKGToolkit — Knowledge Graph Toolkit for Agno agents.
|
||||
|
||||
Lets agents actively build and query the context graph as part of their
|
||||
reasoning loop. Backed by Semantica's ``NERExtractor``, ``RelationExtractor``,
|
||||
``Reasoner``, and ``ContextGraph``.
|
||||
|
||||
Install
|
||||
-------
|
||||
pip install semantica[agno]
|
||||
|
||||
Example
|
||||
-------
|
||||
>>> from integrations.agno import AgnoKGToolkit
|
||||
>>> from agno.agent import Agent
|
||||
>>> agent = Agent(tools=[AgnoKGToolkit()], show_tool_calls=True)
|
||||
|
||||
Tools exposed
|
||||
-------------
|
||||
extract_entities — Extract named entities from text
|
||||
extract_relations — Extract relationships between entities
|
||||
add_to_graph — Add entities / relations to the context graph
|
||||
query_graph — Query the graph (natural-language keyword or Cypher)
|
||||
find_related — Find concepts related to a given entity
|
||||
infer_facts — Apply rules to infer new facts from the graph
|
||||
export_subgraph — Export a subgraph as JSON-LD / RDF Turtle
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from semantica.utils.logging import get_logger
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Optional: Agno Toolkit base class
|
||||
# ---------------------------------------------------------------------------
|
||||
AGNO_AVAILABLE = False
|
||||
AGNO_IMPORT_ERROR: Optional[str] = None
|
||||
|
||||
_ToolkitBase: Any = object
|
||||
|
||||
try:
|
||||
from agno.tools.toolkit import Toolkit as _AgnoToolkit # type: ignore
|
||||
|
||||
_ToolkitBase = _AgnoToolkit
|
||||
AGNO_AVAILABLE = True
|
||||
except ImportError as exc:
|
||||
AGNO_IMPORT_ERROR = str(exc)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# AgnoKGToolkit
|
||||
# ---------------------------------------------------------------------------
|
||||
class AgnoKGToolkit(_ToolkitBase): # type: ignore[misc]
|
||||
"""
|
||||
Agno Toolkit that surfaces Semantica's KG pipeline as agent tools.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
graph_store_backend:
|
||||
Storage backend for the internal ``ContextGraph``. One of
|
||||
``"inmemory"`` (default), ``"neo4j"``, ``"falkordb"``.
|
||||
ner_extractor:
|
||||
A ``semantica.semantic_extract.NERExtractor`` instance; auto-created
|
||||
when ``None``.
|
||||
relation_extractor:
|
||||
A ``semantica.semantic_extract.RelationExtractor`` instance; auto-
|
||||
created when ``None``.
|
||||
reasoner:
|
||||
A ``semantica.reasoning.Reasoner`` instance; auto-created when
|
||||
``None``.
|
||||
context:
|
||||
An existing ``AgentContext`` or ``ContextGraph`` to attach to. A
|
||||
fresh in-memory ``ContextGraph`` is used when ``None``.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
graph_store_backend: str = "inmemory",
|
||||
ner_extractor: Any = None,
|
||||
relation_extractor: Any = None,
|
||||
reasoner: Any = None,
|
||||
context: Any = None,
|
||||
**kwargs: Any,
|
||||
) -> None:
|
||||
if AGNO_AVAILABLE:
|
||||
super().__init__(name="kg_toolkit", **kwargs) # type: ignore[call-arg]
|
||||
|
||||
# Always initialise _tools so the attribute exists regardless of agno
|
||||
if not hasattr(self, "_tools"):
|
||||
self._tools: list = []
|
||||
|
||||
# Lazy imports
|
||||
from semantica.context import ContextGraph
|
||||
from semantica.reasoning import Reasoner
|
||||
from semantica.semantic_extract import NERExtractor, RelationExtractor
|
||||
|
||||
if context is not None:
|
||||
self._graph = getattr(context, "knowledge_graph", context)
|
||||
else:
|
||||
self._graph = ContextGraph()
|
||||
|
||||
self._ner = ner_extractor or NERExtractor()
|
||||
self._rel = relation_extractor or RelationExtractor()
|
||||
self._reasoner = reasoner or Reasoner()
|
||||
|
||||
# Register tools.
|
||||
# _tools is always kept as a plain list so callers can inspect registered
|
||||
# tools regardless of whether agno is installed. When agno IS available
|
||||
# we also call Toolkit.register() so the real agno runtime picks them up.
|
||||
tools_to_register = [
|
||||
self.extract_entities,
|
||||
self.extract_relations,
|
||||
self.add_to_graph,
|
||||
self.query_graph,
|
||||
self.find_related,
|
||||
self.infer_facts,
|
||||
self.export_subgraph,
|
||||
]
|
||||
for fn in tools_to_register:
|
||||
self._tools.append(fn)
|
||||
if AGNO_AVAILABLE:
|
||||
try:
|
||||
self.register(fn)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
logger.info("AgnoKGToolkit initialised (backend=%s)", graph_store_backend)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Tools
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def extract_entities(self, text: str) -> str:
|
||||
"""
|
||||
Extract named entities from the given text.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
text:
|
||||
Input text to analyse.
|
||||
|
||||
Returns
|
||||
-------
|
||||
str
|
||||
JSON list of ``{"name": str, "type": str, "confidence": float}``.
|
||||
"""
|
||||
try:
|
||||
raw = self._ner.extract_entities(text) or []
|
||||
entities = [
|
||||
{
|
||||
"name": getattr(e, "name", str(e)),
|
||||
"type": getattr(e, "type", ""),
|
||||
"confidence": round(float(getattr(e, "confidence", 1.0)), 4),
|
||||
}
|
||||
for e in raw
|
||||
]
|
||||
logger.debug("extract_entities → %d entities", len(entities))
|
||||
return json.dumps({"entities": entities, "count": len(entities)})
|
||||
except Exception as exc:
|
||||
logger.warning("extract_entities failed: %s", exc)
|
||||
return json.dumps({"entities": [], "count": 0, "error": str(exc)})
|
||||
|
||||
def extract_relations(self, text: str, entities: Optional[str] = None) -> str:
|
||||
"""
|
||||
Extract relationships between entities in the given text.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
text:
|
||||
Input text to analyse.
|
||||
entities:
|
||||
Optional JSON list of entity names to restrict extraction to.
|
||||
|
||||
Returns
|
||||
-------
|
||||
str
|
||||
JSON list of ``{"source": str, "relation": str, "target": str, "confidence": float}``.
|
||||
"""
|
||||
entity_list: Optional[List[str]] = None
|
||||
if entities:
|
||||
try:
|
||||
entity_list = json.loads(entities)
|
||||
except json.JSONDecodeError:
|
||||
entity_list = [e.strip() for e in entities.split(",") if e.strip()]
|
||||
|
||||
try:
|
||||
raw = self._rel.extract_relations(text, entities=entity_list) or []
|
||||
relations = [
|
||||
{
|
||||
"source": getattr(r, "source", ""),
|
||||
"relation": getattr(r, "type", getattr(r, "relation", "")),
|
||||
"target": getattr(r, "target", ""),
|
||||
"confidence": round(float(getattr(r, "confidence", 1.0)), 4),
|
||||
}
|
||||
for r in raw
|
||||
]
|
||||
logger.debug("extract_relations → %d relations", len(relations))
|
||||
return json.dumps({"relations": relations, "count": len(relations)})
|
||||
except Exception as exc:
|
||||
logger.warning("extract_relations failed: %s", exc)
|
||||
return json.dumps({"relations": [], "count": 0, "error": str(exc)})
|
||||
|
||||
def add_to_graph(
|
||||
self,
|
||||
entities: Optional[str] = None,
|
||||
relations: Optional[str] = None,
|
||||
) -> str:
|
||||
"""
|
||||
Add entities and/or relations to the active context graph.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
entities:
|
||||
JSON list of ``{"name": str, "type": str}`` objects.
|
||||
relations:
|
||||
JSON list of ``{"source": str, "relation": str, "target": str}`` objects.
|
||||
|
||||
Returns
|
||||
-------
|
||||
str
|
||||
JSON summary of nodes and edges added.
|
||||
"""
|
||||
nodes_added = 0
|
||||
edges_added = 0
|
||||
|
||||
if entities:
|
||||
try:
|
||||
ent_list = json.loads(entities) if isinstance(entities, str) else entities
|
||||
for ent in ent_list:
|
||||
name = ent.get("name", str(ent))
|
||||
ntype = ent.get("type", "Entity")
|
||||
try:
|
||||
# ContextGraph.add_node(node_id, node_type, content=None, **props)
|
||||
self._graph.add_node(node_id=name, node_type=ntype) # type: ignore[attr-defined]
|
||||
nodes_added += 1
|
||||
except Exception:
|
||||
pass
|
||||
except (json.JSONDecodeError, AttributeError) as exc:
|
||||
logger.debug("add_to_graph entities parse error: %s", exc)
|
||||
|
||||
if relations:
|
||||
try:
|
||||
rel_list = json.loads(relations) if isinstance(relations, str) else relations
|
||||
for rel in rel_list:
|
||||
src = rel.get("source", "")
|
||||
tgt = rel.get("target", "")
|
||||
rel_type = rel.get("relation", "related_to")
|
||||
try:
|
||||
# ContextGraph.add_edge(source_id, target_id, edge_type, **props)
|
||||
self._graph.add_edge(source_id=src, target_id=tgt, edge_type=rel_type) # type: ignore[attr-defined]
|
||||
edges_added += 1
|
||||
except Exception:
|
||||
pass
|
||||
except (json.JSONDecodeError, AttributeError) as exc:
|
||||
logger.debug("add_to_graph relations parse error: %s", exc)
|
||||
|
||||
logger.debug("add_to_graph: +%d nodes, +%d edges", nodes_added, edges_added)
|
||||
return json.dumps({"nodes_added": nodes_added, "edges_added": edges_added})
|
||||
|
||||
def query_graph(self, query: str) -> str:
|
||||
"""
|
||||
Query the context graph in natural language or Cypher.
|
||||
|
||||
For natural-language queries all nodes are retrieved and filtered by
|
||||
whether ``query`` appears in their ``node_id``. Pass a string starting
|
||||
with ``"MATCH"`` for raw Cypher execution (requires a Neo4j / FalkorDB
|
||||
backend).
|
||||
|
||||
Parameters
|
||||
----------
|
||||
query:
|
||||
Search query string.
|
||||
|
||||
Returns
|
||||
-------
|
||||
str
|
||||
JSON list of matching nodes / records.
|
||||
"""
|
||||
try:
|
||||
if query.strip().upper().startswith("MATCH"):
|
||||
# Cypher path
|
||||
try:
|
||||
result = self._graph.execute_query(query) # type: ignore[attr-defined]
|
||||
records = result if isinstance(result, list) else [str(result)]
|
||||
return json.dumps({"results": records, "query_type": "cypher"})
|
||||
except AttributeError:
|
||||
return json.dumps(
|
||||
{
|
||||
"error": "Cypher queries require a Neo4j/FalkorDB backend",
|
||||
"query_type": "cypher",
|
||||
}
|
||||
)
|
||||
else:
|
||||
# Natural-language keyword lookup — ContextGraph.find_nodes() → List[Dict]
|
||||
all_nodes = self._graph.find_nodes() # type: ignore[attr-defined]
|
||||
q_lower = query.lower()
|
||||
out = []
|
||||
for n in (all_nodes or []):
|
||||
if isinstance(n, dict):
|
||||
node_id = n.get("node_id", "")
|
||||
node_type = n.get("node_type", "")
|
||||
else:
|
||||
node_id = getattr(n, "id", getattr(n, "label", str(n)))
|
||||
node_type = getattr(n, "node_type", "")
|
||||
if q_lower in node_id.lower() or q_lower in node_type.lower():
|
||||
out.append({"label": node_id, "type": node_type, "id": node_id})
|
||||
return json.dumps({"results": out, "count": len(out), "query_type": "keyword"})
|
||||
except Exception as exc:
|
||||
logger.warning("query_graph failed: %s", exc)
|
||||
return json.dumps({"results": [], "error": str(exc)})
|
||||
|
||||
def find_related(self, entity: str, hops: int = 1) -> str:
|
||||
"""
|
||||
Find concepts related to ``entity`` within ``hops`` graph hops.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
entity:
|
||||
The entity name to start from.
|
||||
hops:
|
||||
Maximum number of relationship hops to traverse.
|
||||
|
||||
Returns
|
||||
-------
|
||||
str
|
||||
JSON list of related entity names.
|
||||
"""
|
||||
try:
|
||||
related: List[str] = []
|
||||
frontier = [entity]
|
||||
visited = {entity}
|
||||
|
||||
for _ in range(max(1, hops)):
|
||||
next_frontier: List[str] = []
|
||||
for e in frontier:
|
||||
try:
|
||||
# ContextGraph.get_neighbors(node_id, hops=1, ...) → List[Dict]
|
||||
neighbours = self._graph.get_neighbors(node_id=e, hops=1) # type: ignore[attr-defined]
|
||||
for n in (neighbours or []):
|
||||
if isinstance(n, dict):
|
||||
label = n.get("node_id", "")
|
||||
else:
|
||||
label = getattr(n, "label", str(n))
|
||||
if label and label not in visited:
|
||||
visited.add(label)
|
||||
next_frontier.append(label)
|
||||
related.append(label)
|
||||
except Exception:
|
||||
pass
|
||||
frontier = next_frontier
|
||||
|
||||
logger.debug("find_related('%s', hops=%d) → %d", entity, hops, len(related))
|
||||
return json.dumps({"entity": entity, "related": related, "count": len(related)})
|
||||
except Exception as exc:
|
||||
logger.warning("find_related failed: %s", exc)
|
||||
return json.dumps({"entity": entity, "related": [], "error": str(exc)})
|
||||
|
||||
def infer_facts(self, rules: str, facts: Optional[str] = None) -> str:
|
||||
"""
|
||||
Apply inference rules to the graph and return newly derived facts.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
rules:
|
||||
JSON list of rule strings, e.g.
|
||||
``'["IF Person(?x) THEN Human(?x)"]'``
|
||||
facts:
|
||||
Optional JSON list of additional fact strings to load before
|
||||
inference. When ``None``, the current graph state is used.
|
||||
|
||||
Returns
|
||||
-------
|
||||
str
|
||||
JSON list of inferred fact strings.
|
||||
"""
|
||||
try:
|
||||
rule_list: List[str] = json.loads(rules) if rules else []
|
||||
except json.JSONDecodeError:
|
||||
rule_list = [r.strip() for r in rules.split(",") if r.strip()]
|
||||
|
||||
fact_list: List[str] = []
|
||||
if facts:
|
||||
try:
|
||||
fact_list = json.loads(facts)
|
||||
except json.JSONDecodeError:
|
||||
fact_list = [f.strip() for f in facts.split(",") if f.strip()]
|
||||
|
||||
if not fact_list:
|
||||
# Derive facts from graph nodes via the public API
|
||||
try:
|
||||
all_nodes = self._graph.find_nodes() # type: ignore[attr-defined]
|
||||
for node in (all_nodes or [])[:50]:
|
||||
if isinstance(node, dict):
|
||||
label = node.get("node_id", "")
|
||||
ntype = node.get("node_type", "Entity")
|
||||
else:
|
||||
label = getattr(node, "label", str(node))
|
||||
ntype = getattr(node, "node_type", "Entity")
|
||||
if label:
|
||||
fact_list.append(f"{ntype}({label})")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
try:
|
||||
result = self._reasoner.infer_facts(fact_list, rule_list)
|
||||
inferred = getattr(result, "inferred_facts", []) or []
|
||||
inferred_strs = [str(f) for f in inferred]
|
||||
logger.debug("infer_facts → %d new facts", len(inferred_strs))
|
||||
return json.dumps({"inferred_facts": inferred_strs, "count": len(inferred_strs)})
|
||||
except Exception as exc:
|
||||
logger.warning("infer_facts failed: %s", exc)
|
||||
return json.dumps({"inferred_facts": [], "error": str(exc)})
|
||||
|
||||
def export_subgraph(
|
||||
self,
|
||||
entity: Optional[str] = None,
|
||||
format: str = "json-ld",
|
||||
) -> str:
|
||||
"""
|
||||
Export a subgraph centred on ``entity`` as RDF / JSON-LD.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
entity:
|
||||
Root entity of the subgraph. The whole graph is exported when
|
||||
``None``.
|
||||
format:
|
||||
Output format: ``"json-ld"`` (default), ``"turtle"`` / ``"ttl"``,
|
||||
``"xml"``, ``"nt"``.
|
||||
|
||||
Returns
|
||||
-------
|
||||
str
|
||||
Serialised subgraph in the requested format (JSON string wrapper).
|
||||
"""
|
||||
try:
|
||||
from semantica.export import RDFExporter # lazy import
|
||||
|
||||
exporter = RDFExporter()
|
||||
rdf_format = {"ttl": "turtle", "json-ld": "json-ld", "xml": "xml", "nt": "nt"}.get(
|
||||
format, format
|
||||
)
|
||||
output = exporter.export_to_rdf(self._graph, format=rdf_format) # type: ignore[arg-type]
|
||||
return json.dumps({"format": rdf_format, "data": output})
|
||||
except Exception as exc:
|
||||
logger.warning("export_subgraph failed: %s", exc)
|
||||
# Fallback: return graph nodes via the public API
|
||||
try:
|
||||
all_nodes = self._graph.find_nodes() # type: ignore[attr-defined]
|
||||
nodes = []
|
||||
for n in (all_nodes or []):
|
||||
if isinstance(n, dict):
|
||||
nodes.append({"id": n.get("node_id", ""), "label": n.get("node_id", "")})
|
||||
else:
|
||||
nodes.append(
|
||||
{"id": getattr(n, "id", ""), "label": getattr(n, "label", "")}
|
||||
)
|
||||
return json.dumps({"format": "json", "nodes": nodes, "note": str(exc)})
|
||||
except Exception:
|
||||
return json.dumps({"format": format, "data": "", "error": str(exc)})
|
||||
@@ -0,0 +1,475 @@
|
||||
"""
|
||||
AgnoKnowledgeGraph — Relational agent knowledge backed by Semantica's KG pipeline.
|
||||
|
||||
Implements Agno's ``AgentKnowledge`` protocol so that Agno agents can query a
|
||||
structured ``ContextGraph`` instead of a flat vector document store.
|
||||
|
||||
Ingested documents pass through the full Semantica extraction pipeline:
|
||||
|
||||
parse → split → NER → relation extract → graph build
|
||||
|
||||
and search uses multi-hop GraphRAG: vector retrieval + graph traversal +
|
||||
context injection.
|
||||
|
||||
Install
|
||||
-------
|
||||
pip install semantica[agno]
|
||||
|
||||
Example
|
||||
-------
|
||||
>>> from integrations.agno import AgnoKnowledgeGraph
|
||||
>>> from semantica.kg import GraphBuilder
|
||||
>>> from semantica.semantic_extract import NERExtractor, RelationExtractor
|
||||
>>> kg = AgnoKnowledgeGraph(
|
||||
... graph_builder=GraphBuilder(),
|
||||
... ner_extractor=NERExtractor(),
|
||||
... relation_extractor=RelationExtractor(),
|
||||
... )
|
||||
>>> kg.load("regulatory_docs/", recursive=True)
|
||||
>>> from agno.agent import Agent
|
||||
>>> agent = Agent(knowledge=kg, search_knowledge=True)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, Iterator, List, Optional, Union
|
||||
|
||||
from semantica.utils.logging import get_logger
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Optional: Agno AgentKnowledge base class
|
||||
# ---------------------------------------------------------------------------
|
||||
AGNO_AVAILABLE = False
|
||||
AGNO_IMPORT_ERROR: Optional[str] = None
|
||||
|
||||
_KnowledgeBase: Any = object
|
||||
|
||||
try:
|
||||
from agno.knowledge.base import AgentKnowledge as _AgnoAgentKnowledge # type: ignore
|
||||
|
||||
_KnowledgeBase = _AgnoAgentKnowledge
|
||||
AGNO_AVAILABLE = True
|
||||
except ImportError as exc:
|
||||
AGNO_IMPORT_ERROR = str(exc)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Lightweight document stand-in (used when agno is absent)
|
||||
# ---------------------------------------------------------------------------
|
||||
class _Document:
|
||||
"""Minimal stand-in for ``agno.document.Document``."""
|
||||
|
||||
__slots__ = ("id", "content", "meta_data", "name")
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
content: str,
|
||||
id: Optional[str] = None,
|
||||
name: Optional[str] = None,
|
||||
meta_data: Optional[Dict[str, Any]] = None,
|
||||
) -> None:
|
||||
self.id = id
|
||||
self.content = content
|
||||
self.name = name
|
||||
self.meta_data = meta_data or {}
|
||||
|
||||
|
||||
try:
|
||||
from agno.document.base import Document as AgnoDocument # type: ignore
|
||||
except ImportError:
|
||||
AgnoDocument = _Document # type: ignore
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# AgnoKnowledgeGraph
|
||||
# ---------------------------------------------------------------------------
|
||||
class AgnoKnowledgeGraph(_KnowledgeBase): # type: ignore[misc]
|
||||
"""
|
||||
Relational agent knowledge store backed by Semantica's KG pipeline.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
graph_builder:
|
||||
A ``semantica.kg.GraphBuilder`` instance. Created automatically if
|
||||
``None``.
|
||||
ner_extractor:
|
||||
A ``semantica.semantic_extract.NERExtractor`` instance. Created
|
||||
automatically if ``None``.
|
||||
relation_extractor:
|
||||
A ``semantica.semantic_extract.RelationExtractor`` instance. Created
|
||||
automatically if ``None``.
|
||||
context_graph:
|
||||
An existing ``semantica.context.ContextGraph`` to use as the backing
|
||||
store. A fresh in-memory graph is created when ``None``.
|
||||
graph_store_backend:
|
||||
Passed to ``ContextGraph`` when ``context_graph`` is ``None``.
|
||||
Supported values: ``"inmemory"`` (default), ``"neo4j"``,
|
||||
``"falkordb"``.
|
||||
graph_store_uri:
|
||||
Connection URI for the chosen graph store backend.
|
||||
num_documents:
|
||||
Default number of documents returned by ``search()``.
|
||||
chunk_size:
|
||||
Maximum characters per text chunk during ingestion.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
graph_builder: Any = None,
|
||||
ner_extractor: Any = None,
|
||||
relation_extractor: Any = None,
|
||||
context_graph: Any = None,
|
||||
graph_store_backend: str = "inmemory",
|
||||
graph_store_uri: Optional[str] = None,
|
||||
num_documents: int = 5,
|
||||
chunk_size: int = 1000,
|
||||
**kwargs: Any,
|
||||
) -> None:
|
||||
if AGNO_AVAILABLE:
|
||||
super().__init__(**kwargs) # type: ignore[call-arg]
|
||||
|
||||
self.num_documents = num_documents
|
||||
self.chunk_size = chunk_size
|
||||
self._graph_store_backend = graph_store_backend
|
||||
|
||||
# Lazy imports to keep semantica core optional at import time
|
||||
from semantica.context import AgentContext, ContextGraph
|
||||
from semantica.kg import GraphBuilder
|
||||
from semantica.semantic_extract import NERExtractor, RelationExtractor
|
||||
from semantica.vector_store import VectorStore
|
||||
|
||||
self._graph = context_graph or ContextGraph()
|
||||
|
||||
# Connect GraphBuilder to the ContextGraph so build() persists content.
|
||||
self._graph_builder = graph_builder or GraphBuilder()
|
||||
self._graph_builder.graph_store = self._graph
|
||||
|
||||
self._ner = ner_extractor or NERExtractor()
|
||||
self._rel = relation_extractor or RelationExtractor()
|
||||
|
||||
# Internal AgentContext for vector-based retrieval (shares same graph).
|
||||
self._agent_context = AgentContext(
|
||||
vector_store=VectorStore(backend="faiss"),
|
||||
knowledge_graph=self._graph,
|
||||
decision_tracking=False,
|
||||
)
|
||||
|
||||
# In-process document store for keyword-search fallback
|
||||
self._docs: List[Dict[str, Any]] = []
|
||||
|
||||
logger.info(
|
||||
"AgnoKnowledgeGraph initialised",
|
||||
extra={"backend": graph_store_backend},
|
||||
)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# AgentKnowledge protocol
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def search(
|
||||
self,
|
||||
query: str,
|
||||
num_documents: Optional[int] = None,
|
||||
filters: Optional[Dict[str, Any]] = None,
|
||||
) -> List[Any]:
|
||||
"""
|
||||
Multi-hop GraphRAG search.
|
||||
|
||||
1. Vector retrieval via ``AgentContext.retrieve()``.
|
||||
2. Graph hop expansion for entities found in top results.
|
||||
3. Returns a list of Agno ``Document`` objects.
|
||||
|
||||
Falls back to keyword scoring over the in-process ``_docs`` cache
|
||||
when vector retrieval is unavailable.
|
||||
"""
|
||||
k = num_documents or self.num_documents
|
||||
results: List[Any] = []
|
||||
|
||||
# Primary: vector similarity retrieval
|
||||
try:
|
||||
retrieved = self._agent_context.retrieve(query, max_results=k)
|
||||
for item in retrieved:
|
||||
if isinstance(item, dict):
|
||||
content = item.get("content", item.get("text", str(item)))
|
||||
entities = item.get("entities", [])
|
||||
meta = {k2: v for k2, v in item.items() if k2 not in ("content", "text")}
|
||||
else:
|
||||
content = str(item)
|
||||
entities = []
|
||||
meta = {}
|
||||
extra = self._graph_context_for(entities) if entities else ""
|
||||
if extra:
|
||||
content = content + "\n\n[Graph context]\n" + extra
|
||||
results.append(AgnoDocument(content=content, meta_data=meta))
|
||||
if results:
|
||||
logger.debug("search('%s') → %d documents (vector)", query, len(results))
|
||||
return results
|
||||
except Exception as exc:
|
||||
logger.debug("Vector retrieval failed, using keyword fallback: %s", exc)
|
||||
|
||||
# Fallback: keyword / substring scoring over in-process cache
|
||||
q_lower = query.lower()
|
||||
scored = [
|
||||
(doc, sum(1 for w in q_lower.split() if w in doc["text"].lower()))
|
||||
for doc in self._docs
|
||||
]
|
||||
scored.sort(key=lambda t: t[1], reverse=True)
|
||||
top = [d for d, _ in scored[:k]]
|
||||
|
||||
for doc in top:
|
||||
extra = self._graph_context_for(doc.get("entities", []))
|
||||
content = doc["text"]
|
||||
if extra:
|
||||
content += "\n\n[Graph context]\n" + extra
|
||||
results.append(
|
||||
AgnoDocument(
|
||||
content=content,
|
||||
id=doc.get("id"),
|
||||
name=doc.get("source"),
|
||||
meta_data=doc.get("metadata", {}),
|
||||
)
|
||||
)
|
||||
|
||||
logger.debug("search('%s') → %d documents (keyword)", query, len(results))
|
||||
return results
|
||||
|
||||
def load(
|
||||
self,
|
||||
path: Union[str, Path, None] = None,
|
||||
urls: Optional[List[str]] = None,
|
||||
texts: Optional[List[str]] = None,
|
||||
recursive: bool = False,
|
||||
recreate: bool = False,
|
||||
) -> None:
|
||||
"""
|
||||
Ingest documents into the knowledge graph.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
path:
|
||||
A file path, directory path, or glob pattern.
|
||||
urls:
|
||||
List of URLs to fetch and ingest.
|
||||
texts:
|
||||
Raw text strings to ingest directly.
|
||||
recursive:
|
||||
When ``path`` points to a directory, walk subdirectories.
|
||||
recreate:
|
||||
Drop all previously loaded documents before ingesting.
|
||||
"""
|
||||
if recreate:
|
||||
self._docs.clear()
|
||||
|
||||
if texts:
|
||||
for text in texts:
|
||||
self._ingest_text(text, source="<inline>")
|
||||
|
||||
if path is not None:
|
||||
self._ingest_path(Path(path), recursive=recursive)
|
||||
|
||||
if urls:
|
||||
self.load_urls(urls)
|
||||
|
||||
def load_urls(self, urls: List[str]) -> None:
|
||||
"""Fetch each URL and ingest the response body.
|
||||
|
||||
Only ``http`` and ``https`` schemes are permitted to prevent SSRF.
|
||||
"""
|
||||
import urllib.request
|
||||
from urllib.parse import urlparse
|
||||
|
||||
for url in urls:
|
||||
parsed = urlparse(url)
|
||||
if parsed.scheme not in ("http", "https"):
|
||||
logger.warning(
|
||||
"Skipping URL with disallowed scheme '%s': %s",
|
||||
parsed.scheme,
|
||||
url,
|
||||
)
|
||||
continue
|
||||
try:
|
||||
with urllib.request.urlopen(url, timeout=10) as resp: # noqa: S310
|
||||
text = resp.read().decode("utf-8", errors="replace")
|
||||
self._ingest_text(text, source=url)
|
||||
logger.info("Loaded URL: %s", url)
|
||||
except Exception as exc:
|
||||
logger.warning("Failed to fetch %s: %s", url, exc)
|
||||
|
||||
# AgentKnowledge also expects `load_documents`
|
||||
def load_documents(
|
||||
self,
|
||||
documents: List[Any],
|
||||
upsert: bool = False,
|
||||
) -> None:
|
||||
"""Ingest a list of Agno ``Document`` objects."""
|
||||
for doc in documents:
|
||||
text = getattr(doc, "content", None) or getattr(doc, "text", str(doc))
|
||||
source = getattr(doc, "name", None) or getattr(doc, "id", "<document>")
|
||||
self._ingest_text(text, source=source)
|
||||
|
||||
def get_graph_context(self, entity: str) -> str:
|
||||
"""
|
||||
Return a structured text representation of an entity's subgraph
|
||||
(neighbours and edge types), suitable for structured reasoning.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
entity:
|
||||
Root entity name (must have been added to the graph).
|
||||
|
||||
Returns
|
||||
-------
|
||||
str
|
||||
Multi-line text with nodes and labelled edge types.
|
||||
"""
|
||||
lines = [f"Entity: {entity}"]
|
||||
try:
|
||||
neighbours = self._graph.get_neighbors(node_id=entity, hops=1)
|
||||
for n in (neighbours or [])[:10]:
|
||||
if isinstance(n, dict):
|
||||
node_id = n.get("node_id", "")
|
||||
ntype = n.get("node_type", "")
|
||||
edge_type = n.get("edge_type", "related_to")
|
||||
suffix = f" (type: {ntype})" if ntype else ""
|
||||
lines.append(f" --[{edge_type}]--> {node_id}{suffix}")
|
||||
else:
|
||||
lines.append(f" --> {getattr(n, 'label', str(n))}")
|
||||
except Exception:
|
||||
pass
|
||||
return "\n".join(lines)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Internal helpers
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _chunk_text(self, text: str) -> List[str]:
|
||||
"""Split text into chunks at paragraph boundaries."""
|
||||
paragraphs = [p.strip() for p in text.split("\n\n") if p.strip()]
|
||||
if not paragraphs:
|
||||
return [text] if text.strip() else []
|
||||
|
||||
chunks: List[str] = []
|
||||
current: List[str] = []
|
||||
current_len = 0
|
||||
|
||||
for para in paragraphs:
|
||||
if current_len + len(para) > self.chunk_size and current:
|
||||
chunks.append("\n\n".join(current))
|
||||
current = []
|
||||
current_len = 0
|
||||
current.append(para)
|
||||
current_len += len(para)
|
||||
|
||||
if current:
|
||||
chunks.append("\n\n".join(current))
|
||||
|
||||
return chunks or [text]
|
||||
|
||||
def _ingest_text(self, text: str, source: str = "<text>") -> None:
|
||||
"""Run the full extraction pipeline and store in graph + doc list."""
|
||||
import uuid
|
||||
|
||||
chunks = self._chunk_text(text)
|
||||
all_entities: List[str] = []
|
||||
all_relations: List[Any] = []
|
||||
|
||||
for chunk in chunks:
|
||||
# NER
|
||||
ner_result: List[Any] = []
|
||||
try:
|
||||
ner_result = self._ner.extract_entities(chunk) or []
|
||||
chunk_entities = [getattr(e, "name", str(e)) for e in ner_result]
|
||||
all_entities.extend(chunk_entities)
|
||||
except Exception as exc:
|
||||
logger.debug("NER failed for chunk in '%s': %s", source, exc)
|
||||
|
||||
# Relation extraction
|
||||
try:
|
||||
chunk_relations = self._rel.extract_relations(chunk, entities=ner_result) or []
|
||||
all_relations.extend(chunk_relations)
|
||||
except Exception as exc:
|
||||
logger.debug("RelationExtractor failed for chunk in '%s': %s", source, exc)
|
||||
|
||||
# Graph build — graph_store is wired to self._graph in __init__
|
||||
try:
|
||||
sources = [
|
||||
{
|
||||
"text": text,
|
||||
"entities": all_entities,
|
||||
"relations": all_relations,
|
||||
"source": source,
|
||||
}
|
||||
]
|
||||
self._graph_builder.build(sources)
|
||||
except Exception as exc:
|
||||
logger.debug("GraphBuilder.build() failed for '%s': %s", source, exc)
|
||||
|
||||
# Vector index for AgentContext.retrieve()
|
||||
try:
|
||||
self._agent_context.store(text, conversation_id=source)
|
||||
except Exception as exc:
|
||||
logger.debug("AgentContext.store() failed for '%s': %s", source, exc)
|
||||
|
||||
# Cache document for keyword-search fallback
|
||||
self._docs.append(
|
||||
{
|
||||
"id": str(uuid.uuid4()),
|
||||
"text": text,
|
||||
"source": source,
|
||||
"entities": all_entities,
|
||||
"metadata": {"source": source},
|
||||
}
|
||||
)
|
||||
logger.debug(
|
||||
"Ingested '%s' — %d entities, %d relations, %d chunks",
|
||||
source,
|
||||
len(all_entities),
|
||||
len(all_relations),
|
||||
len(chunks),
|
||||
)
|
||||
|
||||
def _ingest_path(self, path: Path, recursive: bool = False) -> None:
|
||||
"""Walk a file or directory and ingest all text files."""
|
||||
if path.is_file():
|
||||
self._ingest_file(path)
|
||||
elif path.is_dir():
|
||||
pattern = "**/*" if recursive else "*"
|
||||
for child in path.glob(pattern):
|
||||
if child.is_file():
|
||||
self._ingest_file(child)
|
||||
else:
|
||||
logger.warning("Path not found: %s", path)
|
||||
|
||||
def _ingest_file(self, filepath: Path) -> None:
|
||||
try:
|
||||
text = filepath.read_text(encoding="utf-8", errors="replace")
|
||||
self._ingest_text(text, source=str(filepath))
|
||||
except Exception as exc:
|
||||
logger.warning("Could not read %s: %s", filepath, exc)
|
||||
|
||||
def _graph_context_for(self, entities: List[str]) -> str:
|
||||
"""Build a short text summary of graph neighbours for a set of entities."""
|
||||
if not entities:
|
||||
return ""
|
||||
lines: List[str] = []
|
||||
for entity in entities[:3]: # limit to avoid context bloat
|
||||
try:
|
||||
neighbours = self._graph.get_neighbors(node_id=entity, hops=1)
|
||||
for n in (neighbours or [])[:3]:
|
||||
if isinstance(n, dict):
|
||||
node_id = n.get("node_id", "")
|
||||
ntype = n.get("node_type", "")
|
||||
edge_type = n.get("edge_type", "related_to")
|
||||
lines.append(
|
||||
f"- {entity} --[{edge_type}]--> {node_id}"
|
||||
+ (f" ({ntype})" if ntype else "")
|
||||
)
|
||||
else:
|
||||
lines.append(f"- {entity} --> {getattr(n, 'label', str(n))}")
|
||||
except Exception:
|
||||
pass
|
||||
return "\n".join(lines)
|
||||
@@ -0,0 +1,292 @@
|
||||
"""
|
||||
AgnoSharedContext — Shared ContextGraph for Agno multi-agent teams.
|
||||
|
||||
A single ``ContextGraph`` is shared across all agents in an Agno ``Team``.
|
||||
Each agent gets a **role-scoped view** via ``bind_agent()``, which returns an
|
||||
``AgnoContextStore`` namespaced to that agent's role. This prevents
|
||||
contradictory decisions and enables knowledge reuse without coupling agent
|
||||
implementations.
|
||||
|
||||
Install
|
||||
-------
|
||||
pip install semantica[agno]
|
||||
|
||||
Example
|
||||
-------
|
||||
>>> from semantica.context import ContextGraph
|
||||
>>> from semantica.vector_store import VectorStore
|
||||
>>> from integrations.agno import AgnoSharedContext, AgnoDecisionKit, AgnoKGToolkit
|
||||
>>> shared = AgnoSharedContext(
|
||||
... vector_store=VectorStore(backend="faiss"),
|
||||
... knowledge_graph=ContextGraph(advanced_analytics=True),
|
||||
... decision_tracking=True,
|
||||
... )
|
||||
>>> from agno.agent import Agent
|
||||
>>> from agno.team import Team
|
||||
>>> researcher = Agent(
|
||||
... name="Researcher",
|
||||
... memory=shared.bind_agent("researcher"),
|
||||
... tools=[AgnoKGToolkit(context=shared)],
|
||||
... )
|
||||
>>> analyst = Agent(
|
||||
... name="Analyst",
|
||||
... memory=shared.bind_agent("analyst"),
|
||||
... tools=[AgnoDecisionKit(context=shared)],
|
||||
... )
|
||||
>>> team = Team(agents=[researcher, analyst], mode="coordinate")
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import threading
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from semantica.utils.logging import get_logger
|
||||
|
||||
from .context_store import AgnoContextStore
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
class _AgentScopedStore(AgnoContextStore):
|
||||
"""
|
||||
An ``AgnoContextStore`` bound to a specific agent role.
|
||||
|
||||
All operations are delegated to the parent ``AgnoSharedContext``'s
|
||||
``AgentContext`` but tagged with the agent's ``role`` for filtering.
|
||||
"""
|
||||
|
||||
def __init__(self, shared: "AgnoSharedContext", role: str) -> None:
|
||||
# Re-use the parent's context rather than creating a new one.
|
||||
# We skip the normal __init__ and wire all required parent attributes
|
||||
# directly so that inherited methods (record_decision, find_precedents,
|
||||
# retrieve, get_context_for_prompt) work correctly via self._context.
|
||||
self._role = role
|
||||
self._shared = shared
|
||||
self._memories: Dict[str, Any] = {}
|
||||
self.decision_tracking = shared.decision_tracking
|
||||
self.graph_expansion = shared.graph_expansion
|
||||
self.session_id = f"{shared.session_id}::{role}"
|
||||
# Use the attribute name the parent class expects.
|
||||
self._context = shared._context # type: ignore[attr-defined]
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Override upsert / record to tag with role
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def upsert_memory(self, memory: Any) -> Optional[Any]: # type: ignore[override]
|
||||
import uuid
|
||||
|
||||
mem_id = getattr(memory, "id", None) or str(uuid.uuid4())
|
||||
mem_text = getattr(memory, "memory", str(memory))
|
||||
|
||||
try:
|
||||
self._context.store(mem_text, conversation_id=self.session_id)
|
||||
except Exception as exc:
|
||||
logger.warning("[%s] store failed: %s", self._role, exc)
|
||||
|
||||
if self.decision_tracking:
|
||||
try:
|
||||
self._context.record_decision(
|
||||
category=f"memory:{self._role}",
|
||||
scenario=mem_text[:200],
|
||||
reasoning=f"Stored by agent role='{self._role}'",
|
||||
outcome="stored",
|
||||
confidence=1.0,
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if hasattr(memory, "id"):
|
||||
memory.id = mem_id
|
||||
self._memories[mem_id] = memory
|
||||
|
||||
# Also push into the shared registry so all agents can read it
|
||||
self._shared._shared_memories[mem_id] = memory
|
||||
|
||||
return memory
|
||||
|
||||
def read_memories( # type: ignore[override]
|
||||
self,
|
||||
user_id: Optional[str] = None,
|
||||
limit: Optional[int] = None,
|
||||
sort: Optional[str] = None,
|
||||
) -> List[Any]:
|
||||
# Return own memories + shared memories from all agents
|
||||
combined = dict(self._shared._shared_memories)
|
||||
combined.update(self._memories)
|
||||
|
||||
rows = list(combined.values())
|
||||
if user_id:
|
||||
rows = [r for r in rows if getattr(r, "user_id", None) == user_id]
|
||||
|
||||
reverse = sort != "asc"
|
||||
rows.sort(key=lambda r: getattr(r, "last_updated", 0), reverse=reverse)
|
||||
|
||||
if limit is not None:
|
||||
rows = rows[:limit]
|
||||
return rows
|
||||
|
||||
|
||||
class AgnoSharedContext:
|
||||
"""
|
||||
Shared context graph coordinator for Agno multi-agent teams.
|
||||
|
||||
Maintains a single ``AgentContext`` and ``ContextGraph`` that all agents
|
||||
access concurrently. Thread-safety is ensured via a reentrant lock.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
vector_store:
|
||||
Shared ``semantica.vector_store.VectorStore`` instance.
|
||||
knowledge_graph:
|
||||
Shared ``semantica.context.ContextGraph`` instance.
|
||||
decision_tracking:
|
||||
Enable decision recording for all bound agents.
|
||||
graph_expansion:
|
||||
Enable graph-hop expansion in all bound agents' ``read_memories``.
|
||||
session_id:
|
||||
Team-level session identifier (auto-generated when ``None``).
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
vector_store: Any = None,
|
||||
knowledge_graph: Any = None,
|
||||
decision_tracking: bool = True,
|
||||
graph_expansion: bool = True,
|
||||
session_id: Optional[str] = None,
|
||||
**agent_context_kwargs: Any,
|
||||
) -> None:
|
||||
import uuid
|
||||
|
||||
from semantica.context import AgentContext, ContextGraph
|
||||
from semantica.vector_store import VectorStore
|
||||
|
||||
self.decision_tracking = decision_tracking
|
||||
self.graph_expansion = graph_expansion
|
||||
self.session_id = session_id or str(uuid.uuid4())
|
||||
|
||||
if knowledge_graph is None:
|
||||
knowledge_graph = ContextGraph(advanced_analytics=True)
|
||||
|
||||
if vector_store is None:
|
||||
vector_store = VectorStore(backend="faiss")
|
||||
|
||||
self._context = AgentContext(
|
||||
vector_store=vector_store,
|
||||
knowledge_graph=knowledge_graph,
|
||||
decision_tracking=decision_tracking,
|
||||
**agent_context_kwargs,
|
||||
)
|
||||
self._knowledge_graph = knowledge_graph
|
||||
|
||||
# Shared memory pool (all agents read from this)
|
||||
self._shared_memories: Dict[str, Any] = {}
|
||||
self._lock = threading.RLock()
|
||||
self._bound_agents: Dict[str, _AgentScopedStore] = {}
|
||||
|
||||
logger.info(
|
||||
"AgnoSharedContext initialised (session=%s, decision_tracking=%s)",
|
||||
self.session_id,
|
||||
decision_tracking,
|
||||
)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Public API
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def bind_agent(self, role: str) -> _AgentScopedStore:
|
||||
"""
|
||||
Return a role-scoped ``AgnoContextStore`` for the given agent role.
|
||||
|
||||
Multiple calls with the same ``role`` return the **same** store
|
||||
instance (idempotent).
|
||||
|
||||
Parameters
|
||||
----------
|
||||
role:
|
||||
Agent role name, e.g. ``"researcher"``, ``"analyst"``.
|
||||
|
||||
Returns
|
||||
-------
|
||||
_AgentScopedStore
|
||||
An ``AgnoContextStore`` scoped to ``role`` backed by this shared
|
||||
context.
|
||||
"""
|
||||
with self._lock:
|
||||
if role not in self._bound_agents:
|
||||
store = _AgentScopedStore(shared=self, role=role)
|
||||
self._bound_agents[role] = store
|
||||
logger.info("Bound agent role='%s' to shared context", role)
|
||||
return self._bound_agents[role]
|
||||
|
||||
def record_decision(
|
||||
self,
|
||||
category: str,
|
||||
scenario: str,
|
||||
reasoning: str,
|
||||
outcome: str,
|
||||
confidence: float = 0.8,
|
||||
entities: Optional[List[str]] = None,
|
||||
agent_role: Optional[str] = None,
|
||||
) -> str:
|
||||
"""
|
||||
Record a decision into the shared context graph.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
agent_role:
|
||||
If provided, the decision is tagged with this agent's role.
|
||||
"""
|
||||
tagged_category = f"{category}:{agent_role}" if agent_role else category
|
||||
with self._lock:
|
||||
return self._context.record_decision(
|
||||
category=tagged_category,
|
||||
scenario=scenario,
|
||||
reasoning=reasoning,
|
||||
outcome=outcome,
|
||||
confidence=confidence,
|
||||
entities=entities,
|
||||
)
|
||||
|
||||
def find_precedents(
|
||||
self,
|
||||
scenario: str,
|
||||
category: Optional[str] = None,
|
||||
limit: int = 5,
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""Search all agents' decision history for similar precedents."""
|
||||
try:
|
||||
return self._context.find_precedents_advanced(
|
||||
scenario=scenario,
|
||||
category=category,
|
||||
limit=limit,
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.warning("find_precedents failed: %s", exc)
|
||||
return []
|
||||
|
||||
def get_shared_insights(self) -> Dict[str, Any]:
|
||||
"""Return analytics over the full shared decision graph."""
|
||||
try:
|
||||
return self._context.get_context_insights()
|
||||
except Exception as exc:
|
||||
logger.warning("get_shared_insights failed: %s", exc)
|
||||
return {}
|
||||
|
||||
@property
|
||||
def knowledge_graph(self) -> Any:
|
||||
"""Direct access to the shared ``ContextGraph``."""
|
||||
return self._knowledge_graph
|
||||
|
||||
@property
|
||||
def bound_roles(self) -> List[str]:
|
||||
"""List of agent roles currently bound to this shared context."""
|
||||
return list(self._bound_agents.keys())
|
||||
|
||||
def __repr__(self) -> str: # pragma: no cover
|
||||
return (
|
||||
f"AgnoSharedContext(session={self.session_id!r}, "
|
||||
f"agents={self.bound_roles})"
|
||||
)
|
||||
@@ -138,6 +138,7 @@ nav:
|
||||
- examples.md
|
||||
- glossary.md
|
||||
- Integrations:
|
||||
- Agno: integrations/agno.md
|
||||
- Docling: integrations/docling.md
|
||||
- Snowflake: integrations/snowflake.md
|
||||
- Cookbook: cookbook.md
|
||||
|
||||
+8
-1
@@ -173,6 +173,9 @@ gpu = [
|
||||
"cupy>=10.0.0"
|
||||
]
|
||||
|
||||
# ---- Agentic Framework Integrations ----
|
||||
agno = ["agno>=1.0.0"]
|
||||
|
||||
# ---- Splitting / Chunking ----
|
||||
split-tiktoken = ["tiktoken>=0.5.0"]
|
||||
split-community = ["python-louvain>=0.16"]
|
||||
@@ -211,6 +214,7 @@ explorer-lite = [
|
||||
# Everything
|
||||
all = [
|
||||
"semantica[dev,viz,gpu,infra,cloud,monitoring,llm-all,models-huggingface,split-all,graph-all,vectorstore-all,parse-docling,explorer]"
|
||||
"semantica[dev,viz,gpu,infra,cloud,monitoring,llm-all,models-huggingface,split-all,graph-all,vectorstore-all,parse-docling,agno]"
|
||||
]
|
||||
|
||||
# ---------------- ENTRYPOINTS ----------------
|
||||
@@ -223,7 +227,7 @@ semantica-explorer = "semantica.explorer:main"
|
||||
# ---------------- TOOLING ----------------
|
||||
[tool.setuptools.packages.find]
|
||||
where = ["."]
|
||||
include = ["semantica*"]
|
||||
include = ["semantica*", "integrations*"]
|
||||
|
||||
[tool.black]
|
||||
line-length = 88
|
||||
@@ -233,3 +237,6 @@ profile = "black"
|
||||
|
||||
[tool.pytest.ini_options]
|
||||
testpaths = ["tests"]
|
||||
markers = [
|
||||
"integration: marks tests that require external services or API keys (deselect with '-m not integration')",
|
||||
]
|
||||
|
||||
@@ -28,7 +28,8 @@ License: MIT
|
||||
import re
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime
|
||||
from typing import List, Optional
|
||||
from typing import List, Optional, Any, Dict, Tuple, Union, Set
|
||||
from enum import Enum
|
||||
|
||||
from ..utils.exceptions import ValidationError
|
||||
|
||||
@@ -106,3 +107,152 @@ class ChangeLogEntry:
|
||||
change_id=change_id,
|
||||
related_changes=related_changes or []
|
||||
)
|
||||
|
||||
class Severity(Enum):
|
||||
CRITICAL = "critical"
|
||||
HIGH = "high"
|
||||
MEDIUM = "medium"
|
||||
LOW = "low"
|
||||
INFO = "info"
|
||||
|
||||
class ChangeCategory(Enum):
|
||||
BREAKING = "breaking"
|
||||
POTENTIALLY_BREAKING = "potentially_breaking"
|
||||
NON_BREAKING = "non_breaking"
|
||||
UNKNOWN = "unknown"
|
||||
|
||||
@dataclass
|
||||
class ImpactReport:
|
||||
""" Structured impact analysis report."""
|
||||
timestamp: str = field(default_factory=lambda: datetime.now().isoformat())
|
||||
summary: Dict[str, Any] = field(default_factory=dict)
|
||||
breaking_changes: List[Dict[str, Any]] = field(default_factory=list)
|
||||
potentially_breaking: List[Dict[str, Any]] = field(default_factory=list)
|
||||
safe_changes: List[Dict[str, Any]] = field(default_factory=list)
|
||||
recommendations: List[str] = field(default_factory=list)
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
return {
|
||||
"timestamp": self.timestamp,
|
||||
"summary": self.summary,
|
||||
"impact_classification": {
|
||||
"breaking": self.breaking_changes,
|
||||
"potentially_breaking": self.potentially_breaking,
|
||||
"safe": self.safe_changes
|
||||
},
|
||||
"recommendations": self.recommendations
|
||||
}
|
||||
|
||||
class ChangeLogAnalyzer:
|
||||
"""
|
||||
Analyzes ontology diffs and classifies impact severity.
|
||||
"""
|
||||
|
||||
VALIDITY_CONSTRAINTS = {'domain', 'range', 'cardinality', 'max_cardinality'}
|
||||
STRUCTURAL_FIELDS = {'subclasses', 'superclasses', 'equivalent_to', 'disjoint_with'}
|
||||
|
||||
def analyze(self, diff: Dict[str, Any]) -> ImpactReport:
|
||||
report = ImpactReport()
|
||||
if not diff:
|
||||
report.summary = {"error": "Empty diff provided"}
|
||||
return report
|
||||
|
||||
all_changes = []
|
||||
|
||||
for key, entity_type, change_type in [
|
||||
("added_classes", "class", "added"), ("added_properties", "property", "added"),
|
||||
("removed_classes", "class", "removed"), ("removed_properties", "property", "removed"),
|
||||
("changed_classes", "class", "modified"), ("changed_properties", "property", "modified")
|
||||
]:
|
||||
for item in diff.get(key, []):
|
||||
all_changes.append({
|
||||
"uri": item.get("uri", item.get("name", "unknown")),
|
||||
"entity_type": entity_type,
|
||||
"change_type": change_type,
|
||||
"changes": item.get("changes", {})
|
||||
})
|
||||
|
||||
report.summary = {"total_changes": len(all_changes)}
|
||||
|
||||
for change in all_changes:
|
||||
severity, category, description, mitigation = self._classify_change(change)
|
||||
entry = {
|
||||
"entity_uri": change['uri'],
|
||||
"entity_type": change['entity_type'],
|
||||
"change_type": change['change_type'],
|
||||
"description": description,
|
||||
"severity": severity.value,
|
||||
"mitigation": mitigation
|
||||
}
|
||||
|
||||
if category == ChangeCategory.BREAKING:
|
||||
report.breaking_changes.append(entry)
|
||||
elif category == ChangeCategory.POTENTIALLY_BREAKING:
|
||||
report.potentially_breaking.append(entry)
|
||||
else:
|
||||
report.safe_changes.append(entry)
|
||||
|
||||
self._generate_recommendations(report)
|
||||
return report
|
||||
|
||||
|
||||
def _classify_change(self, change: Dict[str, Any]) -> Tuple[Severity, ChangeCategory, str, str]:
|
||||
change_type = change.get('change_type')
|
||||
entity_type = change.get('entity_type')
|
||||
uri = change.get('uri')
|
||||
|
||||
if change_type == 'removed':
|
||||
if entity_type == 'class':
|
||||
return (Severity.CRITICAL, ChangeCategory.BREAKING, f"Class {uri} removed.", "Migrate orphaned instances.")
|
||||
return (Severity.CRITICAL, ChangeCategory.BREAKING, f"Property {uri} removed.", "Migrate property values.")
|
||||
|
||||
if change_type == 'added':
|
||||
return (Severity.INFO, ChangeCategory.NON_BREAKING, f"New {entity_type} {uri} added.", "No action required.")
|
||||
|
||||
if change_type == 'modified':
|
||||
return self._analyze_field_changes(uri, change.get('changes', {}))
|
||||
|
||||
return (Severity.LOW, ChangeCategory.UNKNOWN, f"Unknown change for {uri}", "Manual review required.")
|
||||
|
||||
def _analyze_field_changes(self, uri: str, field_changes: Dict[str, Any]) -> Tuple[Severity, ChangeCategory, str, str]:
|
||||
has_restriction = False
|
||||
has_structural = False
|
||||
|
||||
for field, vals in field_changes.items():
|
||||
if field in self.VALIDITY_CONSTRAINTS:
|
||||
old_val, new_val = vals.get("old"), vals.get("new")
|
||||
|
||||
if old_val is None or new_val is None:
|
||||
has_restriction = True
|
||||
continue
|
||||
|
||||
# if new constraint is smaller, it is a restriction
|
||||
old_set = set(old_val) if isinstance(old_val, list) else {old_val}
|
||||
new_set = set(new_val) if isinstance(new_val, list) else {new_val}
|
||||
|
||||
if new_set < old_set:
|
||||
has_restriction = True
|
||||
|
||||
elif field in self.STRUCTURAL_FIELDS:
|
||||
has_structural = True
|
||||
|
||||
if has_restriction:
|
||||
return (Severity.HIGH, ChangeCategory.BREAKING, f"Domain/range restricted on {uri}", "Validate existing data against new constraints.")
|
||||
if has_structural:
|
||||
return (Severity.MEDIUM, ChangeCategory.POTENTIALLY_BREAKING, f"Hierarchy modified for {uri}", "Check dependent reasoning chains.")
|
||||
|
||||
return (Severity.LOW, ChangeCategory.NON_BREAKING, f"Safe annotations updated for {uri}", "No action required.")
|
||||
|
||||
def _generate_recommendations(self, report: ImpactReport) -> None:
|
||||
if report.breaking_changes:
|
||||
report.recommendations.append("[BREAKING] Schedule downtime or validate existing data.")
|
||||
if report.potentially_breaking:
|
||||
report.recommendations.append("[WARNING] Run full regression tests on queries.")
|
||||
if not report.breaking_changes and not report.potentially_breaking:
|
||||
report.recommendations.append("[SAFE] Minor version bump sufficient.")
|
||||
|
||||
|
||||
def generate_change_report(diff: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""Public API for generating impact reports from diffs."""
|
||||
analyzer = ChangeLogAnalyzer()
|
||||
return analyzer.analyze(diff).to_dict()
|
||||
|
||||
@@ -295,6 +295,98 @@ class VersionManager:
|
||||
"axioms_removed": len(axioms_removed)
|
||||
}
|
||||
}
|
||||
|
||||
def diff_ontologies(self, base: Dict[str, Any], target: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""
|
||||
Computes a structured diff between two ontology versions.
|
||||
"""
|
||||
def _compute_section_diff(base_list, target_list):
|
||||
|
||||
base_map = {}
|
||||
for item in base_list:
|
||||
if isinstance(item, dict):
|
||||
key = item.get("uri") or item.get("name")
|
||||
if key:
|
||||
base_map[key] = item
|
||||
elif isinstance(item, str):
|
||||
base_map[item] = {"uri": item}
|
||||
|
||||
target_map = {}
|
||||
for item in target_list:
|
||||
if isinstance(item, dict):
|
||||
key = item.get("uri") or item.get("name")
|
||||
if key:
|
||||
target_map[key] = item
|
||||
elif isinstance(item, str):
|
||||
target_map[item] = {"uri": item}
|
||||
|
||||
added, removed, changed = [], [], []
|
||||
|
||||
# Find Added and Changed
|
||||
for key, t_item in target_map.items():
|
||||
if key not in base_map:
|
||||
added.append(t_item)
|
||||
else:
|
||||
b_item = base_map[key]
|
||||
changes = {}
|
||||
|
||||
all_fields = set(b_item.keys()).union(t_item.keys())
|
||||
for field in all_fields:
|
||||
if field in ["uri", "name"]:
|
||||
continue
|
||||
|
||||
b_val = b_item.get(field)
|
||||
t_val = t_item.get(field)
|
||||
|
||||
# Deep equality check for lists
|
||||
if isinstance(b_val, list) and isinstance(t_val, list):
|
||||
if set(str(x) for x in b_val) != set(str(x) for x in t_val):
|
||||
changes[field] = {"old": b_val, "new": t_val}
|
||||
elif b_val != t_val:
|
||||
changes[field] = {"old": b_val, "new": t_val}
|
||||
|
||||
if changes:
|
||||
changed.append({
|
||||
"uri": t_item.get("uri") or key,
|
||||
"name": t_item.get("name") or key,
|
||||
"changes": changes
|
||||
})
|
||||
|
||||
# Find deleted
|
||||
for key, b_item in base_map.items():
|
||||
if key not in target_map:
|
||||
removed.append(b_item)
|
||||
|
||||
return added, removed, changed
|
||||
|
||||
|
||||
classes_added, classes_removed, classes_changed = _compute_section_diff(
|
||||
base.get("classes", []), target.get("classes", [])
|
||||
)
|
||||
props_added, props_removed, props_changed = _compute_section_diff(
|
||||
base.get("properties", []), target.get("properties", [])
|
||||
)
|
||||
inds_added, inds_removed, inds_changed = _compute_section_diff(
|
||||
base.get("individuals", []), target.get("individuals", [])
|
||||
)
|
||||
axioms_added, axioms_removed, axioms_changed = _compute_section_diff(
|
||||
base.get("axioms", []), target.get("axioms", [])
|
||||
)
|
||||
|
||||
return {
|
||||
"added_classes": classes_added,
|
||||
"removed_classes": classes_removed,
|
||||
"changed_classes": classes_changed,
|
||||
"added_properties": props_added,
|
||||
"removed_properties": props_removed,
|
||||
"changed_properties": props_changed,
|
||||
"added_individuals": inds_added,
|
||||
"removed_individuals": inds_removed,
|
||||
"changed_individuals": inds_changed,
|
||||
"added_axioms": axioms_added,
|
||||
"removed_axioms": axioms_removed,
|
||||
"changed_axioms": axioms_changed,
|
||||
}
|
||||
|
||||
def get_version(self, version: str) -> Optional[OntologyVersion]:
|
||||
"""Get version by version string."""
|
||||
|
||||
@@ -951,10 +951,11 @@ class DecisionQuery:
|
||||
|
||||
for measure_type, measure_data in centrality_measures.items():
|
||||
if isinstance(measure_data, dict) and 'centrality' in measure_data:
|
||||
decision_measures[measure_type] = measure_data['centrality'].get(decision_id, 0.0)
|
||||
|
||||
val = measure_data['centrality'].get(decision_id, 0.0)
|
||||
decision_measures[measure_type] = val if isinstance(val, (int, float)) else 0.0
|
||||
|
||||
analysis["centrality_measures"] = decision_measures
|
||||
|
||||
|
||||
# Calculate overall influence score
|
||||
measures = analysis["centrality_measures"]
|
||||
analysis["influence_score"] = (
|
||||
|
||||
@@ -34,10 +34,6 @@ from datetime import datetime
|
||||
from typing import Any, Dict, List, Optional
|
||||
from urllib.parse import urlparse
|
||||
|
||||
import sqlalchemy
|
||||
from sqlalchemy import create_engine, inspect, text
|
||||
from sqlalchemy.engine import Engine
|
||||
|
||||
from ..utils.exceptions import ProcessingError, ValidationError
|
||||
from ..utils.logging import get_logger
|
||||
from ..utils.progress_tracker import get_progress_tracker
|
||||
@@ -101,13 +97,13 @@ class DatabaseConnector:
|
||||
self.logger = get_logger("database_connector")
|
||||
self.db_type = db_type.lower() if db_type else ""
|
||||
self.config = config
|
||||
self.engine: Optional[Engine] = None
|
||||
self.engine: Optional[Any] = None
|
||||
|
||||
self.logger.debug(
|
||||
f"Database connector initialized: db_type={db_type or 'auto-detect'}"
|
||||
)
|
||||
|
||||
def connect(self, connection_string: str) -> Engine:
|
||||
def connect(self, connection_string: str) -> Any:
|
||||
"""
|
||||
Establish database connection.
|
||||
|
||||
@@ -129,6 +125,14 @@ class DatabaseConnector:
|
||||
ProcessingError: If connection fails or database type is unsupported
|
||||
"""
|
||||
try:
|
||||
try:
|
||||
from sqlalchemy import create_engine, text
|
||||
except ImportError:
|
||||
raise ProcessingError(
|
||||
"sqlalchemy is required for database ingestion. "
|
||||
"Install with: pip install sqlalchemy"
|
||||
)
|
||||
|
||||
# Parse connection string to detect database type
|
||||
parsed = urlparse(connection_string)
|
||||
|
||||
@@ -188,6 +192,7 @@ class DatabaseConnector:
|
||||
bool: True if connection successful, False otherwise
|
||||
"""
|
||||
try:
|
||||
from sqlalchemy import create_engine, text
|
||||
engine = create_engine(connection_string)
|
||||
with engine.connect() as conn:
|
||||
conn.execute(text("SELECT 1"))
|
||||
@@ -226,7 +231,7 @@ class DataExporter:
|
||||
|
||||
def export_table_data(
|
||||
self,
|
||||
connection: Engine,
|
||||
connection: Any,
|
||||
table_name: str,
|
||||
schema: Optional[str] = None,
|
||||
limit: Optional[int] = None,
|
||||
@@ -264,6 +269,7 @@ class DataExporter:
|
||||
ProcessingError: If table export fails
|
||||
"""
|
||||
try:
|
||||
from sqlalchemy import inspect
|
||||
inspector = inspect(connection)
|
||||
|
||||
# Get column information
|
||||
@@ -379,7 +385,7 @@ class DataExporter:
|
||||
return transformed
|
||||
|
||||
def export_schema(
|
||||
self, connection: Engine, schema: Optional[str] = None
|
||||
self, connection: Any, schema: Optional[str] = None
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Export database schema information.
|
||||
@@ -406,6 +412,7 @@ class DataExporter:
|
||||
ProcessingError: If schema export fails
|
||||
"""
|
||||
try:
|
||||
from sqlalchemy import inspect
|
||||
inspector = inspect(connection)
|
||||
|
||||
schema_info = {"tables": [], "views": [], "foreign_keys": []}
|
||||
@@ -591,6 +598,7 @@ class DBIngestor:
|
||||
schema = self.analyze_schema(connection_string)
|
||||
|
||||
# Get all table names
|
||||
from sqlalchemy import inspect
|
||||
inspector = inspect(engine)
|
||||
all_tables = inspector.get_table_names()
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@ from .owl_generator import OWLGenerator
|
||||
from .ontology_evaluator import OntologyEvaluator
|
||||
from .ontology_validator import OntologyValidator
|
||||
from .llm_generator import LLMOntologyGenerator
|
||||
from ..semantic_extract.triplet_extractor import Triplet
|
||||
|
||||
|
||||
class OntologyEngine:
|
||||
@@ -25,6 +26,11 @@ class OntologyEngine:
|
||||
self.evaluator = OntologyEvaluator(**config)
|
||||
self.validator = OntologyValidator(**config)
|
||||
self.llm = LLMOntologyGenerator(**config)
|
||||
self.store = config.get("store")
|
||||
|
||||
# Deferred to avoid circular import: change_management → ontology → change_management
|
||||
from ..change_management.ontology_version_manager import VersionManager
|
||||
self.version_manager = config.get("version_manager") or VersionManager(**config)
|
||||
|
||||
def from_data(self, data: Dict[str, Any], **options) -> Dict[str, Any]:
|
||||
tracking_id = self.progress.start_tracking(
|
||||
@@ -56,7 +62,135 @@ class OntologyEngine:
|
||||
**options,
|
||||
) -> List[Dict[str, Any]]:
|
||||
return self.propgen.infer_properties(entities, relationships, classes, **options)
|
||||
|
||||
def _sanitize_uri(self, uri: str) -> str:
|
||||
"""Prevent SPARQL injection by percent-encoding dangerous characters."""
|
||||
if not isinstance(uri, str):
|
||||
return ""
|
||||
return uri.replace("<", "%3C").replace(">", "%3E")
|
||||
|
||||
def create_alignment(self, source_uri: str, target_uri: str, predicate: str, **options) -> None:
|
||||
"""
|
||||
Creates an alignment between two ontology entities and stores it.
|
||||
"""
|
||||
if not self.store:
|
||||
raise ProcessingError("TripletStore instance not configured in OntologyEngine.")
|
||||
|
||||
if not predicate.startswith(("http://", "https://")):
|
||||
raise ProcessingError(
|
||||
f"predicate must be a full URI (e.g. 'http://www.w3.org/2002/07/owl#equivalentClass'), "
|
||||
f"not a CURIE: '{predicate}'"
|
||||
)
|
||||
|
||||
tracking_id = self.progress.start_tracking(
|
||||
module="ontology",
|
||||
submodule="OntologyEngine",
|
||||
message=f"Creating alignment: {source_uri} -> {target_uri}"
|
||||
)
|
||||
try:
|
||||
triplet = Triplet(subject=source_uri, predicate=predicate, object=target_uri)
|
||||
self.store.add_triplet(triplet, **options)
|
||||
|
||||
self.progress.stop_tracking(tracking_id, status="completed", message="Alignment created")
|
||||
except Exception as e:
|
||||
self.progress.stop_tracking(tracking_id, status="failed", message=str(e))
|
||||
self.logger.error(f"Failed to create alignment: {e}")
|
||||
raise ProcessingError(f"Alignment creation failed: {e}")
|
||||
|
||||
def get_alignments(self, entity_uri: str, **options) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
Retrieves all alignments for a specific entity URI (bidirectional).
|
||||
"""
|
||||
if not self.store:
|
||||
raise ProcessingError("TripletStore instance not configured in OntologyEngine.")
|
||||
|
||||
safe_uri = self._sanitize_uri(entity_uri)
|
||||
|
||||
query = f"""
|
||||
SELECT ?s ?p ?o WHERE {{
|
||||
{{ <{safe_uri}> ?p ?o . BIND(<{safe_uri}> AS ?s) }}
|
||||
UNION
|
||||
{{ ?s ?p <{safe_uri}> . BIND(<{safe_uri}> AS ?o) }}
|
||||
|
||||
FILTER (?p IN (
|
||||
<http://www.w3.org/2002/07/owl#equivalentClass>,
|
||||
<http://www.w3.org/2002/07/owl#equivalentProperty>,
|
||||
<http://www.w3.org/2002/07/owl#sameAs>,
|
||||
<http://www.w3.org/2004/02/skos/core#exactMatch>,
|
||||
<http://www.w3.org/2004/02/skos/core#closeMatch>,
|
||||
<http://www.w3.org/2004/02/skos/core#broadMatch>,
|
||||
<http://www.w3.org/2004/02/skos/core#narrowMatch>,
|
||||
<http://www.w3.org/2004/02/skos/core#relatedMatch>
|
||||
))
|
||||
}}
|
||||
"""
|
||||
try:
|
||||
results = self.store.execute_query(query, **options)
|
||||
|
||||
alignments = []
|
||||
if hasattr(results, 'bindings'):
|
||||
for b in results.bindings:
|
||||
alignments.append({
|
||||
"source": b.get("s", {}).get("value") if isinstance(b.get("s"), dict) else b.get("s"),
|
||||
"predicate": b.get("p", {}).get("value") if isinstance(b.get("p"), dict) else b.get("p"),
|
||||
"target": b.get("o", {}).get("value") if isinstance(b.get("o"), dict) else b.get("o")
|
||||
})
|
||||
return alignments
|
||||
except Exception as e:
|
||||
self.logger.error(f"Failed to get alignments for {entity_uri}: {e}")
|
||||
raise ProcessingError(f"Failed to get alignments: {e}")
|
||||
|
||||
def list_alignments(self, ontology_uri: Optional[str] = None, **options) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
Lists all alignments, optionally filtered by an ontology URI.
|
||||
"""
|
||||
if not self.store:
|
||||
raise ProcessingError("TripletStore instance not configured in OntologyEngine.")
|
||||
|
||||
filter_clause = ""
|
||||
if ontology_uri:
|
||||
# Sanitize characters that could break out of the SPARQL string literal or WHERE block
|
||||
safe_ontology_uri = (
|
||||
ontology_uri
|
||||
.replace("\\", "%5C")
|
||||
.replace('"', '%22')
|
||||
.replace("{", "%7B")
|
||||
.replace("}", "%7D")
|
||||
)
|
||||
filter_clause = f'FILTER(STRSTARTS(STR(?s), "{safe_ontology_uri}") || STRSTARTS(STR(?o), "{safe_ontology_uri}"))'
|
||||
|
||||
query = f"""
|
||||
SELECT ?s ?p ?o WHERE {{
|
||||
?s ?p ?o .
|
||||
FILTER (?p IN (
|
||||
<http://www.w3.org/2002/07/owl#equivalentClass>,
|
||||
<http://www.w3.org/2002/07/owl#equivalentProperty>,
|
||||
<http://www.w3.org/2002/07/owl#sameAs>,
|
||||
<http://www.w3.org/2004/02/skos/core#exactMatch>,
|
||||
<http://www.w3.org/2004/02/skos/core#closeMatch>,
|
||||
<http://www.w3.org/2004/02/skos/core#broadMatch>,
|
||||
<http://www.w3.org/2004/02/skos/core#narrowMatch>,
|
||||
<http://www.w3.org/2004/02/skos/core#relatedMatch>
|
||||
))
|
||||
{filter_clause}
|
||||
}}
|
||||
"""
|
||||
try:
|
||||
results = self.store.execute_query(query, **options)
|
||||
|
||||
alignments = []
|
||||
if hasattr(results, 'bindings'):
|
||||
for b in results.bindings:
|
||||
alignments.append({
|
||||
"source": b.get("s", {}).get("value") if isinstance(b.get("s"), dict) else b.get("s"),
|
||||
"predicate": b.get("p", {}).get("value") if isinstance(b.get("p"), dict) else b.get("p"),
|
||||
"target": b.get("o", {}).get("value") if isinstance(b.get("o"), dict) else b.get("o")
|
||||
})
|
||||
return alignments
|
||||
except Exception as e:
|
||||
self.logger.error(f"Failed to list alignments: {e}")
|
||||
raise ProcessingError(f"Failed to list alignments: {e}")
|
||||
|
||||
def evaluate(self, ontology: Dict[str, Any], **options):
|
||||
return self.evaluator.evaluate_ontology(ontology, **options)
|
||||
|
||||
@@ -69,3 +203,70 @@ class OntologyEngine:
|
||||
def export_owl(self, ontology: Dict[str, Any], path: str, format: str = "turtle"):
|
||||
return self.owl.export_owl(ontology, path, format=format)
|
||||
|
||||
def get_ontology_version_dict(self, version_id: str) -> Dict[str, Any]:
|
||||
"""Utility to load an ontology version as plain dict ready for diffing."""
|
||||
version_record = self.version_manager.get_version(version_id)
|
||||
if not version_record:
|
||||
raise ProcessingError(f"Version {version_id} not found.")
|
||||
return version_record.metadata.get("structure", {"classes": [], "properties": []})
|
||||
|
||||
def compare_versions(self, base_id: str, target_id: str, **options) -> Dict[str, Any]:
|
||||
"""
|
||||
Orchestrates version loading, diff computation, and report generation.
|
||||
|
||||
Args:
|
||||
base_id: Version ID of the old ontology
|
||||
target_id: Version ID of the new ontology
|
||||
**options: Can pass 'base_dict' and 'target_dict' directly to bypass loading.
|
||||
Can pass 'run_validation=True' to validate schema.
|
||||
Can pass 'graph_data' to validate instances against new schema.
|
||||
|
||||
Returns:
|
||||
A structured dictionary containing the impact report and machine-readable diff.
|
||||
"""
|
||||
tracking_id = self.progress.start_tracking(
|
||||
module="ontology",
|
||||
submodule="OntologyEngine",
|
||||
message=f"Comparing ontology versions: {base_id} -> {target_id}"
|
||||
)
|
||||
|
||||
try:
|
||||
# Deferred to avoid circular import
|
||||
from ..change_management.change_log import generate_change_report
|
||||
from ..kg.graph_validator import GraphValidator
|
||||
|
||||
base_dict = options.get("base_dict") or self.get_ontology_version_dict(base_id)
|
||||
target_dict = options.get("target_dict") or self.get_ontology_version_dict(target_id)
|
||||
|
||||
diff_result = self.version_manager.diff_ontologies(base_dict, target_dict)
|
||||
report = generate_change_report(diff_result)
|
||||
report["diff"] = diff_result
|
||||
|
||||
if options.get("run_validation"):
|
||||
self.progress.update_tracking(tracking_id, message="Running validation on target schema...")
|
||||
val_res = self.validate(target_dict, **options)
|
||||
report["validation_results"] = {
|
||||
"valid": getattr(val_res, "valid", getattr(val_res, "is_valid", False)),
|
||||
"consistent": getattr(val_res, "consistent", True),
|
||||
"satisfiable": getattr(val_res, "satisfiable", True),
|
||||
"errors": getattr(val_res, "errors", []),
|
||||
"warnings": getattr(val_res, "warnings", [])
|
||||
}
|
||||
|
||||
if "graph_data" in options:
|
||||
self.progress.update_tracking(tracking_id, message="Running graph data validation...")
|
||||
kg_validator = GraphValidator(**self.config)
|
||||
kg_res = kg_validator.validate(options["graph_data"], ontology=target_dict, **options)
|
||||
report["graph_validation"] = {
|
||||
"valid": getattr(kg_res, "valid", getattr(kg_res, "is_valid", False)),
|
||||
"errors": getattr(kg_res, "errors", []),
|
||||
"warnings": getattr(kg_res, "warnings", [])
|
||||
}
|
||||
|
||||
self.progress.stop_tracking(tracking_id, status="completed", message="Comparison complete")
|
||||
return report
|
||||
|
||||
except Exception as e:
|
||||
self.progress.stop_tracking(tracking_id, status="failed", message=str(e))
|
||||
self.logger.error(f"Failed to compare versions: {e}")
|
||||
raise ProcessingError(f"Version comparison failed: {e}") from e
|
||||
|
||||
@@ -206,6 +206,31 @@ class NamespaceManager:
|
||||
Dictionary of prefix -> URI mappings
|
||||
"""
|
||||
return dict(self.namespaces)
|
||||
|
||||
def get_alignment_predicates(self) -> Dict[str, str]:
|
||||
"""
|
||||
Get standard alignment predicates for ontology mapping.
|
||||
|
||||
Returns:
|
||||
Dictionary mapping common alignment types to their full URIs.
|
||||
"""
|
||||
owl_ns = self.get_namespace("owl")
|
||||
skos_ns = self.get_namespace("skos")
|
||||
|
||||
return {
|
||||
#OWL alignments
|
||||
"equivalentClass": f"{owl_ns}equivalentClass",
|
||||
"equivalentProperty": f"{owl_ns}equivalentProperty",
|
||||
"sameAs": f"{owl_ns}sameAs",
|
||||
#SKOS alignments
|
||||
"exactMatch": f"{skos_ns}exactMatch",
|
||||
"closeMatch": f"{skos_ns}closeMatch",
|
||||
"broadMatch": f"{skos_ns}broadMatch",
|
||||
"narrowMatch": f"{skos_ns}narrowMatch",
|
||||
"relatedMatch": f"{skos_ns}relatedMatch",
|
||||
|
||||
}
|
||||
|
||||
|
||||
def _to_pascal_case(self, name: str) -> str:
|
||||
"""Convert name to PascalCase."""
|
||||
|
||||
@@ -363,6 +363,54 @@ class ReuseManager:
|
||||
def list_known_ontologies(self) -> List[str]:
|
||||
"""List known ontology URIs."""
|
||||
return list(self.known_ontologies.keys())
|
||||
|
||||
def suggest_alignments(
|
||||
self, target: Dict[str, Any], source: Dict[str, Any], **options
|
||||
) -> List[Dict[str, str]]:
|
||||
"""
|
||||
Suggest alignments between a source and target ontology based on heuristics.
|
||||
"""
|
||||
suggestions = []
|
||||
|
||||
# Nested function for DRY
|
||||
|
||||
def find_matches(target_items, source_items, entity_type):
|
||||
predicate = (
|
||||
"http://www.w3.org/2002/07/owl#equivalentClass"
|
||||
if entity_type == "class"
|
||||
else "http://www.w3.org/2002/07/owl#equivalentProperty"
|
||||
)
|
||||
|
||||
# Build hash map of target entities by normalized name
|
||||
target_map = {}
|
||||
for t_item in target_items:
|
||||
t_uri = t_item.get("uri")
|
||||
t_name = t_item.get("name", "").strip().lower()
|
||||
if t_uri and t_name:
|
||||
target_map.setdefault(t_name, []).append(t_uri)
|
||||
|
||||
# Single pass through source items checking the hash map
|
||||
for s_item in source_items:
|
||||
s_uri = s_item.get("uri")
|
||||
s_name = s_item.get("name", "").strip().lower()
|
||||
if not s_uri or not s_name:
|
||||
continue
|
||||
|
||||
if s_name in target_map:
|
||||
for t_uri in target_map[s_name]:
|
||||
if s_uri != t_uri:
|
||||
suggestions.append({
|
||||
"source_uri": s_uri,
|
||||
"target_uri": t_uri,
|
||||
"predicate": predicate,
|
||||
"reason": f"Exact label match for {entity_type}: '{s_item.get('name')}'"
|
||||
})
|
||||
|
||||
find_matches(target.get("classes", []), source.get("classes", []), "class")
|
||||
find_matches(target.get("properties", []), source.get("properties", []), "property")
|
||||
|
||||
return suggestions
|
||||
|
||||
|
||||
def merge_ontology_data(
|
||||
self, target: Dict[str, Any], source: Dict[str, Any], **options
|
||||
@@ -437,6 +485,16 @@ class ReuseManager:
|
||||
for imp in source["imports"]:
|
||||
if imp not in target["imports"]:
|
||||
target["imports"].append(imp)
|
||||
|
||||
if options.get("compute_alignments", False):
|
||||
self.progress_tracker.update_tracking(
|
||||
tracking_id, message="Computing suggested alignments..."
|
||||
)
|
||||
suggested = self.suggest_alignments(target, source, **options)
|
||||
if suggested:
|
||||
if "suggested_alignments" not in target:
|
||||
target["suggested_alignments"] = []
|
||||
target["suggested_alignments"].extend(suggested)
|
||||
|
||||
self.progress_tracker.stop_tracking(
|
||||
tracking_id,
|
||||
|
||||
@@ -33,9 +33,6 @@ from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List, Optional, Union
|
||||
|
||||
import pdfplumber
|
||||
from PIL import Image
|
||||
|
||||
from ..utils.exceptions import ProcessingError, ValidationError
|
||||
from ..utils.logging import get_logger
|
||||
from ..utils.progress_tracker import get_progress_tracker
|
||||
@@ -119,6 +116,13 @@ class PDFParser:
|
||||
raise ValidationError(f"File is not a PDF: {file_path}")
|
||||
|
||||
try:
|
||||
try:
|
||||
import pdfplumber
|
||||
except ImportError:
|
||||
raise ProcessingError(
|
||||
"pdfplumber is required for PDF parsing. "
|
||||
"Install with: pip install pdfplumber"
|
||||
)
|
||||
with pdfplumber.open(str(file_path)) as pdf:
|
||||
# Extract metadata
|
||||
metadata = self._extract_metadata(pdf)
|
||||
|
||||
@@ -32,8 +32,6 @@ from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List, Optional, Union
|
||||
|
||||
from pptx import Presentation
|
||||
|
||||
from ..utils.exceptions import ProcessingError, ValidationError
|
||||
from ..utils.logging import get_logger
|
||||
from ..utils.progress_tracker import get_progress_tracker
|
||||
@@ -97,6 +95,13 @@ class PPTXParser:
|
||||
raise ValidationError(f"File is not a PPTX: {file_path}")
|
||||
|
||||
try:
|
||||
try:
|
||||
from pptx import Presentation
|
||||
except ImportError:
|
||||
raise ProcessingError(
|
||||
"python-pptx is required for PPTX parsing. "
|
||||
"Install with: pip install python-pptx"
|
||||
)
|
||||
prs = Presentation(str(file_path))
|
||||
|
||||
# Extract metadata
|
||||
@@ -220,7 +225,7 @@ class PPTXParser:
|
||||
images=images,
|
||||
)
|
||||
|
||||
def _extract_metadata(self, prs: Presentation) -> Dict[str, Any]:
|
||||
def _extract_metadata(self, prs: Any) -> Dict[str, Any]:
|
||||
"""Extract presentation metadata."""
|
||||
metadata = {}
|
||||
|
||||
|
||||
@@ -3,7 +3,8 @@ Reasoning Module
|
||||
|
||||
This module provides reasoning and inference capabilities for knowledge graph
|
||||
analysis and query answering, supporting multiple reasoning strategies including
|
||||
rule-based inference via Rete, SPARQL reasoning, abductive and deductive reasoning.
|
||||
rule-based inference via Rete, SPARQL reasoning, abductive and deductive reasoning,
|
||||
and native Datalog evaluation.
|
||||
"""
|
||||
|
||||
from .reasoner import Reasoner, InferenceResult, Rule, Fact, RuleType
|
||||
@@ -25,6 +26,8 @@ from .rete_engine import (
|
||||
)
|
||||
from .sparql_reasoner import SPARQLQueryResult, SPARQLReasoner
|
||||
|
||||
from .datalog_reasoner import DatalogReasoner, DatalogFact, DatalogRule
|
||||
|
||||
__all__ = [
|
||||
# Reasoner facade
|
||||
"Reasoner",
|
||||
@@ -43,10 +46,14 @@ __all__ = [
|
||||
# SPARQL reasoning
|
||||
"SPARQLReasoner",
|
||||
"SPARQLQueryResult",
|
||||
# Datalog reasoning
|
||||
"DatalogReasoner",
|
||||
"DatalogFact",
|
||||
"DatalogRule",
|
||||
# Explanation
|
||||
"ExplanationGenerator",
|
||||
"Explanation",
|
||||
"ReasoningStep",
|
||||
"ReasoningPath",
|
||||
"Justification",
|
||||
]
|
||||
]
|
||||
@@ -0,0 +1,432 @@
|
||||
"""
|
||||
Datalog reasoner module
|
||||
|
||||
This module provides a native Datalog engine using bottom-up semi-naive fixpoint evaluation.
|
||||
It supports recursive rules, multi-hop inference, and guarantees termination on finite graphs.
|
||||
"""
|
||||
|
||||
import re
|
||||
from collections import defaultdict
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, Dict, List, NamedTuple, Optional, Set, Tuple, Union
|
||||
|
||||
from ..utils.logging import get_logger
|
||||
from ..utils.progress_tracker import get_progress_tracker
|
||||
|
||||
# data structs
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class DatalogFact:
|
||||
"""Represents a ground truth fact."""
|
||||
predicate: str
|
||||
args: Tuple[str, ...]
|
||||
|
||||
class BodyAtom(NamedTuple):
|
||||
"""Represents a single predicate condition in a rule's body."""
|
||||
predicate: str
|
||||
args: Tuple[str, ...]
|
||||
|
||||
@dataclass
|
||||
class DatalogRule:
|
||||
"""Represents a Horn clause rule."""
|
||||
head_predicate: str
|
||||
head_args: Tuple[str, ...]
|
||||
body: List[BodyAtom]
|
||||
|
||||
|
||||
# datalog reasoner
|
||||
|
||||
class DatalogReasoner:
|
||||
"""
|
||||
Datalog reasoning engine supporting recursive rule evaluation via semi-naive
|
||||
bottom-up fixpoint computation.
|
||||
"""
|
||||
|
||||
def __init__(self, config: Optional[Dict[str, Any]] = None, **kwargs):
|
||||
self.logger = get_logger("datalog_reasoner")
|
||||
self.config = config or {}
|
||||
self.config.update(kwargs)
|
||||
|
||||
self.progress_tracker = get_progress_tracker()
|
||||
|
||||
self._fact_index: Dict[str, Set[DatalogFact]] = defaultdict(set)
|
||||
self._all_facts: Set[DatalogFact] = set()
|
||||
|
||||
self._rules: List[DatalogRule] = []
|
||||
self._derived: bool = False
|
||||
|
||||
self._delta_old: Set[DatalogFact] = set()
|
||||
self._delta_new: Set[DatalogFact] = set()
|
||||
|
||||
def clear(self) -> None:
|
||||
"""Clear all facts and rules from the engine."""
|
||||
self._fact_index.clear()
|
||||
self._all_facts.clear()
|
||||
self._rules.clear()
|
||||
self._derived = False
|
||||
self._delta_old.clear()
|
||||
self._delta_new.clear()
|
||||
|
||||
def add_fact(self, fact: Any) -> None:
|
||||
"""
|
||||
Add a ground fact to the engine.
|
||||
Accepts strings like "parent(tom, bob)" or standard Semantica Dicts.
|
||||
"""
|
||||
parsed_fact = None
|
||||
|
||||
if isinstance(fact, str):
|
||||
parsed_fact = self._parse_fact_string(fact)
|
||||
elif isinstance(fact, dict):
|
||||
if "subject" in fact and "predicate" in fact and "object" in fact:
|
||||
parsed_fact = DatalogFact(
|
||||
predicate=str(fact["predicate"]).replace(' ', '_').lower(),
|
||||
args=(str(fact["subject"]).replace(' ', '_').lower(), str(fact["object"]).replace(' ', '_').lower())
|
||||
)
|
||||
elif "source" in fact or "source_id" in fact or "source_name" in fact:
|
||||
source = fact.get("source", fact.get("source_name", fact.get("source_id")))
|
||||
target = fact.get("target", fact.get("target_name", fact.get("target_id")))
|
||||
rtype = fact.get("type", fact.get("relation", "connected_to"))
|
||||
if source and target:
|
||||
parsed_fact = DatalogFact(
|
||||
predicate=str(rtype).replace(' ', '_').lower(),
|
||||
args=(str(source).replace(' ', '_').lower(), str(target).replace(' ', '_').lower())
|
||||
)
|
||||
|
||||
elif "type" in fact and ("id" in fact or "name" in fact):
|
||||
name = fact.get("id", fact.get("name"))
|
||||
etype = fact.get("type", "entity")
|
||||
if name:
|
||||
parsed_fact = DatalogFact(
|
||||
predicate=str(etype).replace(' ', '_').lower(),
|
||||
args=(str(name).replace(' ', '_').lower(),)
|
||||
)
|
||||
|
||||
|
||||
if parsed_fact:
|
||||
for arg in parsed_fact.args:
|
||||
if not arg:
|
||||
raise ValueError("Facts cannot contain empty arguments")
|
||||
if arg[0].isupper():
|
||||
raise ValueError(f"Facts must be constants only. Found variable '{arg}' in {fact}")
|
||||
|
||||
if parsed_fact is None and isinstance(fact, dict):
|
||||
self.logger.warning(f"Unrecognised dict fact format, skipping: {fact}")
|
||||
return
|
||||
|
||||
if parsed_fact and parsed_fact not in self._all_facts:
|
||||
self._all_facts.add(parsed_fact)
|
||||
self._fact_index[parsed_fact.predicate].add(parsed_fact)
|
||||
self._derived = False
|
||||
|
||||
def add_rule(self, rule_str: str) -> None:
|
||||
""" Add a Datalog rule using Horn clause syntax."""
|
||||
rule = self._parse_rule_string(rule_str)
|
||||
self._rules.append(rule)
|
||||
self._derived = False
|
||||
|
||||
# Parsing helpers
|
||||
|
||||
def _parse_fact_string(self, s: str) -> DatalogFact:
|
||||
"""Parse 'predicate(arg1, arg2)' into a DatalogFact."""
|
||||
match = re.match(r'^\s*([a-zA-Z0-9_]+)\s*\(\s*([^)]+)\s*\)\s*\.?\s*$', s.strip())
|
||||
if not match:
|
||||
raise ValueError(f"Invalid fact syntax: {s}")
|
||||
|
||||
predicate = match.group(1)
|
||||
args_str = match.group(2)
|
||||
args = tuple(arg.strip() for arg in args_str.split(','))
|
||||
|
||||
for arg in args:
|
||||
if not arg:
|
||||
raise ValueError(f"Empty argument found in fact: {s}")
|
||||
if arg[0].isupper():
|
||||
raise ValueError(f"Facts must be constants only (no variables). Found variable '{arg}' in {s}")
|
||||
|
||||
return DatalogFact(predicate, args)
|
||||
|
||||
def _parse_rule_string(self, s: str) -> DatalogRule:
|
||||
"""Parse 'head(X, Y) :- body1(X, Z), body2(Z, Y).' into a DatalogRule."""
|
||||
s = s.strip()
|
||||
if ":-" not in s:
|
||||
raise ValueError(f"Invalid rule syntax (missing ':-'): {s}")
|
||||
|
||||
head_str, body_str = s.split(":-", 1)
|
||||
head_str = head_str.strip()
|
||||
body_str = body_str.strip().rstrip('.')
|
||||
|
||||
# Parse head
|
||||
head_match = re.match(r'^([a-zA-Z0-9_]+)\s*\(\s*([^)]+)\s*\)$', head_str)
|
||||
if not head_match:
|
||||
raise ValueError(f"Invalid rule head syntax: {head_str}")
|
||||
|
||||
head_pred = head_match.group(1)
|
||||
head_args = tuple(arg.strip() for arg in head_match.group(2).split(','))
|
||||
|
||||
# Parse body atoms
|
||||
body = []
|
||||
atom_matches = re.findall(r'([a-zA-Z0-9_]+)\s*\(\s*([^)]+)\s*\)', body_str)
|
||||
if not atom_matches:
|
||||
raise ValueError(f"No valid body atoms found in rule: {s}")
|
||||
|
||||
for pred, args_str in atom_matches:
|
||||
args = tuple(arg.strip() for arg in args_str.split(','))
|
||||
body.append(BodyAtom(pred, args))
|
||||
|
||||
return DatalogRule(head_pred, head_args, body)
|
||||
|
||||
# Unification & Instantiation
|
||||
|
||||
|
||||
def _is_variable(self, term: str) -> bool:
|
||||
"""Variables strictly start with an uppercase letter."""
|
||||
return bool(term and term[0].isupper())
|
||||
|
||||
def _unify(
|
||||
self,
|
||||
pattern_args: Tuple[str, ...],
|
||||
fact_args: Tuple[str, ...],
|
||||
bindings: Dict[str, str]
|
||||
) -> Optional[Dict[str, str]]:
|
||||
"""
|
||||
Unifies a rule atom's pattern with a concrete fact.
|
||||
Optimized to prevent unnecessary dictionary allocations.
|
||||
"""
|
||||
if len(pattern_args) != len(fact_args):
|
||||
return None
|
||||
|
||||
new_additions = {}
|
||||
|
||||
for p_arg, f_arg in zip(pattern_args, fact_args):
|
||||
if self._is_variable(p_arg):
|
||||
if p_arg in bindings:
|
||||
if bindings[p_arg] != f_arg:
|
||||
return None
|
||||
elif p_arg in new_additions:
|
||||
if new_additions[p_arg] != f_arg:
|
||||
return None
|
||||
else:
|
||||
new_additions[p_arg] = f_arg
|
||||
else:
|
||||
if p_arg != f_arg:
|
||||
return None
|
||||
|
||||
if new_additions:
|
||||
return {**bindings, **new_additions}
|
||||
return bindings
|
||||
|
||||
def _instantiate(self, args: Tuple[str, ...], bindings: Dict[str, str]) -> Optional[Tuple[str, ...]]:
|
||||
"""Replaces variables in a tuple with their bound values."""
|
||||
result = []
|
||||
for arg in args:
|
||||
if self._is_variable(arg):
|
||||
if arg not in bindings:
|
||||
return None
|
||||
result.append(bindings[arg])
|
||||
else:
|
||||
result.append(arg)
|
||||
|
||||
return tuple(result)
|
||||
|
||||
def _instantiate_fact(
|
||||
self, predicate: str, args: Tuple[str, ...], bindings: Dict[str, str]
|
||||
) -> Optional[DatalogFact]:
|
||||
"""Creates a concrete DatalogFact from a predicate, arguments, and bindings."""
|
||||
ground_args = self._instantiate(args, bindings)
|
||||
if ground_args is None:
|
||||
return None
|
||||
return DatalogFact(predicate, ground_args)
|
||||
|
||||
# Semi-Naive Fixpoint Evaluation
|
||||
|
||||
|
||||
def derive_all(self) -> List[str]:
|
||||
"""
|
||||
Executes bottom-up semi-naive evaluation until fixpoint is reached.
|
||||
Returns a list of all derived facts as strings.
|
||||
"""
|
||||
if self._derived:
|
||||
return [f"{f.predicate}({', '.join(f.args)})" for f in self._all_facts]
|
||||
|
||||
tracking_id = self.progress_tracker.start_tracking(
|
||||
module="reasoning",
|
||||
submodule="DatalogReasoner",
|
||||
message="Starting semi-naive fixpoint evaluation"
|
||||
)
|
||||
|
||||
iteration = 0
|
||||
newly_derived_count = 0
|
||||
|
||||
try:
|
||||
self._delta_new = self._all_facts.copy()
|
||||
|
||||
while self._delta_new:
|
||||
iteration += 1
|
||||
|
||||
# Shift deltas
|
||||
self._delta_old = self._delta_new
|
||||
self._delta_new = set()
|
||||
|
||||
delta_index = defaultdict(set)
|
||||
for f in self._delta_old:
|
||||
delta_index[f.predicate].add(f)
|
||||
|
||||
for rule in self._rules:
|
||||
new_facts = self._apply_rule(rule, delta_index)
|
||||
|
||||
for fact in new_facts:
|
||||
if fact not in self._all_facts:
|
||||
self._delta_new.add(fact)
|
||||
self._all_facts.add(fact)
|
||||
self._fact_index[fact.predicate].add(fact)
|
||||
newly_derived_count += 1
|
||||
|
||||
self.logger.debug(f"Datalog Iteration {iteration}: derived {len(self._delta_new)} new facts")
|
||||
|
||||
self._derived = True
|
||||
finally:
|
||||
self.progress_tracker.stop_tracking(
|
||||
tracking_id,
|
||||
status="completed",
|
||||
message=f"Fixpoint reached in {iteration} iterations. {newly_derived_count} new facts derived."
|
||||
)
|
||||
|
||||
return [f"{f.predicate}({', '.join(f.args)})" for f in self._all_facts]
|
||||
|
||||
def _apply_rule(
|
||||
self, rule: DatalogRule, delta_index: Optional[Dict[str, Set[DatalogFact]]] = None
|
||||
) -> Set[DatalogFact]:
|
||||
"""
|
||||
Evaluates a single rule.
|
||||
Uses semi-naive strategy if delta_index is provided, otherwise falls back to naive evaluation.
|
||||
"""
|
||||
results = set()
|
||||
|
||||
if not rule.body:
|
||||
fact = self._instantiate_fact(rule.head_predicate, rule.head_args, {})
|
||||
if fact:
|
||||
results.add(fact)
|
||||
return results
|
||||
|
||||
is_seminaive = delta_index is not None
|
||||
evaluation_paths = range(len(rule.body)) if is_seminaive else [0]
|
||||
|
||||
for delta_index_pos in evaluation_paths:
|
||||
bindings_list = [{}]
|
||||
|
||||
for i, atom in enumerate(rule.body):
|
||||
new_bindings_list = []
|
||||
|
||||
if is_seminaive and i == delta_index_pos:
|
||||
candidate_facts = delta_index.get(atom.predicate, set())
|
||||
else:
|
||||
candidate_facts = self._fact_index.get(atom.predicate, set())
|
||||
|
||||
for bindings in bindings_list:
|
||||
for fact in candidate_facts:
|
||||
merged_bindings = self._unify(atom.args, fact.args, bindings)
|
||||
if merged_bindings is not None:
|
||||
new_bindings_list.append(merged_bindings)
|
||||
|
||||
bindings_list = new_bindings_list
|
||||
if not bindings_list:
|
||||
break
|
||||
|
||||
for final_bindings in bindings_list:
|
||||
head_fact = self._instantiate_fact(rule.head_predicate, rule.head_args, final_bindings)
|
||||
if head_fact:
|
||||
results.add(head_fact)
|
||||
|
||||
return results
|
||||
|
||||
# Query & ContextGraph Integration
|
||||
|
||||
|
||||
def query(self, pattern: str, bindings: dict = None) -> List[dict]:
|
||||
"""
|
||||
Queries the derived fact set. Automatically runs derive_all() if rules exist.
|
||||
Syntax: "ancestor(tom, ?Y)" or "ancestor(tom, ?y)"
|
||||
Returns: [{"Y": "bob"}] or [{"y": "bob"}]
|
||||
"""
|
||||
if self._rules and not self._derived:
|
||||
self.derive_all()
|
||||
|
||||
match = re.match(r'^\s*([a-zA-Z0-9_]+)\s*\(\s*([^)]+)\s*\)\s*\.?\s*$', pattern.strip())
|
||||
if not match:
|
||||
raise ValueError(f"Invalid query syntax: {pattern}")
|
||||
|
||||
pred = match.group(1)
|
||||
raw_args = tuple(arg.strip() for arg in match.group(2).split(','))
|
||||
|
||||
query_vars = {}
|
||||
pattern_args = []
|
||||
|
||||
for i, arg in enumerate(raw_args):
|
||||
if arg.startswith('?'):
|
||||
var_name = arg[1:]
|
||||
if not var_name:
|
||||
raise ValueError("Empty variable name after '?'")
|
||||
internal_var = var_name[0].upper() + var_name[1:]
|
||||
query_vars[i] = (var_name, internal_var)
|
||||
pattern_args.append(internal_var)
|
||||
elif self._is_variable(arg):
|
||||
query_vars[i] = (arg, arg)
|
||||
pattern_args.append(arg)
|
||||
else:
|
||||
pattern_args.append(arg)
|
||||
|
||||
initial_bindings = {}
|
||||
for k, v in (bindings or {}).items():
|
||||
internal_k = k[0].upper() + k[1:] if k and not k[0].isupper() else k
|
||||
initial_bindings[internal_k] = v
|
||||
|
||||
for i, arg in enumerate(pattern_args):
|
||||
if self._is_variable(arg) and arg in initial_bindings:
|
||||
pattern_args[i] = initial_bindings[arg]
|
||||
|
||||
results = []
|
||||
candidates = self._fact_index.get(pred, set())
|
||||
|
||||
for fact in candidates:
|
||||
match_bindings = self._unify(tuple(pattern_args), fact.args, {})
|
||||
if match_bindings is not None:
|
||||
result_row = {}
|
||||
for idx, (orig_var, internal_var) in query_vars.items():
|
||||
if internal_var in match_bindings:
|
||||
result_row[orig_var] = match_bindings[internal_var]
|
||||
elif internal_var in initial_bindings:
|
||||
result_row[orig_var] = initial_bindings[internal_var]
|
||||
|
||||
if result_row and result_row not in results:
|
||||
results.append(result_row)
|
||||
|
||||
return results
|
||||
|
||||
def load_from_graph(self, graph: Any) -> int:
|
||||
"""
|
||||
Loads a ContextGraph into Datalog facts using central add_fact validation.
|
||||
"""
|
||||
initial_count = len(self._all_facts)
|
||||
|
||||
if hasattr(graph, 'find_edges') and hasattr(graph, 'find_nodes'):
|
||||
for edge_dict in graph.find_edges():
|
||||
self.add_fact(edge_dict)
|
||||
for node_dict in graph.find_nodes():
|
||||
self.add_fact(node_dict)
|
||||
else:
|
||||
if hasattr(graph, 'edges'):
|
||||
edges = graph.edges() if callable(graph.edges) else graph.edges
|
||||
for edge in edges:
|
||||
self.add_fact(edge if isinstance(edge, dict) else edge.__dict__)
|
||||
|
||||
if hasattr(graph, 'nodes'):
|
||||
nodes = graph.nodes() if callable(graph.nodes) else graph.nodes
|
||||
if isinstance(nodes, dict):
|
||||
nodes = nodes.values()
|
||||
for node in nodes:
|
||||
self.add_fact(node if isinstance(node, dict) else node.__dict__)
|
||||
|
||||
facts_added = len(self._all_facts) - initial_count
|
||||
self.logger.info(f"Loaded {facts_added} facts from ContextGraph.")
|
||||
return facts_added
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ supporting multiple configuration sources including environment variables, confi
|
||||
and programmatic configuration.
|
||||
|
||||
Supported Configuration Sources:
|
||||
- Environment variables: OPENAI_API_KEY, GEMINI_API_KEY, GROQ_API_KEY, etc.
|
||||
- Environment variables: OPENAI_API_KEY, GEMINI_API_KEY, GROQ_API_KEY, NOVITA_API_KEY, etc.
|
||||
- Config files: YAML, JSON, TOML formats
|
||||
- Programmatic: Python API for setting provider configurations
|
||||
|
||||
@@ -97,7 +97,7 @@ class Config:
|
||||
def _load_env_vars(self):
|
||||
"""Load configuration from environment variables."""
|
||||
# Common environment variable patterns
|
||||
providers = ["openai", "gemini", "groq", "anthropic", "ollama"]
|
||||
providers = ["openai", "gemini", "groq", "anthropic", "ollama", "novita"]
|
||||
for provider in providers:
|
||||
env_key = f"{provider.upper()}_API_KEY"
|
||||
api_key = os.getenv(env_key)
|
||||
|
||||
@@ -988,6 +988,65 @@ class DeepSeekProvider(BaseProvider):
|
||||
except Exception as e:
|
||||
raise ProcessingError(f"Failed to parse JSON from DeepSeek response: {e}")
|
||||
|
||||
|
||||
class NovitaProvider(BaseProvider):
|
||||
"""Novita AI provider implementation - OpenAI-compatible API."""
|
||||
|
||||
def __init__(self, api_key: Optional[str] = None, model: str = "deepseek/deepseek-v3.2", **kwargs):
|
||||
"""Initialize Novita provider."""
|
||||
super().__init__(**kwargs)
|
||||
self.api_key = api_key or config.get_api_key("novita")
|
||||
self.model = model
|
||||
self.base_url = "https://api.novita.ai/v1"
|
||||
self.client = None
|
||||
self._init_client()
|
||||
|
||||
def _init_client(self):
|
||||
try:
|
||||
from openai import OpenAI
|
||||
|
||||
if self.api_key:
|
||||
self.client = OpenAI(api_key=self.api_key, base_url=self.base_url)
|
||||
except (ImportError, OSError):
|
||||
self.client = None
|
||||
self.logger.warning(
|
||||
"openai library not installed. Install with: pip install semantica[llm-openai]"
|
||||
)
|
||||
|
||||
def is_available(self) -> bool:
|
||||
return self.client is not None
|
||||
|
||||
def generate(self, prompt: str, **kwargs) -> str:
|
||||
if not self.client:
|
||||
raise ProcessingError("Novita client not initialized. Set NOVITA_API_KEY or pass api_key.")
|
||||
|
||||
create_kwargs = {
|
||||
"model": kwargs.get("model", self.model),
|
||||
"messages": [{"role": "user", "content": prompt}],
|
||||
}
|
||||
self._add_if_set(create_kwargs, kwargs, "temperature", "max_tokens")
|
||||
|
||||
response = self.client.chat.completions.create(**create_kwargs)
|
||||
return response.choices[0].message.content
|
||||
|
||||
def generate_structured(self, prompt: str, **kwargs) -> Union[dict, list]:
|
||||
"""Generate structured output."""
|
||||
if not self.client:
|
||||
raise ProcessingError("Novita client not initialized.")
|
||||
|
||||
create_kwargs = {
|
||||
"model": kwargs.get("model", self.model),
|
||||
"messages": [{"role": "user", "content": prompt}],
|
||||
"response_format": {"type": "json_object"},
|
||||
}
|
||||
self._add_if_set(create_kwargs, kwargs, "temperature", "max_tokens")
|
||||
|
||||
response = self.client.chat.completions.create(**create_kwargs)
|
||||
try:
|
||||
return self._parse_json(response.choices[0].message.content)
|
||||
except Exception as e:
|
||||
raise ProcessingError(f"Failed to parse JSON from Novita response: {e}")
|
||||
|
||||
class HuggingFaceLLMProvider(BaseProvider):
|
||||
"""HuggingFace transformers for LLM tasks."""
|
||||
|
||||
@@ -1370,6 +1429,7 @@ class ProviderPool:
|
||||
"ollama": OllamaProvider,
|
||||
"huggingface_llm": HuggingFaceLLMProvider,
|
||||
"deepseek": DeepSeekProvider,
|
||||
"novita": NovitaProvider,
|
||||
}
|
||||
|
||||
provider_class = builtin.get(name.lower())
|
||||
|
||||
@@ -267,6 +267,88 @@ class QueryEngine:
|
||||
execution_steps=execution_steps,
|
||||
metadata={"optimization_enabled": self.enable_optimization},
|
||||
)
|
||||
|
||||
def expand_entity_uri(self, entity_uri: str, store_backend: Any, use_alignments: bool = False) -> List[str]:
|
||||
"""
|
||||
Expand an entity URI to include all aligned/equivalent entities.
|
||||
|
||||
Args:
|
||||
entity_uri: The original URI to expand
|
||||
store_backend: Triplet store backend to query
|
||||
use_alignments: If False, returns only the original URI
|
||||
|
||||
Returns:
|
||||
List of URIs including the original and any aligned entities
|
||||
"""
|
||||
if not use_alignments:
|
||||
return [entity_uri]
|
||||
|
||||
tracking_id = self.progress_tracker.start_tracking(
|
||||
module="triplet_store",
|
||||
submodule="QueryEngine",
|
||||
message=f"Expanding alignments for: {entity_uri}"
|
||||
)
|
||||
|
||||
# SPARQL query to find bidirectional alignments
|
||||
|
||||
safe_uri = self._sanitize_uri(entity_uri)
|
||||
query = f"""
|
||||
SELECT DISTINCT ?aligned WHERE {{
|
||||
{{ <{safe_uri}> ?p ?aligned }}
|
||||
UNION
|
||||
{{ ?aligned ?p <{safe_uri}> }}
|
||||
|
||||
FILTER (?p IN (
|
||||
<http://www.w3.org/2002/07/owl#equivalentClass>,
|
||||
<http://www.w3.org/2002/07/owl#equivalentProperty>,
|
||||
<http://www.w3.org/2002/07/owl#sameAs>,
|
||||
<http://www.w3.org/2004/02/skos/core#exactMatch>,
|
||||
<http://www.w3.org/2004/02/skos/core#closeMatch>,
|
||||
<http://www.w3.org/2004/02/skos/core#broadMatch>,
|
||||
<http://www.w3.org/2004/02/skos/core#narrowMatch>,
|
||||
<http://www.w3.org/2004/02/skos/core#relatedMatch>
|
||||
))
|
||||
}}
|
||||
"""
|
||||
|
||||
expanded_uris = set([entity_uri])
|
||||
try:
|
||||
if hasattr(store_backend, "execute_sparql"):
|
||||
result_data = store_backend.execute_sparql(query)
|
||||
for binding in result_data.get("bindings", []):
|
||||
val = binding.get("aligned", {})
|
||||
uri = val.get("value") if isinstance(val, dict) else val
|
||||
if uri:
|
||||
expanded_uris.add(uri)
|
||||
else:
|
||||
self.logger.warning(
|
||||
"store_backend does not support execute_sparql; returning original URI only"
|
||||
)
|
||||
self.progress_tracker.stop_tracking(
|
||||
tracking_id,
|
||||
status="completed",
|
||||
message=f"Expanded to {len(expanded_uris)} URIs"
|
||||
)
|
||||
except Exception as e:
|
||||
self.logger.error(f"Failed to expand alignments for {entity_uri}: {e}")
|
||||
self.progress_tracker.stop_tracking(tracking_id, status="failed", message=str(e))
|
||||
|
||||
return list(expanded_uris)
|
||||
|
||||
def build_values_clause(self, variable_name: str, uris: List[str]) -> str:
|
||||
"""
|
||||
Helper to generate a SPARQL VALUES clause for a list of URIs.
|
||||
Allows higher-level components to build alignment-aware queries.
|
||||
|
||||
Example:
|
||||
uris = engine.expand_entity_uri("http://ex.org/Person", store, use_alignments=True)
|
||||
clause = engine.build_values_clause("subject", uris)
|
||||
# Returns: VALUES ?subject { <http://ex.org/Person> <http://other.org/Human> }
|
||||
"""
|
||||
if not uris:
|
||||
return ""
|
||||
formatted_uris = " ".join([f"<{self._sanitize_uri(uri)}>" for uri in uris])
|
||||
return f"VALUES ?{variable_name} {{ {formatted_uris} }}"
|
||||
|
||||
def _validate_query(self, query: str) -> bool:
|
||||
"""Validate SPARQL query syntax (basic)."""
|
||||
@@ -344,6 +426,12 @@ class QueryEngine:
|
||||
|
||||
cache_key = self._get_cache_key(query)
|
||||
self.query_cache[cache_key] = result
|
||||
|
||||
def _sanitize_uri(self, uri: str) -> str:
|
||||
"""Prevent SPARQL injection by percent-encoding dangerous characters."""
|
||||
if not isinstance(uri, str):
|
||||
return ""
|
||||
return uri.replace("<", "%3C").replace(">", "%3E")
|
||||
|
||||
def clear_cache(self) -> None:
|
||||
"""Clear query cache."""
|
||||
|
||||
@@ -9,10 +9,13 @@ import os
|
||||
import tempfile
|
||||
import pytest
|
||||
from semantica.change_management import (
|
||||
TemporalVersionManager,
|
||||
TemporalVersionManager,
|
||||
OntologyVersionManager,
|
||||
ChangeLogEntry
|
||||
)
|
||||
from semantica.change_management.change_log import generate_change_report, ChangeLogAnalyzer
|
||||
from semantica.change_management.ontology_version_manager import VersionManager
|
||||
from semantica.ontology.engine import OntologyEngine
|
||||
from semantica.utils.exceptions import ValidationError, ProcessingError
|
||||
|
||||
|
||||
@@ -330,3 +333,142 @@ class TestOntologyVersionManager:
|
||||
finally:
|
||||
if os.path.exists(db_path):
|
||||
os.remove(db_path)
|
||||
|
||||
|
||||
def test_diff_empty_ontologies(self):
|
||||
"""Test diffing entirely empty dictionaries."""
|
||||
manager = VersionManager()
|
||||
diff = manager.diff_ontologies({}, {})
|
||||
|
||||
assert len(diff["added_classes"]) == 0
|
||||
assert len(diff["removed_classes"]) == 0
|
||||
assert len(diff["changed_classes"]) == 0
|
||||
|
||||
def test_diff_unordered_list_equality(self):
|
||||
"""Test that list order doesn't trigger a false positive change."""
|
||||
manager = VersionManager()
|
||||
base = {"classes": [{"uri": "http://ex.org/C1", "domain": ["A", "B"]}]}
|
||||
target = {"classes": [{"uri": "http://ex.org/C1", "domain": ["B", "A"]}]}
|
||||
|
||||
diff = manager.diff_ontologies(base, target)
|
||||
assert len(diff["changed_classes"]) == 0
|
||||
|
||||
def test_diff_missing_uris(self):
|
||||
"""Tests whether missing URIs fallback to 'name' deterministically."""
|
||||
manager = VersionManager()
|
||||
base = {"classes": [{"name": "Person", "label": "Human"}]}
|
||||
target = {"classes": [{"name": "Person", "label": "Homo Sapiens"}]}
|
||||
|
||||
diff = manager.diff_ontologies(base, target)
|
||||
assert len(diff["changed_classes"]) == 1
|
||||
|
||||
change = diff["changed_classes"][0]
|
||||
assert change["name"] == "Person"
|
||||
assert change["changes"]["label"]["old"] == "Human"
|
||||
assert change["changes"]["label"]["new"] == "Homo Sapiens"
|
||||
|
||||
|
||||
class TestChangeLogAnalyzer:
|
||||
"""Test cases for Impact Analysis & Reporting."""
|
||||
|
||||
def test_breaking_change_removed_class(self):
|
||||
"""Test that removing a class is flagged as CRITICAL/BREAKING."""
|
||||
diff = {"removed_classes": [{"uri": "http://ex.org/Person"}]}
|
||||
report = generate_change_report(diff)
|
||||
|
||||
assert len(report["impact_classification"]["breaking"]) == 1
|
||||
assert report["impact_classification"]["breaking"][0]["severity"] == "critical"
|
||||
assert "removed" in report["impact_classification"]["breaking"][0]["description"]
|
||||
|
||||
def test_breaking_change_narrowed_domain(self):
|
||||
"""Test that narrowing a domain is flagged as HIGH/BREAKING."""
|
||||
diff = {
|
||||
"changed_properties": [{
|
||||
"uri": "http://ex.org/worksFor",
|
||||
"changes": {"domain": {"old": ["Person", "Organization"], "new": ["Person"]}}
|
||||
}]
|
||||
}
|
||||
report = generate_change_report(diff)
|
||||
|
||||
assert len(report["impact_classification"]["breaking"]) == 1
|
||||
assert report["impact_classification"]["breaking"][0]["severity"] == "high"
|
||||
assert "restricted" in report["impact_classification"]["breaking"][0]["description"]
|
||||
|
||||
def test_safe_change_added_class_and_label(self):
|
||||
"""Test that adding classes and changing annotations is SAFE."""
|
||||
diff = {
|
||||
"added_classes": [{"uri": "http://ex.org/NewClass"}],
|
||||
"changed_properties": [{
|
||||
"uri": "http://ex.org/name",
|
||||
"changes": {"label": {"old": "Name", "new": "Full Name"}}
|
||||
}]
|
||||
}
|
||||
report = generate_change_report(diff)
|
||||
|
||||
assert len(report["impact_classification"]["safe"]) == 2
|
||||
assert len(report["impact_classification"]["breaking"]) == 0
|
||||
assert len(report["impact_classification"]["potentially_breaking"]) == 0
|
||||
|
||||
|
||||
class TestOntologyEngineMigration:
|
||||
"""Test cases for Public API Orchestration."""
|
||||
|
||||
def test_compare_versions_with_dicts_override(self):
|
||||
"""Test for bypassing the DB fetch by passing dicts directly."""
|
||||
engine = OntologyEngine()
|
||||
|
||||
base_dict = {"classes": [{"uri": "http://ex.org/C1", "label": "Old"}]}
|
||||
target_dict = {"classes": [{"uri": "http://ex.org/C1", "label": "New"}]}
|
||||
|
||||
# We pass fake version IDs ("v1", "v2"), but the engine should use our dicts
|
||||
report = engine.compare_versions("v1", "v2", base_dict=base_dict, target_dict=target_dict)
|
||||
|
||||
assert report["summary"]["total_changes"] == 1
|
||||
assert len(report["impact_classification"]["safe"]) == 1
|
||||
assert report["impact_classification"]["safe"][0]["entity_uri"] == "http://ex.org/C1"
|
||||
|
||||
def test_compare_versions_version_not_found_raises(self):
|
||||
"""Test that compare_versions raises ProcessingError when version ID is not registered."""
|
||||
engine = OntologyEngine()
|
||||
|
||||
with pytest.raises(ProcessingError):
|
||||
engine.compare_versions("nonexistent_v1", "nonexistent_v2")
|
||||
|
||||
def test_compare_versions_diff_includes_individuals_and_axioms(self):
|
||||
"""Test that the diff covers individuals and axioms, not just classes/properties."""
|
||||
engine = OntologyEngine()
|
||||
|
||||
base_dict = {
|
||||
"classes": [],
|
||||
"properties": [],
|
||||
"individuals": [{"uri": "http://ex.org/john"}],
|
||||
"axioms": [{"uri": "http://ex.org/rule1", "expression": "Person hasName exactly 1 string"}],
|
||||
}
|
||||
target_dict = {
|
||||
"classes": [],
|
||||
"properties": [],
|
||||
"individuals": [
|
||||
{"uri": "http://ex.org/john"},
|
||||
{"uri": "http://ex.org/jane"},
|
||||
],
|
||||
"axioms": [],
|
||||
}
|
||||
|
||||
report = engine.compare_versions("v1", "v2", base_dict=base_dict, target_dict=target_dict)
|
||||
|
||||
diff = report["diff"]
|
||||
assert any(i.get("uri") == "http://ex.org/jane" for i in diff["added_individuals"])
|
||||
assert any(a.get("uri") == "http://ex.org/rule1" for a in diff["removed_axioms"])
|
||||
|
||||
def test_compare_versions_null_constraint_value_flagged_as_breaking(self):
|
||||
"""Test that a constraint field going from None to a value is flagged as breaking."""
|
||||
diff = {
|
||||
"changed_properties": [{
|
||||
"uri": "http://ex.org/worksFor",
|
||||
"changes": {"domain": {"old": None, "new": ["Person"]}}
|
||||
}]
|
||||
}
|
||||
report = generate_change_report(diff)
|
||||
|
||||
assert len(report["impact_classification"]["breaking"]) == 1
|
||||
assert report["impact_classification"]["breaking"][0]["severity"] == "high"
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
# tests/integrations package
|
||||
@@ -0,0 +1 @@
|
||||
# tests/integrations/agno package
|
||||
@@ -0,0 +1,125 @@
|
||||
"""
|
||||
Shared pytest configuration for Agno integration tests.
|
||||
|
||||
Installs a comprehensive agno stub into sys.modules before any test in this
|
||||
directory runs, so that every test file can import the integration modules
|
||||
without a real agno installation.
|
||||
|
||||
Each per-file stub only runs `if "agno" in sys.modules: return`, which would
|
||||
skip when another file already loaded a partial stub. This conftest installs
|
||||
ALL required sub-modules at session start so the guard works correctly for
|
||||
every file.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
import types
|
||||
|
||||
|
||||
def _install_agno_stubs() -> None:
|
||||
"""Install a full set of agno stubs into sys.modules."""
|
||||
|
||||
# -----------------------------------------------------------------------
|
||||
# agno root
|
||||
# -----------------------------------------------------------------------
|
||||
agno = sys.modules.get("agno") or types.ModuleType("agno")
|
||||
|
||||
# -----------------------------------------------------------------------
|
||||
# agno.memory.db.base — MemoryDb
|
||||
# -----------------------------------------------------------------------
|
||||
memory_pkg = types.ModuleType("agno.memory")
|
||||
memory_db_pkg = types.ModuleType("agno.memory.db")
|
||||
memory_db_base = types.ModuleType("agno.memory.db.base")
|
||||
memory_db_row = types.ModuleType("agno.memory.db.row")
|
||||
|
||||
class MemoryDb: # noqa: D101
|
||||
def __init__(self, *a, **kw): ... # noqa: E704
|
||||
|
||||
class MemoryRow: # noqa: D101
|
||||
def __init__(self, memory: str, id=None, user_id=None, **kw):
|
||||
self.memory = memory
|
||||
self.id = id
|
||||
self.user_id = user_id
|
||||
self.last_updated = 0.0
|
||||
self.topics = kw.get("topics", [])
|
||||
|
||||
memory_db_base.MemoryDb = MemoryDb # type: ignore
|
||||
memory_db_row.MemoryRow = MemoryRow # type: ignore
|
||||
memory_db_pkg.base = memory_db_base
|
||||
memory_db_pkg.row = memory_db_row
|
||||
memory_pkg.db = memory_db_pkg
|
||||
agno.memory = memory_pkg # type: ignore
|
||||
|
||||
# -----------------------------------------------------------------------
|
||||
# agno.tools.toolkit — Toolkit
|
||||
# -----------------------------------------------------------------------
|
||||
tools_pkg = types.ModuleType("agno.tools")
|
||||
tools_toolkit_mod = types.ModuleType("agno.tools.toolkit")
|
||||
|
||||
class Toolkit: # noqa: D101
|
||||
def __init__(self, name: str = "toolkit", **kw):
|
||||
self.name = name
|
||||
self._tools: list = []
|
||||
|
||||
def register(self, fn): # noqa: D102
|
||||
self._tools.append(fn)
|
||||
|
||||
tools_toolkit_mod.Toolkit = Toolkit # type: ignore
|
||||
tools_pkg.toolkit = tools_toolkit_mod
|
||||
agno.tools = tools_pkg # type: ignore
|
||||
|
||||
# -----------------------------------------------------------------------
|
||||
# agno.knowledge.base — AgentKnowledge
|
||||
# -----------------------------------------------------------------------
|
||||
knowledge_pkg = types.ModuleType("agno.knowledge")
|
||||
knowledge_base_mod = types.ModuleType("agno.knowledge.base")
|
||||
|
||||
class AgentKnowledge: # noqa: D101
|
||||
def __init__(self, *a, **kw): ... # noqa: E704
|
||||
|
||||
def search(self, query, num_documents=None, filters=None): # noqa: D102
|
||||
return []
|
||||
|
||||
knowledge_base_mod.AgentKnowledge = AgentKnowledge # type: ignore
|
||||
knowledge_pkg.base = knowledge_base_mod
|
||||
agno.knowledge = knowledge_pkg # type: ignore
|
||||
|
||||
# -----------------------------------------------------------------------
|
||||
# agno.document.base — Document
|
||||
# -----------------------------------------------------------------------
|
||||
document_pkg = types.ModuleType("agno.document")
|
||||
document_base_mod = types.ModuleType("agno.document.base")
|
||||
|
||||
class Document: # noqa: D101
|
||||
def __init__(self, content="", id=None, name=None, meta_data=None):
|
||||
self.content = content
|
||||
self.id = id
|
||||
self.name = name
|
||||
self.meta_data = meta_data or {}
|
||||
|
||||
document_base_mod.Document = Document # type: ignore
|
||||
document_pkg.base = document_base_mod
|
||||
agno.document = document_pkg # type: ignore
|
||||
|
||||
# -----------------------------------------------------------------------
|
||||
# Register everything
|
||||
# -----------------------------------------------------------------------
|
||||
_mods = {
|
||||
"agno": agno,
|
||||
"agno.memory": memory_pkg,
|
||||
"agno.memory.db": memory_db_pkg,
|
||||
"agno.memory.db.base": memory_db_base,
|
||||
"agno.memory.db.row": memory_db_row,
|
||||
"agno.tools": tools_pkg,
|
||||
"agno.tools.toolkit": tools_toolkit_mod,
|
||||
"agno.knowledge": knowledge_pkg,
|
||||
"agno.knowledge.base": knowledge_base_mod,
|
||||
"agno.document": document_pkg,
|
||||
"agno.document.base": document_base_mod,
|
||||
}
|
||||
for name, mod in _mods.items():
|
||||
sys.modules[name] = mod
|
||||
|
||||
|
||||
# Install once at import time (conftest is imported before any test file)
|
||||
_install_agno_stubs()
|
||||
@@ -0,0 +1,233 @@
|
||||
"""
|
||||
Tests for AgnoContextStore — graph-backed Agno MemoryDb.
|
||||
|
||||
All tests run without a real Agno installation by mocking the base class
|
||||
and using in-memory Semantica components only.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
import types
|
||||
import unittest
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Stub the agno package so the import succeeds without it installed
|
||||
# ---------------------------------------------------------------------------
|
||||
def _stub_agno() -> None:
|
||||
"""Insert minimal agno stubs into sys.modules."""
|
||||
if "agno" in sys.modules:
|
||||
return # real agno installed — no stub needed
|
||||
|
||||
agno = types.ModuleType("agno")
|
||||
|
||||
# agno.memory.db.base
|
||||
memory_pkg = types.ModuleType("agno.memory")
|
||||
memory_db_pkg = types.ModuleType("agno.memory.db")
|
||||
memory_db_base = types.ModuleType("agno.memory.db.base")
|
||||
|
||||
class MemoryDb: # noqa: D101
|
||||
def __init__(self, *a, **kw): ... # noqa: E704
|
||||
|
||||
memory_db_base.MemoryDb = MemoryDb # type: ignore
|
||||
|
||||
# agno.memory.db.row
|
||||
memory_db_row = types.ModuleType("agno.memory.db.row")
|
||||
|
||||
class MemoryRow: # noqa: D101
|
||||
def __init__(self, memory: str, id=None, user_id=None, **kw):
|
||||
self.memory = memory
|
||||
self.id = id
|
||||
self.user_id = user_id
|
||||
self.last_updated = 0.0
|
||||
self.topics = kw.get("topics", [])
|
||||
|
||||
memory_db_row.MemoryRow = MemoryRow # type: ignore
|
||||
|
||||
memory_db_pkg.base = memory_db_base
|
||||
memory_db_pkg.row = memory_db_row
|
||||
memory_pkg.db = memory_db_pkg
|
||||
|
||||
agno.memory = memory_pkg # type: ignore
|
||||
|
||||
for name, mod in [
|
||||
("agno", agno),
|
||||
("agno.memory", memory_pkg),
|
||||
("agno.memory.db", memory_db_pkg),
|
||||
("agno.memory.db.base", memory_db_base),
|
||||
("agno.memory.db.row", memory_db_row),
|
||||
]:
|
||||
sys.modules.setdefault(name, mod)
|
||||
|
||||
|
||||
_stub_agno()
|
||||
|
||||
|
||||
from integrations.agno.context_store import AgnoContextStore # noqa: E402
|
||||
|
||||
|
||||
class TestAgnoContextStoreInit(unittest.TestCase):
|
||||
"""Construction and basic attribute checks."""
|
||||
|
||||
def _make_store(self, **kwargs) -> AgnoContextStore:
|
||||
return AgnoContextStore(decision_tracking=True, graph_expansion=True, **kwargs)
|
||||
|
||||
def test_creates_without_args(self):
|
||||
store = self._make_store()
|
||||
self.assertIsNotNone(store)
|
||||
|
||||
def test_session_id_generated(self):
|
||||
store = self._make_store()
|
||||
self.assertIsInstance(store.session_id, str)
|
||||
self.assertTrue(len(store.session_id) > 0)
|
||||
|
||||
def test_explicit_session_id(self):
|
||||
store = AgnoContextStore(session_id="abc-123")
|
||||
self.assertEqual(store.session_id, "abc-123")
|
||||
|
||||
def test_decision_tracking_flag(self):
|
||||
store = AgnoContextStore(decision_tracking=False)
|
||||
self.assertFalse(store.decision_tracking)
|
||||
|
||||
def test_context_property(self):
|
||||
store = self._make_store()
|
||||
self.assertIsNotNone(store.context)
|
||||
|
||||
|
||||
class TestAgnoContextStoreMemoryDb(unittest.TestCase):
|
||||
"""MemoryDb protocol methods."""
|
||||
|
||||
def setUp(self):
|
||||
self.store = AgnoContextStore(decision_tracking=False)
|
||||
|
||||
def _make_row(self, text: str, uid: str = "u1"):
|
||||
row = MagicMock()
|
||||
row.memory = text
|
||||
row.id = None
|
||||
row.user_id = uid
|
||||
row.last_updated = 0.0
|
||||
row.topics = []
|
||||
return row
|
||||
|
||||
def test_table_exists(self):
|
||||
self.assertTrue(self.store.table_exists())
|
||||
|
||||
def test_create_noop(self):
|
||||
# Should not raise
|
||||
self.store.create()
|
||||
|
||||
def test_upsert_and_read(self):
|
||||
row = self._make_row("Hello world")
|
||||
self.store.upsert_memory(row)
|
||||
memories = self.store.read_memories()
|
||||
self.assertEqual(len(memories), 1)
|
||||
|
||||
def test_upsert_sets_id(self):
|
||||
row = self._make_row("Test memory")
|
||||
self.store.upsert_memory(row)
|
||||
self.assertIsNotNone(row.id)
|
||||
|
||||
def test_memory_exists_after_upsert(self):
|
||||
row = self._make_row("Exists check")
|
||||
self.store.upsert_memory(row)
|
||||
self.assertTrue(self.store.memory_exists(row))
|
||||
|
||||
def test_memory_not_exists_before_upsert(self):
|
||||
row = self._make_row("Not yet")
|
||||
row.id = "unknown-id"
|
||||
self.assertFalse(self.store.memory_exists(row))
|
||||
|
||||
def test_delete_memory(self):
|
||||
row = self._make_row("To delete")
|
||||
self.store.upsert_memory(row)
|
||||
mem_id = row.id
|
||||
self.store.delete_memory(mem_id)
|
||||
self.assertFalse(self.store.memory_exists(row))
|
||||
|
||||
def test_read_memories_user_filter(self):
|
||||
row_a = self._make_row("User A memory", uid="alice")
|
||||
row_b = self._make_row("User B memory", uid="bob")
|
||||
self.store.upsert_memory(row_a)
|
||||
self.store.upsert_memory(row_b)
|
||||
|
||||
alice_rows = self.store.read_memories(user_id="alice")
|
||||
self.assertEqual(len(alice_rows), 1)
|
||||
self.assertEqual(alice_rows[0].user_id, "alice")
|
||||
|
||||
def test_read_memories_limit(self):
|
||||
for i in range(5):
|
||||
self.store.upsert_memory(self._make_row(f"Memory {i}"))
|
||||
rows = self.store.read_memories(limit=3)
|
||||
self.assertEqual(len(rows), 3)
|
||||
|
||||
def test_clear(self):
|
||||
for i in range(3):
|
||||
self.store.upsert_memory(self._make_row(f"M{i}"))
|
||||
result = self.store.clear()
|
||||
self.assertTrue(result)
|
||||
self.assertEqual(len(self.store.read_memories()), 0)
|
||||
|
||||
def test_drop_table(self):
|
||||
self.store.upsert_memory(self._make_row("Drop me"))
|
||||
self.store.drop_table()
|
||||
self.assertEqual(len(self.store.read_memories()), 0)
|
||||
|
||||
|
||||
class TestAgnoContextStoreExtendedAPI(unittest.TestCase):
|
||||
"""Extended Semantica-specific methods."""
|
||||
|
||||
def setUp(self):
|
||||
self.store = AgnoContextStore(decision_tracking=True)
|
||||
# Patch the internal AgentContext to avoid real LLM/vector calls
|
||||
self.store._context = MagicMock()
|
||||
self.store._context.record_decision.return_value = "dec-001"
|
||||
self.store._context.find_precedents_advanced.return_value = []
|
||||
self.store._context.retrieve.return_value = []
|
||||
|
||||
def test_record_decision_returns_id(self):
|
||||
did = self.store.record_decision(
|
||||
category="test",
|
||||
scenario="Unit test scenario",
|
||||
reasoning="Testing",
|
||||
outcome="pass",
|
||||
confidence=0.9,
|
||||
)
|
||||
self.assertEqual(did, "dec-001")
|
||||
self.store._context.record_decision.assert_called_once()
|
||||
|
||||
def test_find_precedents_returns_list(self):
|
||||
result = self.store.find_precedents("some scenario")
|
||||
self.assertIsInstance(result, list)
|
||||
|
||||
def test_retrieve_returns_list(self):
|
||||
result = self.store.retrieve("query text")
|
||||
self.assertIsInstance(result, list)
|
||||
|
||||
def test_record_decision_passes_entities(self):
|
||||
self.store.record_decision(
|
||||
category="finance",
|
||||
scenario="Loan",
|
||||
reasoning="Good credit",
|
||||
outcome="approved",
|
||||
confidence=0.95,
|
||||
entities=["applicant", "loan"],
|
||||
)
|
||||
call_kwargs = self.store._context.record_decision.call_args[1]
|
||||
self.assertEqual(call_kwargs["entities"], ["applicant", "loan"])
|
||||
|
||||
def test_upsert_with_decision_tracking(self):
|
||||
row = MagicMock()
|
||||
row.memory = "Important fact"
|
||||
row.id = None
|
||||
row.user_id = "u1"
|
||||
row.last_updated = 0.0
|
||||
row.topics = []
|
||||
self.store.upsert_memory(row)
|
||||
# decision should have been recorded
|
||||
self.store._context.record_decision.assert_called()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,256 @@
|
||||
"""
|
||||
Tests for AgnoDecisionKit — decision intelligence Agno Toolkit.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import sys
|
||||
import types
|
||||
import unittest
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Stub agno Toolkit
|
||||
# ---------------------------------------------------------------------------
|
||||
def _stub_agno() -> None:
|
||||
if "agno" in sys.modules:
|
||||
return
|
||||
|
||||
agno = types.ModuleType("agno")
|
||||
|
||||
tools_pkg = types.ModuleType("agno.tools")
|
||||
tools_toolkit = types.ModuleType("agno.tools.toolkit")
|
||||
|
||||
class Toolkit:
|
||||
def __init__(self, name="toolkit", **kw):
|
||||
self.name = name
|
||||
self._tools = []
|
||||
|
||||
def register(self, fn):
|
||||
self._tools.append(fn)
|
||||
|
||||
tools_toolkit.Toolkit = Toolkit # type: ignore
|
||||
tools_pkg.toolkit = tools_toolkit
|
||||
agno.tools = tools_pkg # type: ignore
|
||||
|
||||
for name, mod in [
|
||||
("agno", agno),
|
||||
("agno.tools", tools_pkg),
|
||||
("agno.tools.toolkit", tools_toolkit),
|
||||
]:
|
||||
sys.modules.setdefault(name, mod)
|
||||
|
||||
|
||||
_stub_agno()
|
||||
|
||||
from integrations.agno.decision_kit import AgnoDecisionKit # noqa: E402
|
||||
|
||||
|
||||
def _make_context() -> MagicMock:
|
||||
ctx = MagicMock()
|
||||
ctx.record_decision.return_value = "dec-test-001"
|
||||
ctx.find_precedents_advanced.return_value = [
|
||||
{"scenario": "past loan", "outcome": "approved", "confidence": 0.9, "category": "loan"}
|
||||
]
|
||||
ctx.analyze_decision_influence.return_value = {"centrality": 0.75, "influenced": 3}
|
||||
ctx.get_context_insights.return_value = {"total_decisions": 5, "categories": ["loan"]}
|
||||
ctx.knowledge_graph = MagicMock()
|
||||
ctx.knowledge_graph.trace_decision_causality = MagicMock(return_value=["step1", "step2"])
|
||||
return ctx
|
||||
|
||||
|
||||
class TestAgnoDecisionKitInit(unittest.TestCase):
|
||||
|
||||
def test_creates_with_context(self):
|
||||
kit = AgnoDecisionKit(context=_make_context())
|
||||
self.assertIsNotNone(kit)
|
||||
|
||||
def test_creates_without_context(self):
|
||||
# Should auto-create an AgentContext
|
||||
kit = AgnoDecisionKit()
|
||||
self.assertIsNotNone(kit)
|
||||
|
||||
def test_tools_registered(self):
|
||||
kit = AgnoDecisionKit(context=_make_context())
|
||||
# Tools should be registered (Toolkit.register was called)
|
||||
self.assertTrue(len(kit._tools) >= 5)
|
||||
|
||||
def test_policy_tool_can_be_disabled(self):
|
||||
kit = AgnoDecisionKit(context=_make_context(), enable_policy_check=False)
|
||||
tool_names = [fn.__name__ for fn in kit._tools]
|
||||
self.assertNotIn("check_policy", tool_names)
|
||||
|
||||
|
||||
class TestRecordDecision(unittest.TestCase):
|
||||
|
||||
def setUp(self):
|
||||
self.ctx = _make_context()
|
||||
self.kit = AgnoDecisionKit(context=self.ctx)
|
||||
|
||||
def test_returns_json_with_decision_id(self):
|
||||
result = json.loads(self.kit.record_decision(
|
||||
category="loan",
|
||||
scenario="Customer A loan application",
|
||||
reasoning="Good credit score 740",
|
||||
outcome="approved",
|
||||
confidence=0.95,
|
||||
))
|
||||
self.assertIn("decision_id", result)
|
||||
self.assertEqual(result["status"], "recorded")
|
||||
|
||||
def test_delegates_to_context(self):
|
||||
self.kit.record_decision(
|
||||
category="content",
|
||||
scenario="Moderation check",
|
||||
reasoning="No violations",
|
||||
outcome="allowed",
|
||||
confidence=0.88,
|
||||
)
|
||||
self.ctx.record_decision.assert_called_once()
|
||||
|
||||
def test_parses_entities_string(self):
|
||||
self.kit.record_decision(
|
||||
category="hr",
|
||||
scenario="Hire decision",
|
||||
reasoning="Qualified",
|
||||
outcome="hired",
|
||||
confidence=0.9,
|
||||
entities="Alice, ACME Corp, Senior Engineer",
|
||||
)
|
||||
call_kwargs = self.ctx.record_decision.call_args[1]
|
||||
self.assertIsInstance(call_kwargs["entities"], list)
|
||||
self.assertEqual(len(call_kwargs["entities"]), 3)
|
||||
|
||||
def test_returns_error_json_on_failure(self):
|
||||
self.ctx.record_decision.side_effect = RuntimeError("DB unavailable")
|
||||
result = json.loads(self.kit.record_decision(
|
||||
category="x", scenario="y", reasoning="z", outcome="failed",
|
||||
))
|
||||
self.assertEqual(result["status"], "failed")
|
||||
self.assertIn("error", result)
|
||||
|
||||
def test_default_confidence_used(self):
|
||||
self.kit.record_decision(
|
||||
category="test",
|
||||
scenario="Default confidence test",
|
||||
reasoning="N/A",
|
||||
outcome="pass",
|
||||
)
|
||||
call_kwargs = self.ctx.record_decision.call_args[1]
|
||||
self.assertEqual(call_kwargs["confidence"], 0.8)
|
||||
|
||||
|
||||
class TestFindPrecedents(unittest.TestCase):
|
||||
|
||||
def setUp(self):
|
||||
self.ctx = _make_context()
|
||||
self.kit = AgnoDecisionKit(context=self.ctx)
|
||||
|
||||
def test_returns_json_with_precedents(self):
|
||||
result = json.loads(self.kit.find_precedents("new loan application"))
|
||||
self.assertIn("precedents", result)
|
||||
self.assertIsInstance(result["precedents"], list)
|
||||
|
||||
def test_count_in_result(self):
|
||||
result = json.loads(self.kit.find_precedents("test scenario"))
|
||||
self.assertIn("count", result)
|
||||
self.assertEqual(result["count"], len(result["precedents"]))
|
||||
|
||||
def test_category_filter_passed(self):
|
||||
self.kit.find_precedents("scenario", category="finance")
|
||||
call_kwargs = self.ctx.find_precedents_advanced.call_args[1]
|
||||
self.assertEqual(call_kwargs.get("category"), "finance")
|
||||
|
||||
def test_limit_applied(self):
|
||||
self.ctx.find_precedents_advanced.return_value = [
|
||||
{"scenario": f"s{i}", "outcome": "o", "confidence": 0.5, "category": "c"}
|
||||
for i in range(10)
|
||||
]
|
||||
result = json.loads(self.kit.find_precedents("s", limit=3))
|
||||
self.assertTrue(result["count"] <= 3)
|
||||
|
||||
def test_handles_exception_gracefully(self):
|
||||
self.ctx.find_precedents_advanced.side_effect = RuntimeError("fail")
|
||||
result = json.loads(self.kit.find_precedents("broken"))
|
||||
self.assertEqual(result["precedents"], [])
|
||||
self.assertIn("error", result)
|
||||
|
||||
|
||||
class TestTraceCausalChain(unittest.TestCase):
|
||||
|
||||
def setUp(self):
|
||||
self.ctx = _make_context()
|
||||
self.kit = AgnoDecisionKit(context=self.ctx)
|
||||
|
||||
def test_returns_json_with_causal_chain(self):
|
||||
result = json.loads(self.kit.trace_causal_chain("dec-001"))
|
||||
self.assertIn("causal_chain", result)
|
||||
self.assertEqual(result["decision_id"], "dec-001")
|
||||
|
||||
def test_fallback_on_attribute_error(self):
|
||||
del self.ctx.knowledge_graph.trace_decision_causality
|
||||
self.ctx.knowledge_graph.find_precedents = MagicMock(return_value=[])
|
||||
result = json.loads(self.kit.trace_causal_chain("dec-002"))
|
||||
self.assertIn("causal_chain", result)
|
||||
|
||||
def test_depth_passed(self):
|
||||
self.kit.trace_causal_chain("dec-001", depth=5)
|
||||
# Should not raise
|
||||
|
||||
|
||||
class TestAnalyzeImpact(unittest.TestCase):
|
||||
|
||||
def setUp(self):
|
||||
self.ctx = _make_context()
|
||||
self.kit = AgnoDecisionKit(context=self.ctx)
|
||||
|
||||
def test_returns_json_with_decision_id(self):
|
||||
result = json.loads(self.kit.analyze_impact("dec-001"))
|
||||
self.assertEqual(result["decision_id"], "dec-001")
|
||||
|
||||
def test_includes_influence_metrics(self):
|
||||
result = json.loads(self.kit.analyze_impact("dec-001"))
|
||||
self.assertIn("centrality", result)
|
||||
|
||||
|
||||
class TestCheckPolicy(unittest.TestCase):
|
||||
|
||||
def setUp(self):
|
||||
self.ctx = _make_context()
|
||||
self.kit = AgnoDecisionKit(context=self.ctx)
|
||||
|
||||
def test_returns_json_with_compliant_key(self):
|
||||
decision = json.dumps({"category": "loan", "outcome": "approved", "confidence": 0.9})
|
||||
result = json.loads(self.kit.check_policy(decision))
|
||||
self.assertIn("compliant", result)
|
||||
|
||||
def test_invalid_json_returns_error(self):
|
||||
result = json.loads(self.kit.check_policy("{not valid json}"))
|
||||
self.assertIn("error", result)
|
||||
|
||||
|
||||
class TestGetDecisionSummary(unittest.TestCase):
|
||||
|
||||
def setUp(self):
|
||||
self.ctx = _make_context()
|
||||
self.kit = AgnoDecisionKit(context=self.ctx)
|
||||
|
||||
def test_returns_json(self):
|
||||
result_str = self.kit.get_decision_summary()
|
||||
result = json.loads(result_str)
|
||||
self.assertIsInstance(result, dict)
|
||||
|
||||
def test_category_filter_stored(self):
|
||||
result = json.loads(self.kit.get_decision_summary(category="finance"))
|
||||
self.assertEqual(result.get("category_filter"), "finance")
|
||||
|
||||
def test_handles_exception_gracefully(self):
|
||||
self.ctx.get_context_insights.side_effect = RuntimeError("insight fail")
|
||||
result = json.loads(self.kit.get_decision_summary())
|
||||
self.assertIn("error", result)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,371 @@
|
||||
"""
|
||||
Tests for AgnoKGToolkit — knowledge graph Agno Toolkit.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import sys
|
||||
import types
|
||||
import unittest
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Stub agno Toolkit
|
||||
# ---------------------------------------------------------------------------
|
||||
def _stub_agno() -> None:
|
||||
if "agno" in sys.modules:
|
||||
return
|
||||
|
||||
agno = types.ModuleType("agno")
|
||||
tools_pkg = types.ModuleType("agno.tools")
|
||||
tools_toolkit = types.ModuleType("agno.tools.toolkit")
|
||||
|
||||
class Toolkit:
|
||||
def __init__(self, name="toolkit", **kw):
|
||||
self.name = name
|
||||
self._tools = []
|
||||
|
||||
def register(self, fn):
|
||||
self._tools.append(fn)
|
||||
|
||||
tools_toolkit.Toolkit = Toolkit # type: ignore
|
||||
tools_pkg.toolkit = tools_toolkit
|
||||
agno.tools = tools_pkg # type: ignore
|
||||
|
||||
for name, mod in [
|
||||
("agno", agno),
|
||||
("agno.tools", tools_pkg),
|
||||
("agno.tools.toolkit", tools_toolkit),
|
||||
]:
|
||||
sys.modules.setdefault(name, mod)
|
||||
|
||||
|
||||
_stub_agno()
|
||||
|
||||
from integrations.agno.kg_toolkit import AgnoKGToolkit # noqa: E402
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Fakes
|
||||
# ---------------------------------------------------------------------------
|
||||
def _fake_entity(name="Tesla", etype="ORG", conf=0.9):
|
||||
e = MagicMock()
|
||||
e.name = name
|
||||
e.type = etype
|
||||
e.confidence = conf
|
||||
return e
|
||||
|
||||
|
||||
def _fake_relation(src="Tesla", rel="FOUNDED_BY", tgt="Elon Musk", conf=0.85):
|
||||
r = MagicMock()
|
||||
r.source = src
|
||||
r.type = rel
|
||||
r.target = tgt
|
||||
r.confidence = conf
|
||||
return r
|
||||
|
||||
|
||||
class _FakeNER:
|
||||
def extract_entities(self, text):
|
||||
return [_fake_entity("Tesla"), _fake_entity("Elon Musk", "PERSON")]
|
||||
|
||||
|
||||
class _FakeRelExtractor:
|
||||
def extract_relations(self, text, entities=None):
|
||||
return [_fake_relation()]
|
||||
|
||||
|
||||
class _FakeReasoner:
|
||||
def infer_facts(self, facts, rules):
|
||||
result = MagicMock()
|
||||
result.inferred_facts = ["Human(EthicalAI)"]
|
||||
return result
|
||||
|
||||
|
||||
class _FakeGraph:
|
||||
"""Fake ContextGraph whose signatures match the real ContextGraph API."""
|
||||
|
||||
def __init__(self):
|
||||
self._node_store: dict = {} # node_id -> {"node_id": ..., "node_type": ...}
|
||||
self._edge_store: list = []
|
||||
|
||||
# ContextGraph.find_nodes(node_type=None) -> List[Dict]
|
||||
def find_nodes(self, node_type=None):
|
||||
nodes = list(self._node_store.values())
|
||||
if node_type:
|
||||
nodes = [n for n in nodes if n.get("node_type") == node_type]
|
||||
return nodes
|
||||
|
||||
# ContextGraph.add_node(node_id, node_type, content=None, **props) -> bool
|
||||
def add_node(self, node_id, node_type="Entity", content=None, **props):
|
||||
self._node_store[node_id] = {"node_id": node_id, "node_type": node_type}
|
||||
return True
|
||||
|
||||
# ContextGraph.add_edge(source_id, target_id, edge_type, **props) -> bool
|
||||
def add_edge(self, source_id, target_id, edge_type="related_to", **props):
|
||||
self._edge_store.append((source_id, target_id, edge_type))
|
||||
return True
|
||||
|
||||
# ContextGraph.get_neighbors(node_id, hops=1, ...) -> List[Dict]
|
||||
def get_neighbors(self, node_id, hops=1, relationship_types=None, min_weight=0.0):
|
||||
return [{"node_id": f"Neighbour_of_{node_id}", "node_type": "Entity"}]
|
||||
|
||||
|
||||
class TestAgnoKGToolkitInit(unittest.TestCase):
|
||||
|
||||
def test_creates_with_defaults(self):
|
||||
kit = AgnoKGToolkit()
|
||||
self.assertIsNotNone(kit)
|
||||
|
||||
def test_creates_with_custom_components(self):
|
||||
kit = AgnoKGToolkit(
|
||||
ner_extractor=_FakeNER(),
|
||||
relation_extractor=_FakeRelExtractor(),
|
||||
reasoner=_FakeReasoner(),
|
||||
)
|
||||
self.assertIsNotNone(kit)
|
||||
|
||||
def test_tools_registered(self):
|
||||
kit = AgnoKGToolkit()
|
||||
self.assertTrue(len(kit._tools) >= 7)
|
||||
|
||||
def test_context_graph_attached(self):
|
||||
ctx = MagicMock()
|
||||
ctx.knowledge_graph = _FakeGraph()
|
||||
kit = AgnoKGToolkit(context=ctx)
|
||||
self.assertIs(kit._graph, ctx.knowledge_graph)
|
||||
|
||||
|
||||
class TestExtractEntities(unittest.TestCase):
|
||||
|
||||
def setUp(self):
|
||||
self.kit = AgnoKGToolkit(
|
||||
ner_extractor=_FakeNER(),
|
||||
relation_extractor=_FakeRelExtractor(),
|
||||
reasoner=_FakeReasoner(),
|
||||
)
|
||||
|
||||
def test_returns_json(self):
|
||||
result = json.loads(self.kit.extract_entities("Tesla was founded by Elon Musk."))
|
||||
self.assertIn("entities", result)
|
||||
self.assertIn("count", result)
|
||||
|
||||
def test_entity_shape(self):
|
||||
result = json.loads(self.kit.extract_entities("some text"))
|
||||
for ent in result["entities"]:
|
||||
self.assertIn("name", ent)
|
||||
self.assertIn("type", ent)
|
||||
self.assertIn("confidence", ent)
|
||||
|
||||
def test_count_matches_entities(self):
|
||||
result = json.loads(self.kit.extract_entities("text"))
|
||||
self.assertEqual(result["count"], len(result["entities"]))
|
||||
|
||||
def test_handles_ner_failure(self):
|
||||
bad_ner = MagicMock()
|
||||
bad_ner.extract_entities.side_effect = RuntimeError("NER crashed")
|
||||
kit = AgnoKGToolkit(
|
||||
ner_extractor=bad_ner,
|
||||
relation_extractor=_FakeRelExtractor(),
|
||||
reasoner=_FakeReasoner(),
|
||||
)
|
||||
result = json.loads(kit.extract_entities("text"))
|
||||
self.assertEqual(result["count"], 0)
|
||||
self.assertIn("error", result)
|
||||
|
||||
|
||||
class TestExtractRelations(unittest.TestCase):
|
||||
|
||||
def setUp(self):
|
||||
self.kit = AgnoKGToolkit(
|
||||
ner_extractor=_FakeNER(),
|
||||
relation_extractor=_FakeRelExtractor(),
|
||||
reasoner=_FakeReasoner(),
|
||||
)
|
||||
|
||||
def test_returns_json(self):
|
||||
result = json.loads(self.kit.extract_relations("Tesla was founded by Elon Musk."))
|
||||
self.assertIn("relations", result)
|
||||
self.assertIn("count", result)
|
||||
|
||||
def test_relation_shape(self):
|
||||
result = json.loads(self.kit.extract_relations("text"))
|
||||
for rel in result["relations"]:
|
||||
self.assertIn("source", rel)
|
||||
self.assertIn("relation", rel)
|
||||
self.assertIn("target", rel)
|
||||
self.assertIn("confidence", rel)
|
||||
|
||||
def test_entities_filter_parsed_from_json(self):
|
||||
self.kit.extract_relations("text", entities='["Tesla", "Elon Musk"]')
|
||||
# Should not raise
|
||||
|
||||
def test_entities_filter_parsed_from_csv(self):
|
||||
self.kit.extract_relations("text", entities="Tesla, Elon Musk")
|
||||
# Should not raise
|
||||
|
||||
def test_handles_failure_gracefully(self):
|
||||
bad_rel = MagicMock()
|
||||
bad_rel.extract_relations.side_effect = RuntimeError("fail")
|
||||
kit = AgnoKGToolkit(
|
||||
ner_extractor=_FakeNER(),
|
||||
relation_extractor=bad_rel,
|
||||
reasoner=_FakeReasoner(),
|
||||
)
|
||||
result = json.loads(kit.extract_relations("text"))
|
||||
self.assertEqual(result["count"], 0)
|
||||
self.assertIn("error", result)
|
||||
|
||||
|
||||
class TestAddToGraph(unittest.TestCase):
|
||||
|
||||
def setUp(self):
|
||||
self.graph = _FakeGraph()
|
||||
self.kit = AgnoKGToolkit(
|
||||
ner_extractor=_FakeNER(),
|
||||
relation_extractor=_FakeRelExtractor(),
|
||||
reasoner=_FakeReasoner(),
|
||||
)
|
||||
self.kit._graph = self.graph
|
||||
|
||||
def test_add_entities_json(self):
|
||||
entities = json.dumps([{"name": "Alice", "type": "PERSON"}])
|
||||
result = json.loads(self.kit.add_to_graph(entities=entities))
|
||||
self.assertEqual(result["nodes_added"], 1)
|
||||
|
||||
def test_add_relations_json(self):
|
||||
relations = json.dumps([{"source": "Alice", "relation": "WORKS_AT", "target": "ACME"}])
|
||||
result = json.loads(self.kit.add_to_graph(relations=relations))
|
||||
self.assertEqual(result["edges_added"], 1)
|
||||
|
||||
def test_add_both(self):
|
||||
entities = json.dumps([{"name": "Bob", "type": "PERSON"}])
|
||||
relations = json.dumps([{"source": "Bob", "relation": "WORKS_AT", "target": "Corp"}])
|
||||
result = json.loads(self.kit.add_to_graph(entities=entities, relations=relations))
|
||||
self.assertEqual(result["nodes_added"], 1)
|
||||
self.assertEqual(result["edges_added"], 1)
|
||||
|
||||
def test_empty_call(self):
|
||||
result = json.loads(self.kit.add_to_graph())
|
||||
self.assertEqual(result["nodes_added"], 0)
|
||||
self.assertEqual(result["edges_added"], 0)
|
||||
|
||||
|
||||
class TestQueryGraph(unittest.TestCase):
|
||||
|
||||
def setUp(self):
|
||||
self.graph = _FakeGraph()
|
||||
self.kit = AgnoKGToolkit(
|
||||
ner_extractor=_FakeNER(),
|
||||
relation_extractor=_FakeRelExtractor(),
|
||||
reasoner=_FakeReasoner(),
|
||||
)
|
||||
self.kit._graph = self.graph
|
||||
|
||||
def test_keyword_query_returns_results(self):
|
||||
result = json.loads(self.kit.query_graph("Tesla"))
|
||||
self.assertIn("results", result)
|
||||
self.assertEqual(result["query_type"], "keyword")
|
||||
|
||||
def test_cypher_query_without_backend(self):
|
||||
result = json.loads(self.kit.query_graph("MATCH (n) RETURN n LIMIT 5"))
|
||||
# Without a real neo4j backend, should return an error
|
||||
self.assertEqual(result["query_type"], "cypher")
|
||||
|
||||
def test_handles_exception(self):
|
||||
bad_graph = MagicMock()
|
||||
bad_graph.find_nodes.side_effect = RuntimeError("graph error")
|
||||
self.kit._graph = bad_graph
|
||||
result = json.loads(self.kit.query_graph("anything"))
|
||||
self.assertIn("error", result)
|
||||
|
||||
|
||||
class TestFindRelated(unittest.TestCase):
|
||||
|
||||
def setUp(self):
|
||||
self.graph = _FakeGraph()
|
||||
self.kit = AgnoKGToolkit(
|
||||
ner_extractor=_FakeNER(),
|
||||
relation_extractor=_FakeRelExtractor(),
|
||||
reasoner=_FakeReasoner(),
|
||||
)
|
||||
self.kit._graph = self.graph
|
||||
|
||||
def test_returns_json(self):
|
||||
result = json.loads(self.kit.find_related("Tesla"))
|
||||
self.assertIn("entity", result)
|
||||
self.assertIn("related", result)
|
||||
self.assertIn("count", result)
|
||||
|
||||
def test_entity_preserved(self):
|
||||
result = json.loads(self.kit.find_related("Elon"))
|
||||
self.assertEqual(result["entity"], "Elon")
|
||||
|
||||
def test_hops_parameter(self):
|
||||
result = json.loads(self.kit.find_related("Tesla", hops=2))
|
||||
self.assertIsInstance(result["related"], list)
|
||||
|
||||
|
||||
class TestInferFacts(unittest.TestCase):
|
||||
|
||||
def setUp(self):
|
||||
self.kit = AgnoKGToolkit(
|
||||
ner_extractor=_FakeNER(),
|
||||
relation_extractor=_FakeRelExtractor(),
|
||||
reasoner=_FakeReasoner(),
|
||||
)
|
||||
self.kit._graph = _FakeGraph()
|
||||
self.kit._graph._nodes = {"n1": MagicMock(label="EthicalAI", node_type="AI")}
|
||||
|
||||
def test_returns_inferred_facts(self):
|
||||
result = json.loads(self.kit.infer_facts(rules='["IF AI(?x) THEN System(?x)"]'))
|
||||
self.assertIn("inferred_facts", result)
|
||||
self.assertIsInstance(result["inferred_facts"], list)
|
||||
|
||||
def test_count_correct(self):
|
||||
result = json.loads(self.kit.infer_facts(rules='["IF X(?a) THEN Y(?a)"]'))
|
||||
self.assertEqual(result["count"], len(result["inferred_facts"]))
|
||||
|
||||
def test_rules_as_csv(self):
|
||||
result = json.loads(self.kit.infer_facts(rules="IF AI(?x) THEN System(?x)"))
|
||||
self.assertIn("inferred_facts", result)
|
||||
|
||||
def test_facts_passed_explicitly(self):
|
||||
result = json.loads(self.kit.infer_facts(
|
||||
rules='["IF Person(?x) THEN Human(?x)"]',
|
||||
facts='["Person(Alice)"]',
|
||||
))
|
||||
self.assertIn("inferred_facts", result)
|
||||
|
||||
|
||||
class TestExportSubgraph(unittest.TestCase):
|
||||
|
||||
def setUp(self):
|
||||
self.kit = AgnoKGToolkit(
|
||||
ner_extractor=_FakeNER(),
|
||||
relation_extractor=_FakeRelExtractor(),
|
||||
reasoner=_FakeReasoner(),
|
||||
)
|
||||
self.kit._graph = _FakeGraph()
|
||||
|
||||
def test_returns_json(self):
|
||||
result_str = self.kit.export_subgraph()
|
||||
result = json.loads(result_str)
|
||||
self.assertIn("format", result)
|
||||
|
||||
def test_format_passed(self):
|
||||
result = json.loads(self.kit.export_subgraph(format="turtle"))
|
||||
self.assertIn("format", result)
|
||||
|
||||
def test_fallback_to_json_on_import_error(self):
|
||||
# RDFExporter may not be available in test env; should fall back gracefully
|
||||
result_str = self.kit.export_subgraph()
|
||||
result = json.loads(result_str)
|
||||
# Either the real export or the fallback JSON — both are valid
|
||||
self.assertIsInstance(result, dict)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,233 @@
|
||||
"""
|
||||
Tests for AgnoKnowledgeGraph — relational AgentKnowledge with GraphRAG.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
import types
|
||||
import unittest
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Stub agno
|
||||
# ---------------------------------------------------------------------------
|
||||
def _stub_agno() -> None:
|
||||
if "agno" in sys.modules:
|
||||
return
|
||||
|
||||
agno = types.ModuleType("agno")
|
||||
|
||||
# agno.knowledge.base
|
||||
knowledge_pkg = types.ModuleType("agno.knowledge")
|
||||
knowledge_base = types.ModuleType("agno.knowledge.base")
|
||||
|
||||
class AgentKnowledge:
|
||||
def __init__(self, *a, **kw): ... # noqa: E704
|
||||
def search(self, query, num_documents=None, filters=None): return [] # noqa: E704
|
||||
|
||||
knowledge_base.AgentKnowledge = AgentKnowledge # type: ignore
|
||||
knowledge_pkg.base = knowledge_base
|
||||
agno.knowledge = knowledge_pkg # type: ignore
|
||||
|
||||
# agno.document.base
|
||||
document_pkg = types.ModuleType("agno.document")
|
||||
document_base = types.ModuleType("agno.document.base")
|
||||
|
||||
class Document:
|
||||
def __init__(self, content="", id=None, name=None, meta_data=None):
|
||||
self.content = content
|
||||
self.id = id
|
||||
self.name = name
|
||||
self.meta_data = meta_data or {}
|
||||
|
||||
document_base.Document = Document # type: ignore
|
||||
document_pkg.base = document_base
|
||||
agno.document = document_pkg # type: ignore
|
||||
|
||||
for name, mod in [
|
||||
("agno", agno),
|
||||
("agno.knowledge", knowledge_pkg),
|
||||
("agno.knowledge.base", knowledge_base),
|
||||
("agno.document", document_pkg),
|
||||
("agno.document.base", document_base),
|
||||
]:
|
||||
sys.modules.setdefault(name, mod)
|
||||
|
||||
|
||||
_stub_agno()
|
||||
|
||||
from integrations.agno.knowledge_graph import AgnoKnowledgeGraph # noqa: E402
|
||||
|
||||
|
||||
class _FakeNER:
|
||||
def extract_entities(self, text):
|
||||
e = MagicMock()
|
||||
e.name = "FakeEntity"
|
||||
e.type = "ORG"
|
||||
e.confidence = 0.9
|
||||
return [e]
|
||||
|
||||
|
||||
class _FakeRelExtractor:
|
||||
def extract_relations(self, text, entities=None):
|
||||
r = MagicMock()
|
||||
r.source = "FakeEntity"
|
||||
r.type = "RELATED_TO"
|
||||
r.target = "OtherEntity"
|
||||
r.confidence = 0.8
|
||||
return [r]
|
||||
|
||||
|
||||
class _FakeGraphBuilder:
|
||||
def build(self, sources):
|
||||
return MagicMock()
|
||||
|
||||
|
||||
class _FakeContextGraph:
|
||||
def find_nodes(self, label=None):
|
||||
node = MagicMock()
|
||||
node.label = label or "Node"
|
||||
node.node_type = "Entity"
|
||||
return [node]
|
||||
|
||||
|
||||
class TestAgnoKnowledgeGraphInit(unittest.TestCase):
|
||||
|
||||
def test_creates_with_defaults(self):
|
||||
kg = AgnoKnowledgeGraph()
|
||||
self.assertIsNotNone(kg)
|
||||
|
||||
def test_creates_with_custom_components(self):
|
||||
kg = AgnoKnowledgeGraph(
|
||||
graph_builder=_FakeGraphBuilder(),
|
||||
ner_extractor=_FakeNER(),
|
||||
relation_extractor=_FakeRelExtractor(),
|
||||
context_graph=_FakeContextGraph(),
|
||||
)
|
||||
self.assertIsNotNone(kg)
|
||||
|
||||
def test_num_documents_default(self):
|
||||
kg = AgnoKnowledgeGraph(num_documents=10)
|
||||
self.assertEqual(kg.num_documents, 10)
|
||||
|
||||
|
||||
class TestAgnoKnowledgeGraphLoad(unittest.TestCase):
|
||||
|
||||
def setUp(self):
|
||||
self.kg = AgnoKnowledgeGraph(
|
||||
graph_builder=_FakeGraphBuilder(),
|
||||
ner_extractor=_FakeNER(),
|
||||
relation_extractor=_FakeRelExtractor(),
|
||||
context_graph=_FakeContextGraph(),
|
||||
)
|
||||
|
||||
def test_load_texts(self):
|
||||
self.kg.load(texts=["Alice works at Acme Corp.", "Bob is the CEO."])
|
||||
self.assertEqual(len(self.kg._docs), 2)
|
||||
|
||||
def test_load_texts_multiple_calls_accumulate(self):
|
||||
self.kg.load(texts=["First batch"])
|
||||
self.kg.load(texts=["Second batch"])
|
||||
self.assertEqual(len(self.kg._docs), 2)
|
||||
|
||||
def test_load_recreate_clears_docs(self):
|
||||
self.kg.load(texts=["Old doc"])
|
||||
self.kg.load(texts=["New doc"], recreate=True)
|
||||
self.assertEqual(len(self.kg._docs), 1)
|
||||
|
||||
def test_load_documents(self):
|
||||
doc = MagicMock()
|
||||
doc.content = "Agno is a multi-agent framework."
|
||||
doc.name = "agno_intro"
|
||||
self.kg.load_documents([doc])
|
||||
self.assertEqual(len(self.kg._docs), 1)
|
||||
|
||||
def test_ingest_stores_entities(self):
|
||||
self.kg._ingest_text("Tesla was founded by Elon Musk.", source="test")
|
||||
stored = self.kg._docs[-1]
|
||||
self.assertIn("entities", stored)
|
||||
self.assertTrue(len(stored["entities"]) > 0)
|
||||
|
||||
|
||||
class TestAgnoKnowledgeGraphSearch(unittest.TestCase):
|
||||
|
||||
def setUp(self):
|
||||
self.kg = AgnoKnowledgeGraph(
|
||||
graph_builder=_FakeGraphBuilder(),
|
||||
ner_extractor=_FakeNER(),
|
||||
relation_extractor=_FakeRelExtractor(),
|
||||
context_graph=_FakeContextGraph(),
|
||||
)
|
||||
self.kg.load(texts=[
|
||||
"Machine learning is a subset of artificial intelligence.",
|
||||
"Python is a popular programming language.",
|
||||
"Neural networks are inspired by the human brain.",
|
||||
])
|
||||
|
||||
def test_search_returns_list(self):
|
||||
results = self.kg.search("machine learning")
|
||||
self.assertIsInstance(results, list)
|
||||
|
||||
def test_search_returns_agno_documents(self):
|
||||
results = self.kg.search("python", num_documents=2)
|
||||
self.assertTrue(len(results) <= 2)
|
||||
for doc in results:
|
||||
self.assertTrue(hasattr(doc, "content"))
|
||||
|
||||
def test_search_empty_kg_returns_empty(self):
|
||||
kg = AgnoKnowledgeGraph(
|
||||
graph_builder=_FakeGraphBuilder(),
|
||||
ner_extractor=_FakeNER(),
|
||||
relation_extractor=_FakeRelExtractor(),
|
||||
context_graph=_FakeContextGraph(),
|
||||
)
|
||||
results = kg.search("anything")
|
||||
self.assertEqual(results, [])
|
||||
|
||||
def test_search_num_documents_respected(self):
|
||||
results = self.kg.search("a", num_documents=1)
|
||||
self.assertTrue(len(results) <= 1)
|
||||
|
||||
def test_get_graph_context(self):
|
||||
ctx = self.kg.get_graph_context("FakeEntity")
|
||||
self.assertIsInstance(ctx, str)
|
||||
|
||||
|
||||
class TestAgnoKnowledgeGraphPathLoading(unittest.TestCase):
|
||||
"""Test path-based loading with a temporary file."""
|
||||
|
||||
def test_load_missing_path_warns(self):
|
||||
kg = AgnoKnowledgeGraph(
|
||||
graph_builder=_FakeGraphBuilder(),
|
||||
ner_extractor=_FakeNER(),
|
||||
relation_extractor=_FakeRelExtractor(),
|
||||
context_graph=_FakeContextGraph(),
|
||||
)
|
||||
# Should not raise even for non-existent path
|
||||
kg.load(path="/nonexistent/path/xyz")
|
||||
self.assertEqual(len(kg._docs), 0)
|
||||
|
||||
def test_load_file(self):
|
||||
import tempfile, os
|
||||
|
||||
kg = AgnoKnowledgeGraph(
|
||||
graph_builder=_FakeGraphBuilder(),
|
||||
ner_extractor=_FakeNER(),
|
||||
relation_extractor=_FakeRelExtractor(),
|
||||
context_graph=_FakeContextGraph(),
|
||||
)
|
||||
with tempfile.NamedTemporaryFile(mode="w", suffix=".txt", delete=False) as f:
|
||||
f.write("Test document content for loading.")
|
||||
tmp_path = f.name
|
||||
|
||||
try:
|
||||
kg.load(path=tmp_path)
|
||||
self.assertEqual(len(kg._docs), 1)
|
||||
finally:
|
||||
os.unlink(tmp_path)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,237 @@
|
||||
"""
|
||||
Tests for AgnoSharedContext — multi-agent shared ContextGraph coordinator.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
import types
|
||||
import unittest
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Stub agno (MemoryDb needed by AgnoContextStore base)
|
||||
# ---------------------------------------------------------------------------
|
||||
def _stub_agno() -> None:
|
||||
if "agno" in sys.modules:
|
||||
return
|
||||
|
||||
agno = types.ModuleType("agno")
|
||||
|
||||
memory_pkg = types.ModuleType("agno.memory")
|
||||
memory_db_pkg = types.ModuleType("agno.memory.db")
|
||||
memory_db_base = types.ModuleType("agno.memory.db.base")
|
||||
memory_db_row = types.ModuleType("agno.memory.db.row")
|
||||
|
||||
class MemoryDb:
|
||||
def __init__(self, *a, **kw): ... # noqa: E704
|
||||
|
||||
class MemoryRow:
|
||||
def __init__(self, memory, id=None, user_id=None, **kw):
|
||||
self.memory = memory
|
||||
self.id = id
|
||||
self.user_id = user_id
|
||||
self.last_updated = 0.0
|
||||
self.topics = kw.get("topics", [])
|
||||
|
||||
memory_db_base.MemoryDb = MemoryDb # type: ignore
|
||||
memory_db_row.MemoryRow = MemoryRow # type: ignore
|
||||
memory_db_pkg.base = memory_db_base
|
||||
memory_db_pkg.row = memory_db_row
|
||||
memory_pkg.db = memory_db_pkg
|
||||
agno.memory = memory_pkg # type: ignore
|
||||
|
||||
for name, mod in [
|
||||
("agno", agno),
|
||||
("agno.memory", memory_pkg),
|
||||
("agno.memory.db", memory_db_pkg),
|
||||
("agno.memory.db.base", memory_db_base),
|
||||
("agno.memory.db.row", memory_db_row),
|
||||
]:
|
||||
sys.modules.setdefault(name, mod)
|
||||
|
||||
|
||||
_stub_agno()
|
||||
|
||||
from integrations.agno.shared_context import AgnoSharedContext # noqa: E402
|
||||
|
||||
|
||||
def _make_shared(**kwargs) -> AgnoSharedContext:
|
||||
shared = AgnoSharedContext(**kwargs)
|
||||
# Replace internal AgentContext with a mock to avoid real side-effects
|
||||
mock_ctx = MagicMock()
|
||||
mock_ctx.record_decision.return_value = "shared-dec-001"
|
||||
mock_ctx.find_precedents_advanced.return_value = []
|
||||
mock_ctx.get_context_insights.return_value = {"total": 0}
|
||||
shared._context = mock_ctx
|
||||
return shared
|
||||
|
||||
|
||||
class TestAgnoSharedContextInit(unittest.TestCase):
|
||||
|
||||
def test_creates_without_args(self):
|
||||
shared = _make_shared()
|
||||
self.assertIsNotNone(shared)
|
||||
|
||||
def test_session_id_auto_generated(self):
|
||||
shared = _make_shared()
|
||||
self.assertIsInstance(shared.session_id, str)
|
||||
self.assertTrue(len(shared.session_id) > 0)
|
||||
|
||||
def test_explicit_session_id(self):
|
||||
shared = _make_shared(session_id="team-session-xyz")
|
||||
self.assertEqual(shared.session_id, "team-session-xyz")
|
||||
|
||||
def test_decision_tracking_flag(self):
|
||||
shared = _make_shared(decision_tracking=False)
|
||||
self.assertFalse(shared.decision_tracking)
|
||||
|
||||
def test_knowledge_graph_property(self):
|
||||
shared = _make_shared()
|
||||
self.assertIsNotNone(shared.knowledge_graph)
|
||||
|
||||
def test_bound_roles_initially_empty(self):
|
||||
shared = _make_shared()
|
||||
self.assertEqual(shared.bound_roles, [])
|
||||
|
||||
|
||||
class TestBindAgent(unittest.TestCase):
|
||||
|
||||
def setUp(self):
|
||||
self.shared = _make_shared()
|
||||
|
||||
def test_bind_returns_store(self):
|
||||
store = self.shared.bind_agent("researcher")
|
||||
self.assertIsNotNone(store)
|
||||
|
||||
def test_bind_idempotent(self):
|
||||
store1 = self.shared.bind_agent("analyst")
|
||||
store2 = self.shared.bind_agent("analyst")
|
||||
self.assertIs(store1, store2)
|
||||
|
||||
def test_bind_tracks_roles(self):
|
||||
self.shared.bind_agent("researcher")
|
||||
self.shared.bind_agent("analyst")
|
||||
self.assertIn("researcher", self.shared.bound_roles)
|
||||
self.assertIn("analyst", self.shared.bound_roles)
|
||||
|
||||
def test_scoped_session_id(self):
|
||||
store = self.shared.bind_agent("writer")
|
||||
self.assertIn("writer", store.session_id)
|
||||
self.assertIn(self.shared.session_id, store.session_id)
|
||||
|
||||
def test_different_roles_different_stores(self):
|
||||
s1 = self.shared.bind_agent("role_a")
|
||||
s2 = self.shared.bind_agent("role_b")
|
||||
self.assertIsNot(s1, s2)
|
||||
|
||||
|
||||
class TestSharedMemoryPool(unittest.TestCase):
|
||||
"""Memories written by one agent are visible to all others."""
|
||||
|
||||
def setUp(self):
|
||||
self.shared = _make_shared()
|
||||
self.researcher = self.shared.bind_agent("researcher")
|
||||
self.analyst = self.shared.bind_agent("analyst")
|
||||
|
||||
def _make_row(self, text: str):
|
||||
row = MagicMock()
|
||||
row.memory = text
|
||||
row.id = None
|
||||
row.user_id = "u1"
|
||||
row.last_updated = 0.0
|
||||
row.topics = []
|
||||
return row
|
||||
|
||||
def test_researcher_memory_visible_to_analyst(self):
|
||||
row = self._make_row("New regulation: Basel IV applies from 2026")
|
||||
self.researcher.upsert_memory(row)
|
||||
|
||||
analyst_memories = self.analyst.read_memories()
|
||||
texts = [getattr(m, "memory", "") for m in analyst_memories]
|
||||
self.assertIn("New regulation: Basel IV applies from 2026", texts)
|
||||
|
||||
def test_analyst_memory_visible_to_researcher(self):
|
||||
row = self._make_row("Market share: Competitor X grew by 12%")
|
||||
self.analyst.upsert_memory(row)
|
||||
|
||||
researcher_memories = self.researcher.read_memories()
|
||||
texts = [getattr(m, "memory", "") for m in researcher_memories]
|
||||
self.assertIn("Market share: Competitor X grew by 12%", texts)
|
||||
|
||||
def test_both_memories_in_pool(self):
|
||||
self.researcher.upsert_memory(self._make_row("Research insight A"))
|
||||
self.analyst.upsert_memory(self._make_row("Analysis finding B"))
|
||||
|
||||
# Either agent should see both
|
||||
researcher_memories = self.researcher.read_memories()
|
||||
self.assertTrue(len(researcher_memories) >= 2)
|
||||
|
||||
def test_limit_respected_in_read(self):
|
||||
for i in range(5):
|
||||
self.researcher.upsert_memory(self._make_row(f"Fact {i}"))
|
||||
memories = self.analyst.read_memories(limit=2)
|
||||
self.assertTrue(len(memories) <= 2)
|
||||
|
||||
|
||||
class TestSharedContextDecisions(unittest.TestCase):
|
||||
|
||||
def setUp(self):
|
||||
self.shared = _make_shared()
|
||||
|
||||
def test_record_decision_returns_id(self):
|
||||
did = self.shared.record_decision(
|
||||
category="strategy",
|
||||
scenario="Expand to EU market",
|
||||
reasoning="Strong demand signals",
|
||||
outcome="approved",
|
||||
confidence=0.87,
|
||||
)
|
||||
self.assertEqual(did, "shared-dec-001")
|
||||
|
||||
def test_agent_role_tags_category(self):
|
||||
self.shared.record_decision(
|
||||
category="finance",
|
||||
scenario="Budget allocation",
|
||||
reasoning="Q1 performance",
|
||||
outcome="increase",
|
||||
confidence=0.9,
|
||||
agent_role="cfo",
|
||||
)
|
||||
call_kwargs = self.shared._context.record_decision.call_args[1]
|
||||
self.assertIn("cfo", call_kwargs["category"])
|
||||
|
||||
def test_find_precedents_returns_list(self):
|
||||
result = self.shared.find_precedents("expansion strategy")
|
||||
self.assertIsInstance(result, list)
|
||||
|
||||
def test_get_shared_insights_returns_dict(self):
|
||||
result = self.shared.get_shared_insights()
|
||||
self.assertIsInstance(result, dict)
|
||||
|
||||
|
||||
class TestSharedContextThreadSafety(unittest.TestCase):
|
||||
"""Concurrent bind_agent calls should return the same store."""
|
||||
|
||||
def test_concurrent_bind_same_role(self):
|
||||
import threading
|
||||
|
||||
shared = _make_shared()
|
||||
results = []
|
||||
|
||||
def bind():
|
||||
results.append(shared.bind_agent("concurrent_role"))
|
||||
|
||||
threads = [threading.Thread(target=bind) for _ in range(10)]
|
||||
for t in threads:
|
||||
t.start()
|
||||
for t in threads:
|
||||
t.join()
|
||||
|
||||
# All threads should get the same store instance
|
||||
self.assertEqual(len(set(id(s) for s in results)), 1)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -8,6 +8,8 @@ from semantica.ontology.ontology_evaluator import OntologyEvaluator, EvaluationR
|
||||
from semantica.ontology.competency_questions import CompetencyQuestionsManager, CompetencyQuestion
|
||||
from semantica.change_management import VersionManager, OntologyVersion
|
||||
from semantica.ontology.associative_class import AssociativeClassBuilder, AssociativeClass
|
||||
from semantica.ontology.reuse_manager import ReuseManager
|
||||
from semantica.ontology.engine import OntologyEngine
|
||||
|
||||
class TestOntologyAdvanced(unittest.TestCase):
|
||||
|
||||
@@ -151,6 +153,62 @@ class TestOntologyAdvanced(unittest.TestCase):
|
||||
self.assertEqual(len(is_valid), 0)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def test_reuse_manager_suggest_alignments(self):
|
||||
manager = ReuseManager()
|
||||
target = {
|
||||
"classes": [{"uri": "http://target.org/Person", "name": "Person"}],
|
||||
"properties": [{"uri": "http://target.org/hasName", "name": "has name"}]
|
||||
}
|
||||
source = {
|
||||
"classes": [{"uri": "http://source.org/Person", "name": "Person"}],
|
||||
"properties": [{"uri": "http://source.org/hasName", "name": "has name"}]
|
||||
}
|
||||
|
||||
suggestions = manager.suggest_alignments(target, source)
|
||||
|
||||
self.assertEqual(len(suggestions), 2)
|
||||
self.assertEqual(suggestions[0]["predicate"], "http://www.w3.org/2002/07/owl#equivalentClass")
|
||||
self.assertEqual(suggestions[0]["source_uri"], "http://source.org/Person")
|
||||
self.assertEqual(suggestions[1]["predicate"], "http://www.w3.org/2002/07/owl#equivalentProperty")
|
||||
|
||||
def test_reuse_manager_merge_with_alignments(self):
|
||||
manager = ReuseManager()
|
||||
target = {"classes": [{"uri": "http://target.org/Dog", "name": "Dog"}]}
|
||||
source = {"classes": [{"uri": "http://source.org/Dog", "name": "Dog"}]}
|
||||
|
||||
merged = manager.merge_ontology_data(target, source, compute_alignments=True)
|
||||
|
||||
self.assertIn("suggested_alignments", merged)
|
||||
self.assertEqual(len(merged["suggested_alignments"]), 1)
|
||||
self.assertEqual(merged["suggested_alignments"][0]["target_uri"], "http://target.org/Dog")
|
||||
|
||||
def test_engine_create_alignment(self):
|
||||
mock_store = MagicMock()
|
||||
engine = OntologyEngine(store=mock_store)
|
||||
|
||||
engine.create_alignment("http://source.org/1", "http://target.org/2", "http://www.w3.org/2002/07/owl#sameAs")
|
||||
|
||||
mock_store.add_triplet.assert_called_once()
|
||||
args, kwargs = mock_store.add_triplet.call_args
|
||||
self.assertEqual(args[0].subject, "http://source.org/1")
|
||||
self.assertEqual(args[0].object, "http://target.org/2")
|
||||
self.assertEqual(args[0].predicate, "http://www.w3.org/2002/07/owl#sameAs")
|
||||
|
||||
def test_engine_get_alignments(self):
|
||||
mock_store = MagicMock()
|
||||
mock_result = MagicMock()
|
||||
# Mocking the SPARQL binding response format
|
||||
mock_result.bindings = [
|
||||
{"s": {"value": "http://source.org/1"}, "p": {"value": "http://owl#sameAs"}, "o": {"value": "http://target.org/2"}}
|
||||
]
|
||||
mock_store.execute_query.return_value = mock_result
|
||||
|
||||
engine = OntologyEngine(store=mock_store)
|
||||
alignments = engine.get_alignments("http://source.org/1")
|
||||
|
||||
self.assertEqual(len(alignments), 1)
|
||||
self.assertEqual(alignments[0]["target"], "http://target.org/2")
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
|
||||
@@ -0,0 +1,200 @@
|
||||
"""
|
||||
Test suite for the DatalogReasoner module.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from typing import List, Dict, Any
|
||||
|
||||
from semantica.reasoning.datalog_reasoner import DatalogReasoner, DatalogFact
|
||||
|
||||
@pytest.fixture
|
||||
def reasoner():
|
||||
"""Provides a fresh DatalogReasoner instance for each test."""
|
||||
return DatalogReasoner()
|
||||
|
||||
class MockContextGraph:
|
||||
"""A mock to simulate Semantica's actual ContextGraph structure."""
|
||||
def __init__(self, nodes: List[Dict[str, Any]], edges: List[Dict[str, Any]]):
|
||||
self._nodes = nodes
|
||||
self._edges = edges
|
||||
|
||||
def find_nodes(self, node_type=None):
|
||||
return self._nodes
|
||||
|
||||
def find_edges(self, edge_type=None):
|
||||
return self._edges
|
||||
|
||||
|
||||
class TestBasicFacts:
|
||||
def test_add_string_fact(self, reasoner):
|
||||
reasoner.add_fact("parent(tom, bob)")
|
||||
assert len(reasoner._all_facts) == 1
|
||||
fact = list(reasoner._all_facts)[0]
|
||||
assert fact.predicate == "parent"
|
||||
assert fact.args == ("tom", "bob")
|
||||
|
||||
def test_add_dict_fact(self, reasoner):
|
||||
reasoner.add_fact({"subject": "bob", "predicate": "parent", "object": "ann"})
|
||||
assert len(reasoner._all_facts) == 1
|
||||
fact = list(reasoner._all_facts)[0]
|
||||
assert fact.predicate == "parent"
|
||||
assert fact.args == ("bob", "ann")
|
||||
|
||||
def test_duplicate_fact_ignored(self, reasoner):
|
||||
reasoner.add_fact("parent(tom, bob)")
|
||||
reasoner.add_fact("parent(tom, bob)")
|
||||
assert len(reasoner._all_facts) == 1
|
||||
|
||||
def test_empty_arguments_raise_error(self, reasoner):
|
||||
# Proves Issue #6 is fixed
|
||||
with pytest.raises(ValueError, match="Empty argument"):
|
||||
reasoner.add_fact("parent( )")
|
||||
with pytest.raises(ValueError, match="Empty argument"):
|
||||
reasoner.add_fact("parent(tom, )")
|
||||
|
||||
|
||||
class TestRules:
|
||||
def test_single_rule(self, reasoner):
|
||||
reasoner.add_fact("parent(tom, bob)")
|
||||
reasoner.add_rule("ancestor(X, Y) :- parent(X, Y).")
|
||||
|
||||
derived = reasoner.derive_all()
|
||||
assert "ancestor(tom, bob)" in derived
|
||||
|
||||
def test_recursive_ancestor(self, reasoner):
|
||||
reasoner.add_fact("parent(tom, bob)")
|
||||
reasoner.add_fact("parent(bob, ann)")
|
||||
|
||||
reasoner.add_rule("ancestor(X, Y) :- parent(X, Y).")
|
||||
reasoner.add_rule("ancestor(X, Y) :- parent(X, Z), ancestor(Z, Y).")
|
||||
|
||||
derived = reasoner.derive_all()
|
||||
assert "ancestor(tom, bob)" in derived
|
||||
assert "ancestor(bob, ann)" in derived
|
||||
assert "ancestor(tom, ann)" in derived
|
||||
|
||||
def test_multi_hop_three_levels(self, reasoner):
|
||||
reasoner.add_fact("edge(1, 2)")
|
||||
reasoner.add_fact("edge(2, 3)")
|
||||
reasoner.add_fact("edge(3, 4)")
|
||||
|
||||
reasoner.add_rule("reachable(X, Y) :- edge(X, Y).")
|
||||
reasoner.add_rule("reachable(X, Y) :- edge(X, Z), reachable(Z, Y).")
|
||||
|
||||
derived = reasoner.derive_all()
|
||||
assert "reachable(1, 4)" in derived
|
||||
|
||||
def test_two_body_atoms(self, reasoner):
|
||||
reasoner.add_fact("parent(tom, bob)")
|
||||
reasoner.add_fact("parent(bob, ann)")
|
||||
|
||||
reasoner.add_rule("grandparent(X, Y) :- parent(X, Z), parent(Z, Y).")
|
||||
|
||||
derived = reasoner.derive_all()
|
||||
assert "grandparent(tom, ann)" in derived
|
||||
assert "grandparent(tom, bob)" not in derived
|
||||
|
||||
|
||||
class TestQuery:
|
||||
def test_variable_binding(self, reasoner):
|
||||
reasoner.add_fact("parent(tom, bob)")
|
||||
reasoner.add_fact("parent(tom, alex)")
|
||||
reasoner.add_rule("ancestor(X, Y) :- parent(X, Y).")
|
||||
|
||||
results = reasoner.query("ancestor(tom, ?Y)")
|
||||
y_bindings = sorted([res["Y"] for res in results])
|
||||
assert y_bindings == ["alex", "bob"]
|
||||
|
||||
def test_lowercase_variable_query(self, reasoner):
|
||||
reasoner.add_fact("parent(tom, bob)")
|
||||
results = reasoner.query("parent(tom, ?y)")
|
||||
assert len(results) == 1
|
||||
assert results[0]["y"] == "bob"
|
||||
|
||||
def test_pre_bound_variable(self, reasoner):
|
||||
reasoner.add_fact("parent(tom, bob)")
|
||||
reasoner.add_rule("ancestor(X, Y) :- parent(X, Y).")
|
||||
|
||||
results_bob = reasoner.query("ancestor(tom, ?Y)", bindings={"Y": "bob"})
|
||||
assert len(results_bob) == 1
|
||||
assert results_bob[0]["Y"] == "bob"
|
||||
|
||||
results_ann = reasoner.query("ancestor(tom, ?Y)", bindings={"Y": "ann"})
|
||||
assert len(results_ann) == 0
|
||||
|
||||
def test_no_match_returns_empty(self, reasoner):
|
||||
reasoner.add_fact("parent(tom, bob)")
|
||||
results = reasoner.query("parent(sarah, ?Y)")
|
||||
assert results == []
|
||||
|
||||
|
||||
class TestContextGraphIntegration:
|
||||
def test_load_from_graph(self, reasoner):
|
||||
graph = MockContextGraph(
|
||||
nodes=[{"id": "microsoft", "type": "company"}],
|
||||
edges=[{"source": "microsoft", "target": "openai", "type": "invested_in"}]
|
||||
)
|
||||
|
||||
added = reasoner.load_from_graph(graph)
|
||||
assert added == 2
|
||||
|
||||
assert DatalogFact("company", ("microsoft",)) in reasoner._all_facts
|
||||
assert DatalogFact("invested_in", ("microsoft", "openai")) in reasoner._all_facts
|
||||
|
||||
def test_edge_becomes_fact(self, reasoner):
|
||||
graph = MockContextGraph(
|
||||
nodes=[],
|
||||
edges=[
|
||||
{"source": "alice", "target": "bob", "type": "manages"},
|
||||
{"source": "bob", "target": "carol", "type": "manages"},
|
||||
]
|
||||
)
|
||||
reasoner.load_from_graph(graph)
|
||||
|
||||
assert DatalogFact("manages", ("alice", "bob")) in reasoner._all_facts
|
||||
assert DatalogFact("manages", ("bob", "carol")) in reasoner._all_facts
|
||||
|
||||
def test_derive_after_load(self, reasoner):
|
||||
graph = MockContextGraph(
|
||||
nodes=[],
|
||||
edges=[
|
||||
{"source": "alice", "target": "bob", "type": "manages"},
|
||||
{"source": "bob", "target": "carol", "type": "manages"},
|
||||
]
|
||||
)
|
||||
reasoner.load_from_graph(graph)
|
||||
|
||||
reasoner.add_rule("transitive_manages(X, Y) :- manages(X, Y).")
|
||||
reasoner.add_rule("transitive_manages(X, Y) :- manages(X, Z), transitive_manages(Z, Y).")
|
||||
|
||||
derived = reasoner.derive_all()
|
||||
assert "transitive_manages(alice, carol)" in derived
|
||||
|
||||
|
||||
class TestEdgeCases:
|
||||
def test_empty_program(self, reasoner):
|
||||
derived = reasoner.derive_all()
|
||||
assert derived == []
|
||||
|
||||
def test_derive_all_idempotent(self, reasoner):
|
||||
reasoner.add_fact("parent(tom, bob)")
|
||||
reasoner.add_rule("ancestor(X, Y) :- parent(X, Y).")
|
||||
|
||||
first_run = len(reasoner.derive_all())
|
||||
second_run = len(reasoner.derive_all())
|
||||
|
||||
assert first_run == second_run
|
||||
assert first_run == 2
|
||||
|
||||
def test_clear_resets_state(self, reasoner):
|
||||
reasoner.add_fact("parent(tom, bob)")
|
||||
reasoner.add_rule("ancestor(X, Y) :- parent(X, Y).")
|
||||
reasoner.derive_all()
|
||||
|
||||
reasoner.clear()
|
||||
|
||||
assert len(reasoner._all_facts) == 0
|
||||
assert len(reasoner._rules) == 0
|
||||
assert len(reasoner._delta_new) == 0
|
||||
assert len(reasoner._delta_old) == 0
|
||||
assert len(reasoner._fact_index) == 0
|
||||
@@ -0,0 +1,65 @@
|
||||
|
||||
import os
|
||||
import sys
|
||||
import pytest
|
||||
|
||||
sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), '..')))
|
||||
|
||||
from semantica.semantic_extract.methods import (
|
||||
extract_entities_llm,
|
||||
extract_relations_llm,
|
||||
extract_triplets_llm,
|
||||
)
|
||||
from semantica.semantic_extract.providers import create_provider
|
||||
from semantica.semantic_extract import Entity
|
||||
|
||||
NOVITA_API_KEY = os.environ.get("NOVITA_API_KEY")
|
||||
NOVITA_MODEL = "deepseek/deepseek-v3.2"
|
||||
TEXT = (
|
||||
"Apple Inc. was founded by Steve Jobs, Steve Wozniak, and Ronald Wayne in 1976. "
|
||||
"It is headquartered in Cupertino, California. The company designs, manufactures, "
|
||||
"and markets smartphones, personal computers, tablets, wearables, and accessories."
|
||||
)
|
||||
|
||||
pytestmark = pytest.mark.skipif(
|
||||
not NOVITA_API_KEY,
|
||||
reason="NOVITA_API_KEY not set",
|
||||
)
|
||||
|
||||
|
||||
def test_novita_provider_available():
|
||||
provider = create_provider("novita")
|
||||
assert provider.is_available(), "Novita provider not available — check NOVITA_API_KEY and openai install"
|
||||
|
||||
|
||||
def test_novita_entity_extraction():
|
||||
entities = extract_entities_llm(TEXT, provider="novita", model=NOVITA_MODEL)
|
||||
assert isinstance(entities, list), "Expected a list of entities"
|
||||
assert len(entities) > 0, "No entities extracted"
|
||||
|
||||
|
||||
def test_novita_relation_extraction():
|
||||
sample_entities = [
|
||||
Entity(name="Apple Inc.", type="ORGANIZATION"),
|
||||
Entity(name="Steve Jobs", type="PERSON"),
|
||||
]
|
||||
relations = extract_relations_llm(TEXT, entities=sample_entities, provider="novita", model=NOVITA_MODEL)
|
||||
assert isinstance(relations, list), "Expected a list of relations"
|
||||
|
||||
|
||||
def test_novita_triplet_extraction():
|
||||
triplets = extract_triplets_llm(TEXT, provider="novita", model=NOVITA_MODEL)
|
||||
assert isinstance(triplets, list), "Expected a list of triplets"
|
||||
assert len(triplets) > 0, "No triplets extracted"
|
||||
|
||||
|
||||
def test_novita_chunked_extraction():
|
||||
long_text = " ".join([TEXT] * 10)
|
||||
entities = extract_entities_llm(
|
||||
long_text,
|
||||
provider="novita",
|
||||
model=NOVITA_MODEL,
|
||||
max_text_length=200,
|
||||
)
|
||||
assert isinstance(entities, list), "Expected a list of entities from chunked extraction"
|
||||
assert len(entities) > 0, "No entities extracted from chunked text"
|
||||
@@ -98,3 +98,67 @@ class TestTripletStore(unittest.TestCase):
|
||||
|
||||
self.assertTrue(result["success"])
|
||||
mock_backend_instance.delete_triplet.assert_called_once_with(triplet)
|
||||
|
||||
def test_query_engine_build_values_clause(self):
|
||||
engine = QueryEngine()
|
||||
uris = ["http://ex.org/1", "http://ex.org/2"]
|
||||
|
||||
clause = engine.build_values_clause("subject", uris)
|
||||
self.assertEqual(clause, "VALUES ?subject { <http://ex.org/1> <http://ex.org/2> }")
|
||||
|
||||
empty_clause = engine.build_values_clause("subject", [])
|
||||
self.assertEqual(empty_clause, "")
|
||||
|
||||
def test_query_engine_expand_entity_uri_disabled(self):
|
||||
engine = QueryEngine()
|
||||
mock_backend = MagicMock()
|
||||
|
||||
result = engine.expand_entity_uri("http://ex.org/1", mock_backend, use_alignments=False)
|
||||
|
||||
# Should return only the original URI and NOT query the store
|
||||
self.assertEqual(result, ["http://ex.org/1"])
|
||||
mock_backend.execute_sparql.assert_not_called()
|
||||
|
||||
def test_query_engine_expand_entity_uri_enabled(self):
|
||||
engine = QueryEngine()
|
||||
mock_backend = MagicMock()
|
||||
|
||||
# Mock the backend returning an aligned URI
|
||||
mock_backend.execute_sparql.return_value = {
|
||||
"bindings": [{"aligned": {"value": "http://ex.org/aligned_entity"}}]
|
||||
}
|
||||
|
||||
result = engine.expand_entity_uri("http://ex.org/original", mock_backend, use_alignments=True)
|
||||
|
||||
self.assertIn("http://ex.org/original", result)
|
||||
self.assertIn("http://ex.org/aligned_entity", result)
|
||||
self.assertEqual(len(result), 2)
|
||||
mock_backend.execute_sparql.assert_called_once()
|
||||
|
||||
def test_end_to_end_cross_ontology_uri_flow(self):
|
||||
"""
|
||||
Full end-to-end: real expand_entity_uri queries a mock backend,
|
||||
then build_values_clause injects the results into a SPARQL template.
|
||||
"""
|
||||
engine = QueryEngine()
|
||||
mock_backend = MagicMock()
|
||||
mock_backend.execute_sparql.return_value = {
|
||||
"bindings": [{"aligned": {"value": "http://aligned.org/2"}}]
|
||||
}
|
||||
|
||||
original_uri = "http://ex.org/1"
|
||||
expanded = engine.expand_entity_uri(original_uri, store_backend=mock_backend, use_alignments=True)
|
||||
values_clause = engine.build_values_clause("subject", expanded)
|
||||
|
||||
sparql_query = f"""
|
||||
SELECT ?instance ?name WHERE {{
|
||||
{values_clause}
|
||||
?instance a ?subject .
|
||||
?instance <http://schema.org/name> ?name .
|
||||
}}
|
||||
"""
|
||||
|
||||
self.assertIn("http://ex.org/1", sparql_query)
|
||||
self.assertIn("http://aligned.org/2", sparql_query)
|
||||
self.assertIn("VALUES ?subject", sparql_query)
|
||||
mock_backend.execute_sparql.assert_called_once()
|
||||
|
||||
Reference in New Issue
Block a user