fix: harden decision persistence and MCP graph tools

This commit is contained in:
Sameer6305
2026-08-24 17:56:03 +05:30
parent b6c8563cb0
commit f454c48929
4 changed files with 1278 additions and 56 deletions
+251 -50
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,43 +1296,13 @@ class ContextGraph:
if link_id:
self._unresolved_links[link_id] = link_meta
# Rebuild decision indexes from persisted decision nodes so that
# find_precedents_by_scenario / decision counts work after a reload
decision_nodes = [
n for n in self.nodes.values()
if (getattr(n, "node_type", None) or "").lower() == "decision"
]
if decision_nodes:
if not hasattr(self, "_decisions"):
self._decisions = {}
self._decision_index = defaultdict(set)
self._entity_index = defaultdict(set)
self._temporal_index = []
for node in decision_nodes:
meta = dict(getattr(node, "metadata", {}) or {})
meta.update(getattr(node, "properties", {}) or {})
decision = {
"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": meta.get("confidence", 0.0),
"entities": meta.get("entities", []),
"decision_maker": meta.get("decision_maker"),
"timestamp": meta.get("timestamp", 0.0),
"recorded_at": meta.get("recorded_at", ""),
"valid_from": getattr(node, "valid_from", None),
"valid_until": getattr(node, "valid_until", None),
"metadata": {},
}
self._decisions[node.node_id] = decision
if decision["category"]:
self._decision_index[decision["category"]].add(node.node_id)
for entity in decision["entities"]:
self._entity_index[entity].add(node.node_id)
self._temporal_index.append((node.node_id, decision["timestamp"]))
self._temporal_index.sort(key=lambda x: x[1], reverse=True)
# 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}")
@@ -1671,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 = [
@@ -2863,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 ---
@@ -3520,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)
@@ -4826,39 +4812,254 @@ class ContextGraph:
return False
return True
# ── 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:
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)."""
"""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)}
def _calculate_decision_content_similarity(self, scenario: str, decision: Dict[str, Any]) -> float:
"""Calculate content similarity between scenario and decision.
Combines word-level Jaccard (works for space-separated languages)
with character-bigram signals (fallback for CJK text without spaces).
For the bigram side we use the overlap coefficient |A∩B| / min(|A|,|B|)
instead of Jaccard, so that a short query against a long decision
document is not penalised for length mismatch.
Uses word-level Jaccard for space-separated languages. For text where
whitespace tokenisation fails (CJK, single-word queries) a character-
bigram Jaccard is computed over the *stripped* character sequences and
blended in with a weight that diminishes as the query grows so that it
cannot dominate English results.
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']} {' '.join(decision['entities'])}"
decision_text = (
f"{decision['scenario']} {decision['reasoning']} "
f"{' '.join(decision['entities'])}"
)
# Word-based similarity
# --- word-level Jaccard (primary metric for Latin/space-delimited) ---
scenario_words = set(scenario.lower().split())
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
word_sim = (
len(scenario_words & decision_words) / len(word_union)
if word_union
else 0.0
)
# Character-bigram similarity (CJK texts tokenize poorly on whitespace)
# --- character-bigram Jaccard (CJK / very-short-query fallback) ---
scenario_bigrams = self._char_bigrams(scenario)
decision_bigrams = self._char_bigrams(decision_text)
smaller = min(len(scenario_bigrams), len(decision_bigrams))
bigram_sim = len(scenario_bigrams & decision_bigrams) / smaller if smaller 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).
bigram_sim = 0.0
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
)
return max(word_sim, bigram_sim)
except Exception as e:
except Exception:
self.logger.exception("Content similarity calculation failed")
return 0.0
+1 -2
View File
@@ -16,7 +16,6 @@ router = APIRouter(prefix="/api/decisions", tags=["Decisions"])
def _node_to_decision(node: dict) -> DecisionResponse:
properties = node.get("properties", {})
ts = properties.get("timestamp")
return DecisionResponse(
decision_id=node.get("id", ""),
category=properties.get("category", ""),
@@ -24,7 +23,7 @@ def _node_to_decision(node: dict) -> DecisionResponse:
reasoning=properties.get("reasoning", ""),
outcome=properties.get("outcome", ""),
confidence=float(properties.get("confidence", 0.0) or 0.0),
timestamp=ts if isinstance(ts, str) or ts is None else str(ts),
timestamp=properties.get("timestamp"),
metadata=properties,
)
+19 -4
View File
@@ -417,21 +417,35 @@ def _tool_query_graph(args: dict) -> dict:
"direction": "out", "hop": n.get("hop", 1)}
for n in (nb or [])
]
# In-edges (1-hop): scan edges whose target == node_id
# 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
if limit:
# 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}
@@ -444,12 +458,13 @@ def _tool_query_graph(args: dict) -> dict:
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")})
if len(hits) >= limit:
break
return {"query": q, "results": hits, "total": len(hits)}
return {"error": f"unknown mode '{mode}': use node|neighbors|search"}
File diff suppressed because it is too large Load Diff