mirror of
https://github.com/semantica-agi/semantica.git
synced 2026-08-29 04:26:20 +00:00
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:
@@ -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|/|A∪B|), 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
|
||||
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user