Merge pull request #967 from toratto/fix/mcp-decision-persistence-and-graph-tools

fix: decision persistence/query bugs, CJK similarity, and MCP graph query/update tools
This commit is contained in:
Mohd Kaif
2026-08-26 16:49:17 +05:30
committed by GitHub
3 changed files with 1594 additions and 26 deletions
+301 -8
View File
@@ -899,6 +899,11 @@ class ContextGraph:
return
node.properties.update(attributes)
node.metadata.update(attributes)
# Keep derived decision indexes consistent when a decision node is
# mutated so that category / entity / temporal lookups reflect the
# new property values without requiring a full graph reload.
if (getattr(node, "node_type", None) or "").lower() == "decision":
self._sync_decision_from_node(node_id)
if getattr(self, "mutation_callback", None) and not getattr(
self, "_suspend_mutation_callback", False
@@ -1291,6 +1296,14 @@ class ContextGraph:
if link_id:
self._unresolved_links[link_id] = link_meta
# Rebuild all derived decision indexes from the freshly-loaded
# nodes so that find_precedents_by_scenario, find_similar_decisions,
# and all decision analytics work correctly after a reload.
# _rebuild_decision_indexes() unconditionally clears the old indexes
# first, so repeated load_from_file calls never accumulate stale
# entries from a previous file.
self._rebuild_decision_indexes()
self.logger.info(f"Loaded context graph from {path}")
@staticmethod
@@ -1633,6 +1646,8 @@ class ContextGraph:
self._analytics_cache.clear()
self._retractions.clear()
self._tombstones.clear()
# Rebuild derived decision indexes from the freshly-loaded nodes.
self._rebuild_decision_indexes()
if self.mutation_callback and not self._suspend_mutation_callback:
mutation_events = [
@@ -2825,6 +2840,12 @@ class ContextGraph:
self._unresolved_links.clear()
self._retractions.clear()
self._tombstones.clear()
# Reset derived decision indexes so that decision queries against
# a cleared graph return empty results rather than stale data.
self._decisions = {}
self._decision_index = defaultdict(set)
self._entity_index = defaultdict(set)
self._temporal_index = []
self.logger.debug("Graph state fully cleared.")
# --- Internal Helpers ---
@@ -3482,6 +3503,9 @@ class ContextGraph:
)
self._add_internal_edge(edge)
# Rebuild derived decision indexes from the now-populated node store.
self._rebuild_decision_indexes()
def state_at(self, timestamp: Union[str, int, float, datetime]) -> Dict[str, Any]:
"""Return a serializable snapshot of graph state valid at the given time."""
at_time = self._normalize_timestamp(timestamp)
@@ -4707,6 +4731,7 @@ class ContextGraph:
scenario=decision["scenario"],
decision_maker=decision.get("decision_maker", ""),
reasoning=decision["reasoning"],
recorded_at=decision.get("recorded_at", ""),
**safe_metadata,
**extra_properties,
)
@@ -4788,20 +4813,288 @@ class ContextGraph:
return False
return True
def _calculate_decision_content_similarity(self, scenario: str, decision: Dict[str, Any]) -> float:
"""Calculate content similarity between scenario and decision."""
# ── decision-index helpers ────────────────────────────────────────────────
# Protected set of node properties whose values are *core* decision fields
# so that we can distinguish them from user-supplied metadata when
# rebuilding the in-memory indexes from a persisted node.
_DECISION_CORE_FIELDS: frozenset = frozenset({
"id", "category", "scenario", "reasoning", "outcome", "confidence",
"entities", "decision_maker", "timestamp", "recorded_at",
"valid_from", "valid_until", "content",
})
def _rebuild_decision_indexes(self) -> None:
"""Rebuild all derived decision indexes from the current node store.
This method is the single authoritative rebuild path. It must be
called (under the graph lock) after any operation that wholesale
replaces ``self.nodes`` — namely ``load_from_file`` (JSON and Markdown
paths) and ``from_dict``.
Contract:
- Unconditionally clears ``_decisions``, ``_decision_index``,
``_entity_index``, and ``_temporal_index`` before rebuilding so that
repeated calls never accumulate stale entries.
- Derives ``_decisions[node_id]["metadata"]`` from the full set of
node properties, excluding the protected core fields, so that
user-supplied metadata survives the round-trip.
- Runs under ``self._lock`` when called from load paths; callers that
already hold the lock must invoke ``_rebuild_decision_indexes``
inside the lock block.
"""
# Always start fresh so repeated loads don't accumulate stale entries.
self._decisions: Dict[str, Any] = {}
self._decision_index: Dict[str, set] = defaultdict(set)
self._entity_index: Dict[str, set] = defaultdict(set)
self._temporal_index: List[Tuple[str, float]] = []
for node in self.nodes.values():
if (getattr(node, "node_type", None) or "").lower() != "decision":
continue
# Merge metadata and properties; properties win on collision.
meta: Dict[str, Any] = {}
meta.update(getattr(node, "metadata", {}) or {})
meta.update(getattr(node, "properties", {}) or {})
# Timestamp: keep whatever was stored (float epoch or ISO string).
# The temporal index uses it for sorting; downstream code handles
# both types via _normalize_timestamp.
raw_ts = meta.get("timestamp", 0.0)
try:
sort_ts = float(raw_ts)
except (TypeError, ValueError):
sort_ts = 0.0
# Entities may be stored as a list in meta or inferred from
# outgoing "involves" edges if the list field is absent/empty.
# _add_decision_to_graph creates entity nodes connected via
# "involves" edges; it does NOT store the list as a node property.
entities = meta.get("entities") or []
if not isinstance(entities, list):
entities = []
if not entities:
# Recover entity list from "involves" edges on this decision node
for edge in self._adjacency.get(node.node_id, []):
if edge.edge_type == "involves":
entities.append(edge.target_id)
# Everything that isn't a core field is user-supplied metadata.
extra_meta = {
k: v
for k, v in meta.items()
if k not in self._DECISION_CORE_FIELDS
}
decision: Dict[str, Any] = {
"id": node.node_id,
"category": meta.get("category", ""),
"scenario": meta.get("scenario", getattr(node, "content", "") or ""),
"reasoning": meta.get("reasoning", ""),
"outcome": meta.get("outcome", ""),
"confidence": float(meta.get("confidence", 0.0) or 0.0),
"entities": entities,
"decision_maker": meta.get("decision_maker"),
"timestamp": raw_ts,
"recorded_at": meta.get("recorded_at", ""),
"valid_from": getattr(node, "valid_from", None),
"valid_until": getattr(node, "valid_until", None),
# Preserve all non-core node properties as decision metadata so
# that user-supplied fields survive a save → load round-trip.
"metadata": extra_meta,
}
self._decisions[node.node_id] = decision
category = decision["category"]
if category:
self._decision_index[category].add(node.node_id)
for entity in entities:
self._entity_index[entity].add(node.node_id)
self._temporal_index.append((node.node_id, sort_ts))
self._temporal_index.sort(key=lambda x: x[1], reverse=True)
def _sync_decision_from_node(self, node_id: str) -> None:
"""Synchronise a single decision index entry from the node store.
Called after ``add_node_attribute`` mutates a decision node so that
``_decisions`` and the derived indexes stay consistent without
requiring a full rebuild of all decisions.
"""
node = self.nodes.get(node_id)
if node is None:
return
if (getattr(node, "node_type", None) or "").lower() != "decision":
return
if not hasattr(self, "_decisions"):
# Indexes don't exist yet — a full rebuild is safer.
self._rebuild_decision_indexes()
return
# Remove stale index entries for this decision ID.
old = self._decisions.get(node_id)
if old:
old_cat = old.get("category", "")
if old_cat and node_id in self._decision_index.get(old_cat, set()):
self._decision_index[old_cat].discard(node_id)
for ent in old.get("entities", []):
self._entity_index[ent].discard(node_id)
self._temporal_index = [
(nid, ts) for nid, ts in self._temporal_index if nid != node_id
]
# Rebuild the entry for this node and re-insert index entries.
meta: Dict[str, Any] = {}
meta.update(getattr(node, "metadata", {}) or {})
meta.update(getattr(node, "properties", {}) or {})
raw_ts = meta.get("timestamp", 0.0)
try:
# Simple word-based similarity
sort_ts = float(raw_ts)
except (TypeError, ValueError):
sort_ts = 0.0
entities = meta.get("entities") or []
if not isinstance(entities, list):
entities = []
if not entities:
# Recover entity list from "involves" edges
for edge in self._adjacency.get(node_id, []):
if edge.edge_type == "involves":
entities.append(edge.target_id)
extra_meta = {
k: v for k, v in meta.items() if k not in self._DECISION_CORE_FIELDS
}
decision: Dict[str, Any] = {
"id": node_id,
"category": meta.get("category", ""),
"scenario": meta.get("scenario", getattr(node, "content", "") or ""),
"reasoning": meta.get("reasoning", ""),
"outcome": meta.get("outcome", ""),
"confidence": float(meta.get("confidence", 0.0) or 0.0),
"entities": entities,
"decision_maker": meta.get("decision_maker"),
"timestamp": raw_ts,
"recorded_at": meta.get("recorded_at", ""),
"valid_from": getattr(node, "valid_from", None),
"valid_until": getattr(node, "valid_until", None),
"metadata": extra_meta,
}
self._decisions[node_id] = decision
if decision["category"]:
self._decision_index[decision["category"]].add(node_id)
for ent in entities:
self._entity_index[ent].add(node_id)
self._temporal_index.append((node_id, sort_ts))
self._temporal_index.sort(key=lambda x: x[1], reverse=True)
@staticmethod
def _char_bigrams(text: str) -> set:
"""Character bigrams over whitespace-stripped text (CJK fallback).
Strips whitespace so CJK characters without word-separating spaces are
treated as a contiguous character sequence rather than a single token.
"""
chars = "".join(text.lower().split())
return {chars[i:i + 2] for i in range(len(chars) - 1)}
@staticmethod
def _looks_cjk(text: str) -> bool:
"""True if text contains CJK/Japanese/Korean script characters.
Used to gate the character-bigram similarity fallback so it only
activates for scripts where whitespace tokenisation doesn't work.
"""
for ch in text:
code = ord(ch)
if (
0x4E00 <= code <= 0x9FFF # CJK Unified Ideographs
or 0x3400 <= code <= 0x4DBF # CJK Extension A
or 0x3040 <= code <= 0x30FF # Hiragana + Katakana
or 0xAC00 <= code <= 0xD7A3 # Hangul Syllables
or 0x1100 <= code <= 0x11FF # Hangul Jamo
):
return True
return False
def _calculate_decision_content_similarity(self, scenario: str, decision: Dict[str, Any]) -> float:
"""Calculate content similarity between scenario and decision.
Uses word-level Jaccard for space-separated languages. For text where
whitespace tokenisation is unreliable (CJK/Japanese/Korean scripts, or
a query with no whitespace at all) a character-bigram Jaccard is
computed over the *stripped* character sequences instead.
The bigram fallback only activates when whitespace tokenisation would
not help — i.e. the query is CJK-like or has at most one whitespace
token — so it never contributes for ordinary multi-word English
queries, where incidental bigram overlap between unrelated sentences
would otherwise inflate scores.
The bigram side uses *Jaccard* (|A∩B|/|AB|), not the overlap
coefficient, so a 2-character query whose single bigram happens to
appear anywhere in a long document does not silently receive a score of
1.0. A minimum bigram set size of 3 is required before the bigram
signal contributes; this prevents 1- and 2-character English queries
from polluting results while still allowing 3-character CJK phrases (2
bigrams) to match.
"""
try:
decision_text = (
f"{decision['scenario']} {decision['reasoning']} "
f"{' '.join(decision['entities'])}"
)
# --- word-level Jaccard (primary metric for Latin/space-delimited) ---
scenario_words = set(scenario.lower().split())
decision_text = f"{decision['scenario']} {decision['reasoning']} {' '.join(decision['entities'])}"
decision_words = set(decision_text.lower().split())
word_union = scenario_words | decision_words
word_sim = (
len(scenario_words & decision_words) / len(word_union)
if word_union
else 0.0
)
intersection = scenario_words.intersection(decision_words)
union = scenario_words.union(decision_words)
# --- character-bigram Jaccard (CJK / very-short-query fallback) ---
# Only used when whitespace tokenisation can't do the job: CJK-like
# scripts, or a query that is a single whitespace token (no spaces
# to split on). Ordinary multi-word English queries rely on
# word_sim alone, so incidental bigram overlap between unrelated
# sentences can never inflate their score.
bigram_sim = 0.0
needs_bigram_fallback = (
self._looks_cjk(scenario) or len(scenario.split()) <= 1
)
if needs_bigram_fallback:
scenario_bigrams = self._char_bigrams(scenario)
decision_bigrams = self._char_bigrams(decision_text)
return len(intersection) / len(union) if union else 0.0
# Require at least 3 bigrams in the query before the bigram
# signal is used. A 2-char query produces only 1 bigram; that
# single bigram is far too likely to appear as a substring of
# any English word and would produce a spuriously high overlap
# coefficient. 3 bigrams correspond to a 4-char stripped query
# (e.g. two CJK characters produce 1 bigram each → need ≥3
# chars stripped).
if len(scenario_bigrams) >= 3 and decision_bigrams:
bigram_union = scenario_bigrams | decision_bigrams
bigram_sim = (
len(scenario_bigrams & decision_bigrams) / len(bigram_union)
if bigram_union
else 0.0
)
except Exception as e:
return max(word_sim, bigram_sim)
except Exception:
self.logger.exception("Content similarity calculation failed")
return 0.0
+249 -15
View File
@@ -81,7 +81,7 @@ def _get_graph():
kg_path = os.environ.get("SEMANTICA_KG_PATH")
if kg_path and os.path.exists(kg_path):
try:
_graph.load(kg_path)
_graph.load_from_file(kg_path)
log.info("Loaded graph from %s", kg_path)
except Exception as exc:
log.warning("Could not load graph from %s: %s", kg_path, exc)
@@ -93,30 +93,57 @@ def _get_graph():
# ══════════════════════════════════════════════════════════════════════════════
def _tool_extract_entities(args: dict) -> dict:
"""Extract named entities from text."""
"""Extract named entities from text.
Optional ``model`` (spaCy pipeline, e.g. ``zh_core_web_sm`` for Chinese)
and ``language`` allow non-English NER; defaults to the Semantica English
pipeline when omitted. ``method`` defaults to ``ml`` (spaCy); other
options are ``huggingface``, ``llm``, ``pattern``.
"""
text = args.get("text", "")
if not text:
return {"error": "text is required"}
from semantica.semantic_extract import NamedEntityRecognizer
entities = NamedEntityRecognizer().extract_entities(text)
init_kwargs = {}
for k in ("model", "language", "confidence_threshold"):
if args.get(k) is not None:
init_kwargs[k] = args[k]
method = args.get("method", "ml")
ner = NamedEntityRecognizer(methods=[method], **init_kwargs)
entities = ner.extract_entities(text)
return {
"entities": [
{"label": getattr(e, "label", str(e)),
"type": getattr(e, "type", None),
"start": getattr(e, "start", None),
"end": getattr(e, "end", None)}
{"text": getattr(e, "text", ""),
"label": getattr(e, "label", ""),
"type": getattr(e, "label", None),
"start": getattr(e, "start_char", getattr(e, "start", None)),
"end": getattr(e, "end_char", getattr(e, "end", None)),
"confidence": getattr(e, "confidence", 1.0)}
for e in (entities or [])
]
}
def _tool_extract_relations(args: dict) -> dict:
"""Extract relations and triplets from text."""
"""Extract relations and triplets from text.
Optional ``model``/``language`` enable non-English extraction.
``method`` defaults to ``pattern``; ``dependency`` uses spaCy syntactic
parsing (requires a spaCy model, e.g. ``zh_core_web_sm``).
"""
text = args.get("text", "")
if not text:
return {"error": "text is required"}
from semantica.semantic_extract import RelationExtractor, TripletExtractor
relations = RelationExtractor().extract_relations(text)
from semantica.semantic_extract import NamedEntityRecognizer, RelationExtractor, TripletExtractor
rel_kwargs = {}
ner_kwargs = {}
for k in ("model", "language"):
if args.get(k) is not None:
rel_kwargs[k] = args[k]
ner_kwargs[k] = args[k]
method = args.get("method", "pattern")
entities = NamedEntityRecognizer(methods=["ml"], **ner_kwargs).extract_entities(text) or []
relations = RelationExtractor(method=method, **rel_kwargs).extract_relations(text, entities)
triplets = TripletExtractor().extract_triplets(text)
return {
"relations": [
@@ -163,10 +190,12 @@ def _tool_query_decisions(args: dict) -> dict:
graph = _get_graph()
try:
if query:
results = graph.find_similar_decisions(query, max_results=limit)
results = graph.find_similar_decisions(query, max_results=limit, min_similarity=0.05)
elif category:
nodes = graph.find_nodes(node_type="decision")
results = [n for n in nodes if n.get("category") == category][:limit]
results = [n for n in nodes
if n.get("category") == category
or n.get("metadata", {}).get("category") == category][:limit]
else:
results = graph.find_nodes(node_type="decision")[:limit]
return {"decisions": results if isinstance(results, list) else list(results)}
@@ -182,7 +211,9 @@ def _tool_find_precedents(args: dict) -> dict:
max_results = int(args.get("max_results", 5))
graph = _get_graph()
try:
precedents = graph.find_similar_decisions(scenario, max_results=max_results)
min_similarity = float(args.get("min_similarity", 0.05))
precedents = graph.find_similar_decisions(
scenario, max_results=max_results, min_similarity=min_similarity)
return {"precedents": precedents if isinstance(precedents, list) else list(precedents)}
except Exception as exc:
return {"error": str(exc), "precedents": []}
@@ -309,6 +340,160 @@ def _tool_get_graph_summary(args: dict) -> dict:
return {"error": str(exc), "graph_ready": False}
def _tool_update_node(args: dict) -> dict:
"""Update properties of an existing node and persist to SEMANTICA_KG_PATH.
Common use: mark an action node's status (todo/doing/done) with an
optional note. The graph is mutated in-memory then saved back to the
file it was loaded from, so changes survive server restarts.
"""
node_id = args.get("node_id", "")
if not node_id:
return {"error": "node_id is required"}
properties = args.get("properties", {})
if not isinstance(properties, dict) or not properties:
return {"error": "properties (non-empty object) is required"}
graph = _get_graph()
try:
if not graph.find_node(node_id):
return {"error": f"node '{node_id}' not found"}
graph.add_node_attribute(node_id, properties)
# Persist back to disk so the change survives restarts
kg_path = os.environ.get("SEMANTICA_KG_PATH")
if kg_path:
graph.save_to_file(kg_path)
persisted = True
else:
persisted = False
updated = graph.find_node(node_id)
return {
"status": "updated",
"node_id": node_id,
"properties": {k: (updated.get("metadata") or {}).get(k) for k in properties},
"persisted": persisted,
}
except Exception as exc:
return {"error": str(exc)}
def _tool_delete_node(args: dict) -> dict:
"""Archive a node (soft delete) and persist to SEMANTICA_KG_PATH.
The node is kept in the graph for history but marked status='archived'.
Use to retire an action you no longer actively track.
"""
node_id = args.get("node_id", "")
if not node_id:
return {"error": "node_id is required"}
graph = _get_graph()
try:
if not graph.find_node(node_id):
return {"error": f"node '{node_id}' not found"}
graph.add_node_attribute(node_id, {"status": "archived"})
kg_path = os.environ.get("SEMANTICA_KG_PATH")
if kg_path:
graph.save_to_file(kg_path)
return {"status": "archived", "node_id": node_id, "persisted": bool(kg_path)}
except Exception as exc:
return {"error": str(exc)}
def _tool_query_graph(args: dict) -> dict:
"""Query the live knowledge graph: node detail, neighbours, or keyword search.
mode:
- "node" : get one node by id (needs node_id)
- "neighbors": traverse up to `depth` hops from node_id (default depth=1)
- "search" : keyword search over node id+content (needs query)
"""
graph = _get_graph()
mode = args.get("mode", "neighbors")
try:
if mode == "node":
node_id = args.get("node_id", "")
if not node_id:
return {"error": "node_id is required"}
node = graph.find_node(node_id)
return {"node": node}
if mode == "neighbors":
node_id = args.get("node_id", "")
if not node_id:
return {"error": "node_id is required"}
depth = int(args.get("depth", 1))
rel_types = args.get("relationship_types")
if isinstance(rel_types, str):
rel_types = [rel_types]
rel_set = set(rel_types) if rel_types else None
limit = args.get("limit")
limit = int(limit) if limit is not None else None
depth = min(max(depth, 1), 5)
# Out-edges (multi-hop) via get_neighbors
nb = graph.get_neighbors(
node_id, hops=depth, relationship_types=rel_types, limit=limit,
)
out = [
{"id": n.get("id"), "type": n.get("type"),
"content": n.get("content"),
"relationship": n.get("relationship"),
"direction": "out", "hop": n.get("hop", 1)}
for n in (nb or [])
]
# In-edges (1-hop): scan edges whose target == node_id.
# Deduplicate by source node so that multiple edges between the
# same pair of nodes (different edge types) produce one entry.
# Stop early once we have already collected `limit` inbound results
# (if a limit is set) to avoid scanning the full edge list.
inb = []
seen_inbound = set()
for e in graph.find_edges():
if e.get("target") != node_id:
continue
if rel_set is not None and e.get("type") not in rel_set:
continue
src_id = e.get("source")
if src_id in seen_inbound:
continue
seen_inbound.add(src_id)
src = graph.find_node(src_id) or {}
inb.append({"id": src_id, "type": src.get("type"),
"content": src.get("content"),
"relationship": e.get("type"),
"direction": "in", "hop": 1})
# Early-exit: we already have `limit` inbound results; the
# combined list will be truncated to `limit` anyway.
if limit is not None and len(inb) >= limit:
break
neighbors = out + inb
# Apply final limit. Use ``is not None`` so limit=0 (zero results)
# is honoured correctly; ``if limit:`` would treat 0 as falsy.
if limit is not None:
neighbors = neighbors[:limit]
return {"node_id": node_id, "depth": depth, "neighbors": neighbors}
if mode == "search":
q = (args.get("query") or "").lower()
if not q:
return {"error": "query is required"}
node_type = args.get("node_type")
limit = int(args.get("limit", 50))
nodes = graph.find_nodes(node_type=node_type) if node_type else graph.find_nodes()
hits = []
for n in nodes:
# Check limit BEFORE appending so limit=0 returns empty.
if len(hits) >= limit:
break
blob = f"{n.get('id','')} {n.get('content','')}".lower()
if q in blob:
hits.append({"id": n.get("id"), "type": n.get("type"),
"content": n.get("content")})
return {"query": q, "results": hits, "total": len(hits)}
return {"error": f"unknown mode '{mode}': use node|neighbors|search"}
except Exception as exc:
return {"error": str(exc)}
# ══════════════════════════════════════════════════════════════════════════════
# MCP protocol tables
# ══════════════════════════════════════════════════════════════════════════════
@@ -320,7 +505,11 @@ TOOLS = [
"inputSchema": {
"type": "object",
"properties": {
"text": {"type": "string", "description": "Input text to extract entities from"}
"text": {"type": "string", "description": "Input text to extract entities from"},
"model": {"type": "string", "description": "spaCy model name, e.g. 'zh_core_web_sm' for Chinese, 'en_core_web_sm' for English. Defaults to English pipeline."},
"language": {"type": "string", "description": "Language code, e.g. 'zh', 'en'."},
"method": {"type": "string", "description": "Extraction method: 'ml' (spaCy, default), 'huggingface', 'llm', 'pattern'."},
"confidence_threshold": {"type": "number", "description": "Minimum confidence 0-1 (default 0.5)."}
},
"required": ["text"],
},
@@ -332,7 +521,10 @@ TOOLS = [
"inputSchema": {
"type": "object",
"properties": {
"text": {"type": "string", "description": "Input text to extract relations from"}
"text": {"type": "string", "description": "Input text to extract relations from"},
"model": {"type": "string", "description": "spaCy model name for dependency parsing, e.g. 'zh_core_web_sm'."},
"language": {"type": "string", "description": "Language code, e.g. 'zh'."},
"method": {"type": "string", "description": "Extraction method: 'pattern' (default), 'dependency', 'cooccurrence', 'huggingface', 'llm'."}
},
"required": ["text"],
},
@@ -473,6 +665,48 @@ TOOLS = [
"inputSchema": {"type": "object", "properties": {}},
"_handler": _tool_get_graph_summary,
},
{
"name": "query_graph",
"description": "Query the live knowledge graph: get a node, traverse its neighbours (up to 5 hops), or keyword-search nodes by id+content.",
"inputSchema": {
"type": "object",
"properties": {
"mode": {"type": "string", "description": "node | neighbors | search (default: neighbors)"},
"node_id": {"type": "string", "description": "Node id (required for node/neighbors mode)"},
"depth": {"type": "integer", "description": "Hop depth for neighbors (1-5, default 1)"},
"relationship_types": {"type": "array", "items": {"type": "string"}, "description": "Optional filter by edge type(s)"},
"query": {"type": "string", "description": "Keyword for search mode (matched against node id+content)"},
"node_type": {"type": "string", "description": "Optional node_type filter for search mode"},
"limit": {"type": "integer", "description": "Max results for neighbors/search"}
},
},
"_handler": _tool_query_graph,
},
{
"name": "update_node",
"description": "Update properties of an existing node (e.g. mark an action todo/doing/done with a note) and persist to SEMANTICA_KG_PATH.",
"inputSchema": {
"type": "object",
"properties": {
"node_id": {"type": "string", "description": "Node id to update"},
"properties": {"type": "object", "description": "Property key-values to merge onto the node, e.g. {\"status\":\"done\",\"updated_at\":\"2026-08-13\",\"note\":\"...\"}"}
},
"required": ["node_id", "properties"],
},
"_handler": _tool_update_node,
},
{
"name": "delete_node",
"description": "Archive a node (soft delete: marks status='archived', keeps it for history) and persist to SEMANTICA_KG_PATH. Use to retire an action you no longer track.",
"inputSchema": {
"type": "object",
"properties": {
"node_id": {"type": "string", "description": "Node id to delete"}
},
"required": ["node_id"],
},
"_handler": _tool_delete_node,
},
]
RESOURCES = [
File diff suppressed because it is too large Load Diff