mirror of
https://github.com/semantica-agi/semantica.git
synced 2026-08-29 04:26:20 +00:00
Implements the full Semantica × Agno integration stack as described in issue #249, wiring Semantica's semantic intelligence layer into Agno's agent/team primitives via five focused components. ## New components ### integrations/agno/ - `AgnoContextStore` — graph-backed MemoryDb (AgentMemory/storage) - `AgnoKnowledgeGraph` — relational AgentKnowledge with multi-hop GraphRAG - `AgnoDecisionKit` — Agno Toolkit: 6 decision-intelligence tools - `AgnoKGToolkit` — Agno Toolkit: 7 knowledge-graph tools - `AgnoSharedContext` — team-level shared ContextGraph with role scoping ### tests/integrations/agno/ - 110 tests, 0 failures - conftest.py installs comprehensive agno stubs for offline testing - Covers MemoryDb protocol, tool registration, shared memory pool, thread-safety, GraphRAG search, NER/relation extraction, and inference ### cookbook/integrations/ - agno_decision_intelligence.ipynb (finance/loan underwriting) - agno_graphrag_context.ipynb (regulatory compliance GraphRAG) - agno_multi_agent_shared_context.ipynb (multi-agent product strategy team) ### docs/integrations/agno.md - Full reference documentation with examples for all 5 components ## pyproject.toml - Added `agno = ["agno>=1.0.0"]` optional dependency - Added agno to the `all` extra ## Design notes - Zero breaking changes — fully additive - Graceful degradation when agno is not installed - Auto-creates VectorStore(backend="faiss") when none provided - _tools always populated for inspection regardless of agno install state - Works with both real agno package and offline stubs Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
439 lines
16 KiB
Python
439 lines
16 KiB
Python
"""
|
|
AgnoKGToolkit — Knowledge Graph Toolkit for Agno agents.
|
|
|
|
Lets agents actively build and query the context graph as part of their
|
|
reasoning loop. Backed by Semantica's ``NERExtractor``, ``RelationExtractor``,
|
|
``Reasoner``, and ``ContextGraph``.
|
|
|
|
Install
|
|
-------
|
|
pip install semantica[agno]
|
|
|
|
Example
|
|
-------
|
|
>>> from integrations.agno import AgnoKGToolkit
|
|
>>> from agno.agent import Agent
|
|
>>> agent = Agent(tools=[AgnoKGToolkit()], show_tool_calls=True)
|
|
|
|
Tools exposed
|
|
-------------
|
|
extract_entities — Extract named entities from text
|
|
extract_relations — Extract relationships between entities
|
|
add_to_graph — Add entities / relations to the context graph
|
|
query_graph — Query the graph (natural-language or Cypher)
|
|
find_related — Find concepts related to a given entity
|
|
infer_facts — Apply rules to infer new facts from the graph
|
|
export_subgraph — Export a subgraph as JSON-LD / RDF Turtle
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
from typing import Any, Dict, List, Optional
|
|
|
|
from semantica.utils.logging import get_logger
|
|
|
|
logger = get_logger(__name__)
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Optional: Agno Toolkit base class
|
|
# ---------------------------------------------------------------------------
|
|
AGNO_AVAILABLE = False
|
|
AGNO_IMPORT_ERROR: Optional[str] = None
|
|
|
|
_ToolkitBase: Any = object
|
|
|
|
try:
|
|
from agno.tools.toolkit import Toolkit as _AgnoToolkit # type: ignore
|
|
|
|
_ToolkitBase = _AgnoToolkit
|
|
AGNO_AVAILABLE = True
|
|
except ImportError as exc:
|
|
AGNO_IMPORT_ERROR = str(exc)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# AgnoKGToolkit
|
|
# ---------------------------------------------------------------------------
|
|
class AgnoKGToolkit(_ToolkitBase): # type: ignore[misc]
|
|
"""
|
|
Agno Toolkit that surfaces Semantica's KG pipeline as agent tools.
|
|
|
|
Parameters
|
|
----------
|
|
graph_store_backend:
|
|
Storage backend for the internal ``ContextGraph``. One of
|
|
``"inmemory"`` (default), ``"neo4j"``, ``"falkordb"``.
|
|
ner_extractor:
|
|
A ``semantica.semantic_extract.NERExtractor`` instance; auto-created
|
|
when ``None``.
|
|
relation_extractor:
|
|
A ``semantica.semantic_extract.RelationExtractor`` instance; auto-
|
|
created when ``None``.
|
|
reasoner:
|
|
A ``semantica.reasoning.Reasoner`` instance; auto-created when
|
|
``None``.
|
|
context:
|
|
An existing ``AgentContext`` or ``ContextGraph`` to attach to. A
|
|
fresh in-memory ``ContextGraph`` is used when ``None``.
|
|
"""
|
|
|
|
def __init__(
|
|
self,
|
|
graph_store_backend: str = "inmemory",
|
|
ner_extractor: Any = None,
|
|
relation_extractor: Any = None,
|
|
reasoner: Any = None,
|
|
context: Any = None,
|
|
**kwargs: Any,
|
|
) -> None:
|
|
if AGNO_AVAILABLE:
|
|
super().__init__(name="kg_toolkit", **kwargs) # type: ignore[call-arg]
|
|
|
|
# Always initialise _tools so the attribute exists regardless of agno
|
|
if not hasattr(self, "_tools"):
|
|
self._tools: list = []
|
|
|
|
# Lazy imports
|
|
from semantica.context import ContextGraph
|
|
from semantica.reasoning import Reasoner
|
|
from semantica.semantic_extract import NERExtractor, RelationExtractor
|
|
|
|
if context is not None:
|
|
self._graph = getattr(context, "knowledge_graph", context)
|
|
else:
|
|
self._graph = ContextGraph()
|
|
|
|
self._ner = ner_extractor or NERExtractor()
|
|
self._rel = relation_extractor or RelationExtractor()
|
|
self._reasoner = reasoner or Reasoner()
|
|
|
|
# Register tools.
|
|
# _tools is always kept as a plain list so callers can inspect registered
|
|
# tools regardless of whether agno is installed. When agno IS available
|
|
# we also call Toolkit.register() so the real agno runtime picks them up.
|
|
tools_to_register = [
|
|
self.extract_entities,
|
|
self.extract_relations,
|
|
self.add_to_graph,
|
|
self.query_graph,
|
|
self.find_related,
|
|
self.infer_facts,
|
|
self.export_subgraph,
|
|
]
|
|
for fn in tools_to_register:
|
|
self._tools.append(fn)
|
|
if AGNO_AVAILABLE:
|
|
try:
|
|
self.register(fn)
|
|
except Exception:
|
|
pass
|
|
|
|
logger.info("AgnoKGToolkit initialised (backend=%s)", graph_store_backend)
|
|
|
|
# ------------------------------------------------------------------
|
|
# Tools
|
|
# ------------------------------------------------------------------
|
|
|
|
def extract_entities(self, text: str) -> str:
|
|
"""
|
|
Extract named entities from the given text.
|
|
|
|
Parameters
|
|
----------
|
|
text:
|
|
Input text to analyse.
|
|
|
|
Returns
|
|
-------
|
|
str
|
|
JSON list of ``{"name": str, "type": str, "confidence": float}``.
|
|
"""
|
|
try:
|
|
raw = self._ner.extract_entities(text) or []
|
|
entities = [
|
|
{
|
|
"name": getattr(e, "name", str(e)),
|
|
"type": getattr(e, "type", ""),
|
|
"confidence": round(float(getattr(e, "confidence", 1.0)), 4),
|
|
}
|
|
for e in raw
|
|
]
|
|
logger.debug("extract_entities → %d entities", len(entities))
|
|
return json.dumps({"entities": entities, "count": len(entities)})
|
|
except Exception as exc:
|
|
logger.warning("extract_entities failed: %s", exc)
|
|
return json.dumps({"entities": [], "count": 0, "error": str(exc)})
|
|
|
|
def extract_relations(self, text: str, entities: Optional[str] = None) -> str:
|
|
"""
|
|
Extract relationships between entities in the given text.
|
|
|
|
Parameters
|
|
----------
|
|
text:
|
|
Input text to analyse.
|
|
entities:
|
|
Optional JSON list of entity names to restrict extraction to.
|
|
|
|
Returns
|
|
-------
|
|
str
|
|
JSON list of ``{"source": str, "relation": str, "target": str, "confidence": float}``.
|
|
"""
|
|
entity_list: Optional[List[str]] = None
|
|
if entities:
|
|
try:
|
|
entity_list = json.loads(entities)
|
|
except json.JSONDecodeError:
|
|
entity_list = [e.strip() for e in entities.split(",") if e.strip()]
|
|
|
|
try:
|
|
raw = self._rel.extract_relations(text, entities=entity_list) or []
|
|
relations = [
|
|
{
|
|
"source": getattr(r, "source", ""),
|
|
"relation": getattr(r, "type", getattr(r, "relation", "")),
|
|
"target": getattr(r, "target", ""),
|
|
"confidence": round(float(getattr(r, "confidence", 1.0)), 4),
|
|
}
|
|
for r in raw
|
|
]
|
|
logger.debug("extract_relations → %d relations", len(relations))
|
|
return json.dumps({"relations": relations, "count": len(relations)})
|
|
except Exception as exc:
|
|
logger.warning("extract_relations failed: %s", exc)
|
|
return json.dumps({"relations": [], "count": 0, "error": str(exc)})
|
|
|
|
def add_to_graph(
|
|
self,
|
|
entities: Optional[str] = None,
|
|
relations: Optional[str] = None,
|
|
) -> str:
|
|
"""
|
|
Add entities and/or relations to the active context graph.
|
|
|
|
Parameters
|
|
----------
|
|
entities:
|
|
JSON list of ``{"name": str, "type": str}`` objects.
|
|
relations:
|
|
JSON list of ``{"source": str, "relation": str, "target": str}`` objects.
|
|
|
|
Returns
|
|
-------
|
|
str
|
|
JSON summary of nodes and edges added.
|
|
"""
|
|
nodes_added = 0
|
|
edges_added = 0
|
|
|
|
if entities:
|
|
try:
|
|
ent_list = json.loads(entities) if isinstance(entities, str) else entities
|
|
for ent in ent_list:
|
|
name = ent.get("name", str(ent))
|
|
ntype = ent.get("type", "Entity")
|
|
try:
|
|
self._graph.add_node(label=name, node_type=ntype) # type: ignore[attr-defined]
|
|
nodes_added += 1
|
|
except Exception:
|
|
pass
|
|
except (json.JSONDecodeError, AttributeError) as exc:
|
|
logger.debug("add_to_graph entities parse error: %s", exc)
|
|
|
|
if relations:
|
|
try:
|
|
rel_list = json.loads(relations) if isinstance(relations, str) else relations
|
|
for rel in rel_list:
|
|
src = rel.get("source", "")
|
|
tgt = rel.get("target", "")
|
|
rel_type = rel.get("relation", "RELATED_TO")
|
|
try:
|
|
self._graph.add_edge(src, tgt, edge_type=rel_type) # type: ignore[attr-defined]
|
|
edges_added += 1
|
|
except Exception:
|
|
pass
|
|
except (json.JSONDecodeError, AttributeError) as exc:
|
|
logger.debug("add_to_graph relations parse error: %s", exc)
|
|
|
|
logger.debug("add_to_graph: +%d nodes, +%d edges", nodes_added, edges_added)
|
|
return json.dumps({"nodes_added": nodes_added, "edges_added": edges_added})
|
|
|
|
def query_graph(self, query: str) -> str:
|
|
"""
|
|
Query the context graph in natural language or Cypher.
|
|
|
|
For natural-language queries a keyword-based node lookup is performed.
|
|
Pass a string starting with ``"MATCH"`` for raw Cypher execution
|
|
(requires a Neo4j / FalkorDB backend).
|
|
|
|
Parameters
|
|
----------
|
|
query:
|
|
Search query string.
|
|
|
|
Returns
|
|
-------
|
|
str
|
|
JSON list of matching nodes / records.
|
|
"""
|
|
try:
|
|
if query.strip().upper().startswith("MATCH"):
|
|
# Cypher path
|
|
try:
|
|
result = self._graph.execute_query(query) # type: ignore[attr-defined]
|
|
records = result if isinstance(result, list) else [str(result)]
|
|
return json.dumps({"results": records, "query_type": "cypher"})
|
|
except AttributeError:
|
|
return json.dumps({"error": "Cypher queries require a Neo4j/FalkorDB backend", "query_type": "cypher"})
|
|
else:
|
|
# Natural-language keyword lookup
|
|
nodes = self._graph.find_nodes(label=query) # type: ignore[attr-defined]
|
|
out = [
|
|
{
|
|
"label": getattr(n, "label", str(n)),
|
|
"type": getattr(n, "node_type", ""),
|
|
"id": getattr(n, "id", ""),
|
|
}
|
|
for n in (nodes or [])
|
|
]
|
|
return json.dumps({"results": out, "count": len(out), "query_type": "keyword"})
|
|
except Exception as exc:
|
|
logger.warning("query_graph failed: %s", exc)
|
|
return json.dumps({"results": [], "error": str(exc)})
|
|
|
|
def find_related(self, entity: str, hops: int = 1) -> str:
|
|
"""
|
|
Find concepts related to ``entity`` within ``hops`` graph hops.
|
|
|
|
Parameters
|
|
----------
|
|
entity:
|
|
The entity name to start from.
|
|
hops:
|
|
Maximum number of relationship hops to traverse.
|
|
|
|
Returns
|
|
-------
|
|
str
|
|
JSON list of related entity names.
|
|
"""
|
|
try:
|
|
related: List[str] = []
|
|
frontier = [entity]
|
|
visited = {entity}
|
|
|
|
for _ in range(max(1, hops)):
|
|
next_frontier: List[str] = []
|
|
for e in frontier:
|
|
try:
|
|
neighbours = self._graph.get_neighbours(e) # type: ignore[attr-defined]
|
|
for n in (neighbours or []):
|
|
label = getattr(n, "label", str(n))
|
|
if label not in visited:
|
|
visited.add(label)
|
|
next_frontier.append(label)
|
|
related.append(label)
|
|
except Exception:
|
|
pass
|
|
frontier = next_frontier
|
|
|
|
logger.debug("find_related('%s', hops=%d) → %d", entity, hops, len(related))
|
|
return json.dumps({"entity": entity, "related": related, "count": len(related)})
|
|
except Exception as exc:
|
|
logger.warning("find_related failed: %s", exc)
|
|
return json.dumps({"entity": entity, "related": [], "error": str(exc)})
|
|
|
|
def infer_facts(self, rules: str, facts: Optional[str] = None) -> str:
|
|
"""
|
|
Apply inference rules to the graph and return newly derived facts.
|
|
|
|
Parameters
|
|
----------
|
|
rules:
|
|
JSON list of rule strings, e.g.
|
|
``'["IF Person(?x) THEN Human(?x)"]'``
|
|
facts:
|
|
Optional JSON list of additional fact strings to load before
|
|
inference. When ``None``, the current graph state is used.
|
|
|
|
Returns
|
|
-------
|
|
str
|
|
JSON list of inferred fact strings.
|
|
"""
|
|
try:
|
|
rule_list: List[str] = json.loads(rules) if rules else []
|
|
except json.JSONDecodeError:
|
|
rule_list = [r.strip() for r in rules.split(",") if r.strip()]
|
|
|
|
fact_list: List[str] = []
|
|
if facts:
|
|
try:
|
|
fact_list = json.loads(facts)
|
|
except json.JSONDecodeError:
|
|
fact_list = [f.strip() for f in facts.split(",") if f.strip()]
|
|
|
|
if not fact_list:
|
|
# Derive facts from graph nodes
|
|
try:
|
|
nodes = getattr(self._graph, "_nodes", {})
|
|
for nid, node in list(nodes.items())[:50]:
|
|
label = getattr(node, "label", str(nid))
|
|
ntype = getattr(node, "node_type", "Entity")
|
|
fact_list.append(f"{ntype}({label})")
|
|
except Exception:
|
|
pass
|
|
|
|
try:
|
|
result = self._reasoner.infer_facts(fact_list, rule_list)
|
|
inferred = getattr(result, "inferred_facts", []) or []
|
|
inferred_strs = [str(f) for f in inferred]
|
|
logger.debug("infer_facts → %d new facts", len(inferred_strs))
|
|
return json.dumps({"inferred_facts": inferred_strs, "count": len(inferred_strs)})
|
|
except Exception as exc:
|
|
logger.warning("infer_facts failed: %s", exc)
|
|
return json.dumps({"inferred_facts": [], "error": str(exc)})
|
|
|
|
def export_subgraph(
|
|
self,
|
|
entity: Optional[str] = None,
|
|
format: str = "json-ld",
|
|
) -> str:
|
|
"""
|
|
Export a subgraph centred on ``entity`` as RDF / JSON-LD.
|
|
|
|
Parameters
|
|
----------
|
|
entity:
|
|
Root entity of the subgraph. The whole graph is exported when
|
|
``None``.
|
|
format:
|
|
Output format: ``"json-ld"`` (default), ``"turtle"`` / ``"ttl"``,
|
|
``"xml"``, ``"nt"``.
|
|
|
|
Returns
|
|
-------
|
|
str
|
|
Serialised subgraph in the requested format (JSON string wrapper).
|
|
"""
|
|
try:
|
|
from semantica.export import RDFExporter # lazy import
|
|
|
|
exporter = RDFExporter()
|
|
rdf_format = {"ttl": "turtle", "json-ld": "json-ld", "xml": "xml", "nt": "nt"}.get(format, format)
|
|
output = exporter.export_to_rdf(self._graph, format=rdf_format) # type: ignore[arg-type]
|
|
return json.dumps({"format": rdf_format, "data": output})
|
|
except Exception as exc:
|
|
logger.warning("export_subgraph failed: %s", exc)
|
|
# Fallback: return graph as plain JSON
|
|
try:
|
|
nodes = [
|
|
{"id": getattr(n, "id", k), "label": getattr(n, "label", k)}
|
|
for k, n in getattr(self._graph, "_nodes", {}).items()
|
|
]
|
|
return json.dumps({"format": "json", "nodes": nodes, "note": str(exc)})
|
|
except Exception:
|
|
return json.dumps({"format": format, "data": "", "error": str(exc)})
|