mirror of
https://github.com/semantica-agi/semantica.git
synced 2026-08-29 04:26:20 +00:00
Package & distribution - pyproject.toml: add integrations* to packages.find include so pip install semantica[agno] ships the integration context_store.py - upsert_memory(): run NERExtractor after store() to index entities into the ContextGraph - delete_memory() / drop_table() / clear(): call AgentContext.forget() to propagate deletions to vector/graph storage - find_precedents(): pass limit parameter to find_precedents_advanced() - retrieve(): pass limit as max_results to AgentContext.retrieve() - add get_context_for_prompt() for automatic system-prompt injection knowledge_graph.py - __init__: wire graph_builder.graph_store = self._graph so build() persists into the ContextGraph - add internal AgentContext for vector retrieval (shared ContextGraph) - search(): use AgentContext.retrieve() for vector similarity; keyword scoring as fallback - _ingest_text(): add paragraph-level chunking before NER/relation extraction (parse → split → NER → relation extract → graph build) - get_graph_context(): return structured subgraph with edge types via ContextGraph.get_neighbors() - load_urls(): validate scheme (http/https only) to prevent SSRF decision_kit.py - check_policy(): replace broken PolicyEngine.check_compliance() call with inline _eval_rule() that evaluates simple field-op-value rules; return compliant=False (not True) on failure — closes security bug kg_toolkit.py - add_to_graph(): fix add_node(node_id=, node_type=) and add_edge(source_id=, target_id=, edge_type=) to match real API - query_graph(): use find_nodes() (no label param) + keyword filter - find_related(): use get_neighbors(node_id=) returning List[Dict] - infer_facts() / export_subgraph(): use find_nodes() public API instead of private _nodes dict shared_context.py - _AgentScopedStore: store shared context as self._context (not self._ctx) so all inherited AgnoContextStore methods work correctly tests/integrations/agno/test_kg_toolkit.py - _FakeGraph: rewrite to match real ContextGraph signatures — find_nodes(node_type=), add_node(node_id, node_type, **), add_edge(source_id, target_id, edge_type, **), get_neighbors(node_id, hops=1, ...) returning List[Dict] Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
426 lines
14 KiB
Python
426 lines
14 KiB
Python
"""
|
|
AgnoDecisionKit — Decision Intelligence Toolkit for Agno agents.
|
|
|
|
Exposes Semantica's decision intelligence as native Agno tools so that agents
|
|
can actively record, query, and validate decisions during their reasoning loop.
|
|
|
|
Follows Agno's ``Toolkit`` pattern — each method decorated with ``@register``
|
|
(or manually registered via ``self.register()``) becomes a tool the LLM can
|
|
call.
|
|
|
|
Install
|
|
-------
|
|
pip install semantica[agno]
|
|
|
|
Example
|
|
-------
|
|
>>> from semantica.context import AgentContext
|
|
>>> from integrations.agno import AgnoDecisionKit
|
|
>>> ctx = AgentContext(decision_tracking=True)
|
|
>>> from agno.agent import Agent
|
|
>>> agent = Agent(tools=[AgnoDecisionKit(context=ctx)], show_tool_calls=True)
|
|
|
|
Tools exposed
|
|
-------------
|
|
record_decision — Record a decision with reasoning and outcome
|
|
find_precedents — Search for similar past decisions
|
|
trace_causal_chain — Trace causal chain of a decision node
|
|
analyze_impact — Assess downstream influence of a decision
|
|
check_policy — Validate a decision against policy rules
|
|
get_decision_summary — Summarise decision history by category
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import re
|
|
from typing import Any, Dict, List, Optional
|
|
|
|
from semantica.utils.logging import get_logger
|
|
|
|
logger = get_logger(__name__)
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Optional: Agno Toolkit base class
|
|
# ---------------------------------------------------------------------------
|
|
AGNO_AVAILABLE = False
|
|
AGNO_IMPORT_ERROR: Optional[str] = None
|
|
|
|
_ToolkitBase: Any = object
|
|
|
|
try:
|
|
from agno.tools.toolkit import Toolkit as _AgnoToolkit # type: ignore
|
|
|
|
_ToolkitBase = _AgnoToolkit
|
|
AGNO_AVAILABLE = True
|
|
except ImportError as exc:
|
|
AGNO_IMPORT_ERROR = str(exc)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# AgnoDecisionKit
|
|
# ---------------------------------------------------------------------------
|
|
class AgnoDecisionKit(_ToolkitBase): # type: ignore[misc]
|
|
"""
|
|
Agno Toolkit that surfaces Semantica's decision intelligence as agent tools.
|
|
|
|
Parameters
|
|
----------
|
|
context:
|
|
A ``semantica.context.AgentContext`` (or ``AgentContext``-compatible
|
|
object with ``record_decision``, ``find_precedents_advanced``,
|
|
``analyze_decision_influence`` methods). A fresh in-memory context is
|
|
created when ``None``.
|
|
max_precedents:
|
|
Default number of precedents returned by ``find_precedents``.
|
|
causal_depth:
|
|
Default chain depth used by ``trace_causal_chain``.
|
|
enable_policy_check:
|
|
Register the ``check_policy`` tool (default: ``True``).
|
|
"""
|
|
|
|
def __init__(
|
|
self,
|
|
context: Any = None,
|
|
max_precedents: int = 5,
|
|
causal_depth: int = 3,
|
|
enable_policy_check: bool = True,
|
|
**kwargs: Any,
|
|
) -> None:
|
|
if AGNO_AVAILABLE:
|
|
super().__init__(name="decision_kit", **kwargs) # type: ignore[call-arg]
|
|
|
|
# Always initialise _tools so the attribute exists regardless of agno
|
|
if not hasattr(self, "_tools"):
|
|
self._tools: list = []
|
|
|
|
self.max_precedents = max_precedents
|
|
self.causal_depth = causal_depth
|
|
|
|
# Build or reuse AgentContext
|
|
if context is None:
|
|
from semantica.context import AgentContext
|
|
from semantica.vector_store import VectorStore
|
|
|
|
context = AgentContext(
|
|
vector_store=VectorStore(backend="faiss"),
|
|
decision_tracking=True,
|
|
)
|
|
self._ctx = context
|
|
|
|
# Register tools.
|
|
# _tools is always kept as a plain list so callers can inspect registered
|
|
# tools regardless of whether agno is installed. When agno IS available
|
|
# we also call Toolkit.register() so the real agno runtime picks them up.
|
|
tools_to_register = [
|
|
self.record_decision,
|
|
self.find_precedents,
|
|
self.trace_causal_chain,
|
|
self.analyze_impact,
|
|
self.get_decision_summary,
|
|
]
|
|
if enable_policy_check:
|
|
tools_to_register.append(self.check_policy)
|
|
|
|
for fn in tools_to_register:
|
|
self._tools.append(fn)
|
|
if AGNO_AVAILABLE:
|
|
try:
|
|
self.register(fn)
|
|
except Exception:
|
|
pass
|
|
|
|
logger.info("AgnoDecisionKit initialised")
|
|
|
|
# ------------------------------------------------------------------
|
|
# Tools
|
|
# ------------------------------------------------------------------
|
|
|
|
def record_decision(
|
|
self,
|
|
category: str,
|
|
scenario: str,
|
|
reasoning: str,
|
|
outcome: str,
|
|
confidence: float = 0.8,
|
|
entities: Optional[str] = None,
|
|
) -> str:
|
|
"""
|
|
Record a decision with its reasoning and outcome.
|
|
|
|
Parameters
|
|
----------
|
|
category:
|
|
Domain category, e.g. ``"loan_approval"``, ``"content_moderation"``.
|
|
scenario:
|
|
Short description of the situation being decided.
|
|
reasoning:
|
|
Why this outcome was chosen.
|
|
outcome:
|
|
The decision result, e.g. ``"approved"``, ``"rejected"``.
|
|
confidence:
|
|
Confidence score in [0, 1].
|
|
entities:
|
|
Comma-separated list of entity names relevant to the decision.
|
|
|
|
Returns
|
|
-------
|
|
str
|
|
JSON with ``{"decision_id": "<id>", "status": "recorded"}``.
|
|
"""
|
|
entity_list: Optional[List[str]] = None
|
|
if entities:
|
|
entity_list = [e.strip() for e in entities.split(",") if e.strip()]
|
|
|
|
try:
|
|
decision_id = self._ctx.record_decision(
|
|
category=category,
|
|
scenario=scenario,
|
|
reasoning=reasoning,
|
|
outcome=outcome,
|
|
confidence=float(confidence),
|
|
entities=entity_list,
|
|
)
|
|
result = {"decision_id": str(decision_id), "status": "recorded"}
|
|
logger.info("record_decision → %s", decision_id)
|
|
except Exception as exc:
|
|
result = {"error": str(exc), "status": "failed"}
|
|
logger.warning("record_decision failed: %s", exc)
|
|
|
|
return json.dumps(result)
|
|
|
|
def find_precedents(
|
|
self,
|
|
scenario: str,
|
|
category: Optional[str] = None,
|
|
limit: Optional[int] = None,
|
|
) -> str:
|
|
"""
|
|
Search for past decisions similar to the given scenario.
|
|
|
|
Parameters
|
|
----------
|
|
scenario:
|
|
Description of the current situation.
|
|
category:
|
|
Optional category filter.
|
|
limit:
|
|
Maximum number of precedents to return.
|
|
|
|
Returns
|
|
-------
|
|
str
|
|
JSON list of precedent summaries.
|
|
"""
|
|
k = limit or self.max_precedents
|
|
try:
|
|
precedents = self._ctx.find_precedents_advanced(
|
|
scenario=scenario,
|
|
category=category,
|
|
)
|
|
# Normalise to a serialisable list
|
|
out: List[Dict[str, Any]] = []
|
|
for p in (precedents or [])[:k]:
|
|
if isinstance(p, dict):
|
|
out.append(p)
|
|
else:
|
|
out.append(
|
|
{
|
|
"scenario": getattr(p, "scenario", str(p)),
|
|
"outcome": getattr(p, "outcome", ""),
|
|
"confidence": getattr(p, "confidence", 0.0),
|
|
"category": getattr(p, "category", ""),
|
|
}
|
|
)
|
|
logger.info("find_precedents('%s') → %d results", scenario, len(out))
|
|
return json.dumps({"precedents": out, "count": len(out)})
|
|
except Exception as exc:
|
|
logger.warning("find_precedents failed: %s", exc)
|
|
return json.dumps({"precedents": [], "count": 0, "error": str(exc)})
|
|
|
|
def trace_causal_chain(
|
|
self,
|
|
decision_id: str,
|
|
depth: Optional[int] = None,
|
|
) -> str:
|
|
"""
|
|
Trace the causal chain starting from a decision node.
|
|
|
|
Parameters
|
|
----------
|
|
decision_id:
|
|
Identifier of the decision to trace.
|
|
depth:
|
|
Maximum chain depth to traverse.
|
|
|
|
Returns
|
|
-------
|
|
str
|
|
JSON representation of the causal chain.
|
|
"""
|
|
max_depth = depth or self.causal_depth
|
|
try:
|
|
chain = self._ctx.knowledge_graph.trace_decision_causality( # type: ignore[attr-defined]
|
|
decision_id, depth=max_depth
|
|
)
|
|
return json.dumps({"causal_chain": chain, "decision_id": decision_id})
|
|
except AttributeError:
|
|
# Fallback if the graph doesn't expose trace_decision_causality
|
|
try:
|
|
chain = self._ctx.knowledge_graph.find_precedents( # type: ignore[attr-defined]
|
|
category="decision", limit=max_depth
|
|
)
|
|
return json.dumps({"causal_chain": chain, "decision_id": decision_id})
|
|
except Exception as exc:
|
|
return json.dumps({"error": str(exc), "decision_id": decision_id})
|
|
except Exception as exc:
|
|
logger.warning("trace_causal_chain failed: %s", exc)
|
|
return json.dumps({"error": str(exc), "decision_id": decision_id})
|
|
|
|
def analyze_impact(self, decision_id: str) -> str:
|
|
"""
|
|
Assess the downstream influence of a decision using graph centrality.
|
|
|
|
Parameters
|
|
----------
|
|
decision_id:
|
|
Identifier of the decision to analyse.
|
|
|
|
Returns
|
|
-------
|
|
str
|
|
JSON with influence metrics.
|
|
"""
|
|
try:
|
|
influence = self._ctx.analyze_decision_influence(decision_id)
|
|
if not isinstance(influence, dict):
|
|
influence = {"influence": str(influence)}
|
|
influence["decision_id"] = decision_id
|
|
return json.dumps(influence)
|
|
except Exception as exc:
|
|
logger.warning("analyze_impact failed: %s", exc)
|
|
return json.dumps({"error": str(exc), "decision_id": decision_id})
|
|
|
|
def check_policy(
|
|
self,
|
|
decision_data: str,
|
|
policy_rules: Optional[str] = None,
|
|
) -> str:
|
|
"""
|
|
Validate a proposed decision against policy rules.
|
|
|
|
Rules are evaluated inline using simple comparison expressions. This
|
|
avoids misuse of ``PolicyEngine.check_compliance`` (which requires a
|
|
stored ``Decision`` + ``policy_id``) and ensures exceptions never
|
|
silently return ``compliant=True``.
|
|
|
|
Parameters
|
|
----------
|
|
decision_data:
|
|
JSON string describing the decision (must include ``category``,
|
|
``outcome``, ``confidence`` keys at minimum).
|
|
policy_rules:
|
|
JSON list of rule strings, e.g.
|
|
``'["confidence >= 0.7", "category != \\"test\\""]'``.
|
|
Each rule is a simple comparison: ``<field> <op> <value>``
|
|
where op is one of ``>=``, ``<=``, ``!=``, ``==``, ``>``, ``<``.
|
|
|
|
Returns
|
|
-------
|
|
str
|
|
JSON with ``{"compliant": bool, "violations": [...], "warnings": [...]}``
|
|
"""
|
|
try:
|
|
data = json.loads(decision_data) if isinstance(decision_data, str) else decision_data
|
|
except json.JSONDecodeError as exc:
|
|
return json.dumps(
|
|
{
|
|
"compliant": False,
|
|
"violations": [f"Invalid decision_data JSON: {exc}"],
|
|
"warnings": [],
|
|
}
|
|
)
|
|
|
|
rules: List[str] = []
|
|
if policy_rules:
|
|
try:
|
|
rules = json.loads(policy_rules)
|
|
except json.JSONDecodeError:
|
|
rules = [r.strip() for r in policy_rules.split(",") if r.strip()]
|
|
|
|
violations: List[str] = []
|
|
warnings: List[str] = []
|
|
|
|
for rule in rules:
|
|
try:
|
|
if not self._eval_rule(rule, data):
|
|
violations.append(f"Rule violated: {rule}")
|
|
except Exception as exc:
|
|
warnings.append(f"Could not evaluate rule '{rule}': {exc}")
|
|
|
|
compliant = len(violations) == 0
|
|
logger.debug("check_policy: compliant=%s, violations=%d", compliant, len(violations))
|
|
return json.dumps(
|
|
{
|
|
"compliant": compliant,
|
|
"violations": violations,
|
|
"warnings": warnings,
|
|
}
|
|
)
|
|
|
|
def _eval_rule(self, rule: str, data: Dict[str, Any]) -> bool:
|
|
"""Evaluate a simple comparison rule (``field op value``) against data."""
|
|
m = re.match(r"(\w+)\s*(>=|<=|!=|==|>|<)\s*(.+)", rule.strip())
|
|
if not m:
|
|
return True # unrecognised format — pass through
|
|
field, op, val_str = m.group(1), m.group(2), m.group(3).strip().strip("\"'")
|
|
actual = data.get(field)
|
|
if actual is None:
|
|
return True # field absent — cannot evaluate
|
|
try:
|
|
val: Any = type(actual)(val_str)
|
|
except (ValueError, TypeError):
|
|
val = val_str
|
|
ops = {
|
|
">=": lambda a, b: a >= b,
|
|
"<=": lambda a, b: a <= b,
|
|
"!=": lambda a, b: a != b,
|
|
"==": lambda a, b: a == b,
|
|
">": lambda a, b: a > b,
|
|
"<": lambda a, b: a < b,
|
|
}
|
|
return ops[op](actual, val)
|
|
|
|
def get_decision_summary(
|
|
self,
|
|
category: Optional[str] = None,
|
|
since: Optional[str] = None,
|
|
limit: int = 10,
|
|
) -> str:
|
|
"""
|
|
Summarise the decision history, optionally filtered by category.
|
|
|
|
Parameters
|
|
----------
|
|
category:
|
|
Filter to a specific decision category.
|
|
since:
|
|
ISO-8601 timestamp — only include decisions after this time.
|
|
limit:
|
|
Maximum number of decisions to include.
|
|
|
|
Returns
|
|
-------
|
|
str
|
|
JSON summary of recent decisions.
|
|
"""
|
|
try:
|
|
insights = self._ctx.get_context_insights()
|
|
if not isinstance(insights, dict):
|
|
insights = {"raw": str(insights)}
|
|
insights["category_filter"] = category
|
|
return json.dumps(insights)
|
|
except Exception as exc:
|
|
logger.warning("get_decision_summary failed: %s", exc)
|
|
return json.dumps({"error": str(exc)})
|