From d94d8f6ab83cbfd6efb2782224bda0574a3d9433 Mon Sep 17 00:00:00 2001
From: Shinde vinayak rao patil <119512435+Shindevrp@users.noreply.github.com>
Date: Sun, 16 Aug 2026 11:45:43 +0530
Subject: [PATCH 1/7] Feat/crewai integration (#988)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
* feat(crewai): add first-class CrewAI integration (#962)
Add native CrewAI support so Crew agents can share a ContextGraph and
AgentContext via BaseTool subclasses and a BaseKnowledgeSource, matching
the existing agno integration pattern.
- SemanticaKGTool: 5 KG actions (extract_entities, extract_relations,
add_to_graph, query_graph, find_related) with sync run()/async arun()
- SemanticaDecisionTool: 5 decision-intelligence actions
(record_decision, find_precedents, trace_causal_chain,
analyze_impact, check_policy) over AgentContext
- SemanticaKnowledgeSource: serializes a ContextGraph into crew
knowledge storage; bridges legacy load_content() and current
validate_content()/aadd() contracts for crewai>=0.80.0
- All classes degrade gracefully when crewai is absent
- New pip extra crewai=... included in the all bundle
- 70 new tests (stub-based present-case + subprocess degradation path)
- Docs: integrations/crewai.md, docs.json nav, README matrix updates
* fix(crewai): harden tools against real Semantica dataclass shapes (#962)
Bugs found during live testing with crewai 1.15.16:
- SemanticaKGTool.add_to_graph crashed on real Entity/Relation dataclasses
('str' object has no attribute 'end_char'): string names were passed to
extract_relations(entities=...), which requires Entity objects, and the
tool read .name/.source/.target instead of Entity's .text/.label and
Relation's .subject/.object. Add shape-agnostic field helpers.
- SemanticaDecisionTool() created an AgentContext without a knowledge_graph,
so _decision_backend was never set and record_decision raised 'Decision
tracking is not enabled'. Wire in a ContextGraph.
- record_decision hard-failed when the agent omitted optional fields; fall
back to category='general', reasoning='agent decision',
outcome='recorded'.
Add tests covering real Entity/Relation dataclass shapes and the live
auto-created AgentContext path (now 77 crewai tests, 212 total).
* fix(crewai): make find_related traverse edges undirected (#962)
ContextGraph.get_neighbors only follows outgoing edges, so a node whose
only edge is incoming (A -> B) reported no related concepts. Rebuild a
bidirectional adjacency from find_edges() in SemanticaKGTool._find_related
so 'related' honors both directions.
* fix(crewai): harden tools for checkpoint serialization and correct action semantics (#962)
- Exclude live graph/context/extractor state from JSON serialization
(model_dump(mode="json")) so CrewAI checkpointing no longer raises
PydanticSerializationError; model_post_init self-heals defaults on restore
- query_graph now searches node content via graph.query() plus id/type
- trace_causal_chain returns an explicit error when causal tracing is
unavailable instead of substituting similarity precedents; call
trace_decision_causality(..., max_depth=...) with the correct kwarg name
- find_precedents propagates max_precedents/limit to the backend instead of
being silently capped at 10
- Serialize add_to_graph batches under a module lock to prevent concurrent
double-counting; skip nameless entities instead of creating repr()-junk nodes
- aadd() runs CPU-bound serialization in a thread executor
- Mirror crewai args_schema serialize/restore in the conftest stub and add
serialization regression tests (crewai: 92 tests)
* fix(crewai): correct check_policy coercion, guard causal tracing, and harden concurrency (#962)
- _eval_rule now coerces rule values type-aware: bool("false") was truthy, so
'enabled == false' reported a violation for enabled=false, and string datums
like "0.90" were compared lexicographically instead of numerically
- _trace_causal_chain no longer raises AttributeError (which escaped _run) when
the decision context lacks knowledge_graph; returns honest error JSON
- SemanticaKnowledgeSource storage failures log an actionable ERROR; without a
configured crew embedder agents previously retrieved nothing silently
- add_to_graph uses a per-graph re-entrant lock (WeakKeyDictionary) instead of a
process-global one: independent graphs no longer serialize each other and
re-entrant extractor callbacks cannot deadlock
- entity/relation confidence=None normalizes to 1.0 instead of failing the
whole extraction with float(None)
- add subprocess integration test against real crewai covering Crew-level
serialization round-trip and checkpoint restore (stub tests cannot see it)
- docs: embedder requirement for SemanticaKnowledgeSource; resume contract note
* fix(crewai): surface knowledge-source save failures at ERROR when storage is wired (#962)
Re-verification against real crewai showed the embedder-missing failure raises
ValueError even though storage IS wired, so the old except-ValueError branch
mislabeled it as 'storage not wired' and logged DEBUG — hiding the failure.
Distinguish by storage presence instead of exception type: storage is None ->
DEBUG keep-in-memory (legitimate standalone use); storage wired but save()
raises -> actionable ERROR. Add regression test mirroring real crewai's
ValueError-on-missing-embedder behavior.
* fix(crewai): expose run()/arun() entry points in degraded mode (#962)
The public crewai contract is run()/arun(); without crewai installed they were
missing (only the private _run existed), so the documented 'usable without
crewai' path raised AttributeError at the entry point. Define them in degraded
mode only, leaving crewai's BaseTool implementations untouched when present.
Extend the degradation subprocess test to exercise run() and arun().
* fix(crewai): standardize query shape, field-name rules, and restore-state flag
- _query_graph: id/type matches now return the same schema as content
matches (id/type/label/content/score) instead of a bare list
- _eval_rule: non-greedy field capture so hyphen/dot/space JSON keys
(e.g. "risk-score >= 0.9") are addressable in policy rules
- add had_live_state/reconstructed_state so checkpoint-restored tools
and knowledge sources signal that their live graph/context was lost
and an empty one reconstructed; knowledge source no longer hides the
loss by eagerly rebuilding its graph inside __init__ (pydantic calls
__init__ during model_validate)
* fix(crewai): address Qodo review — confidence errors, string trim, holistic availability
- record_decision: stop calling float() in _run, so malformed confidence
values surface as JSON errors (via _record_decision's handling) instead
of crashing the tool
- _coerce_value: return the stripped string for non-numeric literals so
whitespace-padded decision_data fields match policy rules
- centralize crewai availability in _availability.py so the exported
CREWAI_AVAILABLE flag is holistic across tools and knowledge source
(previously each module probed crewai independently and the package
flag came from decision_tool only)
* ci: regenerate requirements-ci.txt for the crewai extra
The crewai extra in pyproject.toml brings in crewai, crewai-tools and
transitive deps (chromadb, lancedb, ...). Recompile with
uv==0.12.1 per CONTRIBUTING.md so the CI staleness check passes.
* ci: keep crewai out of the locked CI dependency set
crewai (all versions) hard-requires chromadb~=1.1.0, which carries a
pre-authentication code-injection advisory (CVE-2026-45829 / GHSA-f4j7-r4q5-qw2c)
with NO fixed release — even the latest 1.5.9 is affected. Keeping crewai in
the 'all' extra failed pip-audit and the safety check on requirements-ci.txt.
- drop crewai from the 'all' aggregate (standalone semantica[crewai] extra is
unchanged and still installs crewai)
- stop listing crewai-tools in the extra: the integration only uses crewai core
(BaseTool, BaseKnowledgeSource) and crewai-tools pulled extra transitive deps
- regenerate requirements-ci.txt: OSV/pip-audit 0 vulnerabilities, safety 0
vulnerabilities, staleness check matches
* docs(crewai): document crewai extra scope and chromadb CVE-2026-45829
- CHANGELOG: extra is crewai>=0.80.0 only (no crewai-tools) and is not
part of the 'all' bundle, with the chromadb CVE-2026-45829 reason
- integrations/crewai/README.md: add a security warning that installing
the extra pulls chromadb~=1.1.0, which is affected by the unpatched
pre-auth code-injection CVE-2026-45829
---------
---
CHANGELOG.md | 11 +
README.md | 20 +-
docs/docs.json | 1 +
docs/integrations/crewai.md | 147 +++++
integrations/crewai/README.md | 108 ++++
integrations/crewai/__init__.py | 44 ++
integrations/crewai/_availability.py | 24 +
integrations/crewai/decision_tool.py | 555 +++++++++++++++++
integrations/crewai/kg_tool.py | 573 ++++++++++++++++++
integrations/crewai/knowledge_source.py | 331 ++++++++++
pyproject.toml | 8 +
requirements-ci.txt | 8 +-
tests/integrations/crewai/conftest.py | 151 +++++
.../integrations/crewai/test_decision_tool.py | 562 +++++++++++++++++
tests/integrations/crewai/test_degradation.py | 103 ++++
tests/integrations/crewai/test_kg_tool.py | 453 ++++++++++++++
.../crewai/test_knowledge_source.py | 228 +++++++
.../crewai/test_real_crewai_integration.py | 123 ++++
18 files changed, 3434 insertions(+), 16 deletions(-)
create mode 100644 docs/integrations/crewai.md
create mode 100644 integrations/crewai/README.md
create mode 100644 integrations/crewai/__init__.py
create mode 100644 integrations/crewai/_availability.py
create mode 100644 integrations/crewai/decision_tool.py
create mode 100644 integrations/crewai/kg_tool.py
create mode 100644 integrations/crewai/knowledge_source.py
create mode 100644 tests/integrations/crewai/conftest.py
create mode 100644 tests/integrations/crewai/test_decision_tool.py
create mode 100644 tests/integrations/crewai/test_degradation.py
create mode 100644 tests/integrations/crewai/test_kg_tool.py
create mode 100644 tests/integrations/crewai/test_knowledge_source.py
create mode 100644 tests/integrations/crewai/test_real_crewai_integration.py
diff --git a/CHANGELOG.md b/CHANGELOG.md
index a94fd882..78212679 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -11,6 +11,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Added
+- **First-class CrewAI integration** (#962)
+ - New `pip install semantica[crewai]` extra (`crewai>=0.80.0`) — crewai core provides `BaseTool`/`BaseKnowledgeSource`, so `crewai-tools` is intentionally not included, and the extra is intentionally **not** part of the `all` bundle: crewai hard-requires `chromadb~=1.1.0`, which is affected by the unpatched pre-auth code-injection CVE-2026-45829 (see `integrations/crewai/README.md`)
+ - `integrations/crewai/SemanticaKGTool` — a CrewAI `BaseTool` exposing 5 KG actions (`extract_entities`, `extract_relations`, `add_to_graph`, `query_graph`, `find_related`) backed by `NERExtractor` / `RelationExtractor` / `ContextGraph`; supports both sync `run()` and async `arun()`
+ - `integrations/crewai/SemanticaDecisionTool` — a CrewAI `BaseTool` wrapping `AgentContext` with 5 decision-intelligence actions (`record_decision`, `find_precedents`, `trace_causal_chain`, `analyze_impact`, `check_policy`)
+ - `integrations/crewai/SemanticaKnowledgeSource` — a CrewAI `BaseKnowledgeSource` that serializes a `ContextGraph` into crew knowledge storage; implements both the legacy `load_content()` and current `validate_content()`/`aadd()` contracts so it works across `crewai>=0.80.0`
+ - All three classes degrade gracefully when `crewai` is not installed (still importable, full Semantica API available)
+ - New `tests/integrations/crewai/`: 70 tests covering stub-based present-case behavior (Pydantic/BaseTool subclassing, every action, knowledge-source chunking/storage) plus a subprocess isolation test for the crewai-absent degradation path
+ - Docs: `docs/integrations/crewai.md` page, `docs.json` Integrations nav entry, and README integration-matrix/install updates
+ - **Hardened during code review**: live `graph`/`context`/extractor state is excluded from CrewAI JSON serialization (`model_dump(mode="json")`) with `model_post_init` self-healing defaults, so checkpoint/resume no longer raises `PydanticSerializationError`; `query_graph` now searches node content (not just ids/types); `trace_causal_chain` returns an explicit error instead of substituting similarity precedents when causal tracing is unavailable, and calls `trace_decision_causality(..., max_depth=...)` with the correct argument name; `find_precedents` propagates `max_precedents` as the backend `limit`; `add_to_graph` writes are serialized under a module lock so concurrent agents can't double-count duplicate adds; nameless entities are skipped instead of creating `repr()`-junk nodes
+ - **Hardened during second code review**: `check_policy` rules are now coerced type-aware — `bool("false")` was truthy, so `enabled == false` reported a violation for `enabled: false`, and string datums like `"0.90"` were compared lexicographically instead of numerically; `trace_causal_chain` no longer raises `AttributeError` (which escaped the tool) when the decision context has no `knowledge_graph`, returning honest error JSON instead; knowledge-source storage failures log an actionable ERROR (a missing crew embedder otherwise silently left agents with empty retrieval); `add_to_graph` uses a per-graph re-entrant lock instead of a process-global one (independent graphs no longer serialize each other, and re-entrant extractors can't deadlock); entity/relation `confidence=None` normalizes to `1.0` instead of failing the whole extraction; added a subprocess integration test against the real `crewai` package covering `Crew`-level serialization round-trip and restore
+
- **`ContextGraph` gains retraction and purge — the graph previously had no way to remove a node or edge without discarding everything via `clear()`** (#957, closes #955) by @pravit-amp, reviewed by @KaifAhmad1
- `retract_node()`/`retract_edge()` close an entity's validity window rather than deleting it, reusing the existing `valid_from`/`valid_until`/`state_at()` machinery: the entity drops out of `find_active_nodes()` and future `state_at()` queries going forward, but `state_at()` calls before the retraction time still return it, so decisions recorded against it stay explainable. A `("kind", id)`-keyed retraction record captures who/why/when, retrievable via `get_retraction()`/`list_retractions()`
- `purge_node()`/`purge_edge()` are the destructive counterpart: the entity is removed outright, from history as well as the active view, for erasure obligations retraction alone cannot satisfy (e.g. GDPR Article 17). Only a tombstone remains — that a purge happened, when, and why — deliberately never the purged content, via `get_tombstone()`/`list_tombstones()`. Purge is graph-scope only: `AgentMemory` and any bound vector store are not reached, so it is one step of an erasure workflow rather than the whole of it
diff --git a/README.md b/README.md
index fbc20781..fe3272ea 100644
--- a/README.md
+++ b/README.md
@@ -77,7 +77,7 @@ Semantica sits underneath your LLM, vector store, and agent framework as a deter
- **Graph Analytics:** Centrality, community detection, link prediction, and shortest-path queries over the graph you just built
- **Polyglot Graph Storage:** Native RDF (embedded Oxigraph, Blazegraph, Apache Jena, Eclipse RDF4J via SPARQL) and Labeled Property Graphs (Neo4j, FalkorDB, Apache AGE, AWS Neptune via Cypher), plus vector stores, all swappable without touching your code
- **Visualization:** Explore any graph, ontology, or timeline in an interactive browser workbench
-- **Drop-in Integrations:** Native Agno support, a full-featured MCP server, a comprehensive CLI, a REST API, and plugins across major editors
+- **Drop-in Integrations:** Native Agno and CrewAI support, a full-featured MCP server, a comprehensive CLI, a REST API, and plugins across major editors
---
@@ -1189,7 +1189,7 @@ Start with `semantica`, verify with `doctor`, build a graph, and explore the com
## Integrations
-Native plugin bundles for Claude Code, Cursor, Codex, Windsurf, Cline, Continue, VS Code, and OpenClaw; a full-featured MCP server for any MCP-compatible client; a comprehensive REST API; and first-class Agno support for multi-agent shared context. Every major LLM provider is already supported via `semantica.llms` and LiteLLM: OpenAI, Anthropic, Gemini, Mistral, Llama, Groq, Cohere, Azure, Bedrock, Ollama, DeepSeek, HuggingFace, and more.
+Native plugin bundles for Claude Code, Cursor, Codex, Windsurf, Cline, Continue, VS Code, and OpenClaw; a full-featured MCP server for any MCP-compatible client; a comprehensive REST API; and first-class Agno and CrewAI support for agentic frameworks. Every major LLM provider is already supported via `semantica.llms` and LiteLLM: OpenAI, Anthropic, Gemini, Mistral, Llama, Groq, Cohere, Azure, Bedrock, Ollama, DeepSeek, HuggingFace, and more.
MCP setup takes 30 seconds — see [MCP Server](#mcp-server) below.
@@ -1303,6 +1303,11 @@ MCP setup takes 30 seconds — see [MCP Server](#mcp-server) below.
Agno
First-class · pip install semantica[agno]
+
| Already Supported via REST API & MCP |
@@ -1319,11 +1324,6 @@ MCP setup takes 30 seconds — see [MCP Server](#mcp-server) below.
REST API · MCP
-
-CrewAI
-REST API · MCP
- |
-

LlamaIndex
REST API · MCP
@@ -1354,11 +1354,6 @@ MCP setup takes 30 seconds — see [MCP Server](#mcp-server) below.
Dedicated toolkit
|
-
-CrewAI
-Dedicated toolkit
- |
-

LlamaIndex
Dedicated toolkit
@@ -1514,6 +1509,7 @@ pip install semantica[all] # everything
```bash
pip install semantica[agno] # Agno multi-agent integration
+pip install semantica[crewai] # CrewAI integration
pip install semantica[llm-litellm] # OpenAI, Anthropic, Gemini, Mistral, Llama, Groq, Cohere, Bedrock, Ollama, DeepSeek, and more
pip install semantica[graph-neo4j] # Neo4j graph store (LPG)
pip install semantica[graph-falkordb] # FalkorDB graph store (LPG)
diff --git a/docs/docs.json b/docs/docs.json
index f52cdbd6..d2ad5da2 100644
--- a/docs/docs.json
+++ b/docs/docs.json
@@ -102,6 +102,7 @@
"group": "Integrations",
"pages": [
"integrations/agno",
+ "integrations/crewai",
"integrations/docling",
"integrations/snowflake",
"integrations/databricks"
diff --git a/docs/integrations/crewai.md b/docs/integrations/crewai.md
new file mode 100644
index 00000000..fb555cda
--- /dev/null
+++ b/docs/integrations/crewai.md
@@ -0,0 +1,147 @@
+---
+title: "CrewAI Integration"
+description: "Give CrewAI crews a shared semantic knowledge graph, decision intelligence, and graph-based retrieval via three drop-in components."
+icon: "users"
+---
+
+> Three drop-in components that bring Semantica's knowledge graph and decision intelligence into any CrewAI crew.
+
+## Installation
+
+```bash
+pip install "semantica[crewai]"
+```
+
+Requires `crewai >= 0.80.0`. If `crewai` is not installed, the integration still imports — every class carries the full Semantica API and degrades gracefully, but cannot be passed to a `Crew`.
+
+## Components at a Glance
+
+- **SemanticaKGTool** — `Agent(tools=[…])`: 5 KG construction/query actions: extract entities, extract relations, add to graph, query graph, find related.
+- **SemanticaDecisionTool** — `Agent(tools=[…])`: 5 decision intelligence actions: record decisions, find precedents, trace causal chains, analyze impact, check policies.
+- **SemanticaKnowledgeSource** — `Crew(knowledge_sources=[…])`: Serializes a `ContextGraph` into CrewAI knowledge storage so every agent gets retrieval access to the graph.
+
+## Component Details
+
+
+
+ Lets agents actively **build and query** a shared `ContextGraph` mid-reasoning.
+
+ ```python
+ from crewai import Agent, Crew, Task
+ from semantica.context import ContextGraph
+ from integrations.crewai import SemanticaKGTool
+
+ graph = ContextGraph()
+
+ analyst = Agent(
+ role="Knowledge Analyst",
+ goal="Build and explore a knowledge graph from documents",
+ backstory="You map entities and relationships into a shared graph.",
+ tools=[SemanticaKGTool(graph=graph)],
+ )
+
+ crew = Crew(
+ agents=[analyst],
+ tasks=[Task(
+ description="Extract and link key entities from the brief",
+ expected_output="JSON",
+ agent=analyst,
+ )],
+ )
+ crew.kickoff()
+ ```
+
+ | Tool | Description |
+ | :------ | :------------- |
+ | `extract_entities` | Extract named entities from `text` |
+ | `extract_relations` | Extract relationships between entities in `text` |
+ | `add_to_graph` | Extract entities/relations from `text` and add them to the shared graph |
+ | `query_graph` | Keyword-search the graph by node id, type, and content using `query` |
+ | `find_related` | Find concepts related to `entity` within `hops` hops |
+
+ All actions return JSON so agents get parseable results.
+
+ **Sharing a graph:** the tool reads/writes whatever `graph` you pass in. When no `graph` is given, a fresh in-memory `ContextGraph()` is created (and a warning is logged) — two tool instances that each auto-create their own graph do **not** share knowledge. Pass the same `ContextGraph` to every agent that must share state.
+
+
+ Exposes Semantica's decision intelligence as a native CrewAI tool, backed by `AgentContext`.
+
+ ```python
+ from crewai import Agent, Crew, Task
+ from integrations.crewai import SemanticaDecisionTool
+
+ planner = Agent(
+ role="Decision Planner",
+ goal="Make grounded, precedented decisions",
+ backstory="You record decisions and validate them against policy.",
+ tools=[SemanticaDecisionTool()],
+ )
+
+ crew = Crew(agents=[planner], tasks=[...])
+ ```
+
+ When no `AgentContext` is passed, one is created in-memory with `decision_tracking=True` and its own `ContextGraph`, so decision actions work out of the box (a warning is logged — pass the same `AgentContext` to every agent that must share decision state). Missing optional fields in `record_decision` fall back to `category="general"`, `reasoning="agent decision"`, and `outcome="recorded"`. `find_precedents` returns up to `max_precedents` results. If a knowledge graph cannot trace causality, `trace_causal_chain` returns an explicit error rather than substituting similarity-based results.
+
+ | Tool | Description |
+ | :------ | :------------- |
+ | `record_decision` | Record a decision with reasoning, outcome, and confidence |
+ | `find_precedents` | Search for similar past decisions |
+ | `trace_causal_chain` | Trace the causal chain from a decision |
+ | `analyze_impact` | Assess downstream influence of a decision |
+ | `check_policy` | Validate a proposed decision against policy rules |
+
+
+ Gives **every agent in the crew** retrieval access to a `ContextGraph`.
+
+ ```python
+ from crewai import Agent, Crew, Task
+ from semantica.context import ContextGraph
+ from integrations.crewai import SemanticaKnowledgeSource
+
+ graph = ContextGraph()
+ graph.add_node(node_id="privacy", node_type="policy", content="...")
+
+ researcher = Agent(
+ role="Policy Researcher",
+ goal="Answer questions from the knowledge base",
+ backstory="You retrieve from graph knowledge to answer accurately.",
+ )
+
+ crew = Crew(
+ agents=[researcher],
+ tasks=[...],
+ knowledge_sources=[SemanticaKnowledgeSource(graph=graph)],
+ )
+ ```
+
+ On kickoff the graph's nodes and edges are serialized, chunked, and stored through CrewAI's knowledge pipeline.
+
+ > **Embedder required:** storing chunks goes through CrewAI's knowledge pipeline, which needs an embedder to be configured. Set `Crew(embedder=...)` (or provide the default credentials CrewAI falls back to, e.g. `OPENAI_API_KEY`). If no working embedder is configured, storage fails, an ERROR is logged, and agents will retrieve **nothing** — the crew still runs, but its knowledge queries return empty.
+
+ **Compatibility:** CrewAI's `BaseKnowledgeSource` contract changed between `0.80.x` and current releases (`load_content()` → `validate_content()`/`aadd()`). `SemanticaKnowledgeSource` implements both legacy and current methods, so it works across `crewai>=0.80.0`.
+
+
+
+## Checkpoints & Serialization
+
+CrewAI serializes tools and knowledge sources to JSON for checkpointing/resume. Live Semantica state (`ContextGraph`, `AgentContext`, extractors) is **excluded from that serialization** — a restored tool/source comes back with a fresh in-memory `ContextGraph` and logs a warning. Until you re-attach the live graph/context, the restored objects answer queries against an **empty** graph, so re-wire them after resuming (e.g. `restored_tool.graph = live_graph`) before agents continue.
+
+## API Reference
+
+```python
+from integrations.crewai import (
+ SemanticaKGTool, # BaseTool: KG construction/query actions
+ SemanticaDecisionTool, # BaseTool: decision intelligence actions
+ SemanticaKnowledgeSource, # BaseKnowledgeSource: graph → crew knowledge
+ CREWAI_AVAILABLE, # bool: True if crewai is installed
+)
+```
+
+All three classes are usable without `crewai` installed: they carry the full Semantica API and degrade gracefully.
+
+## See Also
+
+- [Context Module](../reference/context) — AgentContext and ContextGraph backing the integration.
+- [Semantic Extraction](../reference/semantic_extract) — NERExtractor / RelationExtractor used by SemanticaKGTool.
+- [LLMs](../reference/llms) — Configure LLM providers for your crew's agents.
+- [Vector Store](../reference/vector_store) — Vector backend used by SemanticaDecisionTool.
diff --git a/integrations/crewai/README.md b/integrations/crewai/README.md
new file mode 100644
index 00000000..e083dd7e
--- /dev/null
+++ b/integrations/crewai/README.md
@@ -0,0 +1,108 @@
+# Semantica × CrewAI
+
+First-class integration between Semantica and [CrewAI](https://github.com/crewAIInc/crewAI) — give your crews a shared semantic knowledge graph, decision intelligence, and graph-based retrieval.
+
+## Installation
+
+```bash
+pip install semantica[crewai]
+```
+
+Requires `crewai >= 0.80.0`. If `crewai` is not installed, the integration still imports (classes degrade gracefully), but you can't pass the objects to a `Crew`.
+
+> **⚠️ Security note:** crewai hard-requires `chromadb~=1.1.0`, which is currently affected by the unpatched pre-authentication code-injection advisory **CVE-2026-45829** (no fixed release — even the latest chromadb 1.5.9 is affected). Installing `semantica[crewai]` pulls that dependency into your environment. The `crewai` extra is intentionally **not** part of `semantica[all]` for this reason — only install it where you actually use CrewAI, and follow chromadb for a patched release.
+
+## 1. SemanticaKGTool
+
+A `BaseTool` that lets agents **build and query** a shared `ContextGraph` mid-reasoning:
+
+- `extract_entities` — extract named entities from `text`
+- `extract_relations` — extract relationships from `text`
+- `add_to_graph` — extract entities/relations from `text` and add them to the shared graph
+- `query_graph` — keyword-search the graph using `query`
+- `find_related` — find concepts related to `entity` within `hops`
+
+```python
+from crewai import Agent, Crew, Task
+from semantica.context import ContextGraph
+from integrations.crewai import SemanticaKGTool
+
+graph = ContextGraph()
+
+analyst = Agent(
+ role="Knowledge Analyst",
+ goal="Build and explore a knowledge graph from documents",
+ backstory="You map entities and relationships into a shared graph.",
+ tools=[SemanticaKGTool(graph=graph)],
+)
+
+crew = Crew(
+ agents=[analyst],
+ tasks=[Task(description="Extract and link key entities from the brief", expected_output="JSON", agent=analyst)],
+)
+result = crew.kickoff()
+```
+
+All actions return JSON, so agents get parseable results.
+
+## 2. SemanticaDecisionTool
+
+A `BaseTool` that wraps `AgentContext` and exposes decision intelligence:
+
+- `record_decision` — record a decision with reasoning and outcome
+- `find_precedents` — retrieve past decisions similar to a scenario
+- `trace_causal_chain` — trace the causal chain from a decision
+- `analyze_impact` — assess downstream influence using graph centrality
+- `check_policy` — validate a proposed decision against rule-based policies
+
+```python
+from crewai import Agent, Crew, Task
+from integrations.crewai import SemanticaDecisionTool
+
+planner = Agent(
+ role="Decision Planner",
+ goal="Make grounded, precedented decisions",
+ backstory="You record decisions and validate them against policy.",
+ tools=[SemanticaDecisionTool()],
+)
+
+crew = Crew(agents=[planner], tasks=[...])
+```
+
+When no `AgentContext` is passed, one is created in-memory with `decision_tracking=True`.
+
+## 3. SemanticaKnowledgeSource
+
+A `BaseKnowledgeSource` that serializes the current state of a `ContextGraph` (nodes, edges, metadata) into CrewAI's knowledge storage, giving **every agent in the crew** retrieval access to the graph:
+
+```python
+from crewai import Agent, Crew, Task
+from semantica.context import ContextGraph
+from integrations.crewai import SemanticaKnowledgeSource
+
+graph = ContextGraph()
+graph.add_node(node_id="privacy", node_type="policy", content="...")
+
+researcher = Agent(
+ role="Policy Researcher",
+ goal="Answer questions from the knowledge base",
+ backstory="You retrieve from graph knowledge to answer accurately.",
+)
+
+crew = Crew(
+ agents=[researcher],
+ tasks=[...],
+ knowledge_sources=[SemanticaKnowledgeSource(graph=graph)],
+)
+```
+
+> **Embedder required:** storing chunks goes through CrewAI's knowledge pipeline, which needs an embedder. Set `Crew(embedder=...)` (or provide CrewAI's default credentials, e.g. `OPENAI_API_KEY`). Without a working embedder, storage fails, an ERROR is logged, and agents retrieve nothing — the crew still runs with empty knowledge queries.
+
+### Compatibility note
+
+CrewAI's `BaseKnowledgeSource` contract changed between `0.80.x` and current releases (`load_content()` → `validate_content()`/`aadd()`). `SemanticaKnowledgeSource` implements both the legacy and current methods, so it works across `crewai>=0.80.0`.
+
+### Sharing state & checkpoints
+
+- Each tool/source holds whatever `graph`/`context` you pass it. When omitted, a fresh in-memory object is created and a warning is logged — instances that auto-create their own state do **not** share knowledge, so pass the same object to every agent that must share.
+- Live state (`ContextGraph`, `AgentContext`, extractors) is excluded from CrewAI's JSON serialization. After restoring from a checkpoint, re-attach the live graph/context to the restored objects.
diff --git a/integrations/crewai/__init__.py b/integrations/crewai/__init__.py
new file mode 100644
index 00000000..96c027c7
--- /dev/null
+++ b/integrations/crewai/__init__.py
@@ -0,0 +1,44 @@
+"""
+Semantica × CrewAI Integration
+==============================
+
+First-class integration between the Semantica semantic intelligence stack and
+the `CrewAI `_ agentic framework.
+
+Public surface
+--------------
+SemanticaKGTool — CrewAI ``BaseTool`` exposing KG construction/query actions
+SemanticaDecisionTool — CrewAI ``BaseTool`` exposing decision-intelligence actions
+SemanticaKnowledgeSource— CrewAI ``BaseKnowledgeSource`` giving crews graph knowledge
+
+Quick start
+-----------
+ pip install semantica[crewai]
+
+ >>> from integrations.crewai import (
+ ... SemanticaKGTool,
+ ... SemanticaDecisionTool,
+ ... SemanticaKnowledgeSource,
+ ... )
+
+Compatibility
+-------------
+Requires ``crewai >= 0.80.0``. All three classes degrade gracefully when
+``crewai`` is not installed — they are still importable and carry the full
+Semantica API, but cannot be passed to ``Crew`` / ``Agent`` constructors.
+"""
+
+from ._availability import CREWAI_AVAILABLE, CREWAI_IMPORT_ERROR
+from .decision_tool import SemanticaDecisionTool
+from .kg_tool import SemanticaKGTool
+from .knowledge_source import SemanticaKnowledgeSource
+
+__all__ = [
+ "SemanticaKGTool",
+ "SemanticaDecisionTool",
+ "SemanticaKnowledgeSource",
+ "CREWAI_AVAILABLE",
+ "CREWAI_IMPORT_ERROR",
+]
+
+__version__ = "0.1.0"
diff --git a/integrations/crewai/_availability.py b/integrations/crewai/_availability.py
new file mode 100644
index 00000000..6c871628
--- /dev/null
+++ b/integrations/crewai/_availability.py
@@ -0,0 +1,24 @@
+"""
+Shared CrewAI availability probe.
+
+Every integration module needs to know whether the real ``crewai`` package is
+installed. Probing once here (instead of once per module) guarantees the
+exported ``CREWAI_AVAILABLE`` flag means the *whole* integration is ready — a
+caller gating on it will never see tools using CrewAI while a knowledge source
+silently degrades (or vice versa).
+"""
+
+from typing import Optional
+
+CREWAI_AVAILABLE = False
+CREWAI_IMPORT_ERROR: Optional[str] = None
+
+try:
+ from crewai.knowledge.source.base_knowledge_source import ( # noqa: F401
+ BaseKnowledgeSource,
+ )
+ from crewai.tools import BaseTool # noqa: F401
+
+ CREWAI_AVAILABLE = True
+except ImportError as exc:
+ CREWAI_IMPORT_ERROR = str(exc)
diff --git a/integrations/crewai/decision_tool.py b/integrations/crewai/decision_tool.py
new file mode 100644
index 00000000..bf3552bf
--- /dev/null
+++ b/integrations/crewai/decision_tool.py
@@ -0,0 +1,555 @@
+"""
+SemanticaDecisionTool — a CrewAI ``BaseTool`` exposing Semantica's decision
+intelligence (``AgentContext``) to agents.
+
+Lets agents record decisions with reasoning, retrieve past precedents, trace
+causal chains, analyse downstream impact, and validate proposed decisions
+against policy rules.
+
+Install
+-------
+ pip install semantica[crewai]
+
+Example
+-------
+ >>> from integrations.crewai import SemanticaDecisionTool
+ >>> from crewai import Agent, Crew, Task
+ >>> tool = SemanticaDecisionTool()
+ >>> crew = Crew(
+ ... agents=[Agent(role="...", goal="...", backstory="...", tools=[tool])],
+ ... tasks=[...],
+ ... )
+
+Tools exposed
+-------------
+record_decision — Record a decision with reasoning and outcome
+find_precedents — Search past decisions similar to a scenario
+trace_causal_chain— Trace the causal chain from a decision node
+analyze_impact — Assess downstream influence of a decision
+check_policy — Validate a proposed decision against policy rules
+"""
+
+from __future__ import annotations
+
+import json
+import re
+from typing import Any, Dict, List, Literal, Optional, Type
+
+from pydantic import BaseModel, Field
+
+from semantica.utils.logging import get_logger
+
+from ._availability import CREWAI_AVAILABLE
+
+logger = get_logger(__name__)
+
+# ---------------------------------------------------------------------------
+# Optional: CrewAI BaseTool base class
+# ---------------------------------------------------------------------------
+_BaseTool: Any = object
+
+if CREWAI_AVAILABLE:
+ from crewai.tools import BaseTool as _BaseTool # type: ignore
+
+
+# ---------------------------------------------------------------------------
+# Input schema
+# ---------------------------------------------------------------------------
+class SemanticaDecisionToolInput(BaseModel):
+ """
+ Input schema for ``SemanticaDecisionTool``.
+
+ Exactly one action is dispatched per call; the remaining fields are only
+ used by the actions that need them.
+ """
+
+ action: Literal[
+ "record_decision",
+ "find_precedents",
+ "trace_causal_chain",
+ "analyze_impact",
+ "check_policy",
+ ] = Field(
+ ...,
+ description=(
+ "Which decision-intelligence operation to run. One of: "
+ "'record_decision', 'find_precedents', 'trace_causal_chain', "
+ "'analyze_impact', 'check_policy'."
+ ),
+ )
+ category: Optional[str] = Field(
+ None,
+ description="Domain category, e.g. 'loan_approval'. Used by 'record_decision'.",
+ )
+ scenario: Optional[str] = Field(
+ None,
+ description=(
+ "Short description of the situation. Used by 'record_decision' and "
+ "'find_precedents'."
+ ),
+ )
+ reasoning: Optional[str] = Field(
+ None, description="Why this outcome was chosen. Used by 'record_decision'."
+ )
+ outcome: Optional[str] = Field(
+ None, description="The decision result. Used by 'record_decision'."
+ )
+ confidence: float = Field(
+ 0.8,
+ ge=0.0,
+ le=1.0,
+ description="Confidence score in [0, 1]. Used by 'record_decision'.",
+ )
+ entities: Optional[str] = Field(
+ None,
+ description="Comma-separated entity names. Used by 'record_decision'.",
+ )
+ decision_id: Optional[str] = Field(
+ None,
+ description=(
+ "Identifier of a decision. Used by 'trace_causal_chain' and "
+ "'analyze_impact'."
+ ),
+ )
+ depth: int = Field(
+ 3,
+ ge=1,
+ le=20,
+ description="Maximum chain depth. Used by 'trace_causal_chain'.",
+ )
+ decision_data: Optional[str] = Field(
+ None,
+ description=(
+ "JSON object describing a proposed decision. Used by 'check_policy'."
+ ),
+ )
+ policy_rules: Optional[str] = Field(
+ None,
+ description=(
+ "JSON list of rule strings like 'confidence >= 0.7'. Used by "
+ "'check_policy'."
+ ),
+ )
+
+
+# ---------------------------------------------------------------------------
+# SemanticaDecisionTool
+# ---------------------------------------------------------------------------
+class SemanticaDecisionTool(_BaseTool): # type: ignore[misc]
+ """
+ CrewAI tool that surfaces Semantica's decision intelligence as agent actions.
+
+ Parameters
+ ----------
+ context:
+ A ``semantica.context.AgentContext`` (or compatible object exposing
+ ``record_decision``, ``find_precedents_advanced``,
+ ``analyze_decision_influence``). 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``.
+ """
+
+ name: str = "semantica_decision"
+ description: str = (
+ "Decision intelligence toolkit. Actions: 'record_decision' (record a "
+ "decision with category, scenario, reasoning, outcome, confidence), "
+ "'find_precedents' (search past decisions similar to 'scenario'), "
+ "'trace_causal_chain' (trace the causal chain from 'decision_id'), "
+ "'analyze_impact' (assess downstream influence of 'decision_id'), "
+ "'check_policy' (validate 'decision_data' JSON against 'policy_rules' "
+ "rules like 'confidence >= 0.7'). Returns JSON."
+ )
+ args_schema: Type[BaseModel] = SemanticaDecisionToolInput
+ context: Any = Field(default=None, exclude=True)
+ max_precedents: int = 5
+ causal_depth: int = 3
+ had_live_state: bool = False
+ reconstructed_state: bool = Field(default=False, exclude=True)
+
+ def __init__(
+ self,
+ context: Any = None,
+ max_precedents: int = 5,
+ causal_depth: int = 3,
+ **kwargs: Any,
+ ) -> None:
+ if CREWAI_AVAILABLE:
+ super().__init__(
+ context=context,
+ max_precedents=max_precedents,
+ causal_depth=causal_depth,
+ **kwargs,
+ )
+ else:
+ super().__init__()
+ self.context = context
+ self.max_precedents = max_precedents
+ self.causal_depth = causal_depth
+ # Degraded mode is a plain class — no model_post_init lifecycle.
+ self._ensure_defaults()
+
+ logger.info("SemanticaDecisionTool initialised (crewai=%s)", CREWAI_AVAILABLE)
+
+ def model_post_init(self, __context: Any) -> None:
+ """Re-create default state after validation/deserialisation.
+
+ ``context`` is excluded from JSON serialisation (CrewAI checkpoints
+ serialise every tool via ``model_dump(mode="json")``), so a tool
+ restored from a checkpoint has ``None`` state until this runs.
+ """
+ self._ensure_defaults()
+ super().model_post_init(__context)
+
+ def _ensure_defaults(self) -> None:
+ """Lazy-import and build a real AgentContext when none is wired."""
+ if self.context is None:
+ from semantica.context import AgentContext, ContextGraph
+ from semantica.vector_store import VectorStore
+
+ self.context = AgentContext(
+ vector_store=VectorStore(backend="faiss"),
+ decision_tracking=True,
+ knowledge_graph=ContextGraph(),
+ )
+ if self.had_live_state:
+ self.reconstructed_state = True
+ logger.warning(
+ "SemanticaDecisionTool: the live decision context was lost "
+ "during serialization/checkpoint restore — an EMPTY "
+ "context was reconstructed; re-attach the original context "
+ "before continuing"
+ )
+ else:
+ logger.warning(
+ "SemanticaDecisionTool created a fresh in-memory "
+ "AgentContext — agents sharing decision state must be "
+ "wired to the same context"
+ )
+ self.had_live_state = True
+
+ # ------------------------------------------------------------------
+ # CrewAI entry points
+ # ------------------------------------------------------------------
+
+ def _run(
+ self,
+ action: str,
+ category: Optional[str] = None,
+ scenario: Optional[str] = None,
+ reasoning: Optional[str] = None,
+ outcome: Optional[str] = None,
+ confidence: float = 0.8,
+ entities: Optional[str] = None,
+ decision_id: Optional[str] = None,
+ depth: int = 3,
+ decision_data: Optional[str] = None,
+ policy_rules: Optional[str] = None,
+ **kwargs: Any,
+ ) -> str:
+ valid = {
+ "record_decision",
+ "find_precedents",
+ "trace_causal_chain",
+ "analyze_impact",
+ "check_policy",
+ }
+ if action not in valid:
+ return json.dumps(
+ {
+ "error": f"Unknown action '{action}'. Valid actions: "
+ + ", ".join(sorted(valid))
+ }
+ )
+
+ if action == "record_decision":
+ return self._record_decision(
+ category=category or "general",
+ scenario=scenario or "decision recorded",
+ reasoning=reasoning or "agent decision",
+ outcome=outcome or "recorded",
+ confidence=confidence,
+ entities=entities,
+ )
+ if action == "find_precedents":
+ return self._find_precedents(scenario=scenario or "", category=category)
+ if action == "trace_causal_chain":
+ return self._trace_causal_chain(decision_id or "", depth=depth)
+ if action == "analyze_impact":
+ return self._analyze_impact(decision_id or "")
+ return self._check_policy(decision_data or "", policy_rules)
+
+ async def _arun(self, action: str, **kwargs: Any) -> str:
+ """Async variant of ``_run`` for CrewAI's async tool path."""
+ return self._run(action=action, **kwargs)
+
+ # ------------------------------------------------------------------
+ # Actions
+ # ------------------------------------------------------------------
+
+ def _record_decision(
+ self,
+ category: str,
+ scenario: str,
+ reasoning: str,
+ outcome: str,
+ confidence: float = 0.8,
+ entities: Optional[str] = None,
+ ) -> str:
+ entity_list: Optional[List[str]] = None
+ if entities:
+ entity_list = [e.strip() for e in entities.split(",") if e.strip()]
+
+ try:
+ decision_id = self.context.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:
+ k = limit if limit is not None else self.max_precedents
+ try:
+ precedents = self.context.find_precedents_advanced(
+ scenario=scenario,
+ category=category,
+ limit=k,
+ )
+ 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:
+ if not decision_id:
+ return json.dumps(
+ {
+ "error": "decision_id is required for trace_causal_chain",
+ "causal_chain": [],
+ "decision_id": "",
+ }
+ )
+ max_depth = depth or self.causal_depth
+ try:
+ graph = getattr(self.context, "knowledge_graph", None)
+ if graph is None:
+ return json.dumps(
+ {
+ "error": (
+ "causal tracing is not available on this knowledge "
+ "graph (the decision context has no knowledge_graph)"
+ ),
+ "causal_chain": [],
+ "decision_id": decision_id,
+ }
+ )
+ trace = getattr(graph, "trace_decision_causality", None)
+ if trace is None:
+ return json.dumps(
+ {
+ "error": (
+ "causal tracing is not available on this knowledge graph "
+ "(graph.trace_decision_causality is not implemented)"
+ ),
+ "causal_chain": [],
+ "decision_id": decision_id,
+ }
+ )
+ chain = trace(decision_id, max_depth=max_depth)
+ return json.dumps({"causal_chain": chain, "decision_id": decision_id})
+ except Exception as exc:
+ logger.warning("trace_causal_chain failed: %s", exc)
+ return json.dumps(
+ {"error": str(exc), "causal_chain": [], "decision_id": decision_id}
+ )
+
+ def _analyze_impact(self, decision_id: str) -> str:
+ try:
+ influence = self.context.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:
+ 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": [],
+ }
+ )
+
+ if not isinstance(data, dict):
+ return json.dumps(
+ {
+ "compliant": False,
+ "violations": [
+ f"decision_data must decode to a JSON object, "
+ f"got {type(data).__name__}: {data!r}"
+ ],
+ "warnings": [],
+ }
+ )
+
+ violations: List[str] = []
+ warnings: List[str] = []
+
+ rules: List[str] = []
+ if policy_rules:
+ try:
+ parsed_rules = json.loads(policy_rules)
+ except json.JSONDecodeError:
+ rules = [r.strip() for r in policy_rules.split(",") if r.strip()]
+ else:
+ if isinstance(parsed_rules, str):
+ rules = [parsed_rules]
+ elif isinstance(parsed_rules, list):
+ for item in parsed_rules:
+ if isinstance(item, str):
+ rules.append(item)
+ else:
+ warnings.append(
+ f"Ignoring non-string policy rule entry: {item!r}"
+ )
+ else:
+ warnings.append(
+ f"policy_rules must decode to a JSON list of rule strings, "
+ f"got {type(parsed_rules).__name__}: {parsed_rules!r}"
+ )
+
+ 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.
+
+ This is a small standalone evaluator for the tool's ``check_policy``
+ action — it is intentionally independent of Semantica's policy engine
+ so agents get a bounded, side-effect-free rule check. Rules are
+ `` `` comparisons only; there is no expression
+ evaluation (no ``eval``), so untrusted rule strings are safe to pass.
+
+ Values are coerced type-aware: ``true``/``false`` (and ``1``/``0``)
+ become booleans, numeric literals become numbers, and string values
+ that parse as numbers are compared numerically, so ``score == 0.9``
+ holds for ``score: "0.90"`` and ``enabled == false`` holds for
+ ``enabled: false``. Field names may contain hyphens, dots and spaces
+ (e.g. ``risk-score >= 0.9``); they are matched against ``data`` keys
+ as-is.
+ """
+ m = re.match(r"(.+?)\s*(>=|<=|!=|==|>|<)\s*(.+)$", rule.strip())
+ if not m:
+ raise ValueError(f"unrecognised rule format: {rule!r}")
+ field, op, val_str = m.group(1), m.group(2), m.group(3).strip().strip("\"'")
+ if field not in data:
+ raise ValueError(f"rule references undefined field {field!r}")
+ actual = data[field]
+ if actual is None:
+ raise ValueError(f"field {field!r} is null — cannot evaluate rule")
+ val = self._coerce_value(val_str)
+ if isinstance(actual, str):
+ actual = self._coerce_value(actual)
+ 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)
+
+ @staticmethod
+ def _coerce_value(value: str) -> Any:
+ """Parse a rule literal into its most specific Python type."""
+ text = value.strip()
+ lowered = text.lower()
+ if lowered in ("true", "1"):
+ return True
+ if lowered in ("false", "0"):
+ return False
+ try:
+ return int(text)
+ except ValueError:
+ pass
+ try:
+ return float(text)
+ except ValueError:
+ pass
+ return text
+
+ # When crewai is absent there is no BaseTool to provide the public
+ # ``run``/``arun`` entry points, so expose them directly. With crewai
+ # installed these are left untouched so crewai's own implementations
+ # (usage tracking, ``result_as_answer``) win.
+ if not CREWAI_AVAILABLE:
+
+ def run(self, *args: Any, **kwargs: Any) -> str:
+ """Run the tool synchronously (degraded mode, no crewai)."""
+ return self._run(*args, **kwargs)
+
+ async def arun(self, *args: Any, **kwargs: Any) -> str:
+ """Run the tool asynchronously (degraded mode, no crewai)."""
+ return self._run(*args, **kwargs)
diff --git a/integrations/crewai/kg_tool.py b/integrations/crewai/kg_tool.py
new file mode 100644
index 00000000..7740e3fa
--- /dev/null
+++ b/integrations/crewai/kg_tool.py
@@ -0,0 +1,573 @@
+"""
+SemanticaKGTool — a CrewAI ``BaseTool`` exposing Semantica's knowledge-graph
+pipeline (``NERExtractor``, ``RelationExtractor``, ``ContextGraph``) to agents.
+
+Lets agents build and query a shared ``ContextGraph`` as part of their
+reasoning loop.
+
+Install
+-------
+ pip install semantica[crewai]
+
+Example
+-------
+ >>> from integrations.crewai import SemanticaKGTool
+ >>> from semantica.context import ContextGraph
+ >>> from crewai import Agent, Crew, Task
+ >>> graph = ContextGraph()
+ >>> tool = SemanticaKGTool(graph=graph)
+ >>> crew = Crew(
+ ... agents=[Agent(role="...", goal="...", backstory="...", tools=[tool])],
+ ... tasks=[...],
+ ... )
+
+Tools exposed
+-------------
+extract_entities — Extract named entities from text
+extract_relations — Extract relationships between entities
+add_to_graph — Extract entities/relations from text and add them to the graph
+query_graph — Query the graph by keyword
+find_related — Find concepts related to a given entity within ``hops``
+"""
+
+from __future__ import annotations
+
+import json
+import threading
+import weakref
+from typing import Any, Dict, List, Literal, Optional, Sequence, Type
+
+from pydantic import BaseModel, Field
+
+from semantica.utils.logging import get_logger
+
+from ._availability import CREWAI_AVAILABLE, CREWAI_IMPORT_ERROR # noqa: F401
+
+logger = get_logger(__name__)
+
+# ---------------------------------------------------------------------------
+# Optional: CrewAI BaseTool base class
+# ---------------------------------------------------------------------------
+_BaseTool: Any = object
+
+if CREWAI_AVAILABLE:
+ from crewai.tools import BaseTool as _BaseTool # type: ignore
+
+# One re-entrant lock per graph so concurrent tool invocations sharing a graph
+# cannot double-count duplicate adds (check-then-act is not atomic), while
+# independent graphs are never serialised against each other. An RLock also
+# means an extractor callback that re-enters add_to_graph on the same graph
+# cannot deadlock.
+_graph_locks_guard = threading.Lock()
+_graph_locks: "weakref.WeakKeyDictionary[Any, threading.RLock]" = (
+ weakref.WeakKeyDictionary()
+)
+
+
+# ---------------------------------------------------------------------------
+# Input schema
+# ---------------------------------------------------------------------------
+class SemanticaKGToolInput(BaseModel):
+ """
+ Input schema for ``SemanticaKGTool``.
+
+ Exactly one action is dispatched per call; the remaining fields are only
+ used by the actions that need them.
+ """
+
+ action: Literal[
+ "extract_entities",
+ "extract_relations",
+ "add_to_graph",
+ "query_graph",
+ "find_related",
+ ] = Field(
+ ...,
+ description=(
+ "Which graph operation to run. One of: 'extract_entities', "
+ "'extract_relations', 'add_to_graph', 'query_graph', 'find_related'."
+ ),
+ )
+ text: Optional[str] = Field(
+ None,
+ description=(
+ "Input text. Used by 'extract_entities', 'extract_relations' and "
+ "'add_to_graph'."
+ ),
+ )
+ query: Optional[str] = Field(
+ None, description="Search query. Used by 'query_graph'."
+ )
+ entity: Optional[str] = Field(
+ None,
+ description="Root entity name. Used by 'find_related'.",
+ )
+ hops: int = Field(
+ 1,
+ ge=1,
+ le=10,
+ description="Maximum relationship hops. Used by 'find_related'.",
+ )
+
+
+# ---------------------------------------------------------------------------
+# SemanticaKGTool
+# ---------------------------------------------------------------------------
+class SemanticaKGTool(_BaseTool): # type: ignore[misc]
+ """
+ CrewAI tool that surfaces Semantica's KG pipeline as agent actions.
+
+ Parameters
+ ----------
+ graph:
+ A ``semantica.context.ContextGraph`` to read/write. A fresh in-memory
+ graph is used when ``None``.
+ ner_extractor:
+ A ``semantica.semantic_extract.NERExtractor`` instance; auto-created
+ when ``None``.
+ relation_extractor:
+ A ``semantica.semantic_extract.RelationExtractor`` instance; auto-
+ created when ``None``.
+ """
+
+ name: str = "semantica_knowledge_graph"
+ description: str = (
+ "Build and query a semantic knowledge graph. Actions: "
+ "'extract_entities' (extract named entities from 'text'), "
+ "'extract_relations' (extract relationships from 'text'), "
+ "'add_to_graph' (extract entities/relations from 'text' and add them "
+ "to the shared graph), 'query_graph' (keyword search using 'query'), "
+ "'find_related' (find concepts related to 'entity' within 'hops' "
+ "hops). Returns JSON."
+ )
+ args_schema: Type[BaseModel] = SemanticaKGToolInput
+ graph: Any = Field(default=None, exclude=True)
+ ner_extractor: Any = Field(default=None, exclude=True)
+ relation_extractor: Any = Field(default=None, exclude=True)
+ had_live_state: bool = False
+ reconstructed_state: bool = Field(default=False, exclude=True)
+
+ def __init__(
+ self,
+ graph: Any = None,
+ ner_extractor: Any = None,
+ relation_extractor: Any = None,
+ **kwargs: Any,
+ ) -> None:
+ if CREWAI_AVAILABLE:
+ super().__init__(
+ graph=graph,
+ ner_extractor=ner_extractor,
+ relation_extractor=relation_extractor,
+ **kwargs,
+ )
+ else:
+ super().__init__()
+ self.graph = graph
+ self.ner_extractor = ner_extractor
+ self.relation_extractor = relation_extractor
+ # Degraded mode is a plain class — no model_post_init lifecycle.
+ self._ensure_defaults()
+
+ logger.info("SemanticaKGTool initialised (crewai=%s)", CREWAI_AVAILABLE)
+
+ def model_post_init(self, __context: Any) -> None:
+ """Re-create default state after validation/deserialisation.
+
+ ``graph``/extractors are excluded from JSON serialisation (CrewAI
+ checkpoints serialise every tool via ``model_dump(mode="json")``), so a
+ tool restored from a checkpoint has ``None`` state until this runs.
+ """
+ self._ensure_defaults()
+ super().model_post_init(__context)
+
+ def _ensure_defaults(self) -> None:
+ """Lazy-import and build defaults for any missing shared state."""
+ # Lazy imports keep the module importable without heavy deps
+ if self.graph is None:
+ from semantica.context import ContextGraph
+
+ self.graph = ContextGraph()
+ if self.had_live_state:
+ self.reconstructed_state = True
+ logger.warning(
+ "SemanticaKGTool: the live graph was lost during "
+ "serialization/checkpoint restore — an EMPTY graph was "
+ "reconstructed; re-attach the original graph before "
+ "continuing"
+ )
+ else:
+ logger.warning(
+ "SemanticaKGTool created a fresh in-memory ContextGraph — "
+ "agents sharing this tool's graph must be wired explicitly"
+ )
+ self.had_live_state = True
+ if self.ner_extractor is None:
+ from semantica.semantic_extract import NERExtractor
+
+ self.ner_extractor = NERExtractor()
+ if self.relation_extractor is None:
+ from semantica.semantic_extract import RelationExtractor
+
+ self.relation_extractor = RelationExtractor()
+
+ # ------------------------------------------------------------------
+ # CrewAI entry points
+ # ------------------------------------------------------------------
+
+ def _run(
+ self,
+ action: str,
+ text: Optional[str] = None,
+ query: Optional[str] = None,
+ entity: Optional[str] = None,
+ hops: int = 1,
+ **kwargs: Any,
+ ) -> str:
+ """
+ Dispatch a graph action. Always returns a JSON string so the agent
+ receives a structured, parseable result.
+ """
+ valid = {
+ "extract_entities",
+ "extract_relations",
+ "add_to_graph",
+ "query_graph",
+ "find_related",
+ }
+ if action not in valid:
+ return json.dumps(
+ {
+ "error": f"Unknown action '{action}'. Valid actions: "
+ + ", ".join(sorted(valid))
+ }
+ )
+
+ if action == "extract_entities":
+ return self._extract_entities(text or "")
+ if action == "extract_relations":
+ return self._extract_relations(text or "")
+ if action == "add_to_graph":
+ return self._add_from_text(text or "")
+ if action == "query_graph":
+ return self._query_graph(query or "")
+ return self._find_related(entity or "", hops=hops)
+
+ async def _arun(
+ self,
+ action: str,
+ text: Optional[str] = None,
+ query: Optional[str] = None,
+ entity: Optional[str] = None,
+ hops: int = 1,
+ **kwargs: Any,
+ ) -> str:
+ """
+ Async variant of ``_run`` for CrewAI's async tool path.
+ """
+ return self._run(
+ action=action, text=text, query=query, entity=entity, hops=hops, **kwargs
+ )
+
+ # ------------------------------------------------------------------
+ # Entity/relation field access (handles both Semantica dataclasses and
+ # third-party shapes like MagicMock/plain dicts in stubs)
+ # ------------------------------------------------------------------
+
+ @staticmethod
+ def _first_str(obj: Any, attrs: Sequence[str]) -> str:
+ """Return the first attribute value that is a non-empty string."""
+ for attr in attrs:
+ value = getattr(obj, attr, None)
+ if isinstance(value, str) and value:
+ return value
+ if isinstance(obj, dict):
+ for key in attrs:
+ value = obj.get(key)
+ if isinstance(value, str) and value:
+ return value
+ return ""
+
+ @classmethod
+ def _entity_name(cls, e: Any) -> str:
+ """Best-effort name for an entity-like object."""
+ return cls._first_str(e, ("name", "text", "label", "node_id", "id"))
+
+ @classmethod
+ def _entity_type(cls, e: Any) -> str:
+ """Best-effort type/label for an entity-like object."""
+ return cls._first_str(e, ("type", "label")) or "Entity"
+
+ @classmethod
+ def _relation_source(cls, r: Any) -> str:
+ """Best-effort source of a relation-like object."""
+ src = cls._first_str(r, ("source",))
+ if not src:
+ src = cls._entity_name(getattr(r, "subject", None))
+ return src
+
+ @classmethod
+ def _relation_target(cls, r: Any) -> str:
+ """Best-effort target of a relation-like object."""
+ tgt = cls._first_str(r, ("target",))
+ if not tgt:
+ tgt = cls._entity_name(getattr(r, "object", None))
+ return tgt
+
+ @classmethod
+ def _relation_type(cls, r: Any) -> str:
+ """Best-effort relation type of a relation-like object."""
+ rtype = cls._first_str(r, ("type", "relation", "predicate"))
+ return rtype or "related_to"
+
+ @classmethod
+ def _confidence(cls, e: Any) -> float:
+ """Normalise an entity/relation confidence value to a float."""
+ try:
+ val = getattr(e, "confidence", None)
+ if val is None:
+ return 1.0
+ return round(float(val), 4)
+ except (TypeError, ValueError):
+ return 1.0
+
+ @classmethod
+ def _graph_lock(cls, graph: Any) -> threading.RLock:
+ """Return the re-entrant lock guarding a specific graph."""
+ with _graph_locks_guard:
+ lock = _graph_locks.get(graph)
+ if lock is None:
+ lock = threading.RLock()
+ _graph_locks[graph] = lock
+ return lock
+
+ # ------------------------------------------------------------------
+ # Actions
+ # ------------------------------------------------------------------
+
+ def _extract_entities(self, text: str) -> str:
+ """Extract named entities from ``text``."""
+ try:
+ raw = self.ner_extractor.extract_entities(text) or []
+ entities = [
+ {
+ "name": self._entity_name(e),
+ "type": self._entity_type(e),
+ "confidence": self._confidence(e),
+ }
+ for e in raw
+ if self._entity_name(e)
+ ]
+ 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) -> str:
+ """Extract relationships between entities in ``text``."""
+ try:
+ raw = self.relation_extractor.extract_relations(text) or []
+ relations = [
+ {
+ "source": self._relation_source(r),
+ "relation": self._relation_type(r),
+ "target": self._relation_target(r),
+ "confidence": self._confidence(r),
+ }
+ 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_from_text(self, text: str) -> str:
+ """
+ Extract entities and relations from ``text`` and add them to the graph.
+
+ Duplicate nodes/edges (same id, or same source/type/target) are
+ skipped so repeated calls are idempotent. Returns JSON with the
+ number of nodes/edges added.
+ """
+ nodes_added = 0
+ edges_added = 0
+ try:
+ with self._graph_lock(self.graph):
+ existing_nodes = {
+ n.get("id") or n.get("node_id")
+ for n in (
+ self.graph.find_nodes() or [] # type: ignore[attr-defined]
+ )
+ if n.get("id") or n.get("node_id")
+ }
+ existing_edges = {
+ (e.get("source"), e.get("type") or "related_to", e.get("target"))
+ for e in (
+ self.graph.find_edges() or [] # type: ignore[attr-defined]
+ )
+ if e.get("source") and e.get("target")
+ }
+
+ raw_entities = self.ner_extractor.extract_entities(text) or []
+ entities: List[Any] = []
+ seen: set = set()
+ for e in raw_entities:
+ name = self._entity_name(e)
+ ntype = self._entity_type(e)
+ if not name or name in seen:
+ continue
+ seen.add(name)
+ entities.append(e)
+ if name in existing_nodes:
+ continue
+ try:
+ if self.graph.add_node(node_id=name, node_type=ntype):
+ nodes_added += 1
+ existing_nodes.add(name)
+ except Exception as exc:
+ logger.debug("add_node(%r) failed: %s", name, exc)
+
+ raw_relations = (
+ self.relation_extractor.extract_relations(text, entities=entities)
+ or []
+ )
+ for r in raw_relations:
+ src = self._relation_source(r)
+ tgt = self._relation_target(r)
+ rtype = self._relation_type(r)
+ if not src or not tgt:
+ continue
+ key = (src, rtype, tgt)
+ if key in existing_edges:
+ continue
+ try:
+ if self.graph.add_edge(
+ source_id=src, target_id=tgt, edge_type=rtype
+ ):
+ edges_added += 1
+ existing_edges.add(key)
+ except Exception as exc:
+ logger.debug("add_edge(%r) failed: %s", key, 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})
+ except Exception as exc:
+ logger.warning("add_to_graph failed: %s", exc)
+ return json.dumps({"nodes_added": 0, "edges_added": 0, "error": str(exc)})
+
+ def _query_graph(self, query: str) -> str:
+ """Keyword-search graph nodes by id, type and content."""
+ try:
+ q = (query or "").strip().lower()
+ out: List[dict] = []
+ seen: set = set()
+
+ query_method = getattr(self.graph, "query", None)
+ if query_method is not None:
+ for match in query_method(query) or []:
+ node = match.get("node") or {}
+ nid = node.get("id", "") or node.get("node_id", "")
+ if not nid or nid in seen:
+ continue
+ seen.add(nid)
+ content = match.get("content") or node.get("content", "")
+ out.append(
+ {
+ "id": nid,
+ "type": node.get("type", "") or node.get("node_type", ""),
+ "label": nid,
+ "content": str(content)[:500],
+ "score": round(float(match.get("score") or 0.0), 4),
+ }
+ )
+
+ if q:
+ for n in self.graph.find_nodes() or []: # type: ignore[attr-defined]
+ if isinstance(n, dict):
+ nid = n.get("id", "") or n.get("node_id", "")
+ ntype = n.get("type", "") or n.get("node_type", "")
+ content = str(
+ n.get("content")
+ or (n.get("properties") or {}).get("content", "")
+ or ""
+ )
+ else:
+ nid = getattr(n, "id", getattr(n, "label", ""))
+ ntype = getattr(n, "node_type", "")
+ content = str(getattr(n, "content", "") or "")
+ if not nid or nid in seen:
+ continue
+ if q in str(nid).lower() or q in str(ntype).lower():
+ seen.add(nid)
+ out.append(
+ {
+ "id": nid,
+ "type": ntype,
+ "label": nid,
+ "content": content[:500],
+ "score": 1.0,
+ }
+ )
+ return json.dumps({"results": out, "count": len(out)})
+ except Exception as exc:
+ logger.warning("query_graph failed: %s", exc)
+ return json.dumps({"results": [], "count": 0, "error": str(exc)})
+
+ def _find_related(self, entity: str, hops: int = 1) -> str:
+ """Find concepts related to ``entity`` within ``hops`` graph hops.
+
+ Traversal is undirected — an edge counts as related regardless of
+ direction, so both outgoing and incoming edges are honored.
+ """
+ try:
+ adjacency: Dict[str, List[str]] = {}
+ for edge in self.graph.find_edges() or []: # type: ignore[attr-defined]
+ if isinstance(edge, dict):
+ src = edge.get("source")
+ tgt = edge.get("target")
+ else:
+ src = getattr(edge, "source", None)
+ tgt = getattr(edge, "target", None)
+ if not src or not tgt:
+ continue
+ adjacency.setdefault(src, []).append(tgt)
+ adjacency.setdefault(tgt, []).append(src)
+
+ related: List[str] = []
+ frontier = [entity]
+ visited = {entity}
+ for _ in range(max(1, hops)):
+ next_frontier: List[str] = []
+ for e in frontier:
+ for n in adjacency.get(e, []):
+ if n in visited:
+ continue
+ visited.add(n)
+ next_frontier.append(n)
+ related.append(n)
+ 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": [], "count": 0, "error": str(exc)}
+ )
+
+ # When crewai is absent there is no BaseTool to provide the public
+ # ``run``/``arun`` entry points, so expose them directly. With crewai
+ # installed these are left untouched so crewai's own implementations
+ # (usage tracking, ``result_as_answer``) win.
+ if not CREWAI_AVAILABLE:
+
+ def run(self, *args: Any, **kwargs: Any) -> str:
+ """Run the tool synchronously (degraded mode, no crewai)."""
+ return self._run(*args, **kwargs)
+
+ async def arun(self, *args: Any, **kwargs: Any) -> str:
+ """Run the tool asynchronously (degraded mode, no crewai)."""
+ return self._run(*args, **kwargs)
diff --git a/integrations/crewai/knowledge_source.py b/integrations/crewai/knowledge_source.py
new file mode 100644
index 00000000..a61bbfce
--- /dev/null
+++ b/integrations/crewai/knowledge_source.py
@@ -0,0 +1,331 @@
+"""
+SemanticaKnowledgeSource — expose a Semantica ``ContextGraph`` as a CrewAI
+knowledge source.
+
+Lets a ``Crew`` load the current state of a knowledge graph (nodes, edges,
+metadata) into its knowledge storage, so every agent gets retrieval access to
+graph knowledge during the kickoff.
+
+Install
+-------
+ pip install semantica[crewai]
+
+Example
+-------
+ >>> from integrations.crewai import SemanticaKnowledgeSource
+ >>> from semantica.context import ContextGraph
+ >>> from crewai import Agent, Crew, Task
+ >>> graph = ContextGraph()
+ >>> graph.add_node(node_id="privacy", node_type="policy")
+ >>> crew = Crew(
+ ... agents=[...],
+ ... tasks=[...],
+ ... knowledge_sources=[SemanticaKnowledgeSource(graph=graph)],
+ ... )
+
+Compatibility
+-------------
+Works with ``crewai >= 0.80.0``. The ``BaseKnowledgeSource`` contract changed
+between versions (``load_content`` → ``validate_content``/``aadd``), so this
+source implements both legacy and current methods. It degrades gracefully
+when ``crewai`` is not installed: the class is still importable and carries the
+full Semantica API, but cannot be passed to a ``Crew``.
+"""
+
+from __future__ import annotations
+
+import asyncio
+from typing import Any, Dict, List, Optional
+
+from pydantic import Field
+
+from semantica.utils.logging import get_logger
+
+from ._availability import CREWAI_AVAILABLE
+
+logger = get_logger(__name__)
+
+# ---------------------------------------------------------------------------
+# Optional: CrewAI BaseKnowledgeSource base class
+# ---------------------------------------------------------------------------
+_BaseKnowledgeSource: Any = object
+
+if CREWAI_AVAILABLE:
+ from crewai.knowledge.source.base_knowledge_source import (
+ BaseKnowledgeSource as _BaseKnowledgeSource, # type: ignore
+ )
+
+
+def _chunk_text_manual(text: str, chunk_size: int, chunk_overlap: int) -> List[str]:
+ """Fallback plain-text chunker for when CrewAI helpers are unavailable."""
+ if not text:
+ return []
+ if int(chunk_size) <= 0:
+ return [text]
+ size = max(1, int(chunk_size))
+ overlap = max(0, int(chunk_overlap))
+ if len(text) <= size:
+ return [text]
+ step = max(1, size - overlap)
+ return [text[i : i + size] for i in range(0, len(text), step)]
+
+
+class SemanticaKnowledgeSource(_BaseKnowledgeSource): # type: ignore[misc]
+ """
+ CrewAI knowledge source backed by a Semantica ``ContextGraph``.
+
+ On ``add()`` the graph's nodes and edges are serialised into readable text
+ and pushed through the standard CrewAI chunking / storage pipeline, making
+ graph knowledge retrievable by every agent in the crew.
+
+ Parameters
+ ----------
+ graph:
+ A ``semantica.context.ContextGraph`` to expose. A fresh in-memory
+ graph is created when ``None``.
+ name:
+ Source name. Defaults to ``"semantica_knowledge_graph"``.
+ chunk_size:
+ Max characters per chunk (default 4000).
+ chunk_overlap:
+ Character overlap between adjacent chunks (default 200).
+ """
+
+ name: str = "semantica_knowledge_graph"
+ graph: Any = Field(default=None, exclude=True)
+ chunk_size: int = 4000
+ chunk_overlap: int = 200
+ had_live_state: bool = False
+ reconstructed_state: bool = Field(default=False, exclude=True)
+
+ def __init__(
+ self,
+ graph: Any = None,
+ name: Optional[str] = None,
+ chunk_size: int = 4000,
+ chunk_overlap: int = 200,
+ **kwargs: Any,
+ ) -> None:
+ if CREWAI_AVAILABLE:
+ # Do NOT eagerly build a graph here: pydantic calls this ``__init__``
+ # during ``model_validate`` (checkpoint restore), and the eager
+ # build would hide that a live graph was lost. ``model_post_init``
+ # rebuilds defaults and flags ``reconstructed_state`` instead.
+ super().__init__(
+ graph=graph,
+ name=name or "semantica_knowledge_graph",
+ chunk_size=int(chunk_size),
+ chunk_overlap=int(chunk_overlap),
+ **kwargs,
+ )
+ else:
+ if graph is None:
+ from semantica.context import ContextGraph
+
+ graph = ContextGraph()
+ super().__init__()
+ self.graph = graph
+ self.name = name or "semantica_knowledge_graph"
+ self.chunk_size = int(chunk_size)
+ self.chunk_overlap = int(chunk_overlap)
+
+ logger.info(
+ "SemanticaKnowledgeSource initialised (crewai=%s, chunk_size=%d)",
+ CREWAI_AVAILABLE,
+ self.chunk_size,
+ )
+ self.had_live_state = True
+
+ def model_post_init(self, __context: Any) -> None:
+ """Re-create default state after validation/deserialisation.
+
+ ``graph`` is excluded from JSON serialisation (CrewAI checkpoints
+ serialise their models via ``model_dump(mode="json")``), so a source
+ restored from a checkpoint has ``None`` state until this runs.
+ """
+ if self.graph is None:
+ from semantica.context import ContextGraph
+
+ self.graph = ContextGraph()
+ if self.had_live_state:
+ self.reconstructed_state = True
+ logger.warning(
+ "SemanticaKnowledgeSource: the live graph was lost during "
+ "serialization/checkpoint restore — an EMPTY graph was "
+ "reconstructed; re-attach the original graph before "
+ "continuing"
+ )
+ else:
+ logger.warning(
+ "SemanticaKnowledgeSource created a fresh in-memory "
+ "ContextGraph — sources sharing knowledge must be wired to "
+ "the same graph explicitly"
+ )
+ self.had_live_state = True
+ super().model_post_init(__context)
+
+ # ------------------------------------------------------------------
+ # Content extraction
+ # ------------------------------------------------------------------
+
+ def load_content(self) -> Dict[str, str]:
+ """
+ Serialise the graph into ``{id: readable_text}`` pairs.
+
+ Nodes are rendered with their type/content/metadata, edges with their
+ source, relation type and target. This satisfies the legacy CrewAI
+ ``BaseKnowledgeSource.load_content`` contract.
+ """
+ content: Dict[str, str] = {}
+ graph = self.graph
+ if graph is None:
+ return content
+
+ try:
+ for node in graph.find_nodes() or []: # type: ignore[attr-defined]
+ nid = node.get("id") or node.get("node_id") or ""
+ if not nid:
+ continue
+ parts = [
+ "Entity",
+ str(nid),
+ "type: " + str(node.get("type", "entity")),
+ ]
+ if node.get("content"):
+ parts.append("content: " + str(node["content"]))
+ if node.get("metadata"):
+ try:
+ import json
+
+ parts.append("metadata: " + json.dumps(node["metadata"]))
+ except Exception:
+ parts.append("metadata: " + str(node["metadata"]))
+ content[str(nid)] = " | ".join(parts)
+ except Exception as exc:
+ logger.warning(
+ "SemanticaKnowledgeSource.load_content (nodes) failed: %s", exc
+ )
+
+ try:
+ for idx, edge in enumerate(
+ graph.find_edges() or [] # type: ignore[attr-defined]
+ ):
+ src = edge.get("source")
+ tgt = edge.get("target")
+ if not src or not tgt:
+ continue
+ rel = edge.get("type") or edge.get("edge_type") or "related_to"
+ weight = edge.get("weight")
+ text = f"{src} -[{rel}]-> {tgt}"
+ if weight is not None:
+ text += f" (weight: {weight})"
+ content[f"edge-{idx}"] = text
+ except Exception as exc:
+ logger.warning(
+ "SemanticaKnowledgeSource.load_content (edges) failed: %s", exc
+ )
+
+ return content
+
+ def validate_content(self) -> Any:
+ """
+ Validate that a readable graph is attached.
+
+ Satisfies the current CrewAI ``BaseKnowledgeSource.validate_content``
+ contract.
+ """
+ if self.graph is None:
+ raise ValueError("SemanticaKnowledgeSource requires a ContextGraph.")
+ return True
+
+ # ------------------------------------------------------------------
+ # Chunking + storage (abstract in both CrewAI generations)
+ # ------------------------------------------------------------------
+
+ def _chunk(self, text: str) -> List[str]:
+ """Chunk ``text`` using CrewAI's helper when available, else manual."""
+ helper = getattr(self, "_chunk_text", None)
+ if helper is not None:
+ try:
+ return list(helper(text) or [])
+ except Exception as exc:
+ logger.debug(
+ "SemanticaKnowledgeSource._chunk_text failed, falling back: %s", exc
+ )
+ return _chunk_text_manual(text, self.chunk_size, self.chunk_overlap)
+
+ def add(self) -> None:
+ """
+ Process the graph into chunks and store them via CrewAI storage.
+
+ Sets both ``chunks`` (current CrewAI) and ``_chunks`` (legacy CrewAI)
+ so either ``_save_documents`` implementation picks them up. If no
+ storage has been wired (e.g. not yet attached to a ``Crew``), chunks
+ are kept in memory.
+ """
+ content = self.load_content()
+ if not content:
+ logger.debug("SemanticaKnowledgeSource.add: empty graph — nothing to store")
+ return
+
+ chunks: List[str] = []
+ for _, text in content.items():
+ if text:
+ chunks.extend(self._chunk(text))
+
+ self.chunks = chunks
+ self._chunks = chunks
+
+ save = getattr(self, "_save_documents", None)
+ if save is not None:
+ if getattr(self, "storage", None) is None:
+ logger.debug(
+ "SemanticaKnowledgeSource.add: storage not wired — "
+ "keeping chunks in memory"
+ )
+ else:
+ try:
+ save()
+ logger.info(
+ "SemanticaKnowledgeSource.add: stored %d chunks", len(chunks)
+ )
+ return
+ except Exception as exc:
+ logger.error(
+ "SemanticaKnowledgeSource.add: storage save FAILED (%s) — "
+ "chunks are only kept in memory and agents will retrieve "
+ "nothing. Configure the Crew embedder (e.g. an OpenAI "
+ "embedder with OPENAI_API_KEY, or a local embedder) before "
+ "running the crew.",
+ exc,
+ )
+
+ logger.info(
+ "SemanticaKnowledgeSource.add: %d chunks ready in memory", len(chunks)
+ )
+
+ async def aadd(self) -> None:
+ """
+ Asynchronous variant of ``add()`` (current CrewAI contract).
+
+ The graph serialisation is CPU-bound, so it runs in a thread pool to
+ avoid blocking the event loop.
+ """
+ loop = asyncio.get_running_loop()
+ await loop.run_in_executor(None, self.add)
+
+ # ------------------------------------------------------------------
+ # Inspection helpers
+ # ------------------------------------------------------------------
+
+ def get_content_summary(self) -> Dict[str, Any]:
+ """
+ Summarise what the source exposes (helpful for debugging / testing).
+ """
+ content = self.load_content()
+ return {
+ "name": self.name,
+ "source_count": len(content),
+ "chunks": len(getattr(self, "chunks", []) or []),
+ "crewai_available": CREWAI_AVAILABLE,
+ }
diff --git a/pyproject.toml b/pyproject.toml
index 03949d4e..341e57ed 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -201,6 +201,10 @@ gpu = [
# ---- Agentic Framework Integrations ----
agno = ["agno>=1.0.0"]
+# crewai core provides BaseTool and BaseKnowledgeSource; crewai-tools is not
+# needed (it pulls vulnerable transitive deps like chromadb) and would only
+# duplicate the prebuilt tooling users can install separately.
+crewai = ["crewai>=0.80.0"]
# ---- File Watching ----
watch = ["watchdog>=6.0.0"]
@@ -242,6 +246,10 @@ explorer-lite = [
]
# Everything (cross-platform — gpu excluded; install semantica[gpu] separately on Linux)
+# NOTE: the ``crewai`` extra is intentionally NOT in ``all``: crewai hard-requires
+# ``chromadb~=1.1.0``, which carries a pre-authentication code-injection advisory
+# (CVE-2026-45829) with no fixed release — including it here would fail the CI
+# dependency-audit/security gates. Install it explicitly via ``semantica[crewai]``.
all = [
"semantica[dev,viz,infra,cloud,monitoring,watch,llm-all,models-huggingface,split-all,graph-all,tripletstore-oxigraph,vectorstore-all,parse-docling,ingest-parquet,ingest-arrow,shacl,explorer]",
"semantica[dev,viz,infra,cloud,monitoring,watch,llm-all,models-huggingface,split-all,graph-all,tripletstore-oxigraph,vectorstore-all,parse-docling,ingest-parquet,ingest-arrow,shacl,agno]"
diff --git a/requirements-ci.txt b/requirements-ci.txt
index d57fa744..17df46d4 100644
--- a/requirements-ci.txt
+++ b/requirements-ci.txt
@@ -1,5 +1,5 @@
# This file was autogenerated by uv via the following command:
-# uv pip compile -p 3.11 --extra all --generate-hashes -o requirements-ci.txt pyproject.toml
+# uv pip compile pyproject.toml --python-version 3.11 --extra all --generate-hashes -o requirements-ci.txt
accelerate==1.14.0 \
--hash=sha256:41b9c4377a54e0b460a959b0defa1b736e4ca0a2373252d9a539964c2afe3c8d \
--hash=sha256:e94390c2863b873be18f623f9df48a0d8fe5eff13ea7f1a00092b0a7904888c6
@@ -4123,9 +4123,9 @@ pooch==1.9.0 \
--hash=sha256:de46729579b9857ffd3e741987a2f6d5e0e03219892c167c6578c0091fb511ed \
--hash=sha256:f265597baa9f760d25ceb29d0beb8186c243d6607b0f60b83ecf14078dbc703b
# via librosa
-portalocker==3.2.0 \
- --hash=sha256:1f3002956a54a8c3730586c5c77bf18fae4149e07eaf1c29fc3faf4d5a3f89ac \
- --hash=sha256:3cdc5f565312224bc570c49337bd21428bba0ef363bbcf58b9ef4a9f11779968
+portalocker==2.7.0 \
+ --hash=sha256:032e81d534a88ec1736d03f780ba073f047a06c478b06e2937486f334e955c51 \
+ --hash=sha256:a07c5b4f3985c3cf4798369631fb7011adb498e2a46d8440efc75a8f29a0f983
# via qdrant-client
pre-commit==4.6.2 \
--hash=sha256:8f5d7bfb021ecdbcd9d49d89847082dd24172ccde534390081a679ad046e2441 \
diff --git a/tests/integrations/crewai/conftest.py b/tests/integrations/crewai/conftest.py
new file mode 100644
index 00000000..68991e54
--- /dev/null
+++ b/tests/integrations/crewai/conftest.py
@@ -0,0 +1,151 @@
+"""
+Shared pytest configuration for CrewAI integration tests.
+
+Installs comprehensive crewai stubs into sys.modules before any test in this
+directory runs, so every test file can import the integration modules with
+``CREWAI_AVAILABLE == True`` and exercise the real subclassing code paths
+without a real crewai installation.
+
+The stubs mirror the current CrewAI contracts:
+- ``crewai.tools.BaseTool`` — Pydantic ``BaseModel`` (arbitrary types allowed)
+- ``crewai.knowledge.source.base_knowledge_source.BaseKnowledgeSource`` —
+ Pydantic model with ``validate_content``/``add``/``aadd`` abstract methods
+ and ``_chunk_text``/``_save_documents`` helpers.
+
+The graceful-degradation path (crewai genuinely absent) is covered separately
+in ``test_degradation.py`` via a subprocess, so this stub never has to be torn
+down mid-session.
+"""
+
+from __future__ import annotations
+
+import sys
+import types
+from typing import Any, Optional
+
+from pydantic import BaseModel, ConfigDict, Field, field_serializer, field_validator
+
+
+def _install_crewai_stubs() -> None:
+ """Install a full set of crewai stubs into sys.modules."""
+
+ # -----------------------------------------------------------------------
+ # crewai.tools — BaseTool
+ # -----------------------------------------------------------------------
+ class BaseTool(BaseModel): # noqa: D101
+ """Stub mirroring crewai.tools.base_tool.BaseTool."""
+
+ model_config = ConfigDict(arbitrary_types_allowed=True)
+
+ name: str = "base_tool"
+ description: str = ""
+ args_schema: Any = None
+ result_as_answer: bool = False
+
+ @field_serializer("args_schema", when_used="json")
+ def _ser_args_schema(self, schema): # noqa: D102
+ if schema is None:
+ return None
+ return {"__schema__": f"{schema.__module__}.{schema.__qualname__}"}
+
+ @field_validator("args_schema", mode="before")
+ @classmethod
+ def _restore_args_schema(cls, v): # noqa: D102
+ if isinstance(v, dict) and "__schema__" in v:
+ import importlib
+
+ mod_name, cls_name = v["__schema__"].rsplit(".", 1)
+ return getattr(importlib.import_module(mod_name), cls_name)
+ return v
+
+ def run(self, *args: Any, **kwargs: Any) -> str: # noqa: D102
+ return self._run(*args, **kwargs)
+
+ async def arun(self, *args: Any, **kwargs: Any) -> str: # noqa: D102
+ return await self._arun(*args, **kwargs)
+
+ def _run(self, *args: Any, **kwargs: Any) -> str: # noqa: D102
+ raise NotImplementedError
+
+ async def _arun(self, *args: Any, **kwargs: Any) -> str: # noqa: D102
+ raise NotImplementedError
+
+ tools_mod = types.ModuleType("crewai.tools")
+ tools_mod.BaseTool = BaseTool # type: ignore[attr-defined]
+
+ tools_base_mod = types.ModuleType("crewai.tools.base_tool")
+ tools_base_mod.BaseTool = BaseTool # type: ignore[attr-defined]
+
+ # -----------------------------------------------------------------------
+ # crewai.knowledge.source.base_knowledge_source — BaseKnowledgeSource
+ # -----------------------------------------------------------------------
+ class BaseKnowledgeSource(BaseModel): # noqa: D101
+ """Stub mirroring crewai.knowledge.source.base_knowledge_source."""
+
+ model_config = ConfigDict(arbitrary_types_allowed=True)
+
+ chunk_size: int = 4000
+ chunk_overlap: int = 200
+ chunks: list = Field(default_factory=list)
+ chunk_embeddings: list = Field(default_factory=list, exclude=True)
+ storage: Any = None
+ metadata: dict = Field(default_factory=dict)
+ collection_name: Optional[str] = None
+
+ def _chunk_text(self, text: str) -> list: # noqa: D102
+ return [
+ text[i : i + self.chunk_size]
+ for i in range(0, len(text), self.chunk_size - self.chunk_overlap)
+ ]
+
+ def _save_documents(self) -> None: # noqa: D102
+ if self.storage is not None:
+ self.storage.save(self.chunks)
+ else:
+ raise ValueError("No storage found to save documents.")
+
+ async def _asave_documents(self) -> None: # noqa: D102
+ if self.storage is not None:
+ await self.storage.asave(self.chunks)
+ else:
+ raise ValueError("No storage found to save documents.")
+
+ def validate_content(self) -> Any: # noqa: D102
+ raise NotImplementedError
+
+ def add(self) -> None: # noqa: D102
+ raise NotImplementedError
+
+ async def aadd(self) -> None: # noqa: D102
+ raise NotImplementedError
+
+ knowledge_pkg = types.ModuleType("crewai.knowledge")
+ source_pkg = types.ModuleType("crewai.knowledge.source")
+ source_base_mod = types.ModuleType("crewai.knowledge.source.base_knowledge_source")
+ source_base_mod.BaseKnowledgeSource = ( # type: ignore[attr-defined]
+ BaseKnowledgeSource
+ )
+ source_pkg.BaseKnowledgeSource = BaseKnowledgeSource # type: ignore[attr-defined]
+ knowledge_pkg.source = source_pkg
+
+ # -----------------------------------------------------------------------
+ # Register everything
+ # -----------------------------------------------------------------------
+ crewai = types.ModuleType("crewai")
+ crewai.tools = tools_mod # type: ignore[attr-defined]
+ crewai.knowledge = knowledge_pkg # type: ignore[attr-defined]
+
+ _mods = {
+ "crewai": crewai,
+ "crewai.tools": tools_mod,
+ "crewai.tools.base_tool": tools_base_mod,
+ "crewai.knowledge": knowledge_pkg,
+ "crewai.knowledge.source": source_pkg,
+ "crewai.knowledge.source.base_knowledge_source": source_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_crewai_stubs()
diff --git a/tests/integrations/crewai/test_decision_tool.py b/tests/integrations/crewai/test_decision_tool.py
new file mode 100644
index 00000000..c7d4a1e6
--- /dev/null
+++ b/tests/integrations/crewai/test_decision_tool.py
@@ -0,0 +1,562 @@
+"""
+Tests for SemanticaDecisionTool — decision intelligence CrewAI tool.
+
+Runs with the crewai stubs installed by conftest, so ``CREWAI_AVAILABLE`` is
+``True`` and the real Pydantic/BaseTool subclassing path is exercised. A
+MagicMock ``AgentContext`` is used so no vector store / faiss is required.
+"""
+
+from __future__ import annotations
+
+import json
+import unittest
+from unittest.mock import MagicMock
+
+from integrations.crewai import SemanticaDecisionTool
+from integrations.crewai.decision_tool import (
+ CREWAI_AVAILABLE,
+ SemanticaDecisionToolInput,
+)
+
+
+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.knowledge_graph = MagicMock()
+ ctx.knowledge_graph.trace_decision_causality = MagicMock(
+ return_value=["step1", "step2"]
+ )
+ return ctx
+
+
+class TestSemanticaDecisionToolInit(unittest.TestCase):
+
+ def test_crewai_available_via_stub(self):
+ self.assertTrue(CREWAI_AVAILABLE)
+
+ def test_is_base_tool_subclass(self):
+ from crewai.tools import BaseTool
+
+ self.assertTrue(issubclass(SemanticaDecisionTool, BaseTool))
+
+ def test_creates_with_explicit_context(self):
+ ctx = _make_context()
+ tool = SemanticaDecisionTool(context=ctx)
+ self.assertIs(tool.context, ctx)
+
+ def test_creates_context_when_none(self):
+ tool = SemanticaDecisionTool()
+ self.assertIsNotNone(tool.context)
+
+ def test_default_metadata(self):
+ tool = SemanticaDecisionTool(context=_make_context())
+ self.assertEqual(tool.name, "semantica_decision")
+ self.assertTrue(tool.description)
+ self.assertEqual(tool.args_schema, SemanticaDecisionToolInput)
+
+ def test_input_schema_validates(self):
+ inp = SemanticaDecisionToolInput(action="record_decision", confidence=0.5)
+ self.assertEqual(inp.confidence, 0.5)
+ with self.assertRaises(Exception):
+ SemanticaDecisionToolInput(action="bogus")
+
+ def test_max_precedents_and_causal_depth_defaults(self):
+ tool = SemanticaDecisionTool(context=_make_context())
+ self.assertEqual(tool.max_precedents, 5)
+ self.assertEqual(tool.causal_depth, 3)
+
+
+class TestSemanticaDecisionToolSerialization(unittest.TestCase):
+ """CrewAI checkpoints serialise tools via ``model_dump(mode="json")`` — the
+ live context must not break that (regression for PydanticSerializationError
+ on arbitrary state objects)."""
+
+ def test_model_dump_json_excludes_context(self):
+ tool = SemanticaDecisionTool(context=_make_context())
+ dumped = tool.model_dump(mode="json")
+ self.assertNotIn("context", dumped)
+ self.assertEqual(dumped["max_precedents"], 5)
+ self.assertEqual(dumped["causal_depth"], 3)
+
+ def test_model_validate_restores_defaults(self):
+ tool = SemanticaDecisionTool(context=_make_context())
+ restored = SemanticaDecisionTool.model_validate(tool.model_dump(mode="json"))
+ self.assertIsNotNone(restored.context)
+ self.assertEqual(restored.max_precedents, 5)
+ self.assertEqual(restored.causal_depth, 3)
+
+ def test_restore_flags_lost_live_state(self):
+ """A tool restored from a checkpoint must signal that its live context
+ was excluded and an empty one reconstructed (``reconstructed_state``)."""
+ tool = SemanticaDecisionTool(context=_make_context())
+ dumped = tool.model_dump(mode="json")
+ self.assertTrue(dumped["had_live_state"])
+ self.assertNotIn("reconstructed_state", dumped)
+ restored = SemanticaDecisionTool.model_validate(dumped)
+ self.assertTrue(restored.reconstructed_state)
+ self.assertFalse(SemanticaDecisionTool().reconstructed_state)
+
+
+class TestRecordDecision(unittest.TestCase):
+
+ def setUp(self):
+ self.ctx = _make_context()
+ self.tool = SemanticaDecisionTool(context=self.ctx)
+
+ def test_returns_json_with_decision_id(self):
+ result = json.loads(
+ self.tool._run(
+ action="record_decision",
+ category="loan",
+ scenario="Customer A loan application",
+ reasoning="Good credit score 740",
+ outcome="approved",
+ confidence=0.95,
+ )
+ )
+ self.assertEqual(result["decision_id"], "dec-test-001")
+ self.assertEqual(result["status"], "recorded")
+
+ def test_delegates_to_context(self):
+ self.tool._run(
+ action="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.tool._run(
+ action="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.tool._run(
+ action="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.tool._run(
+ action="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)
+
+ def test_malformed_confidence_returns_error_json(self):
+ """A non-numeric confidence must not crash the tool — it is coerced
+ inside ``_record_decision``'s error handling and reported as JSON."""
+ for bad in ("high", None, "0.9"):
+ result = json.loads(
+ self.tool._run(
+ action="record_decision",
+ category="x",
+ scenario="y",
+ reasoning="z",
+ outcome="failed",
+ confidence=bad,
+ )
+ )
+ if bad == "0.9":
+ self.assertEqual(result["status"], "recorded")
+ else:
+ self.assertEqual(result["status"], "failed")
+ self.assertIn("error", result)
+
+ def test_missing_fields_get_sane_defaults(self):
+ """record_decision must not hard-fail when the agent omits optional
+ fields — category/reasoning/outcome get defaults."""
+ result = json.loads(self.tool._run(action="record_decision"))
+ self.assertEqual(result["status"], "recorded")
+ call_kwargs = self.ctx.record_decision.call_args[1]
+ self.assertEqual(call_kwargs["category"], "general")
+ self.assertEqual(call_kwargs["scenario"], "decision recorded")
+ self.assertEqual(call_kwargs["reasoning"], "agent decision")
+ self.assertEqual(call_kwargs["outcome"], "recorded")
+
+
+class TestRealAutoCreatedContext(unittest.TestCase):
+ """The no-context path builds a real AgentContext with a knowledge graph so
+ decision tracking is actually enabled (regression for the live
+ 'Decision tracking is not enabled' failure)."""
+
+ def setUp(self):
+ self.tool = SemanticaDecisionTool()
+
+ def test_context_is_real_agent_context(self):
+ from semantica.context import AgentContext
+
+ self.assertIsInstance(self.tool.context, AgentContext)
+ self.assertIsNotNone(self.tool.context.knowledge_graph)
+
+ def test_record_decision_actually_records(self):
+ result = json.loads(
+ self.tool.run(
+ action="record_decision",
+ scenario="ship v2",
+ reasoning="user demand",
+ confidence=0.9,
+ )
+ )
+ self.assertEqual(result["status"], "recorded")
+ self.assertTrue(result["decision_id"])
+
+ def test_find_precedents_runs_against_real_context(self):
+ result = json.loads(self.tool.run(action="find_precedents", scenario="ship v2"))
+ self.assertIn("precedents", result)
+
+ def test_trace_causal_chain_runs_against_real_context(self):
+ """Regression: trace_decision_causality takes ``max_depth``, not
+ ``depth`` — must not raise against a real ContextGraph."""
+ rec = json.loads(
+ self.tool.run(
+ action="record_decision",
+ scenario="ship v2",
+ reasoning="user demand",
+ confidence=0.9,
+ )
+ )
+ trace = json.loads(
+ self.tool.run(action="trace_causal_chain", decision_id=rec["decision_id"])
+ )
+ self.assertIn("causal_chain", trace)
+ self.assertEqual(trace["decision_id"], rec["decision_id"])
+
+
+class TestFindPrecedents(unittest.TestCase):
+
+ def setUp(self):
+ self.ctx = _make_context()
+ self.tool = SemanticaDecisionTool(context=self.ctx)
+
+ def test_returns_json_with_precedents(self):
+ result = json.loads(
+ self.tool._run(action="find_precedents", scenario="new loan application")
+ )
+ self.assertIn("precedents", result)
+ self.assertIsInstance(result["precedents"], list)
+
+ def test_count_in_result(self):
+ result = json.loads(
+ self.tool._run(action="find_precedents", scenario="test scenario")
+ )
+ self.assertEqual(result["count"], len(result["precedents"]))
+
+ def test_category_filter_passed(self):
+ self.tool._run(
+ action="find_precedents", scenario="scenario", category="finance"
+ )
+ call_kwargs = self.ctx.find_precedents_advanced.call_args[1]
+ self.assertEqual(call_kwargs.get("category"), "finance")
+
+ def test_limit_propagated_to_backend(self):
+ self.tool.max_precedents = 20
+ self.tool._run(action="find_precedents", scenario="scenario")
+ call_kwargs = self.ctx.find_precedents_advanced.call_args[1]
+ self.assertEqual(call_kwargs.get("limit"), 20)
+
+ def test_handles_exception_gracefully(self):
+ self.ctx.find_precedents_advanced.side_effect = RuntimeError("fail")
+ result = json.loads(self.tool._run(action="find_precedents", scenario="broken"))
+ self.assertEqual(result["precedents"], [])
+ self.assertIn("error", result)
+
+
+class TestTraceCausalChain(unittest.TestCase):
+
+ def setUp(self):
+ self.ctx = _make_context()
+ self.tool = SemanticaDecisionTool(context=self.ctx)
+
+ def test_returns_json_with_causal_chain(self):
+ result = json.loads(
+ self.tool._run(action="trace_causal_chain", decision_id="dec-001")
+ )
+ self.assertIn("causal_chain", result)
+ self.assertEqual(result["decision_id"], "dec-001")
+
+ def test_honest_error_when_causal_trace_unavailable(self):
+ """When the graph cannot trace causality, the tool must say so — it
+ must NOT substitute similarity-based precedents as a causal chain."""
+ del self.ctx.knowledge_graph.trace_decision_causality
+ result = json.loads(
+ self.tool._run(action="trace_causal_chain", decision_id="dec-002")
+ )
+ self.assertEqual(result["causal_chain"], [])
+ self.assertIn("error", result)
+ self.ctx.knowledge_graph.find_precedents.assert_not_called()
+
+ def test_missing_decision_id_reports_error(self):
+ result = json.loads(self.tool._run(action="trace_causal_chain"))
+ self.assertIn("error", result)
+ self.assertEqual(result["causal_chain"], [])
+
+ def test_depth_used(self):
+ self.tool._run(action="trace_causal_chain", decision_id="dec-001", depth=5)
+ self.ctx.knowledge_graph.trace_decision_causality.assert_called_once_with(
+ "dec-001", max_depth=5
+ )
+
+ def test_graceful_error_when_context_has_no_knowledge_graph(self):
+ """Regression: an unguarded ``self.context.knowledge_graph`` read raised
+ AttributeError out of ``_run`` and could hard-fail a crew task. It must
+ return honest error JSON instead."""
+ del self.ctx.knowledge_graph
+ result = json.loads(
+ self.tool._run(action="trace_causal_chain", decision_id="dec-003")
+ )
+ self.assertEqual(result["causal_chain"], [])
+ self.assertIn("error", result)
+
+
+class TestAnalyzeImpact(unittest.TestCase):
+
+ def setUp(self):
+ self.ctx = _make_context()
+ self.tool = SemanticaDecisionTool(context=self.ctx)
+
+ def test_returns_json_with_decision_id(self):
+ result = json.loads(
+ self.tool._run(action="analyze_impact", decision_id="dec-001")
+ )
+ self.assertEqual(result["decision_id"], "dec-001")
+
+ def test_includes_influence_metrics(self):
+ result = json.loads(
+ self.tool._run(action="analyze_impact", decision_id="dec-001")
+ )
+ self.assertIn("centrality", result)
+
+
+class TestCheckPolicy(unittest.TestCase):
+
+ def setUp(self):
+ self.ctx = _make_context()
+ self.tool = SemanticaDecisionTool(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.tool._run(action="check_policy", decision_data=decision)
+ )
+ self.assertIn("compliant", result)
+
+ def test_invalid_json_returns_error(self):
+ result = json.loads(
+ self.tool._run(action="check_policy", decision_data="{not valid json}")
+ )
+ self.assertFalse(result["compliant"])
+ self.assertGreater(len(result["violations"]), 0)
+
+ def test_rule_violation_detected(self):
+ decision = json.dumps({"confidence": 0.5})
+ rules = json.dumps(["confidence >= 0.9"])
+ result = json.loads(
+ self.tool._run(
+ action="check_policy", decision_data=decision, policy_rules=rules
+ )
+ )
+ self.assertFalse(result["compliant"])
+ self.assertEqual(len(result["violations"]), 1)
+
+ def test_bool_false_rule_is_compliant(self):
+ """Regression: ``enabled == false`` with ``enabled: false`` must be
+ compliant — bool("false") is truthy, so the old coercion inverted it."""
+ decision = json.dumps({"enabled": False, "confidence": 0.95})
+ rules = json.dumps(["enabled == false"])
+ result = json.loads(
+ self.tool._run(
+ action="check_policy", decision_data=decision, policy_rules=rules
+ )
+ )
+ self.assertTrue(result["compliant"])
+ self.assertEqual(result["violations"], [])
+
+ def test_bool_true_rule_is_compliant(self):
+ decision = json.dumps({"enabled": True})
+ result = json.loads(
+ self.tool._run(
+ action="check_policy",
+ decision_data=decision,
+ policy_rules=json.dumps(["enabled == true"]),
+ )
+ )
+ self.assertTrue(result["compliant"])
+
+ def test_whitespace_padded_strings_are_trimmed(self):
+ """Regression: ``_coerce_value`` must return the *stripped* string for
+ non-numeric literals, or padded decision_data fields never match."""
+ decision = json.dumps({"status": " approved "})
+ result = json.loads(
+ self.tool._run(
+ action="check_policy",
+ decision_data=decision,
+ policy_rules=json.dumps(["status == approved"]),
+ )
+ )
+ self.assertTrue(result["compliant"])
+ self.assertEqual(result["violations"], [])
+
+ def test_bool_false_rule_violated_when_true(self):
+ decision = json.dumps({"enabled": True})
+ result = json.loads(
+ self.tool._run(
+ action="check_policy",
+ decision_data=decision,
+ policy_rules=json.dumps(["enabled == false"]),
+ )
+ )
+ self.assertFalse(result["compliant"])
+ self.assertEqual(len(result["violations"]), 1)
+
+ def test_zero_one_flag_parsed_as_bool(self):
+ result = json.loads(
+ self.tool._run(
+ action="check_policy",
+ decision_data=json.dumps({"flag": 1}),
+ policy_rules=json.dumps(["flag != 0"]),
+ )
+ )
+ self.assertTrue(result["compliant"])
+ result = json.loads(
+ self.tool._run(
+ action="check_policy",
+ decision_data=json.dumps({"flag": 0}),
+ policy_rules=json.dumps(["flag != 0"]),
+ )
+ )
+ self.assertFalse(result["compliant"])
+
+ def test_numeric_string_value_compared_numerically(self):
+ """Regression: a string datum like "0.90" must compare numerically to
+ rule literal 0.9, not lexicographically."""
+ decision = json.dumps({"score": "0.90"})
+ result = json.loads(
+ self.tool._run(
+ action="check_policy",
+ decision_data=decision,
+ policy_rules=json.dumps(["score == 0.9"]),
+ )
+ )
+ self.assertTrue(result["compliant"])
+
+ def test_numeric_string_ordering(self):
+ result = json.loads(
+ self.tool._run(
+ action="check_policy",
+ decision_data=json.dumps({"pct": "0.95"}),
+ policy_rules=json.dumps(["pct >= 0.9"]),
+ )
+ )
+ self.assertTrue(result["compliant"])
+ result = json.loads(
+ self.tool._run(
+ action="check_policy",
+ decision_data=json.dumps({"pct": "0.85"}),
+ policy_rules=json.dumps(["pct >= 0.9"]),
+ )
+ )
+ self.assertFalse(result["compliant"])
+
+ def test_field_names_with_hyphens_dots_spaces(self):
+ """Rule field names are not limited to ``\\w+`` — hyphenated/dotted
+ (and space-containing) JSON keys must be addressable."""
+ decision = json.dumps({"risk-score": 0.95, "max.risk": 0.2, "min score": 0.4})
+ compliant = json.loads(
+ self.tool._run(
+ action="check_policy",
+ decision_data=decision,
+ policy_rules=json.dumps(
+ ["risk-score >= 0.9", "max.risk <= 0.5", "min score >= 0.3"]
+ ),
+ )
+ )
+ self.assertTrue(compliant["compliant"])
+ self.assertEqual(compliant["violations"], [])
+ violated = json.loads(
+ self.tool._run(
+ action="check_policy",
+ decision_data=decision,
+ policy_rules=json.dumps(["max.risk >= 0.5"]),
+ )
+ )
+ self.assertFalse(violated["compliant"])
+ self.assertEqual(len(violated["violations"]), 1)
+
+ def test_rule_missing_field_warns_not_silently_compliant(self):
+ decision = json.dumps({"confidence": 0.95})
+ rules = json.dumps(["minimum_score >= 0.9"])
+ result = json.loads(
+ self.tool._run(
+ action="check_policy", decision_data=decision, policy_rules=rules
+ )
+ )
+ self.assertTrue(result["compliant"])
+ self.assertEqual(result["violations"], [])
+ self.assertEqual(len(result["warnings"]), 1)
+ self.assertIn("minimum_score", result["warnings"][0])
+
+ def test_decision_data_non_object_rejected(self):
+ result = json.loads(
+ self.tool._run(
+ action="check_policy",
+ decision_data=json.dumps(["confidence", 0.95]),
+ policy_rules=json.dumps(["confidence >= 0.9"]),
+ )
+ )
+ self.assertFalse(result["compliant"])
+ self.assertEqual(len(result["violations"]), 1)
+ self.assertIn("JSON object", result["violations"][0])
+
+ def test_unknown_action_returns_error(self):
+ result = json.loads(self.tool._run(action="nope"))
+ self.assertIn("error", result)
+
+ def test_run_entrypoint(self):
+ result = json.loads(
+ self.tool.run(
+ action="check_policy",
+ decision_data=json.dumps({"confidence": 0.95}),
+ policy_rules=json.dumps(["confidence >= 0.9"]),
+ )
+ )
+ self.assertTrue(result["compliant"])
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/tests/integrations/crewai/test_degradation.py b/tests/integrations/crewai/test_degradation.py
new file mode 100644
index 00000000..7986f783
--- /dev/null
+++ b/tests/integrations/crewai/test_degradation.py
@@ -0,0 +1,103 @@
+"""
+Graceful-degradation tests for the CrewAI integration.
+
+These run the integration modules in a fresh subprocess (no conftest crewai
+stubs, no real crewai) to prove that every public class remains importable and
+functional when ``crewai`` is absent. A subprocess is used because the other
+test files in this directory install crewai stubs into ``sys.modules`` for the
+whole pytest session; a subprocess keeps the two scenarios isolated.
+"""
+
+from __future__ import annotations
+
+import os
+import subprocess
+import sys
+import unittest
+
+REPO_ROOT = os.path.dirname(os.path.dirname(os.path.dirname(os.path.dirname(__file__))))
+
+_SCRIPT = r"""
+import json
+import sys
+
+try:
+ import crewai # noqa: F401
+ real_crewai = True
+except ImportError:
+ real_crewai = False
+
+from integrations.crewai import (
+ CREWAI_AVAILABLE,
+ SemanticaKGTool,
+ SemanticaDecisionTool,
+ SemanticaKnowledgeSource,
+)
+from semantica.context import ContextGraph
+
+assert CREWAI_AVAILABLE == real_crewai, (
+ f"CREWAI_AVAILABLE={CREWAI_AVAILABLE} but real crewai={real_crewai}"
+)
+
+# --- SemanticaKGTool: importable + functional without crewai -----------------
+graph = ContextGraph()
+graph.add_node(node_id="privacy", node_type="policy", content="privacy policy doc")
+
+tool = SemanticaKGTool(graph=graph)
+assert tool.name == "semantica_knowledge_graph"
+assert tool.args_schema is not None
+
+res = json.loads(tool._run(action="query_graph", query="privacy"))
+assert res["count"] == 1, res
+res = json.loads(tool._run(action="find_related", entity="ghost", hops=1))
+assert res["count"] == 0, res
+
+# The public run()/arun() entry points must exist without crewai too.
+res = json.loads(tool.run(action="query_graph", query="privacy"))
+assert res["count"] == 1, res
+import asyncio
+res = json.loads(asyncio.run(tool.arun(action="query_graph", query="privacy")))
+assert res["count"] == 1, res
+
+# --- SemanticaKnowledgeSource: importable + functional without crewai --------
+src = SemanticaKnowledgeSource(graph=graph, chunk_size=40, chunk_overlap=5)
+assert src.load_content() != {}
+assert src.validate_content() is True
+src.add() # must not raise; chunks kept in memory
+assert len(src.chunks) > 0
+
+# --- SemanticaDecisionTool: importable, builds its own context --------------
+dt = SemanticaDecisionTool()
+assert dt.name == "semantica_decision"
+res = json.loads(dt.run(action="find_precedents", scenario="x"))
+assert "precedents" in res, res
+res = json.loads(asyncio.run(dt.arun(action="find_precedents", scenario="x")))
+assert "precedents" in res, res
+
+print("DEGRADATION_OK")
+"""
+
+
+class TestDegradation(unittest.TestCase):
+
+ def test_importable_and_functional_without_crewai(self):
+ result = subprocess.run(
+ [sys.executable, "-c", _SCRIPT],
+ cwd=REPO_ROOT,
+ capture_output=True,
+ text=True,
+ timeout=180,
+ )
+ self.assertEqual(
+ result.returncode,
+ 0,
+ msg=(
+ f"subprocess failed:\nSTDOUT:\n{result.stdout}\n"
+ f"STDERR:\n{result.stderr}"
+ ),
+ )
+ self.assertIn("DEGRADATION_OK", result.stdout)
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/tests/integrations/crewai/test_kg_tool.py b/tests/integrations/crewai/test_kg_tool.py
new file mode 100644
index 00000000..18ee1eda
--- /dev/null
+++ b/tests/integrations/crewai/test_kg_tool.py
@@ -0,0 +1,453 @@
+"""
+Tests for SemanticaKGTool — knowledge graph CrewAI tool.
+
+Runs with the crewai stubs installed by conftest, so ``CREWAI_AVAILABLE`` is
+``True`` and the real Pydantic/BaseTool subclassing path is exercised.
+"""
+
+from __future__ import annotations
+
+import asyncio
+import json
+import unittest
+from unittest.mock import MagicMock
+
+from integrations.crewai import SemanticaKGTool as ImportedSemanticaKGTool
+from integrations.crewai.kg_tool import (
+ CREWAI_AVAILABLE,
+ CREWAI_IMPORT_ERROR,
+ SemanticaKGTool,
+ SemanticaKGToolInput,
+)
+from semantica.context import ContextGraph
+
+
+# ---------------------------------------------------------------------------
+# 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 _DataclassNER:
+ """Returns Semantica's real ``Entity`` dataclass shape (text/label, no name)."""
+
+ def extract_entities(self, text):
+ from semantica.semantic_extract.types import Entity
+
+ return [
+ Entity(text="Tesla", label="ORG", start_char=0, end_char=5),
+ Entity(text="Elon Musk", label="PERSON", start_char=17, end_char=26),
+ ]
+
+
+class _DataclassRelExtractor:
+ """Returns Semantica's real ``Relation`` dataclass shape (subject/object)."""
+
+ def __init__(self):
+ self.received_entities = None
+
+ def extract_relations(self, text, entities=None):
+ from semantica.semantic_extract.types import Entity, Relation
+
+ self.received_entities = entities
+ return [
+ Relation(
+ subject=Entity(text="Tesla", label="ORG", start_char=0, end_char=5),
+ predicate="FOUNDED_BY",
+ object=Entity(
+ text="Elon Musk", label="PERSON", start_char=17, end_char=26
+ ),
+ )
+ ]
+
+
+class TestSemanticaKGToolInit(unittest.TestCase):
+
+ def test_crewai_available_via_stub(self):
+ self.assertTrue(CREWAI_AVAILABLE)
+ self.assertIsNone(CREWAI_IMPORT_ERROR)
+
+ def test_is_base_tool_subclass(self):
+ from crewai.tools import BaseTool
+
+ self.assertTrue(issubclass(SemanticaKGTool, BaseTool))
+
+ def test_exposed_from_package_init(self):
+ self.assertIs(ImportedSemanticaKGTool, SemanticaKGTool)
+
+ def test_creates_with_explicit_graph(self):
+ graph = ContextGraph()
+ tool = SemanticaKGTool(graph=graph)
+ self.assertIs(tool.graph, graph)
+
+ def test_creates_fresh_graph_when_none(self):
+ tool = SemanticaKGTool(
+ ner_extractor=_FakeNER(), relation_extractor=_FakeRelExtractor()
+ )
+ self.assertIsNotNone(tool.graph)
+ self.assertIsInstance(tool.graph, ContextGraph)
+
+ def test_default_metadata(self):
+ tool = SemanticaKGTool(
+ ner_extractor=_FakeNER(), relation_extractor=_FakeRelExtractor()
+ )
+ self.assertEqual(tool.name, "semantica_knowledge_graph")
+ self.assertTrue(tool.description)
+ self.assertEqual(tool.args_schema, SemanticaKGToolInput)
+
+ def test_input_schema_validates(self):
+ inp = SemanticaKGToolInput(action="query_graph", query="privacy", hops=2)
+ self.assertEqual(inp.hops, 2)
+ with self.assertRaises(Exception):
+ SemanticaKGToolInput(action="bogus")
+
+ def test_custom_kwargs_forwarded(self):
+ tool = SemanticaKGTool(
+ ner_extractor=_FakeNER(),
+ relation_extractor=_FakeRelExtractor(),
+ result_as_answer=True,
+ )
+ self.assertTrue(tool.result_as_answer)
+
+
+class TestSemanticaKGToolSerialization(unittest.TestCase):
+ """CrewAI checkpoints serialise tools via ``model_dump(mode="json")`` — the
+ live graph/extractors must not break that (regression for
+ PydanticSerializationError on arbitrary state objects)."""
+
+ def setUp(self):
+ self.tool = SemanticaKGTool(
+ graph=ContextGraph(),
+ ner_extractor=_FakeNER(),
+ relation_extractor=_FakeRelExtractor(),
+ )
+
+ def test_model_dump_json_excludes_shared_state(self):
+ dumped = self.tool.model_dump(mode="json")
+ self.assertNotIn("graph", dumped)
+ self.assertNotIn("ner_extractor", dumped)
+ self.assertNotIn("relation_extractor", dumped)
+ self.assertEqual(dumped["name"], "semantica_knowledge_graph")
+
+ def test_model_validate_restores_defaults(self):
+ restored = SemanticaKGTool.model_validate(self.tool.model_dump(mode="json"))
+ self.assertIsInstance(restored.graph, ContextGraph)
+ self.assertIs(restored.args_schema, SemanticaKGToolInput)
+ self.assertEqual(restored.name, "semantica_knowledge_graph")
+
+ def test_model_validate_restored_tool_still_runs(self):
+ restored = SemanticaKGTool.model_validate(self.tool.model_dump(mode="json"))
+ restored.graph.add_node(node_id="privacy", node_type="policy")
+ result = json.loads(restored._run(action="query_graph", query="privacy"))
+ self.assertEqual(result["count"], 1)
+
+ def test_restore_flags_lost_live_state(self):
+ """A tool restored from a checkpoint must signal that its live graph
+ was excluded and an empty one reconstructed (``reconstructed_state``)."""
+ dumped = self.tool.model_dump(mode="json")
+ self.assertTrue(dumped["had_live_state"])
+ self.assertNotIn("reconstructed_state", dumped)
+ restored = SemanticaKGTool.model_validate(dumped)
+ self.assertTrue(restored.reconstructed_state)
+ self.assertFalse(SemanticaKGTool().reconstructed_state)
+
+
+class TestSemanticaKGToolActions(unittest.TestCase):
+
+ def setUp(self):
+ self.graph = ContextGraph()
+ self.tool = SemanticaKGTool(
+ graph=self.graph,
+ ner_extractor=_FakeNER(),
+ relation_extractor=_FakeRelExtractor(),
+ )
+
+ def test_extract_entities(self):
+ result = json.loads(
+ self.tool._run(
+ action="extract_entities", text="Tesla was founded by Elon Musk"
+ )
+ )
+ self.assertEqual(result["count"], 2)
+ self.assertEqual(result["entities"][0]["name"], "Tesla")
+ self.assertEqual(result["entities"][0]["type"], "ORG")
+
+ def test_extract_relations(self):
+ result = json.loads(
+ self.tool._run(
+ action="extract_relations", text="Tesla was founded by Elon Musk"
+ )
+ )
+ self.assertEqual(result["count"], 1)
+ self.assertEqual(result["relations"][0]["source"], "Tesla")
+ self.assertEqual(result["relations"][0]["target"], "Elon Musk")
+
+ def test_add_to_graph_populates_graph(self):
+ result = json.loads(
+ self.tool._run(action="add_to_graph", text="Tesla was founded by Elon Musk")
+ )
+ self.assertGreaterEqual(result["nodes_added"], 2)
+ self.assertGreaterEqual(result["edges_added"], 1)
+ nodes = self.graph.find_nodes()
+ node_ids = {n["id"] for n in nodes}
+ self.assertIn("Tesla", node_ids)
+ self.assertIn("Elon Musk", node_ids)
+
+ def test_add_to_graph_is_idempotent(self):
+ self.tool._run(action="add_to_graph", text="Tesla was founded by Elon Musk")
+ second = json.loads(
+ self.tool._run(action="add_to_graph", text="Tesla was founded by Elon Musk")
+ )
+ self.assertEqual(second["nodes_added"], 0)
+ self.assertEqual(second["edges_added"], 0)
+
+ def test_query_graph_finds_matching_node(self):
+ self.graph.add_node(
+ node_id="privacy", node_type="policy", content="privacy policy doc"
+ )
+ result = json.loads(self.tool._run(action="query_graph", query="privacy"))
+ self.assertEqual(result["count"], 1)
+ self.assertEqual(result["results"][0]["id"], "privacy")
+
+ def test_query_graph_no_match(self):
+ result = json.loads(
+ self.tool._run(action="query_graph", query="nothing-matches")
+ )
+ self.assertEqual(result["count"], 0)
+ self.assertEqual(result["results"], [])
+
+ def test_query_graph_searches_node_content(self):
+ """query_graph must match node content, not just ids/types."""
+ self.graph.add_node(
+ node_id="n1",
+ node_type="policy",
+ content="all refunds must be processed within 30 days",
+ )
+ result = json.loads(self.tool._run(action="query_graph", query="refunds"))
+ self.assertEqual(result["count"], 1)
+ self.assertEqual(result["results"][0]["id"], "n1")
+
+ def test_query_graph_matches_type(self):
+ self.graph.add_node(node_id="n2", node_type="risk")
+ result = json.loads(self.tool._run(action="query_graph", query="risk"))
+ self.assertEqual(result["count"], 1)
+ self.assertEqual(result["results"][0]["id"], "n2")
+
+ def test_query_graph_result_shape_is_consistent(self):
+ """Every result — content match or id/type match — must carry the same
+ keys (id, type, label, content, score) so agents get one schema."""
+ self.graph.add_node(
+ node_id="n1",
+ node_type="policy",
+ content="all refunds within 30 days",
+ )
+ by_content = json.loads(self.tool._run(action="query_graph", query="refunds"))[
+ "results"
+ ][0]
+ expected_keys = {"id", "type", "label", "content", "score"}
+ self.assertEqual(set(by_content.keys()), expected_keys)
+
+ by_id = json.loads(self.tool._run(action="query_graph", query="n1"))["results"][
+ 0
+ ]
+ self.assertEqual(set(by_id.keys()), expected_keys)
+ self.assertEqual(by_id["content"], "all refunds within 30 days")
+ self.assertEqual(by_id["score"], 1.0)
+
+ def test_extract_entities_skips_nameless_entities(self):
+ class _NamelessNER:
+ def extract_entities(self, text):
+ e = MagicMock()
+ e.name = None
+ e.type = "MISC"
+ e.confidence = 0.5
+ return [e]
+
+ tool = SemanticaKGTool(
+ graph=self.graph,
+ ner_extractor=_NamelessNER(),
+ relation_extractor=_FakeRelExtractor(),
+ )
+ result = json.loads(tool._run(action="extract_entities", text="text"))
+ self.assertEqual(result["count"], 0)
+ self.assertEqual(result["entities"], [])
+
+ def test_find_related_multi_hop(self):
+ self.graph.add_node(node_id="A", node_type="concept")
+ self.graph.add_node(node_id="B", node_type="concept")
+ self.graph.add_node(node_id="C", node_type="concept")
+ self.graph.add_edge(source_id="A", target_id="B", edge_type="related_to")
+ self.graph.add_edge(source_id="B", target_id="C", edge_type="related_to")
+ result = json.loads(self.tool._run(action="find_related", entity="A", hops=2))
+ self.assertEqual(result["count"], 2)
+ self.assertIn("B", result["related"])
+ self.assertIn("C", result["related"])
+
+ def test_find_related_unknown_entity(self):
+ result = json.loads(
+ self.tool._run(action="find_related", entity="Ghost", hops=1)
+ )
+ self.assertEqual(result["count"], 0)
+ self.assertEqual(result["related"], [])
+
+ def test_find_related_honors_incoming_edges(self):
+ """find_related must be undirected: a node whose only edge is
+ incoming (A -> B) is still related to A."""
+ self.graph.add_node(node_id="OpenAI", node_type="ORG")
+ self.graph.add_node(node_id="Google", node_type="ORG")
+ self.graph.add_edge(
+ source_id="OpenAI", target_id="Google", edge_type="related_to"
+ )
+ result = json.loads(self.tool._run(action="find_related", entity="Google"))
+ self.assertEqual(result["related"], ["OpenAI"])
+ result_out = json.loads(self.tool._run(action="find_related", entity="OpenAI"))
+ self.assertEqual(result_out["related"], ["Google"])
+
+ def test_unknown_action_returns_error(self):
+ result = json.loads(self.tool._run(action="do_something_else"))
+ self.assertIn("error", result)
+ self.assertIn("do_something_else", result["error"])
+
+ def test_extract_entities_empty_text_is_graceful(self):
+ result = json.loads(self.tool._run(action="extract_entities", text=""))
+ self.assertIn("entities", result)
+
+ def test_extract_entities_confidence_none_defaults_to_one(self):
+ """A single entity with ``confidence=None`` must not nuke the whole
+ extract result — it normalises to 1.0 instead of raising float(None)."""
+
+ class _NoneConfNER:
+ def extract_entities(self, text):
+ e = MagicMock()
+ e.name = "X"
+ e.type = "MISC"
+ e.confidence = None
+ return [e]
+
+ tool = SemanticaKGTool(
+ graph=self.graph,
+ ner_extractor=_NoneConfNER(),
+ relation_extractor=_FakeRelExtractor(),
+ )
+ result = json.loads(tool._run(action="extract_entities", text="text"))
+ self.assertEqual(result["count"], 1)
+ self.assertEqual(result["entities"][0]["name"], "X")
+ self.assertEqual(result["entities"][0]["confidence"], 1.0)
+ self.assertNotIn("error", result)
+
+ def test_graph_lock_is_per_graph(self):
+ """Independent graphs must not share a batch lock."""
+ g2 = ContextGraph()
+ lock_a = self.tool._graph_lock(self.graph)
+ lock_a_again = self.tool._graph_lock(self.graph)
+ lock_b = self.tool._graph_lock(g2)
+ self.assertIs(lock_a, lock_a_again)
+ self.assertIsNot(lock_a, lock_b)
+
+
+class TestSemanticaKGToolDataclassShapes(unittest.TestCase):
+ """Real Semantica ``Entity``/``Relation`` dataclasses (text/label,
+ subject/object) instead of MagicMock-shaped fakes."""
+
+ def setUp(self):
+ self.ner = _DataclassNER()
+ self.rel = _DataclassRelExtractor()
+ self.graph = ContextGraph()
+ self.tool = SemanticaKGTool(
+ graph=self.graph, ner_extractor=self.ner, relation_extractor=self.rel
+ )
+
+ def test_extract_entities_reads_text_label(self):
+ result = json.loads(
+ self.tool._run(action="extract_entities", text="Tesla founded by Elon Musk")
+ )
+ self.assertEqual(result["count"], 2)
+ self.assertEqual(result["entities"][0]["name"], "Tesla")
+ self.assertEqual(result["entities"][0]["type"], "ORG")
+ self.assertEqual(result["entities"][1]["name"], "Elon Musk")
+ self.assertEqual(result["entities"][1]["type"], "PERSON")
+
+ def test_extract_relations_reads_subject_object(self):
+ result = json.loads(
+ self.tool._run(
+ action="extract_relations", text="Tesla founded by Elon Musk"
+ )
+ )
+ self.assertEqual(result["count"], 1)
+ self.assertEqual(result["relations"][0]["source"], "Tesla")
+ self.assertEqual(result["relations"][0]["relation"], "FOUNDED_BY")
+ self.assertEqual(result["relations"][0]["target"], "Elon Musk")
+
+ def test_add_to_graph_passes_entity_objects_to_relation_extractor(self):
+ result = json.loads(
+ self.tool._run(action="add_to_graph", text="Tesla founded by Elon Musk")
+ )
+ self.assertEqual(result["nodes_added"], 2)
+ self.assertEqual(result["edges_added"], 1)
+ from semantica.semantic_extract.types import Entity
+
+ self.assertIsNotNone(self.rel.received_entities)
+ for e in self.rel.received_entities:
+ self.assertIsInstance(e, Entity)
+ node_ids = {n["id"] for n in self.graph.find_nodes()}
+ self.assertIn("Tesla", node_ids)
+ self.assertIn("Elon Musk", node_ids)
+ edge_keys = {
+ (e["source"], e["type"], e["target"]) for e in self.graph.find_edges()
+ }
+ self.assertIn(("Tesla", "FOUNDED_BY", "Elon Musk"), edge_keys)
+
+
+class TestSemanticaKGToolCrewAIEntrypoints(unittest.TestCase):
+
+ def setUp(self):
+ self.tool = SemanticaKGTool(
+ graph=ContextGraph(),
+ ner_extractor=_FakeNER(),
+ relation_extractor=_FakeRelExtractor(),
+ )
+
+ def test_run_delegates_to_run(self):
+ result = json.loads(
+ self.tool.run(action="extract_entities", text="Tesla led by Elon Musk")
+ )
+ self.assertEqual(result["count"], 2)
+
+ def test_arun_async(self):
+ async def _call():
+ return await self.tool.arun(action="query_graph", query="x")
+
+ result = json.loads(asyncio.run(_call()))
+ self.assertIn("results", result)
+
+ def test_run_returns_string(self):
+ out = self.tool.run(action="extract_entities", text="hello world")
+ self.assertIsInstance(out, str)
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/tests/integrations/crewai/test_knowledge_source.py b/tests/integrations/crewai/test_knowledge_source.py
new file mode 100644
index 00000000..f76cc815
--- /dev/null
+++ b/tests/integrations/crewai/test_knowledge_source.py
@@ -0,0 +1,228 @@
+"""
+Tests for SemanticaKnowledgeSource — CrewAI knowledge source backed by a
+Semantica ContextGraph.
+
+Runs with the crewai stubs installed by conftest, so ``CREWAI_AVAILABLE`` is
+``True`` and the real Pydantic/BaseKnowledgeSource subclassing path (including
+the current ``validate_content`` / ``add`` / ``aadd`` contract) is exercised.
+"""
+
+from __future__ import annotations
+
+import asyncio
+import unittest
+
+from integrations.crewai import SemanticaKnowledgeSource
+from integrations.crewai.knowledge_source import CREWAI_AVAILABLE, _chunk_text_manual
+from semantica.context import ContextGraph
+
+
+class _FakeStorage:
+ def __init__(self):
+ self.saved_chunks: list = []
+
+ def save(self, chunks: list) -> None:
+ self.saved_chunks.extend(chunks)
+
+ async def asave(self, chunks: list) -> None:
+ self.saved_chunks.extend(chunks)
+
+
+class _RaisingStorage(_FakeStorage):
+ """Mirrors real crewai: storage is wired but ``save`` raises ``ValueError``
+ (e.g. the embedder has no credentials configured)."""
+
+ def save(self, chunks: list) -> None:
+ raise ValueError("The OPENAI_API_KEY environment variable is not set.")
+
+ async def asave(self, chunks: list) -> None:
+ raise ValueError("The OPENAI_API_KEY environment variable is not set.")
+
+
+def _build_graph() -> ContextGraph:
+ graph = ContextGraph()
+ graph.add_node(node_id="privacy", node_type="policy", content="privacy policy doc")
+ graph.add_node(node_id="fraud", node_type="risk", content="fraud detection rules")
+ graph.add_edge(source_id="privacy", target_id="fraud", edge_type="constrains")
+ return graph
+
+
+class TestSemanticaKnowledgeSourceInit(unittest.TestCase):
+
+ def test_crewai_available_via_stub(self):
+ self.assertTrue(CREWAI_AVAILABLE)
+
+ def test_is_base_knowledge_source_subclass(self):
+ from crewai.knowledge.source import BaseKnowledgeSource
+
+ self.assertTrue(issubclass(SemanticaKnowledgeSource, BaseKnowledgeSource))
+
+ def test_creates_with_explicit_graph(self):
+ graph = _build_graph()
+ src = SemanticaKnowledgeSource(graph=graph)
+ self.assertIs(src.graph, graph)
+
+ def test_creates_fresh_graph_when_none(self):
+ src = SemanticaKnowledgeSource()
+ self.assertIsNotNone(src.graph)
+ self.assertIsInstance(src.graph, ContextGraph)
+
+ def test_default_metadata(self):
+ src = SemanticaKnowledgeSource(graph=_build_graph())
+ self.assertEqual(src.name, "semantica_knowledge_graph")
+ self.assertEqual(src.chunk_size, 4000)
+ self.assertEqual(src.chunk_overlap, 200)
+
+ def test_custom_chunking_params(self):
+ src = SemanticaKnowledgeSource(
+ graph=_build_graph(), chunk_size=50, chunk_overlap=10
+ )
+ self.assertEqual(src.chunk_size, 50)
+ self.assertEqual(src.chunk_overlap, 10)
+
+
+class TestLoadContent(unittest.TestCase):
+
+ def setUp(self):
+ self.graph = _build_graph()
+ self.src = SemanticaKnowledgeSource(graph=self.graph)
+
+ def test_nodes_serialized(self):
+ content = self.src.load_content()
+ text = "\n".join(content.values())
+ self.assertIn("privacy", text)
+ self.assertIn("fraud", text)
+ self.assertIn("policy", text)
+
+ def test_edges_serialized(self):
+ content = self.src.load_content()
+ text = "\n".join(content.values())
+ self.assertIn("-[" + "constrains" + "]->", text)
+
+ def test_empty_graph_returns_empty(self):
+ src = SemanticaKnowledgeSource(graph=ContextGraph())
+ self.assertEqual(src.load_content(), {})
+
+ def test_validate_content_passes(self):
+ self.assertTrue(self.src.validate_content())
+
+ def test_validate_content_raises_without_graph(self):
+ self.src.graph = None
+ with self.assertRaises(ValueError):
+ self.src.validate_content()
+
+
+class TestAdd(unittest.TestCase):
+
+ def setUp(self):
+ self.graph = _build_graph()
+ self.src = SemanticaKnowledgeSource(
+ graph=self.graph, chunk_size=40, chunk_overlap=5
+ )
+
+ def test_add_saves_chunks_to_storage(self):
+ storage = _FakeStorage()
+ self.src.storage = storage
+ self.src.add()
+ self.assertGreater(len(storage.saved_chunks), 0)
+ self.assertTrue(all(isinstance(c, str) and c for c in storage.saved_chunks))
+
+ def test_add_without_storage_keeps_chunks_in_memory(self):
+ self.src.add()
+ self.assertGreater(len(self.src.chunks), 0)
+ self.assertGreater(len(self.src._chunks), 0)
+
+ def test_add_wired_storage_failure_logs_error_not_debug(self):
+ """Regression: real crewai raises ``ValueError`` for a missing embedder
+ even though storage IS wired. That used to fall into the "storage not
+ wired" DEBUG branch, silently hiding the failure — it must log an
+ actionable ERROR instead."""
+ self.src.storage = _RaisingStorage()
+ with self.assertLogs(
+ f"semantica.{SemanticaKnowledgeSource.__module__}", level="ERROR"
+ ) as caught:
+ self.src.add()
+ joined = "\n".join(caught.output)
+ self.assertIn("storage save FAILED", joined)
+ self.assertIn("OPENAI_API_KEY", joined)
+ self.assertGreater(len(self.src.chunks), 0)
+
+ def test_add_empty_graph_no_chunks(self):
+ src = SemanticaKnowledgeSource(
+ graph=ContextGraph(), chunk_size=40, chunk_overlap=5
+ )
+ src.add()
+ self.assertEqual(src.chunks, [])
+
+ def test_aadd_async(self):
+ storage = _FakeStorage()
+ self.src.storage = storage
+ asyncio.run(self.src.aadd())
+ self.assertGreater(len(storage.saved_chunks), 0)
+
+ def test_content_summary(self):
+ summary = self.src.get_content_summary()
+ self.assertEqual(summary["name"], "semantica_knowledge_graph")
+ self.assertGreater(summary["source_count"], 0)
+ self.assertTrue(summary["crewai_available"])
+
+
+class TestSemanticaKnowledgeSourceSerialization(unittest.TestCase):
+ """CrewAI checkpoints serialise their models via ``model_dump(mode="json")``
+ — the live graph must not break that (regression for
+ PydanticSerializationError on arbitrary state objects)."""
+
+ def test_model_dump_json_excludes_graph(self):
+ src = SemanticaKnowledgeSource(graph=_build_graph())
+ dumped = src.model_dump(mode="json")
+ self.assertNotIn("graph", dumped)
+ self.assertEqual(dumped["name"], "semantica_knowledge_graph")
+
+ def test_model_validate_restores_graph(self):
+ src = SemanticaKnowledgeSource(graph=_build_graph())
+ restored = SemanticaKnowledgeSource.model_validate(src.model_dump(mode="json"))
+ self.assertIsInstance(restored.graph, ContextGraph)
+
+ def test_restored_source_still_loads_content(self):
+ """A checkpoint-restored source gets a fresh graph (the live graph is
+ excluded from serialisation); once a graph is attached it works."""
+ src = SemanticaKnowledgeSource(graph=_build_graph())
+ restored = SemanticaKnowledgeSource.model_validate(src.model_dump(mode="json"))
+ restored.graph = _build_graph()
+ self.assertNotEqual(restored.load_content(), {})
+
+ def test_restore_flags_lost_live_state(self):
+ """A source restored from a checkpoint must signal that its live graph
+ was excluded and an empty one reconstructed (``reconstructed_state``).
+ Regression: an eager graph build in ``__init__`` used to hide this."""
+ src = SemanticaKnowledgeSource(graph=_build_graph())
+ dumped = src.model_dump(mode="json")
+ self.assertTrue(dumped["had_live_state"])
+ self.assertNotIn("reconstructed_state", dumped)
+ restored = SemanticaKnowledgeSource.model_validate(dumped)
+ self.assertTrue(restored.reconstructed_state)
+ self.assertFalse(SemanticaKnowledgeSource().reconstructed_state)
+ self.assertIsInstance(SemanticaKnowledgeSource().graph, ContextGraph)
+
+
+class TestManualChunker(unittest.TestCase):
+
+ def test_short_text_single_chunk(self):
+ self.assertEqual(_chunk_text_manual("hello", 40, 5), ["hello"])
+
+ def test_empty_text(self):
+ self.assertEqual(_chunk_text_manual("", 40, 5), [])
+
+ def test_long_text_overlaps(self):
+ chunks = _chunk_text_manual("a" * 100, 40, 10)
+ self.assertGreater(len(chunks), 1)
+ self.assertTrue(all(len(c) <= 40 for c in chunks))
+ # Overlap means consecutive chunks share tail/head content
+ self.assertIn("a" * 10, chunks[0][-10:] + chunks[1][:10])
+
+ def test_zero_chunk_size_guarded(self):
+ self.assertEqual(_chunk_text_manual("hello world", 0, 5), ["hello world"])
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/tests/integrations/crewai/test_real_crewai_integration.py b/tests/integrations/crewai/test_real_crewai_integration.py
new file mode 100644
index 00000000..b9b9d619
--- /dev/null
+++ b/tests/integrations/crewai/test_real_crewai_integration.py
@@ -0,0 +1,123 @@
+"""
+End-to-end integration tests against the REAL crewai package.
+
+These run in a subprocess because the stubs in ``conftest.py`` install a fake
+``crewai`` module into ``sys.modules`` for the whole pytest session — the same
+interpreter can never see both. Each test launches a fresh interpreter; if
+crewai is genuinely not installed there, the test is skipped.
+
+This covers the failure class the stubs cannot: ``Crew``-level serialization
+(list[BaseTool] inside Agent.tools), checkpoint restore via ``model_validate``,
+and knowledge-source behaviour with a real ``Crew``.
+"""
+
+from __future__ import annotations
+
+import subprocess
+import sys
+import textwrap
+import unittest
+from pathlib import Path
+
+REPO_ROOT = Path(__file__).resolve().parents[3]
+
+_SCRIPT = textwrap.dedent(
+ """
+ import os
+ import json
+ import sys
+
+ sys.path.insert(0, os.getcwd())
+
+ try:
+ import crewai
+ except ImportError:
+ print("CREWAI_IMPORT_FAILED")
+ sys.exit(2)
+
+ import crewai as crewai_mod
+ from crewai import Agent, Task, Crew
+
+ from semantica.context import ContextGraph
+ from integrations.crewai import (
+ SemanticaKGTool,
+ SemanticaDecisionTool,
+ SemanticaKnowledgeSource,
+ )
+
+ os.environ["CREWAI_DESERIALIZE_CALLBACKS"] = "1"
+
+ # --- 1. Crew-level serialization round-trip ------------------------------
+ graph = ContextGraph()
+ graph.add_node(node_id="privacy", node_type="policy",
+ content="privacy policy: no data sharing")
+ tool = SemanticaKGTool(graph=graph)
+
+ decision_ctx = SemanticaDecisionTool()
+ decision_tool = SemanticaDecisionTool(context=decision_ctx.context)
+
+ agent = Agent(role="researcher", goal="answer questions",
+ backstory="retrieves from a knowledge graph",
+ tools=[tool, decision_tool])
+ task = Task(description="answer", expected_output="an answer", agent=agent)
+ crew = Crew(agents=[agent], tasks=[task])
+
+ dump = crew.model_dump(mode="json")
+ agents = dump["agents"]
+ assert len(agents) == 1, f"expected 1 agent, got {len(agents)}"
+ dumped_tools = agents[0]["tools"]
+ assert len(dumped_tools) == 2, f"expected 2 tools, got {len(dumped_tools)}"
+ for t in dumped_tools:
+ assert isinstance(t, dict), f"tool not serialized to dict: {type(t)}"
+ assert "graph" not in t, "live graph leaked into serialized tool"
+ assert "context" not in t, "live context leaked into serialized tool"
+ assert "ner_extractor" not in t, "extractor leaked into serialized tool"
+
+ # --- 2. Restore a tool from the crew dump --------------------------------
+ kg_dump = dumped_tools[0]
+ assert kg_dump["name"] == "semantica_knowledge_graph", kg_dump["name"]
+ restored = SemanticaKGTool.model_validate(kg_dump)
+ assert restored.graph is not None, "restored tool did not self-heal a graph"
+ q = json.loads(restored._run(action="query_graph", query="privacy"))
+ assert "results" in q, f"restored tool query_graph failed: {q}"
+
+ # --- 3. Knowledge source with no embedder must not crash a Crew ----------
+ ks = SemanticaKnowledgeSource(graph=graph)
+ agent2 = Agent(role="researcher2", goal="answer",
+ backstory="retrieves from knowledge")
+ task2 = Task(description="q", expected_output="a", agent=agent2)
+ crew2 = Crew(agents=[agent2], tasks=[task2],
+ knowledge_sources=[ks])
+ assert ks.chunks, "knowledge source retained no chunks in memory"
+ assert crew2.knowledge is not None, "crew.knowledge not created"
+
+ print("REAL_CREWAI_OK")
+ """
+)
+
+
+class TestRealCrewAIIntegration(unittest.TestCase):
+
+ def _run(self) -> subprocess.CompletedProcess:
+ return subprocess.run(
+ [sys.executable, "-c", _SCRIPT],
+ cwd=str(REPO_ROOT),
+ capture_output=True,
+ text=True,
+ timeout=240,
+ )
+
+ def test_crew_level_round_trip_with_real_crewai(self):
+ proc = self._run()
+ if proc.returncode == 2:
+ self.skipTest("real crewai is not installed in this environment")
+ self.assertEqual(
+ proc.returncode,
+ 0,
+ msg=f"subprocess failed:\n{proc.stdout}\n{proc.stderr}",
+ )
+ self.assertIn("REAL_CREWAI_OK", proc.stdout)
+
+
+if __name__ == "__main__":
+ unittest.main()
From 8177d887538560d137edc705c7d60abcb9e23faa Mon Sep 17 00:00:00 2001
From: "Guofang.Tang" <136770748@qq.com>
Date: Sun, 16 Aug 2026 14:23:24 +0800
Subject: [PATCH 2/7] fix(kg): preserve isolated nodes in graph analytics
(#1011)
* fix(kg): preserve isolated nodes in graph analytics
* fix(kg): support node fallbacks and community payloads
---------
---
semantica/kg/_graph_view.py | 206 ++++++++++++++++++++++++++
semantica/kg/centrality_calculator.py | 72 +--------
semantica/kg/community_detector.py | 127 ++++++----------
semantica/kg/connectivity_analyzer.py | 49 +-----
tests/kg/test_analytics_node_scope.py | 112 ++++++++++++++
5 files changed, 374 insertions(+), 192 deletions(-)
create mode 100644 semantica/kg/_graph_view.py
create mode 100644 tests/kg/test_analytics_node_scope.py
diff --git a/semantica/kg/_graph_view.py b/semantica/kg/_graph_view.py
new file mode 100644
index 00000000..f467125b
--- /dev/null
+++ b/semantica/kg/_graph_view.py
@@ -0,0 +1,206 @@
+"""Internal graph view helpers shared by KG analytics modules."""
+
+from dataclasses import dataclass
+from typing import Any, Dict, Iterable, List, Optional, Set, Tuple
+
+
+@dataclass
+class GraphView:
+ """Normalized node and edge view used by graph analytics."""
+
+ nodes: List[Any]
+ edges: List[Tuple[Any, Any]]
+
+
+def build_graph_view(graph: Any) -> GraphView:
+ """Build a graph view without dropping explicitly declared nodes.
+
+ Graph analytics accepts graph dictionaries, ContextGraph-like objects, and
+ NetworkX graphs. Nodes declared without an incident edge remain in the
+ returned view so callers can choose how to handle isolated nodes.
+ """
+ nodes: List[Any] = []
+ edges: List[Tuple[Any, Any]] = []
+ seen_nodes: Set[Any] = set()
+ seen_edges: Set[Tuple[Any, Any]] = set()
+
+ def add_node(value: Any) -> Optional[Any]:
+ node_id = _node_id(value)
+ if node_id is None or node_id == "":
+ return None
+ if node_id not in seen_nodes:
+ seen_nodes.add(node_id)
+ nodes.append(node_id)
+ return node_id
+
+ for node in _extract_nodes(graph):
+ add_node(node)
+
+ for raw_edge in _extract_edges(graph):
+ edge = _edge_endpoints(raw_edge)
+ if edge is None:
+ continue
+ source, target = edge
+ source = add_node(source)
+ target = add_node(target)
+ if source is None or target is None:
+ continue
+ if (source, target) not in seen_edges:
+ seen_edges.add((source, target))
+ edges.append((source, target))
+
+ return GraphView(nodes=nodes, edges=edges)
+
+
+def build_adjacency(graph: Any, directed: bool = False) -> Dict[Any, List[Any]]:
+ """Build an adjacency list while preserving isolated graph nodes."""
+ view = build_graph_view(graph)
+ adjacency: Dict[Any, List[Any]] = {node: [] for node in view.nodes}
+
+ for source, target in view.edges:
+ if target not in adjacency[source]:
+ adjacency[source].append(target)
+ if not directed and source not in adjacency[target]:
+ adjacency[target].append(source)
+
+ return adjacency
+
+
+def _extract_nodes(graph: Any) -> Iterable[Any]:
+ if isinstance(graph, dict):
+ raw_nodes: List[Any] = []
+ for key in ("entities", "nodes"):
+ values = graph.get(key, [])
+ if isinstance(values, dict):
+ raw_nodes.extend(values.keys())
+ elif values:
+ raw_nodes.extend(values)
+ return raw_nodes
+
+ raw_nodes = getattr(graph, "nodes", None)
+ if callable(raw_nodes):
+ return raw_nodes()
+ if isinstance(raw_nodes, dict):
+ return raw_nodes.keys()
+ if raw_nodes is not None:
+ return raw_nodes
+
+ get_nodes = getattr(graph, "get_nodes", None)
+ if callable(get_nodes):
+ return get_nodes()
+ return []
+
+
+def _extract_edges(graph: Any) -> Iterable[Any]:
+ if isinstance(graph, dict):
+ raw_edges: List[Any] = []
+ for key in ("relationships", "edges"):
+ values = graph.get(key, [])
+ if values:
+ raw_edges.extend(values)
+ return raw_edges
+
+ raw_edges: List[Any] = []
+ relationships = getattr(graph, "relationships", None)
+ if relationships is not None:
+ raw_edges.extend(relationships)
+ edges = getattr(graph, "edges", None)
+ if callable(edges):
+ raw_edges.extend(edges())
+ elif edges is not None:
+ raw_edges.extend(edges)
+ if raw_edges:
+ return raw_edges
+
+ get_relationships = getattr(graph, "get_relationships", None)
+ if callable(get_relationships):
+ return get_relationships()
+ return []
+
+
+def _edge_endpoints(edge: Any) -> Optional[Tuple[Any, Any]]:
+ if isinstance(edge, (tuple, list)) and len(edge) >= 2:
+ return edge[0], edge[1]
+
+ if isinstance(edge, dict):
+ source = _first_value(
+ edge,
+ "source",
+ "source_id",
+ "subject",
+ "start",
+ "start_id",
+ "from",
+ "src",
+ "START_ID",
+ ":START_ID",
+ )
+ target = _first_value(
+ edge,
+ "target",
+ "target_id",
+ "object",
+ "end",
+ "end_id",
+ "to",
+ "dst",
+ "END_ID",
+ ":END_ID",
+ )
+ else:
+ source = _first_attribute(
+ edge,
+ "source_id",
+ "source",
+ "subject",
+ "start",
+ "start_id",
+ "from_id",
+ )
+ target = _first_attribute(
+ edge,
+ "target_id",
+ "target",
+ "object",
+ "end",
+ "end_id",
+ "to_id",
+ )
+
+ if source is None or target is None:
+ return None
+ return source, target
+
+
+def _node_id(value: Any) -> Any:
+ if isinstance(value, dict):
+ value = _first_value(
+ value, "id", "node_id", "entity_id", "key", "name", "text"
+ )
+ elif not isinstance(value, (str, int, float, bool, bytes, tuple)):
+ value = _first_attribute(
+ value, "node_id", "id", "entity_id", "key", "name", "text"
+ )
+
+ if value is None:
+ return None
+ try:
+ hash(value)
+ except TypeError:
+ return str(value)
+ return value
+
+
+def _first_value(mapping: Dict[str, Any], *keys: str) -> Any:
+ for key in keys:
+ if key in mapping and mapping[key] not in (None, ""):
+ return mapping[key]
+ return None
+
+
+def _first_attribute(value: Any, *names: str) -> Any:
+ for name in names:
+ attribute = getattr(value, name, None)
+ if attribute not in (None, ""):
+ return attribute
+ return None
diff --git a/semantica/kg/centrality_calculator.py b/semantica/kg/centrality_calculator.py
index 9fe9a956..ac59db6e 100644
--- a/semantica/kg/centrality_calculator.py
+++ b/semantica/kg/centrality_calculator.py
@@ -43,7 +43,7 @@ Author: Semantica Contributors
License: MIT
"""
-from collections import defaultdict, deque
+from collections import deque
from typing import Any, Dict, List, Optional
import numpy as np
@@ -51,6 +51,7 @@ from scipy import sparse
from ..utils.logging import get_logger
from ..utils.progress_tracker import get_progress_tracker
+from ._graph_view import build_adjacency, build_graph_view
class CentralityCalculator:
@@ -518,76 +519,15 @@ class CentralityCalculator:
def _build_adjacency(self, graph) -> Dict[str, List[str]]:
"""Build adjacency list from graph."""
- adjacency = defaultdict(list)
-
- # Extract relationships
- relationships = []
- if hasattr(graph, "relationships"):
- relationships = graph.relationships
- elif hasattr(graph, "get_relationships"):
- relationships = graph.get_relationships()
- elif isinstance(graph, dict):
- relationships = graph.get("relationships", graph.get("edges", []))
- elif hasattr(graph, "edges") and not callable(graph.edges):
- # ContextGraph-style: edges is a list of dataclass objects with source_id/target_id
- for edge in (graph.edges or []):
- if isinstance(edge, dict):
- src = edge.get("source") or edge.get("source_id")
- tgt = edge.get("target") or edge.get("target_id")
- else:
- src = getattr(edge, "source_id", None) or getattr(edge, "source", None)
- tgt = getattr(edge, "target_id", None) or getattr(edge, "target", None)
- if src and tgt:
- src, tgt = str(src), str(tgt)
- if tgt not in adjacency[src]:
- adjacency[src].append(tgt)
- if src not in adjacency[tgt]:
- adjacency[tgt].append(src)
- return dict(adjacency)
-
- # Build adjacency
- for rel in relationships:
- # Handle tuple/list edges (e.g., from NetworkX)
- if isinstance(rel, (tuple, list)) and len(rel) >= 2:
- source, target = str(rel[0]), str(rel[1])
- if source and target:
- if target not in adjacency[source]:
- adjacency[source].append(target)
- if source not in adjacency[target]:
- adjacency[target].append(source)
- continue
- source = rel.get("source") or rel.get("subject")
- target = rel.get("target") or rel.get("object")
-
- # Extract IDs if objects are passed
- if source and not isinstance(source, (str, int, float)):
- if isinstance(source, dict):
- source = source.get("id") or source.get("entity_id") or source.get("text") or str(source)
- else:
- source = getattr(source, "id", getattr(source, "text", str(source)))
-
- if target and not isinstance(target, (str, int, float)):
- if isinstance(target, dict):
- target = target.get("id") or target.get("entity_id") or target.get("text") or str(target)
- else:
- target = getattr(target, "id", getattr(target, "text", str(target)))
-
- if source and target:
- if target not in adjacency[source]:
- adjacency[source].append(target)
- if source not in adjacency[target]:
- adjacency[target].append(source)
-
- return dict(adjacency)
+ return build_adjacency(graph)
def _to_networkx(self, graph):
"""Convert graph to NetworkX format."""
- adjacency = self._build_adjacency(graph)
+ view = build_graph_view(graph)
nx_graph = self.nx.Graph()
- for source, targets in adjacency.items():
- for target in targets:
- nx_graph.add_edge(source, target)
+ nx_graph.add_nodes_from(view.nodes)
+ nx_graph.add_edges_from(view.edges)
return nx_graph
diff --git a/semantica/kg/community_detector.py b/semantica/kg/community_detector.py
index 8aaaa236..01fa6064 100644
--- a/semantica/kg/community_detector.py
+++ b/semantica/kg/community_detector.py
@@ -49,6 +49,16 @@ from typing import Any, Dict, List, Optional
from ..utils.logging import get_logger
from ..utils.progress_tracker import get_progress_tracker
+from ._graph_view import build_adjacency, build_graph_view
+
+
+def _is_hashable(value: Any) -> bool:
+ """Return whether a community identifier can be used in a set."""
+ try:
+ hash(value)
+ except TypeError:
+ return False
+ return True
class CommunityDetector:
@@ -157,17 +167,18 @@ class CommunityDetector:
nx_graph = self._to_networkx(graph)
- # Check if graph is empty or has no edges
+ # An empty graph has no communities. A graph with nodes but
+ # no edges still has singleton communities.
num_nodes = nx_graph.number_of_nodes()
num_edges = nx_graph.number_of_edges()
self.logger.debug(f"Graph stats: nodes={num_nodes}, edges={num_edges}")
- if num_nodes == 0 or num_edges == 0:
- self.logger.warning("Graph is empty or has no edges, returning 0 communities")
+ if num_nodes == 0:
+ self.logger.warning("Graph is empty, returning 0 communities")
self.progress_tracker.stop_tracking(
tracking_id,
status="completed",
- message="Detected 0 communities (empty graph/no edges)",
+ message="Detected 0 communities (empty graph)",
)
return {
"communities": [],
@@ -350,17 +361,7 @@ class CommunityDetector:
adjacency = self._build_adjacency(graph)
- # Extract community structure
- if isinstance(communities, dict):
- node_communities = communities
- elif isinstance(communities, dict) and "node_assignments" in communities:
- node_communities = communities["node_assignments"]
- else:
- # Convert list of communities to node assignments
- node_communities = {}
- for i, community in enumerate(communities):
- for node in community:
- node_communities[node] = i
+ node_communities = self._to_node_assignments(communities)
# Calculate metrics
num_communities = len(set(node_communities.values()))
@@ -408,16 +409,7 @@ class CommunityDetector:
metrics = self.calculate_community_metrics(graph, communities)
- # Extract node assignments
- if isinstance(communities, dict) and "node_assignments" in communities:
- node_communities = communities["node_assignments"]
- elif isinstance(communities, dict):
- node_communities = communities
- else:
- node_communities = {}
- for i, community in enumerate(communities):
- for node in community:
- node_communities[node] = i
+ node_communities = self._to_node_assignments(communities)
# Analyze connectivity between communities
adjacency = self._build_adjacency(graph)
@@ -440,6 +432,32 @@ class CommunityDetector:
"edge_ratio": intra_community_edges / (inter_community_edges + 1),
}
+ @staticmethod
+ def _to_node_assignments(communities: Any) -> Dict[Any, Any]:
+ """Normalize community results to a node-to-community mapping."""
+ if isinstance(communities, dict):
+ assignments = communities.get("node_assignments")
+ if isinstance(assignments, dict):
+ return assignments
+
+ detected_communities = communities.get("communities")
+ if isinstance(detected_communities, (list, tuple)):
+ communities = detected_communities
+ elif "communities" in communities:
+ raise ValueError("Community results must contain a list of communities")
+ elif not all(_is_hashable(value) for value in communities.values()):
+ raise ValueError(
+ "Community assignments must map nodes to hashable community IDs"
+ )
+ else:
+ return communities
+
+ node_assignments: Dict[Any, Any] = {}
+ for community_id, community in enumerate(communities or []):
+ for node in community:
+ node_assignments[node] = community_id
+ return node_assignments
+
def detect_communities(
self, graph: Any, algorithm: str = "louvain", method: str = None, **options
) -> Dict[str, Any]:
@@ -478,57 +496,7 @@ class CommunityDetector:
def _build_adjacency(self, graph) -> Dict[str, List[str]]:
"""Build adjacency list from graph."""
- from collections import defaultdict
-
- adjacency = defaultdict(list)
-
- # Extract relationships
- relationships = []
- raw_edges = [] # flat (u, v) tuples
- if hasattr(graph, "relationships"):
- relationships = graph.relationships
- elif hasattr(graph, "get_relationships"):
- relationships = graph.get_relationships()
- elif isinstance(graph, dict):
- relationships = graph.get("relationships", [])
- # Also handle 'edges' key (list of tuples or dicts)
- for edge in graph.get("edges", []):
- if isinstance(edge, (list, tuple)) and len(edge) >= 2:
- raw_edges.append((str(edge[0]), str(edge[1])))
- elif isinstance(edge, dict):
- relationships.append(edge)
-
- # Add raw (u, v) edges
- for u, v in raw_edges:
- if u and v:
- adjacency[u].append(v)
- adjacency[v].append(u)
-
- # Build adjacency
- for rel in relationships:
- source = rel.get("source") or rel.get("subject")
- target = rel.get("target") or rel.get("object")
-
- # Extract IDs if objects are passed
- if source and not isinstance(source, (str, int, float)):
- if isinstance(source, dict):
- source = source.get("id") or source.get("entity_id") or source.get("text") or str(source)
- else:
- source = getattr(source, "id", getattr(source, "text", str(source)))
-
- if target and not isinstance(target, (str, int, float)):
- if isinstance(target, dict):
- target = target.get("id") or target.get("entity_id") or target.get("text") or str(target)
- else:
- target = getattr(target, "id", getattr(target, "text", str(target)))
-
- if source and target:
- if target not in adjacency[source]:
- adjacency[source].append(target)
- if source not in adjacency[target]:
- adjacency[target].append(source)
-
- return dict(adjacency)
+ return build_adjacency(graph)
def _to_networkx(self, graph):
"""Convert graph to NetworkX format."""
@@ -536,12 +504,11 @@ class CommunityDetector:
if hasattr(graph, 'nodes') and hasattr(graph, 'edges') and hasattr(graph, 'number_of_nodes'):
return graph
- adjacency = self._build_adjacency(graph)
+ view = build_graph_view(graph)
nx_graph = self.nx.Graph()
- for source, targets in adjacency.items():
- for target in targets:
- nx_graph.add_edge(source, target)
+ nx_graph.add_nodes_from(view.nodes)
+ nx_graph.add_edges_from(view.edges)
return nx_graph
diff --git a/semantica/kg/connectivity_analyzer.py b/semantica/kg/connectivity_analyzer.py
index 00d2ad22..463c9ed5 100644
--- a/semantica/kg/connectivity_analyzer.py
+++ b/semantica/kg/connectivity_analyzer.py
@@ -48,11 +48,12 @@ Author: Semantica Contributors
License: MIT
"""
-from collections import defaultdict, deque
+from collections import deque
from typing import Any, Dict, List, Optional, Set, Tuple
from ..utils.logging import get_logger
from ..utils.progress_tracker import get_progress_tracker
+from ._graph_view import build_adjacency
class ConnectivityAnalyzer:
@@ -385,51 +386,7 @@ class ConnectivityAnalyzer:
def _build_adjacency(self, graph) -> Dict[str, List[str]]:
"""Build adjacency list from graph."""
- adjacency = defaultdict(list)
-
- # Extract relationships
- relationships = []
- if hasattr(graph, "relationships"):
- relationships = graph.relationships
- elif hasattr(graph, "get_relationships"):
- relationships = graph.get_relationships()
- elif isinstance(graph, dict):
- relationships = graph.get("relationships", graph.get("edges", []))
-
- # Build adjacency
- for rel in relationships:
- # Handle tuple/list edges (e.g., from NetworkX)
- if isinstance(rel, (tuple, list)) and len(rel) >= 2:
- source, target = str(rel[0]), str(rel[1])
- if source and target:
- if target not in adjacency[source]:
- adjacency[source].append(target)
- if source not in adjacency[target]:
- adjacency[target].append(source)
- continue
- source = rel.get("source") or rel.get("subject")
- target = rel.get("target") or rel.get("object")
-
- # Extract IDs if objects are passed
- if source and not isinstance(source, (str, int, float)):
- if isinstance(source, dict):
- source = source.get("id") or source.get("entity_id") or source.get("text") or str(source)
- else:
- source = getattr(source, "id", getattr(source, "text", str(source)))
-
- if target and not isinstance(target, (str, int, float)):
- if isinstance(target, dict):
- target = target.get("id") or target.get("entity_id") or target.get("text") or str(target)
- else:
- target = getattr(target, "id", getattr(target, "text", str(target)))
-
- if source and target:
- if target not in adjacency[source]:
- adjacency[source].append(target)
- if source not in adjacency[target]:
- adjacency[target].append(source)
-
- return dict(adjacency)
+ return build_adjacency(graph)
def _bfs_shortest_path(
self, adjacency: Dict[str, List[str]], source: str, target: str
diff --git a/tests/kg/test_analytics_node_scope.py b/tests/kg/test_analytics_node_scope.py
new file mode 100644
index 00000000..fda5d4f8
--- /dev/null
+++ b/tests/kg/test_analytics_node_scope.py
@@ -0,0 +1,112 @@
+"""Regression tests for KG analytics node scope handling."""
+
+import networkx as nx
+
+from semantica.kg.centrality_calculator import CentralityCalculator
+from semantica.kg.community_detector import CommunityDetector
+from semantica.kg.connectivity_analyzer import ConnectivityAnalyzer
+
+
+def _graph_with_isolated_node():
+ return {
+ "entities": [{"id": "A"}, {"id": "B"}, {"id": "C"}],
+ "relationships": [{"source": "A", "target": "B"}],
+ }
+
+
+def test_centrality_keeps_declared_isolated_nodes():
+ result = CentralityCalculator().calculate_degree_centrality(
+ _graph_with_isolated_node()
+ )
+
+ assert result["total_nodes"] == 3
+ assert result["centrality"]["C"] == 0.0
+
+
+def test_connectivity_reports_declared_isolated_nodes():
+ result = ConnectivityAnalyzer().analyze_connectivity(
+ _graph_with_isolated_node()
+ )
+
+ assert result["num_nodes"] == 3
+ assert result["num_components"] == 2
+ assert ["C"] in result["components"]
+ assert result["is_connected"] is False
+
+
+def test_community_detection_keeps_declared_isolated_nodes():
+ detector = CommunityDetector()
+ result = detector.detect_communities(_graph_with_isolated_node())
+
+ assert set(result["node_assignments"]) == {"A", "B", "C"}
+ metrics = detector.calculate_community_metrics(
+ _graph_with_isolated_node(), result
+ )
+ assert metrics["num_communities"] == 2
+ structure = detector.analyze_community_structure(
+ _graph_with_isolated_node(), result
+ )
+ assert structure["num_communities"] == 2
+
+
+def test_community_detection_returns_singletons_for_edgeless_graph():
+ graph = {"entities": [{"id": "A"}, {"id": "B"}], "relationships": []}
+
+ result = CommunityDetector().detect_communities(graph)
+
+ assert {frozenset(community) for community in result["communities"]} == {
+ frozenset({"A"}),
+ frozenset({"B"}),
+ }
+
+
+def test_networkx_graph_keeps_isolated_nodes_for_analytics():
+ graph = nx.Graph()
+ graph.add_nodes_from(["A", "B", "C"])
+ graph.add_edge("A", "B")
+
+ centrality = CentralityCalculator().calculate_degree_centrality(graph)
+ connectivity = ConnectivityAnalyzer().analyze_connectivity(graph)
+
+ assert centrality["total_nodes"] == 3
+ assert centrality["centrality"]["C"] == 0.0
+ assert connectivity["num_nodes"] == 3
+ assert connectivity["num_components"] == 2
+
+
+def test_nodes_edges_payload_keeps_declared_isolated_nodes():
+ graph = {
+ "nodes": [{"id": "A"}, {"id": "B"}, {"id": "C"}],
+ "edges": [("A", "B")],
+ }
+
+ result = CentralityCalculator().calculate_degree_centrality(graph)
+
+ assert result["total_nodes"] == 3
+ assert result["centrality"]["C"] == 0.0
+
+
+def test_name_and_text_nodes_are_kept_when_ids_are_missing():
+ graph = {
+ "entities": [{"name": "Alice"}, {"text": "Bob"}],
+ "relationships": [],
+ }
+
+ result = CentralityCalculator().calculate_degree_centrality(graph)
+
+ assert result["total_nodes"] == 2
+ assert set(result["centrality"]) == {"Alice", "Bob"}
+
+
+def test_community_metrics_accepts_communities_payload():
+ detector = CommunityDetector()
+ graph = {
+ "entities": [{"id": "A"}, {"id": "B"}, {"id": "C"}],
+ "relationships": [{"source": "A", "target": "B"}],
+ }
+ result = {"communities": [["A", "B"], ["C"]]}
+
+ metrics = detector.calculate_community_metrics(graph, result)
+
+ assert metrics["num_communities"] == 2
+ assert metrics["community_sizes"] == {0: 2, 1: 1}
From 15171fd31a61a488391ffac97efcfd0ef97ea553 Mon Sep 17 00:00:00 2001
From: pravit-amp <43916793+pravit-amp@users.noreply.github.com>
Date: Sun, 16 Aug 2026 02:07:10 -0700
Subject: [PATCH 3/7] fix(parse): import get_progress_tracker in ExcelParser
(#1016)
ExcelParser.__init__ called get_progress_tracker() without importing it,
so every instantiation raised NameError and the class was unusable. The
existing test imported ExcelParser but never constructed it, so nothing
caught it. Same defect as #530 in SimilarityCalculator, which was fixed
without sweeping the rest of the codebase.
Add construction coverage for every parser exported from semantica.parse,
driven off __all__ so later additions are covered automatically. These
live outside test_parse_comprehensive.py, whose setUp patches
get_progress_tracker into each parse module and would mock away the
interaction under test.
Closes #1014
Co-authored-by: Pravit Ampapathini
---
semantica/parse/excel_parser.py | 1 +
tests/parse/test_parser_construction.py | 73 +++++++++++++++++++++++++
2 files changed, 74 insertions(+)
create mode 100644 tests/parse/test_parser_construction.py
diff --git a/semantica/parse/excel_parser.py b/semantica/parse/excel_parser.py
index efd84aa5..b8943939 100644
--- a/semantica/parse/excel_parser.py
+++ b/semantica/parse/excel_parser.py
@@ -37,6 +37,7 @@ from openpyxl import load_workbook
from ..utils.exceptions import ProcessingError, ValidationError
from ..utils.logging import get_logger
+from ..utils.progress_tracker import get_progress_tracker
@dataclass
diff --git a/tests/parse/test_parser_construction.py b/tests/parse/test_parser_construction.py
new file mode 100644
index 00000000..b649954b
--- /dev/null
+++ b/tests/parse/test_parser_construction.py
@@ -0,0 +1,73 @@
+"""Construction coverage for the parse module's public parser classes.
+
+Regression tests for #1014: ``ExcelParser.__init__`` called ``get_progress_tracker()``
+without importing it, so every instantiation raised ``NameError``. The class was
+covered by an import-only test, which passes regardless of whether ``__init__``
+works, so nothing caught it. #530 was the same bug in ``SimilarityCalculator``.
+
+These tests deliberately do **not** patch ``get_logger``/``get_progress_tracker``.
+``tests/parse/test_parse_comprehensive.py`` patches both into every parse module
+that exposes them, which would mock away the exact interaction under test here and
+let the regression back in silently.
+"""
+
+import unittest
+
+import semantica.parse as parse_module
+from semantica.parse.excel_parser import ExcelParser
+
+
+def _exported_parser_classes():
+ """Public parser classes, taken from the package's own ``__all__``.
+
+ Driven off ``__all__`` rather than a hand-written list so a parser added later
+ is covered without anyone remembering to update this file.
+ """
+ return [
+ (name, getattr(parse_module, name))
+ for name in parse_module.__all__
+ if name.endswith("Parser")
+ ]
+
+
+class TestExcelParserConstruction(unittest.TestCase):
+ """ExcelParser must be constructible -- see #1014."""
+
+ def test_excel_parser_constructs(self):
+ parser = ExcelParser()
+ self.assertIsNotNone(parser)
+
+ def test_excel_parser_wires_progress_tracker(self):
+ """The missing import was for the tracker, so assert it is actually set.
+
+ A bare construction check would pass against a version that dropped the
+ tracker call entirely; this pins the attribute the import exists to provide.
+ """
+ parser = ExcelParser()
+ self.assertIsNotNone(parser.progress_tracker)
+
+
+class TestExportedParsersConstruct(unittest.TestCase):
+ """Every parser the package exports must survive ``__init__``."""
+
+ def test_all_exported_parsers_construct(self):
+ classes = _exported_parser_classes()
+ self.assertGreater(len(classes), 0, "no exported parser classes found")
+
+ for name, cls in classes:
+ with self.subTest(parser=name):
+ try:
+ self.assertIsNotNone(cls())
+ except ImportError as exc:
+ # Parsers backed by an optional dependency raise a deliberate,
+ # actionable ImportError when it is absent (e.g. DoclingParser
+ # without `docling`). That is correct behavior, not a defect.
+ self.assertIn(
+ "install",
+ str(exc).lower(),
+ f"{name} raised ImportError without install guidance: {exc}",
+ )
+
+
+if __name__ == "__main__":
+ unittest.main()
From c53ca4e84bd183fcf3aa708b56dadb86ffa88901 Mon Sep 17 00:00:00 2001
From: Mohd Kaif <98801504+KaifAhmad1@users.noreply.github.com>
Date: Sun, 16 Aug 2026 15:18:55 +0530
Subject: [PATCH 4/7] docs: formalize issue assignment and duplicate-PR triage
workflow (#1030)
* docs(contributing): formalize issue assignment and duplicate-PR triage workflow
Comments are no longer required before an issue can be assigned - maintainers
may assign directly based on recent activity. Also documents the duplicate-PR
priority order for triage (contributor PR, claimed issue, activity tiebreak,
late duplicates, overlapping scope).
* docs(contributing): clarify assignment precedence and define activity tiebreak
Addresses Qodo review feedback on PR #1030: the duplicate-PR priority list
now states these rules apply on top of the assignment workflow (opening a PR
pre-assignment doesn't grant priority), and the "most active" tiebreak now
specifies a concrete 60-day window and signals instead of being subjective.
---
.github/pull_request_template.md | 2 +-
CONTRIBUTING.md | 20 +++++++++++++++++---
2 files changed, 18 insertions(+), 4 deletions(-)
diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md
index 66f38cd9..b4d34e46 100644
--- a/.github/pull_request_template.md
+++ b/.github/pull_request_template.md
@@ -1,4 +1,4 @@
-> **Before you submit:** make sure you followed the [issue workflow in CONTRIBUTING.md](https://github.com/semantica-agi/semantica/blob/main/CONTRIBUTING.md#-working-on-an-existing-issue) — comment on the issue and wait for assignment before opening a PR, to avoid duplicate work.
+> **Before you submit:** make sure you followed the [issue workflow in CONTRIBUTING.md](https://github.com/semantica-agi/semantica/blob/main/CONTRIBUTING.md#-working-on-an-existing-issue) — wait for the issue to be assigned to you before opening a PR, to avoid duplicate work.
## Description
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index 3edcc239..8a6c5c44 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -25,9 +25,9 @@ If you want to work on an open GitHub issue, please follow these steps to keep t
1. **Check the issue.** Look at the issue's assignees and recent comments. If someone is already actively working on it, consider a different issue or ask in the comments whether help is welcome.
-2. **Comment before you start.** Leave a comment on the issue saying you'd like to work on it — something like *"I'd like to take this on"* is enough. This gives maintainers the context they need to assign the issue appropriately.
+2. **Comment if you'd like the issue reserved.** Leaving a comment like *"I'd like to take this on"* is the fastest way to get assigned, but it isn't required — maintainers can also assign an issue directly to a contributor (e.g., based on recent activity in the repo) without waiting for a comment first.
-3. **Wait for assignment.** A maintainer will review the request and assign the issue when appropriate. Please wait for this before investing significant time in implementation, as priorities and approaches can shift.
+3. **Wait for assignment.** A maintainer will assign the issue when appropriate, whether or not a comment was left. Please wait for this before investing significant time in implementation, as priorities and approaches can shift.
4. **Create a branch and implement.** Once assigned, fork the repository (if you haven't already), create a dedicated branch, and begin your work.
@@ -37,12 +37,26 @@ If you want to work on an open GitHub issue, please follow these steps to keep t
5. **Open a focused PR and link the issue.** When you're ready, open a pull request and reference the issue in the description (e.g., `Closes #123`). Keep the PR scoped to the work described in the issue.
-> **Why this matters:** Commenting before opening a PR helps maintainers track who is working on what, assign issues correctly, and prevent two contributors from solving the same problem independently. It also gives you a chance to align on the expected approach before writing code.
+> **Why this matters:** Assignment (with or without a comment) helps maintainers track who is working on what and prevent two contributors from solving the same problem independently. It also gives you a chance to align on the expected approach before writing code.
Not sure where to start? Try a [`good first issue`](https://github.com/semantica-agi/semantica/labels/good%20first%20issue) or ask in [Discord](https://discord.gg/sV34vps5hH).
---
+## 🔀 Duplicate PRs & Issue Priority
+
+When more than one pull request targets the same issue, maintainers triage using this order of priority. These rules decide between PRs that are otherwise following the [assignment workflow above](#-working-on-an-existing-issue) — opening a PR before being assigned doesn't grant priority on its own, and an unassigned PR can still be closed as a duplicate once someone else is assigned to the issue.
+
+1. **Contributor-raised issue with an existing PR.** If the person who opened the issue has also opened a PR for it, that PR is prioritized (they still need to be assigned before it's merged).
+2. **Maintainer-raised issue with a claim comment.** If we opened the issue and someone has commented asking to work on it, we assign it to them and check their PR before picking up any other PR for the same issue.
+3. **No prior assignment or comment.** If multiple PRs exist and no one was assigned or claimed the issue first, priority goes to whichever contributor has the most consistent activity in the repo over the last 60 days (e.g., merged PRs, substantive reviews, or issue triage participation) — not just PR volume.
+4. **Late duplicate PRs.** If a PR is opened after another contributor has already been assigned to the issue, we close the duplicate early rather than let it sit open, and point the author to another open issue (or ask them to check `main` for newly opened ones). This avoids contributors spending time updating a PR that won't be merged.
+5. **Overlapping scope.** If a PR covers multiple issues, or there's genuine overlap between competing PRs, maintainers discuss it on [Discord](https://discord.gg/sV34vps5hH) before deciding rather than resolving it unilaterally.
+
+**Why this matters:** it keeps triage predictable, avoids wasted contributor effort on PRs that won't merge, and helps retain active contributors.
+
+---
+
## 🎯 Ways to Contribute
### 💻 Code
From 70aa9d01bf6cf9dac735b344f4a7354fad700b8b Mon Sep 17 00:00:00 2001
From: hari
Date: Sun, 16 Aug 2026 15:24:34 +0530
Subject: [PATCH 5/7] fix(normalize): validate symbol currencies (#940)
* fix(normalize): validate symbol currencies
Signed-off-by: Mr-Neutr0n
* fix(normalize): match currency codes by token boundaries
Signed-off-by: Mr-Neutr0n
---------
Signed-off-by: Mr-Neutr0n
Co-authored-by: Zohaib Hassnain <109234410+ZohaibHassan16@users.noreply.github.com>
---
semantica/normalize/number_normalizer.py | 17 ++++++++++++-----
tests/normalize/test_number_normalizer.py | 18 ++++++++++++++++++
2 files changed, 30 insertions(+), 5 deletions(-)
diff --git a/semantica/normalize/number_normalizer.py b/semantica/normalize/number_normalizer.py
index f7876b7c..29911cb9 100644
--- a/semantica/normalize/number_normalizer.py
+++ b/semantica/normalize/number_normalizer.py
@@ -562,6 +562,11 @@ class CurrencyNormalizer:
"SEK",
"NOK",
"DKK",
+ "RUB",
+ "KRW",
+ "ILS",
+ "NGN",
+ "PKR",
]
self.logger.debug("Currency normalizer initialized")
@@ -606,13 +611,15 @@ class CurrencyNormalizer:
# Check for currency code
if not currency_code:
for code in self.currency_codes:
- if code in currency_input.upper():
+ match = re.search(
+ rf"(?
Date: Sun, 16 Aug 2026 17:44:02 +0530
Subject: [PATCH 6/7] docs: clarify explainability is system-level, not
foundation-model internal (#1033)
Adds a consistent scope note to README and docs (concepts, FAQ, index)
stating Semantica does not expose or reconstruct an LLM's internal
reasoning/chain-of-thought. It explains and audits the AI system
around the model: context, provenance, policies, decisions, and
execution history.
---
README.md | 2 ++
docs/concepts.md | 3 +++
docs/faq.md | 10 ++++++++++
docs/index.md | 6 +++++-
4 files changed, 20 insertions(+), 1 deletion(-)
diff --git a/README.md b/README.md
index fe3272ea..58afaf3f 100644
--- a/README.md
+++ b/README.md
@@ -1498,6 +1498,8 @@ Semantica is designed for environments where AI outputs must be explainable, aud
- **Cybersecurity:** Threat attribution, incident response timelines, and IOC provenance tracking
- **Autonomous Systems:** Decision logs, safety validation, and explainable AI for certification
+> ⚠️ **This is system-level explainability, not foundation-model explainability.** Semantica does not expose, reconstruct, or explain what happens *inside* the LLM/foundation model — its internal reasoning or chain-of-thought stays opaque, as it does for any external system. What Semantica explains is *outside* the model: the context and data fed in, the decision produced, its provenance, the relevant relationships, the policies applied, and the full execution trail. In short, Semantica explains and audits what the AI system did, not the LLM's private internal reasoning.
+
---
## Installation
diff --git a/docs/concepts.md b/docs/concepts.md
index 698ef7d9..e05564f5 100644
--- a/docs/concepts.md
+++ b/docs/concepts.md
@@ -16,6 +16,9 @@ At its core, Semantica adds a **context and accountability layer** on top of you
- **Accountability Layer** — Provenance tracking, decision intelligence, conflict detection, and W3C PROV-O compliance make every claim in your AI stack auditable and explainable.
- **Extension Layer** — `PluginRegistry` and `MethodRegistry` let you replace or augment any component: ingestors, extractors, reasoning engines, backends: without changing framework code.
+
+ **This is system-level explainability, not foundation-model explainability.** Semantica does not expose, reconstruct, or explain what happens *inside* the LLM/foundation model — its internal reasoning or chain-of-thought stays opaque, as it does for any external system. What Semantica explains is *outside* the model: the context and data fed in, the decision produced, its provenance, the relevant relationships, the policies applied, and the full execution trail. In short, Semantica explains and audits *what the AI system did*, not the foundation model's private internal reasoning.
+
## Knowledge Graphs
diff --git a/docs/faq.md b/docs/faq.md
index e050df4c..05c708c1 100644
--- a/docs/faq.md
+++ b/docs/faq.md
@@ -52,6 +52,16 @@ Semantica works alongside these frameworks, not against them.
+
+
+No. This is **system-level explainability, not foundation-model explainability**. Semantica does not expose, reconstruct, or explain what happens *inside* the LLM/foundation model — its internal reasoning or chain-of-thought stays opaque, as it does for any external system.
+
+What Semantica explains is *outside* the model: what context and data were used, what decision was produced, the provenance behind it, the relevant relationships, the policies applied, and the resulting decision trail.
+
+In short: Semantica explains and audits *what the AI system did* — not the foundation model's private internal reasoning.
+
+
+
Yes: MIT licensed, no vendor lock-in, no paywalled features. Some capabilities require third-party API keys (e.g., OpenAI embeddings, Groq inference), but Semantica itself is always free and open source.
diff --git a/docs/index.md b/docs/index.md
index 80ce7b12..a16d0107 100644
--- a/docs/index.md
+++ b/docs/index.md
@@ -192,7 +192,11 @@ decision_id = context.record_decision(
## Built for Where Mistakes Have Consequences
-Semantica was designed for domains where every decision must be explainable and every fact must be traceable:
+Semantica was designed for domains where every decision must be explainable and every fact must be traceable.
+
+
+ **This is system-level explainability, not foundation-model explainability.** Semantica does not expose, reconstruct, or explain what happens *inside* the LLM/foundation model — its internal reasoning or chain-of-thought stays opaque, as it does for any external system. What Semantica explains is *outside* the model: the context and data fed in, the decision produced, its provenance, the relevant relationships, the policies applied, and the full execution trail. See [Core Concepts](concepts) for the full scope note.
+
**Healthcare & Life Sciences**
- Clinical decision support with full audit trails
From 4d37920007e75289fe80a40651250cb6aa27cc10 Mon Sep 17 00:00:00 2001
From: Mohd Kaif <98801504+KaifAhmad1@users.noreply.github.com>
Date: Sun, 16 Aug 2026 17:51:23 +0530
Subject: [PATCH 7/7] docs: surface explainability scope note near the top of
the README (#1034)
Moves a concise version of the system-level vs. foundation-model
explainability clarification up next to the opening pitch, so it's
visible before readers scroll to the high-stakes-domains section.
---
README.md | 2 ++
1 file changed, 2 insertions(+)
diff --git a/README.md b/README.md
index 58afaf3f..8b89fd5f 100644
--- a/README.md
+++ b/README.md
@@ -52,6 +52,8 @@ Most AI agents act without a trail. They store embeddings, not meaning: context
Semantica sits underneath your LLM, vector store, and agent framework as a deterministic infrastructure layer: no LLM required for graph construction, reasoning, or provenance.
+> ⚠️ **System-level explainability, not foundation-model explainability.** Semantica does not expose or reconstruct what happens *inside* the LLM — its internal reasoning or chain-of-thought stays opaque, as it does for any external system. Semantica explains what's *outside* the model: the context and data fed in, the decision produced, its provenance, relevant relationships, applied policies, and the full execution trail.
+
**Who it's for:**
- **AI/ML platform teams** shipping agents that make consequential decisions and need structured, queryable context built from fragmented raw data, not just a vector index
|