mirror of
https://github.com/semantica-agi/semantica.git
synced 2026-09-10 04:00:35 +00:00
Adds Google ADK (Agent Development Kit) support to Semantica. `semantica_kg_tools()` and `semantica_decision_tools()` expose entity/relation extraction, graph updates, and decision recording as ADK `FunctionTool`s. `SemanticaSessionService` implements ADK session storage on top of a Semantica `ContextGraph`, so session state, events, and knowledge graph data can live in the same graph instead of keeping sessions in memory. There were also a number of dependency and CI fixes needed to get the integration working reliably. `google-adk` is pinned to a range that avoids the CI `websockets` conflict, the deprecated `pinecone-client` dependency was replaced with `pinecone`, Windows-only dependencies now have the appropriate platform markers, and `requirements-ci.txt` was regenerated to match. A `pip-audit` pass also required updates to `google-adk` and `starlette` for known CVEs. Some unrelated `pyproject.toml` changes had slipped in during rebases, so the previous version, dependency bounds, `ingest-sap`/LangChain entries, and package-data settings were restored. A few bugs in the initial ADK implementation were fixed during review: * `extract_relations()` was calling `RelationExtractor.extract_entities()`, which doesn't exist on that extractor. The failure was being caught and returned in the tool's `error` field, leaving callers with an empty relation list. It now calls the correct extraction path. * The repo's top-level `mcp/` package shadowed the third-party `mcp` package imported by `google.adk`, causing `google.adk` imports to fail from a normal repo checkout. The local package was moved to `semantica_mcp/mcp/`. The MCP move needed a follow-up as well. `semantica/cli.py` and four existing tests were still importing from `mcp.*`, and the modules under `semantica_mcp/mcp/` still used the old absolute imports internally. `semantica_mcp` was also missing from the setuptools package include list and had no `__init__.py`, so it wouldn't have been included in an installed package. Those imports and packaging settings are fixed now. The session service and ADK tools also had a few other problems: * `list_sessions()` returned a plain list instead of ADK's `ListSessionsResponse`. The original import for that type doesn't work against the installed `google-adk` package, so it was silently falling back to a stub. `user_id` was also incorrectly required instead of being optional. * Session node IDs were built by joining `app_name`, `user_id`, and `session_id` with unescaped colons, which allowed different identities to produce the same graph node ID. Each component is now encoded before joining. * `kg_tools.py` and `decision_tools.py` each had their own lock registry and default graph instance. Sharing a graph between the two modules therefore didn't share the lock, and using both factories without an explicit graph produced two different defaults. The shared state now lives in one module used by both. * `add_to_graph` had a `TypeError` compatibility fallback that couldn't succeed with the current `RelationExtractor` API and could hide the original extraction error. That fallback was removed. * `append_event` persisted partial streaming events even though ADK's base session service skips them. * `get_session()` ignored its `config` argument, so `num_recent_events` and `after_timestamp` had no effect. * The async session-service methods performed synchronous graph scans while holding a `threading.RLock` on the event loop thread. That work now runs in worker threads with `asyncio.to_thread()` so a slow or contended graph operation doesn't block the loop. --- Co-authored-by: Zohaib Hassnain [109234410+ZohaibHassan16@users.noreply.github.com](mailto:109234410+ZohaibHassan16@users.noreply.github.com)
659 lines
18 KiB
Python
659 lines
18 KiB
Python
from __future__ import annotations
|
|
|
|
from typing import Any, Dict, List, Optional
|
|
|
|
from ._shared import graph_lock as _graph_lock
|
|
from ._shared import get_default_graph as _get_default_graph
|
|
|
|
try:
|
|
from google.adk.tools import FunctionTool
|
|
|
|
ADK_AVAILABLE = True
|
|
except ImportError:
|
|
FunctionTool = None # type: ignore
|
|
ADK_AVAILABLE = False
|
|
|
|
|
|
def _get_ner_extractor() -> Any:
|
|
"""Create Semantica default NER extractor."""
|
|
from semantica.semantic_extract import NERExtractor
|
|
|
|
return NERExtractor()
|
|
|
|
|
|
def _get_relation_extractor() -> Any:
|
|
"""Create Semantica default relation extractor."""
|
|
from semantica.semantic_extract import RelationExtractor
|
|
|
|
return RelationExtractor()
|
|
|
|
|
|
def _first_string(obj: Any, attributes: tuple[str, ...]) -> str:
|
|
"""Return the first non-empty string from an object or dictionary."""
|
|
if obj is None:
|
|
return ""
|
|
|
|
if isinstance(obj, dict):
|
|
for attribute in attributes:
|
|
value = obj.get(attribute)
|
|
|
|
if isinstance(value, str) and value.strip():
|
|
return value.strip()
|
|
|
|
return ""
|
|
|
|
for attribute in attributes:
|
|
value = getattr(obj, attribute, None)
|
|
|
|
if isinstance(value, str) and value.strip():
|
|
return value.strip()
|
|
|
|
return ""
|
|
|
|
|
|
def _entity_name(entity: Any) -> str:
|
|
"""Return a best-effort name for an extracted entity."""
|
|
return _first_string(
|
|
entity,
|
|
(
|
|
"name",
|
|
"text",
|
|
"label",
|
|
"node_id",
|
|
"id",
|
|
),
|
|
)
|
|
|
|
|
|
def _entity_type(entity: Any) -> str:
|
|
"""Return a best-effort type for an extracted entity."""
|
|
return (
|
|
_first_string(
|
|
entity,
|
|
(
|
|
"type",
|
|
"label",
|
|
),
|
|
)
|
|
or "Entity"
|
|
)
|
|
|
|
|
|
def _entity_confidence(entity: Any) -> float:
|
|
"""Normalize an entity confidence value."""
|
|
try:
|
|
confidence = (
|
|
entity.get("confidence")
|
|
if isinstance(entity, dict)
|
|
else getattr(entity, "confidence", None)
|
|
)
|
|
|
|
if confidence is None:
|
|
return 1.0
|
|
|
|
return round(float(confidence), 4)
|
|
|
|
except (TypeError, ValueError):
|
|
return 1.0
|
|
|
|
|
|
def _relation_source(relation: Any) -> str:
|
|
"""Return the source entity of an extracted relation."""
|
|
source = _first_string(
|
|
relation,
|
|
(
|
|
"source",
|
|
"source_id",
|
|
),
|
|
)
|
|
|
|
if source:
|
|
return source
|
|
|
|
if isinstance(relation, dict):
|
|
return _entity_name(relation.get("subject"))
|
|
|
|
return _entity_name(getattr(relation, "subject", None))
|
|
|
|
|
|
def _relation_target(relation: Any) -> str:
|
|
"""Return the target entity of an extracted relation."""
|
|
target = _first_string(
|
|
relation,
|
|
(
|
|
"target",
|
|
"target_id",
|
|
),
|
|
)
|
|
|
|
if target:
|
|
return target
|
|
|
|
if isinstance(relation, dict):
|
|
return _entity_name(relation.get("object"))
|
|
|
|
return _entity_name(getattr(relation, "object", None))
|
|
|
|
|
|
def _relation_type(relation: Any) -> str:
|
|
"""Return the relation predicate/type."""
|
|
return (
|
|
_first_string(
|
|
relation,
|
|
(
|
|
"type",
|
|
"relation",
|
|
"predicate",
|
|
),
|
|
)
|
|
or "related_to"
|
|
)
|
|
|
|
|
|
def _json_safe(value: Any) -> Any:
|
|
"""
|
|
Convert common Semantica objects into values suitable for ADK tool output.
|
|
|
|
ADK tools should return values that can be serialized into the tool
|
|
response sent back to the model.
|
|
"""
|
|
if value is None or isinstance(value, (str, int, float, bool)):
|
|
return value
|
|
|
|
if isinstance(value, dict):
|
|
return {
|
|
str(key): _json_safe(item)
|
|
for key, item in value.items()
|
|
}
|
|
|
|
if isinstance(value, (list, tuple, set)):
|
|
return [_json_safe(item) for item in value]
|
|
|
|
if hasattr(value, "to_dict"):
|
|
try:
|
|
return _json_safe(value.to_dict())
|
|
except Exception:
|
|
pass
|
|
|
|
if hasattr(value, "model_dump"):
|
|
try:
|
|
return _json_safe(value.model_dump())
|
|
except Exception:
|
|
pass
|
|
|
|
return str(value)
|
|
|
|
|
|
def extract_entities(text: str) -> dict:
|
|
"""Extract named entities from text using Semantica's NER pipeline."""
|
|
if not isinstance(text, str):
|
|
return {
|
|
"entities": [],
|
|
"count": 0,
|
|
"error": "text must be a string",
|
|
}
|
|
|
|
try:
|
|
extractor = _get_ner_extractor()
|
|
raw_entities = extractor.extract_entities(text) or []
|
|
|
|
entities: List[Dict[str, Any]] = []
|
|
|
|
for entity in raw_entities:
|
|
name = _entity_name(entity)
|
|
|
|
if not name:
|
|
continue
|
|
|
|
entities.append(
|
|
{
|
|
"name": name,
|
|
"type": _entity_type(entity),
|
|
"confidence": _entity_confidence(entity),
|
|
}
|
|
)
|
|
|
|
return {
|
|
"entities": entities,
|
|
"count": len(entities),
|
|
}
|
|
|
|
except Exception as exc:
|
|
return {
|
|
"entities": [],
|
|
"count": 0,
|
|
"error": str(exc),
|
|
}
|
|
|
|
|
|
def extract_relations(text: str) -> dict:
|
|
"""Extract relationships between entities from text using Semantica."""
|
|
if not isinstance(text, str):
|
|
return {
|
|
"relations": [],
|
|
"count": 0,
|
|
"error": "text must be a string",
|
|
}
|
|
|
|
try:
|
|
ner_extractor = _get_ner_extractor()
|
|
entities = ner_extractor.extract_entities(text)
|
|
|
|
relation_extractor = _get_relation_extractor()
|
|
raw_relations = relation_extractor.extract_relations(text, entities=entities) or []
|
|
|
|
relations: List[Dict[str, Any]] = []
|
|
|
|
for relation in raw_relations:
|
|
source = _relation_source(relation)
|
|
target = _relation_target(relation)
|
|
|
|
if not source or not target:
|
|
continue
|
|
|
|
relations.append(
|
|
{
|
|
"source": source,
|
|
"relation": _relation_type(relation),
|
|
"target": target,
|
|
"confidence": _entity_confidence(relation),
|
|
}
|
|
)
|
|
|
|
return {
|
|
"relations": relations,
|
|
"count": len(relations),
|
|
}
|
|
|
|
except Exception as exc:
|
|
return {
|
|
"relations": [],
|
|
"count": 0,
|
|
"error": str(exc),
|
|
}
|
|
|
|
|
|
def add_to_graph(text: str) -> dict:
|
|
"""
|
|
Extract entities and relationships from text and add them to a ContextGraph.
|
|
|
|
This standalone function uses a process-local default graph. For a shared
|
|
graph across ADK agents, use ``semantica_kg_tools(graph=shared_graph)``.
|
|
"""
|
|
return _add_to_graph(text, _get_default_graph())
|
|
|
|
|
|
def query_graph(query: str) -> dict:
|
|
"""
|
|
Query the shared Semantica knowledge graph by keyword.
|
|
|
|
This standalone function uses a process-local default graph. For a shared
|
|
graph, use ``semantica_kg_tools(graph=shared_graph)``.
|
|
"""
|
|
return _query_graph(query, _get_default_graph())
|
|
|
|
|
|
def _add_to_graph(text: str, graph: Any) -> dict:
|
|
"""Internal graph mutation implementation."""
|
|
if not isinstance(text, str):
|
|
return {
|
|
"nodes_added": 0,
|
|
"edges_added": 0,
|
|
"error": "text must be a string",
|
|
}
|
|
|
|
try:
|
|
ner_extractor = _get_ner_extractor()
|
|
relation_extractor = _get_relation_extractor()
|
|
|
|
nodes_added = 0
|
|
edges_added = 0
|
|
|
|
with _graph_lock(graph):
|
|
existing_nodes = set()
|
|
|
|
for node in graph.find_nodes() or []:
|
|
if isinstance(node, dict):
|
|
node_id = node.get("id") or node.get("node_id")
|
|
else:
|
|
node_id = getattr(
|
|
node,
|
|
"id",
|
|
getattr(node, "node_id", None),
|
|
)
|
|
|
|
if node_id:
|
|
existing_nodes.add(str(node_id))
|
|
|
|
existing_edges = set()
|
|
|
|
for edge in graph.find_edges() or []:
|
|
if isinstance(edge, dict):
|
|
source = edge.get("source") or edge.get("source_id")
|
|
target = edge.get("target") or edge.get("target_id")
|
|
edge_type = edge.get("type") or edge.get("edge_type")
|
|
else:
|
|
source = getattr(
|
|
edge,
|
|
"source_id",
|
|
getattr(edge, "source", None),
|
|
)
|
|
target = getattr(
|
|
edge,
|
|
"target_id",
|
|
getattr(edge, "target", None),
|
|
)
|
|
edge_type = getattr(
|
|
edge,
|
|
"edge_type",
|
|
getattr(edge, "type", None),
|
|
)
|
|
|
|
if source and target:
|
|
existing_edges.add(
|
|
(
|
|
str(source),
|
|
str(edge_type or "related_to"),
|
|
str(target),
|
|
)
|
|
)
|
|
|
|
raw_entities = ner_extractor.extract_entities(text) or []
|
|
|
|
entities: List[Any] = []
|
|
seen_entities = set()
|
|
|
|
for entity in raw_entities:
|
|
name = _entity_name(entity)
|
|
entity_type = _entity_type(entity)
|
|
|
|
if not name or name in seen_entities:
|
|
continue
|
|
|
|
seen_entities.add(name)
|
|
entities.append(entity)
|
|
|
|
if name in existing_nodes:
|
|
continue
|
|
|
|
try:
|
|
added = graph.add_node(
|
|
node_id=name,
|
|
node_type=entity_type,
|
|
)
|
|
|
|
if added:
|
|
nodes_added += 1
|
|
existing_nodes.add(name)
|
|
|
|
except Exception:
|
|
# Do not fail the entire tool because one node could not
|
|
# be inserted.
|
|
continue
|
|
|
|
raw_relations = relation_extractor.extract_relations(
|
|
text,
|
|
entities=entities,
|
|
) or []
|
|
|
|
for relation in raw_relations:
|
|
source = _relation_source(relation)
|
|
target = _relation_target(relation)
|
|
relation_type = _relation_type(relation)
|
|
|
|
if not source or not target:
|
|
continue
|
|
|
|
edge_key = (
|
|
source,
|
|
relation_type,
|
|
target,
|
|
)
|
|
|
|
if edge_key in existing_edges:
|
|
continue
|
|
|
|
try:
|
|
added = graph.add_edge(
|
|
source_id=source,
|
|
target_id=target,
|
|
edge_type=relation_type,
|
|
)
|
|
|
|
if added:
|
|
edges_added += 1
|
|
existing_edges.add(edge_key)
|
|
|
|
except Exception:
|
|
continue
|
|
|
|
return {
|
|
"nodes_added": nodes_added,
|
|
"edges_added": edges_added,
|
|
}
|
|
|
|
except Exception as exc:
|
|
return {
|
|
"nodes_added": 0,
|
|
"edges_added": 0,
|
|
"error": str(exc),
|
|
}
|
|
|
|
|
|
def _query_graph(query: str, graph: Any) -> dict:
|
|
"""Internal graph query implementation."""
|
|
if not isinstance(query, str):
|
|
return {
|
|
"query": query,
|
|
"results": [],
|
|
"count": 0,
|
|
"error": "query must be a string",
|
|
}
|
|
|
|
query = query.strip()
|
|
|
|
if not query:
|
|
return {
|
|
"query": query,
|
|
"results": [],
|
|
"count": 0,
|
|
}
|
|
|
|
try:
|
|
results: List[Dict[str, Any]] = []
|
|
seen = set()
|
|
|
|
# Prefer ContextGraph.query() when available because it can provide
|
|
# richer semantic/structural results.
|
|
query_method = getattr(graph, "query", None)
|
|
|
|
if callable(query_method):
|
|
try:
|
|
matches = query_method(query) or []
|
|
|
|
for match in matches:
|
|
if not isinstance(match, dict):
|
|
continue
|
|
|
|
node = match.get("node") or {}
|
|
|
|
if not isinstance(node, dict):
|
|
node = _json_safe(node)
|
|
|
|
node_id = (
|
|
node.get("id")
|
|
or node.get("node_id")
|
|
or match.get("id")
|
|
)
|
|
|
|
if not node_id:
|
|
continue
|
|
|
|
node_id = str(node_id)
|
|
|
|
if node_id in seen:
|
|
continue
|
|
|
|
seen.add(node_id)
|
|
|
|
results.append(
|
|
{
|
|
"id": node_id,
|
|
"type": (
|
|
node.get("type")
|
|
or node.get("node_type")
|
|
or ""
|
|
),
|
|
"content": str(
|
|
match.get("content")
|
|
or node.get("content")
|
|
or (
|
|
node.get("properties") or {}
|
|
).get("content", "")
|
|
)[:500],
|
|
"score": round(
|
|
float(match.get("score") or 0.0),
|
|
4,
|
|
),
|
|
}
|
|
)
|
|
|
|
except Exception:
|
|
# Fall back to deterministic keyword search below.
|
|
pass
|
|
|
|
# Deterministic fallback/search enrichment.
|
|
query_lower = query.lower()
|
|
|
|
for node in graph.find_nodes() or []:
|
|
if isinstance(node, dict):
|
|
node_id = (
|
|
node.get("id")
|
|
or node.get("node_id")
|
|
or ""
|
|
)
|
|
node_type = (
|
|
node.get("type")
|
|
or node.get("node_type")
|
|
or ""
|
|
)
|
|
|
|
properties = node.get("properties") or {}
|
|
|
|
content = (
|
|
node.get("content")
|
|
or properties.get("content")
|
|
or ""
|
|
)
|
|
|
|
else:
|
|
node_id = getattr(
|
|
node,
|
|
"id",
|
|
getattr(node, "node_id", ""),
|
|
)
|
|
node_type = getattr(
|
|
node,
|
|
"node_type",
|
|
getattr(node, "type", ""),
|
|
)
|
|
content = getattr(node, "content", "")
|
|
|
|
node_id = str(node_id or "")
|
|
node_type = str(node_type or "")
|
|
content = str(content or "")
|
|
|
|
if not node_id or node_id in seen:
|
|
continue
|
|
|
|
haystack = " ".join(
|
|
(
|
|
node_id,
|
|
node_type,
|
|
content,
|
|
)
|
|
).lower()
|
|
|
|
if query_lower in haystack:
|
|
seen.add(node_id)
|
|
|
|
results.append(
|
|
{
|
|
"id": node_id,
|
|
"type": node_type,
|
|
"content": content[:500],
|
|
"score": 1.0,
|
|
}
|
|
)
|
|
|
|
return {
|
|
"query": query,
|
|
"results": results,
|
|
"count": len(results),
|
|
}
|
|
|
|
except Exception as exc:
|
|
return {
|
|
"query": query,
|
|
"results": [],
|
|
"count": 0,
|
|
"error": str(exc),
|
|
}
|
|
|
|
|
|
def semantica_kg_tools(
|
|
graph: Optional[Any] = None,
|
|
) -> List[Any]:
|
|
"""
|
|
Return Google ADK FunctionTools bound to a shared ContextGraph instance.
|
|
|
|
Args:
|
|
graph:
|
|
Optional Semantica ContextGraph. When supplied, all returned tools
|
|
operate on this same graph instance.
|
|
|
|
Returns:
|
|
A list containing FunctionTools for:
|
|
- extract_entities
|
|
- extract_relations
|
|
- add_to_graph
|
|
- query_graph
|
|
|
|
Raises:
|
|
ImportError:
|
|
If google-adk is not installed.
|
|
"""
|
|
if not ADK_AVAILABLE or FunctionTool is None:
|
|
raise ImportError(
|
|
"Google ADK is required for semantica_kg_tools(). "
|
|
"Install it with: pip install semantica[google-adk]"
|
|
)
|
|
|
|
shared_graph = graph if graph is not None else _get_default_graph()
|
|
|
|
def add_to_shared_graph(text: str) -> dict:
|
|
"""Extract entities and relationships from text and add them to the shared Semantica graph."""
|
|
return _add_to_graph(text, shared_graph)
|
|
|
|
def query_shared_graph(query: str) -> dict:
|
|
"""Query the shared Semantica knowledge graph by keyword."""
|
|
return _query_graph(query, shared_graph)
|
|
|
|
# FunctionTool derives the tool name/schema from the wrapped callable and
|
|
# its docstring, which is exactly the ADK convention we want.
|
|
return [
|
|
FunctionTool(extract_entities),
|
|
FunctionTool(extract_relations),
|
|
FunctionTool(add_to_shared_graph),
|
|
FunctionTool(query_shared_graph),
|
|
]
|
|
|
|
|
|
__all__ = [
|
|
"ADK_AVAILABLE",
|
|
"extract_entities",
|
|
"extract_relations",
|
|
"add_to_graph",
|
|
"query_graph",
|
|
"semantica_kg_tools",
|
|
] |