mirror of
https://github.com/semantica-agi/semantica.git
synced 2026-08-29 04:26:20 +00:00
5432 lines
212 KiB
Python
5432 lines
212 KiB
Python
"""
|
||
Context Graph Implementation
|
||
|
||
In-memory GraphStore implementation for building and querying context graphs
|
||
from conversations and entities with advanced analytics integration.
|
||
|
||
Core Features:
|
||
- In-memory GraphStore implementation
|
||
- Entity and relationship extraction from conversations
|
||
- BFS-based neighbor discovery
|
||
- Type-based indexing
|
||
- Export to dictionary format
|
||
- Decision tracking integration
|
||
|
||
Comprehensive Decision Management:
|
||
- Decision Recording: Store decisions with full context and metadata
|
||
- Precedent Search: Find similar decisions using hybrid search algorithms
|
||
- Influence Analysis: Analyze decision impact and relationships
|
||
- Causal Analysis: Trace decision causality chains
|
||
- Policy Enforcement: Built-in policy compliance checking
|
||
- Advanced Analytics: Comprehensive decision insights
|
||
|
||
KG Algorithm Integration:
|
||
- Centrality Analysis: Degree, betweenness, closeness, eigenvector centrality
|
||
- Community Detection: Modularity-based community identification
|
||
- Node Embeddings: Node2Vec embeddings for similarity analysis
|
||
- Path Finding: Shortest path and advanced path algorithms
|
||
- Link Prediction: Relationship prediction between entities
|
||
- Similarity Calculation: Multi-type similarity measures
|
||
|
||
Vector Store Integration:
|
||
- Hybrid Search: Semantic + structural similarity
|
||
- Custom Similarity Weights: Configurable scoring
|
||
- Advanced Precedent Search: KG-enhanced similarity
|
||
- Multi-Embedding Support: Multiple embedding types
|
||
|
||
Advanced Graph Analytics:
|
||
- Node Centrality Analysis: Multiple centrality measures
|
||
- Community Detection: Identify clusters and communities
|
||
- Node Similarity: Content and structural similarity
|
||
- Graph Structure Analysis: Comprehensive metrics
|
||
- Path Analysis: Find paths and connectivity
|
||
- Embedding Generation: Node embeddings for ML
|
||
|
||
Decision Tracking Integration:
|
||
- Decision Storage: Store decisions with full context
|
||
- Precedent Search: Find similar decisions using graph traversal
|
||
- Causal Analysis: Trace decision influence
|
||
- Decision Analytics: Analyze decision patterns
|
||
- Influence Analysis: Decision influence scoring and analysis
|
||
- Policy Engine: Policy enforcement and compliance checking
|
||
- Relationship Mapping: Map decision dependencies
|
||
|
||
Enhanced Methods:
|
||
- analyze_graph_with_kg(): Comprehensive graph analysis
|
||
- get_node_centrality(): Get centrality measures for nodes
|
||
- find_similar_nodes(): Find similar nodes with advanced similarity
|
||
- record_decision(): Add decisions with context integration
|
||
- find_precedents(): Find decision precedents
|
||
- analyze_decision_influence(): Analyze decision influence
|
||
- get_decision_insights(): Get comprehensive decision analytics
|
||
- trace_decision_causality(): Trace decision causality
|
||
- enforce_decision_policy(): Enforce decision policies
|
||
- get_graph_metrics(): Get comprehensive statistics
|
||
- export_graph(): Export graph in various formats
|
||
|
||
Example Usage:
|
||
>>> from semantica.context import ContextGraph
|
||
>>> graph = ContextGraph(advanced_analytics=True,
|
||
... centrality_analysis=True,
|
||
... community_detection=True,
|
||
... node_embeddings=True)
|
||
>>>
|
||
>>> # Basic graph operations
|
||
>>> graph.add_node("Python", "language", popularity="high")
|
||
>>> graph.add_node("Programming", "concept")
|
||
>>> graph.add_edge("Python", "Programming", "related_to")
|
||
>>> centrality = graph.get_node_centrality("Python")
|
||
>>> similar = graph.find_similar_nodes("Python", similarity_type="content")
|
||
>>> analysis = graph.analyze_graph_with_kg()
|
||
>>>
|
||
>>> # Decision management
|
||
>>> decision_id = graph.record_decision(
|
||
... category="loan_approval",
|
||
... scenario="First-time homebuyer",
|
||
... reasoning="Good credit score",
|
||
... outcome="approved",
|
||
... confidence=0.95,
|
||
... entities=["customer_123", "property_456"]
|
||
... )
|
||
>>> precedents = graph.find_precedents(decision_id, limit=5)
|
||
>>> influence = graph.analyze_decision_influence(decision_id)
|
||
>>> insights = graph.get_decision_insights()
|
||
>>> causality = graph.trace_decision_causality(decision_id)
|
||
|
||
Production Use Cases:
|
||
- Knowledge Management: Build and analyze knowledge graphs
|
||
- Decision Support: Context graphs for decision making
|
||
- Recommendation Systems: Graph-based recommendations
|
||
- Social Networks: Analyze connections and influence
|
||
- Research Networks: Map collaborations and citations
|
||
- Financial Services: Loan approvals, fraud detection, risk assessment
|
||
- Healthcare: Treatment decisions, policy compliance, clinical pathways
|
||
- Legal: Case precedent analysis, decision consistency
|
||
- Business: Workflow decisions, policy compliance, audit trails
|
||
"""
|
||
|
||
import copy
|
||
import errno
|
||
import hashlib
|
||
import itertools
|
||
import json
|
||
import os
|
||
import re
|
||
import shutil
|
||
import stat
|
||
import tempfile
|
||
import threading
|
||
import uuid
|
||
from collections import defaultdict, deque
|
||
from dataclasses import dataclass, field
|
||
from datetime import date, datetime, timezone
|
||
from pathlib import Path
|
||
from typing import Any, Dict, List, Optional, Set, Tuple, Union
|
||
|
||
import yaml
|
||
|
||
from ..utils.helpers import classify_path_distance
|
||
from ..utils.logging import get_logger
|
||
from ..utils.progress_tracker import get_progress_tracker
|
||
from ..utils.skos import is_skos_hierarchy_edge, validate_skos_hierarchy
|
||
from ._markdown_filesystem import find_filesystem_link
|
||
from .entity_linker import EntityLinker
|
||
|
||
|
||
class _UniqueKeySafeLoader(yaml.SafeLoader):
|
||
"""Safe YAML loader that rejects ambiguous duplicate mapping keys."""
|
||
|
||
|
||
def _construct_unique_mapping(
|
||
loader: _UniqueKeySafeLoader, node: yaml.MappingNode, deep: bool = False
|
||
) -> Dict[Any, Any]:
|
||
loader.flatten_mapping(node)
|
||
mapping = {}
|
||
for key_node, value_node in node.value:
|
||
key = loader.construct_object(key_node, deep=deep)
|
||
try:
|
||
duplicate = key in mapping
|
||
except TypeError as exc:
|
||
raise yaml.constructor.ConstructorError(
|
||
"while constructing a mapping",
|
||
node.start_mark,
|
||
"found an unhashable key",
|
||
key_node.start_mark,
|
||
) from exc
|
||
if duplicate:
|
||
raise yaml.constructor.ConstructorError(
|
||
"while constructing a mapping",
|
||
node.start_mark,
|
||
f"found duplicate key {key!r}",
|
||
key_node.start_mark,
|
||
)
|
||
mapping[key] = loader.construct_object(value_node, deep=deep)
|
||
return mapping
|
||
|
||
|
||
_UniqueKeySafeLoader.add_constructor(
|
||
yaml.resolver.BaseResolver.DEFAULT_MAPPING_TAG, _construct_unique_mapping
|
||
)
|
||
|
||
|
||
# Optional imports for advanced features
|
||
try:
|
||
from ..kg import (
|
||
CentralityCalculator,
|
||
CommunityDetector,
|
||
ConnectivityAnalyzer,
|
||
GraphAnalyzer,
|
||
GraphBuilder,
|
||
LinkPredictor,
|
||
NodeEmbedder,
|
||
PathFinder,
|
||
SimilarityCalculator,
|
||
)
|
||
KG_AVAILABLE = True
|
||
except ImportError:
|
||
KG_AVAILABLE = False
|
||
|
||
|
||
class _CausalChain(dict):
|
||
"""Dict response that still iterates over hops for legacy callers."""
|
||
|
||
def __iter__(self):
|
||
return iter(self.get("hops", []))
|
||
|
||
|
||
def _parse_iso_dt(value: str) -> Optional[datetime]:
|
||
"""Parse an ISO datetime string into a tz-naive UTC datetime.
|
||
|
||
Supported formats (in priority order):
|
||
- Year-only shorthand: "1990" → "1990-01-01"
|
||
- Date-only: "1990-06-15"
|
||
- Full ISO (with tz): "1990-06-15T00:00:00+00:00" / "...Z"
|
||
- Full ISO (naive): "1990-06-15T00:00:00"
|
||
|
||
Returns None on failure; callers must treat the node as Always-Active.
|
||
"""
|
||
import logging
|
||
import re as _re
|
||
if not value:
|
||
return None
|
||
s = str(value).strip()
|
||
if _re.fullmatch(r"\d{4}", s):
|
||
s = f"{s}-01-01"
|
||
s = s.replace("Z", "+00:00")
|
||
try:
|
||
dt = datetime.fromisoformat(s)
|
||
if dt.tzinfo is not None:
|
||
dt = dt.astimezone(timezone.utc).replace(tzinfo=None)
|
||
return dt
|
||
except (ValueError, AttributeError) as e:
|
||
logging.getLogger("semantica.context").warning(
|
||
"Malformed temporal value %r — treating node as Always-Active. (%s)", value, e
|
||
)
|
||
return None
|
||
|
||
|
||
def _normalize_temporal_input(value: Optional[Union[str, int, float, datetime]]) -> Optional[str]:
|
||
"""Normalize supported temporal inputs to ISO strings."""
|
||
if value is None:
|
||
return None
|
||
if isinstance(value, datetime):
|
||
if value.tzinfo is not None:
|
||
value = value.astimezone(timezone.utc).replace(tzinfo=None)
|
||
return value.isoformat()
|
||
if isinstance(value, (int, float)):
|
||
return datetime.fromtimestamp(value, tz=timezone.utc).replace(tzinfo=None).isoformat()
|
||
if isinstance(value, str):
|
||
parsed = _parse_iso_dt(value)
|
||
if parsed is None:
|
||
raise ValueError(f"Temporal value {value!r} is not a valid ISO datetime string")
|
||
return parsed.isoformat()
|
||
raise ValueError("Temporal values must be datetime, epoch seconds, ISO strings, or None")
|
||
|
||
|
||
def _closing_valid_until(current: Optional[str], at_iso: str) -> str:
|
||
"""Return the earlier of an existing end bound and a retraction time.
|
||
|
||
Retraction closes a validity window and must never widen one: an entity
|
||
added with ``valid_until`` already in the past would otherwise be reported
|
||
active by ``is_active``/``state_at`` for the span between its original end
|
||
and the retraction. An unparseable ``current`` imposes no end bound at all
|
||
(see :func:`_parse_iso_dt`), so ``at_iso`` still closes it.
|
||
"""
|
||
if current is None:
|
||
return at_iso
|
||
existing = _parse_iso_dt(current)
|
||
if existing is None:
|
||
return at_iso
|
||
requested = _parse_iso_dt(at_iso)
|
||
if requested is None or existing <= requested:
|
||
return current
|
||
return at_iso
|
||
|
||
|
||
def _pick_first(*values: Any) -> Any:
|
||
for value in values:
|
||
if value is None:
|
||
continue
|
||
if isinstance(value, str) and not value.strip():
|
||
continue
|
||
return value
|
||
return None
|
||
|
||
|
||
def _default_edge_id(
|
||
source_id: str,
|
||
target_id: str,
|
||
edge_type: str,
|
||
weight: float,
|
||
metadata: Dict[str, Any],
|
||
valid_from: Optional[str],
|
||
valid_until: Optional[str],
|
||
) -> str:
|
||
payload = json.dumps(
|
||
{
|
||
"source": source_id,
|
||
"target": target_id,
|
||
"type": edge_type,
|
||
"weight": weight,
|
||
"valid_from": valid_from,
|
||
"valid_until": valid_until,
|
||
"metadata": metadata,
|
||
},
|
||
sort_keys=True,
|
||
default=str,
|
||
separators=(",", ":"),
|
||
)
|
||
return str(uuid.uuid5(uuid.NAMESPACE_URL, payload))
|
||
|
||
|
||
def _resolve_edge_identity(
|
||
*,
|
||
source_id: str,
|
||
target_id: str,
|
||
edge_type: str,
|
||
weight: float,
|
||
metadata: Dict[str, Any],
|
||
valid_from: Optional[str],
|
||
valid_until: Optional[str],
|
||
edge_id: Any = None,
|
||
family_id: Any = None,
|
||
) -> Tuple[str, str]:
|
||
resolved_edge_id = str(
|
||
_pick_first(
|
||
edge_id,
|
||
_default_edge_id(
|
||
source_id=source_id,
|
||
target_id=target_id,
|
||
edge_type=edge_type,
|
||
weight=weight,
|
||
metadata=metadata,
|
||
valid_from=valid_from,
|
||
valid_until=valid_until,
|
||
),
|
||
)
|
||
)
|
||
resolved_family_id = str(_pick_first(family_id, resolved_edge_id))
|
||
return resolved_edge_id, resolved_family_id
|
||
|
||
|
||
def _coerce_metadata_map(*values: Any) -> Dict[str, Any]:
|
||
merged: Dict[str, Any] = {}
|
||
for value in values:
|
||
if isinstance(value, dict):
|
||
merged.update(value)
|
||
return merged
|
||
|
||
|
||
def _coerce_node_id(raw_node: Dict[str, Any]) -> Optional[str]:
|
||
value = _pick_first(
|
||
raw_node.get("id"),
|
||
raw_node.get("node_id"),
|
||
raw_node.get("_id"),
|
||
raw_node.get("uri"),
|
||
raw_node.get("key"),
|
||
)
|
||
if value is None:
|
||
return None
|
||
text = str(value).strip()
|
||
return text or None
|
||
|
||
|
||
def _coerce_edge_endpoint(raw_edge: Dict[str, Any], prefix: str) -> Optional[str]:
|
||
prefix = prefix.lower()
|
||
candidates = {
|
||
"source": ["source_id", "source", "start", "start_id", "from", "src", "START_ID", ":START_ID"],
|
||
"target": ["target_id", "target", "end", "end_id", "to", "dst", "END_ID", ":END_ID"],
|
||
}[prefix]
|
||
value = _pick_first(*(raw_edge.get(candidate) for candidate in candidates))
|
||
if value is None:
|
||
return None
|
||
text = str(value).strip()
|
||
return text or None
|
||
|
||
|
||
def _coerce_float(value: Any, default: float = 1.0) -> float:
|
||
if value in (None, ""):
|
||
return default
|
||
try:
|
||
return float(value)
|
||
except (TypeError, ValueError):
|
||
return default
|
||
|
||
|
||
@dataclass
|
||
class ContextNode:
|
||
"""Context graph node (Internal implementation)."""
|
||
|
||
node_id: str
|
||
node_type: str
|
||
content: str
|
||
metadata: Dict[str, Any] = field(default_factory=dict)
|
||
properties: Dict[str, Any] = field(default_factory=dict)
|
||
valid_from: Optional[str] = None
|
||
valid_until: Optional[str] = None
|
||
|
||
def is_active(self, at_time: Optional[datetime] = None) -> bool:
|
||
"""Return True if this node is active at the given time (defaults to now).
|
||
|
||
Both ``at_time`` and stored bounds are normalized to tz-naive UTC so that
|
||
callers may pass either aware or naive datetimes without raising TypeError.
|
||
"""
|
||
if self.valid_from is None and self.valid_until is None:
|
||
return True
|
||
now = at_time if at_time is not None else datetime.utcnow()
|
||
if now.tzinfo is not None:
|
||
now = now.astimezone(timezone.utc).replace(tzinfo=None)
|
||
start = _parse_iso_dt(self.valid_from) if self.valid_from is not None else None
|
||
end = _parse_iso_dt(self.valid_until) if self.valid_until is not None else None
|
||
if start is not None and now < start:
|
||
return False
|
||
if end is not None and now > end:
|
||
return False
|
||
return True
|
||
|
||
def to_dict(self) -> Dict[str, Any]:
|
||
"""Convert to dictionary format."""
|
||
props = self.properties.copy()
|
||
props.update(self.metadata)
|
||
props["content"] = self.content
|
||
if self.valid_from is not None:
|
||
props["valid_from"] = self.valid_from
|
||
if self.valid_until is not None:
|
||
props["valid_until"] = self.valid_until
|
||
return {"id": self.node_id, "type": self.node_type, "properties": props}
|
||
|
||
|
||
@dataclass
|
||
class ContextEdge:
|
||
"""Context graph edge (Internal implementation)."""
|
||
|
||
source_id: str
|
||
target_id: str
|
||
edge_type: str
|
||
edge_id: str = ""
|
||
weight: float = 1.0
|
||
family_id: Optional[str] = None
|
||
metadata: Dict[str, Any] = field(default_factory=dict)
|
||
valid_from: Optional[str] = None
|
||
valid_until: Optional[str] = None
|
||
|
||
def __post_init__(self) -> None:
|
||
self.source_id = str(self.source_id)
|
||
self.target_id = str(self.target_id)
|
||
self.edge_type = str(self.edge_type or "related_to")
|
||
self.weight = _coerce_float(self.weight, default=1.0)
|
||
if not isinstance(self.metadata, dict):
|
||
self.metadata = {}
|
||
self.edge_id, self.family_id = _resolve_edge_identity(
|
||
source_id=self.source_id,
|
||
target_id=self.target_id,
|
||
edge_type=self.edge_type,
|
||
weight=self.weight,
|
||
metadata=self.metadata,
|
||
valid_from=self.valid_from,
|
||
valid_until=self.valid_until,
|
||
edge_id=self.edge_id,
|
||
family_id=self.family_id,
|
||
)
|
||
def is_active(self, at_time: Optional[datetime] = None) -> bool:
|
||
"""Return True if this edge is active at the given time (defaults to now).
|
||
|
||
Both ``at_time`` and stored bounds are normalized to tz-naive UTC so that
|
||
callers may pass either aware or naive datetimes without raising TypeError.
|
||
"""
|
||
if self.valid_from is None and self.valid_until is None:
|
||
return True
|
||
now = at_time if at_time is not None else datetime.utcnow()
|
||
if now.tzinfo is not None:
|
||
now = now.astimezone(timezone.utc).replace(tzinfo=None)
|
||
start = _parse_iso_dt(self.valid_from) if self.valid_from is not None else None
|
||
end = _parse_iso_dt(self.valid_until) if self.valid_until is not None else None
|
||
if start is not None and now < start:
|
||
return False
|
||
if end is not None and now > end:
|
||
return False
|
||
return True
|
||
|
||
def to_dict(self) -> Dict[str, Any]:
|
||
"""Convert to dictionary format."""
|
||
d = {
|
||
"id": self.edge_id,
|
||
"familyId": self.family_id or self.edge_id,
|
||
"source_id": self.source_id,
|
||
"target_id": self.target_id,
|
||
"type": self.edge_type,
|
||
"weight": self.weight,
|
||
"properties": self.metadata,
|
||
}
|
||
if self.valid_from is not None:
|
||
d["valid_from"] = self.valid_from
|
||
if self.valid_until is not None:
|
||
d["valid_until"] = self.valid_until
|
||
return d
|
||
|
||
|
||
_ATTRS_MISSING = object()
|
||
|
||
#: Edge types that represent an explicitly recorded causal relationship between
|
||
#: two decisions. These are authoritative: they are what the caller asserted via
|
||
#: add_causal_relationship(), as opposed to relationships inferred from shared
|
||
#: entities and timestamps.
|
||
_CAUSAL_EDGE_TYPES = ("CAUSED", "INFLUENCED", "PRECEDENT_FOR")
|
||
|
||
# Causal edges circulate under two vocabularies: this module's canonical
|
||
# spellings above, and the present-tense spellings CausalChainAnalyzer also
|
||
# accepts ("causes", "influences", "leads_to", "supports"). The present-tense
|
||
# forms normalize onto the canonical types for storage; traversal accepts
|
||
# both vocabularies so an edge recorded either way is never invisible.
|
||
_CAUSAL_EDGE_ALIASES = {
|
||
"CAUSES": "CAUSED",
|
||
"CAUSED": "CAUSED",
|
||
"INFLUENCES": "INFLUENCED",
|
||
"INFLUENCED": "INFLUENCED",
|
||
"PRECEDES": "PRECEDENT_FOR",
|
||
"PRECEDENT_FOR": "PRECEDENT_FOR",
|
||
}
|
||
_CAUSAL_TRAVERSAL_TYPES = frozenset(_CAUSAL_EDGE_ALIASES) | {
|
||
"LEADS_TO", "LEAD_TO", "SUPPORTS", "SUPPORT",
|
||
}
|
||
|
||
|
||
class ContextGraph:
|
||
"""
|
||
Easy-to-Use Context Graph with All Advanced Features.
|
||
|
||
This class provides simple methods for:
|
||
- Building knowledge graphs
|
||
- Recording and analyzing decisions
|
||
- Finding precedents and patterns
|
||
- Causal analysis and policy enforcement
|
||
- Advanced graph analytics
|
||
|
||
Perfect for building intelligent AI agents that can learn from decisions!
|
||
"""
|
||
|
||
_MARKDOWN_FORMAT = "semantica-context-graph"
|
||
_MARKDOWN_VERSION = 1
|
||
_MARKDOWN_MANIFEST = "graph.md"
|
||
_MARKDOWN_NODES_DIRECTORY = "nodes"
|
||
_MARKDOWN_EXTENSIONS = frozenset({".md", ".markdown"})
|
||
|
||
def __init__(self, config: Optional[Dict[str, Any]] = None, **kwargs):
|
||
"""
|
||
Initialize context graph with optional advanced features.
|
||
|
||
Args:
|
||
config: Configuration dictionary
|
||
**kwargs: Additional configuration options:
|
||
- extract_entities: Extract entities from content (default: True)
|
||
- extract_relationships: Extract relationships (default: True)
|
||
- entity_linker: Entity linker instance
|
||
- advanced_analytics: Enable KG algorithms (default: True)
|
||
- centrality_analysis: Enable centrality measures (default: True)
|
||
- community_detection: Enable community detection (default: True)
|
||
- node_embeddings: Enable Node2Vec embeddings (default: True)
|
||
"""
|
||
self.logger = get_logger("context_graph")
|
||
self.config = config or {}
|
||
self.config.update(kwargs)
|
||
|
||
self.extract_entities = self.config.get("extract_entities", True)
|
||
self.extract_relationships = self.config.get("extract_relationships", True)
|
||
|
||
self.entity_linker = self.config.get("entity_linker") or EntityLinker()
|
||
|
||
|
||
self._lock = threading.RLock()
|
||
|
||
self.graph_id: str = str(uuid.uuid4())
|
||
|
||
self.nodes: Dict[str, ContextNode] = {}
|
||
self.edges: List[ContextEdge] = []
|
||
self._edge_index: Dict[str, ContextEdge] = {}
|
||
|
||
self._adjacency: Dict[str, List[ContextEdge]] = defaultdict(list)
|
||
|
||
|
||
self.node_type_index: Dict[str, Set[str]] = defaultdict(set)
|
||
self.edge_type_index: Dict[str, List[ContextEdge]] = defaultdict(list)
|
||
|
||
self._linked_graphs: Dict[str, Tuple["ContextGraph", str, str]] = {}
|
||
|
||
self._unresolved_links: Dict[str, Dict[str, str]] = {}
|
||
|
||
# Retraction closes an entity's validity window but keeps it in the
|
||
# graph; a tombstone records that an entity was purged outright,
|
||
# without retaining the purged content. Keyed by
|
||
# ``(entity_kind, entity_id)`` -- node ids are caller-supplied strings
|
||
# and edge ids are UUID strings, so a single id keyspace would let a
|
||
# node record mask an edge of the same id, and vice versa.
|
||
self._retractions: Dict[Tuple[str, str], Dict[str, Any]] = {}
|
||
self._tombstones: Dict[Tuple[str, str], Dict[str, Any]] = {}
|
||
|
||
|
||
self.progress_tracker = get_progress_tracker()
|
||
|
||
if not self.progress_tracker.enabled:
|
||
self.progress_tracker.enabled = True
|
||
|
||
|
||
self.kg_components = {}
|
||
self._analytics_cache = {}
|
||
|
||
self.mutation_callback = self.config.get("mutation_callback", None)
|
||
self._suspend_mutation_callback = False
|
||
|
||
enable_advanced = self.config.get("advanced_analytics", True)
|
||
|
||
if KG_AVAILABLE and enable_advanced:
|
||
try:
|
||
if self.config.get("centrality_analysis", True):
|
||
self.kg_components["centrality_calculator"] = CentralityCalculator()
|
||
if self.config.get("community_detection", True):
|
||
self.kg_components["community_detector"] = CommunityDetector()
|
||
if self.config.get("node_embeddings", True):
|
||
self.kg_components["node_embedder"] = NodeEmbedder()
|
||
self.kg_components["path_finder"] = PathFinder()
|
||
self.kg_components["similarity_calculator"] = SimilarityCalculator()
|
||
self.kg_components["connectivity_analyzer"] = ConnectivityAnalyzer()
|
||
|
||
self.logger.info("Advanced KG components initialized successfully")
|
||
except Exception as e:
|
||
self.logger.warning(f"Failed to initialize KG components: {e}")
|
||
self.kg_components = {}
|
||
|
||
|
||
|
||
def add_nodes(self, nodes: List[Dict[str, Any]]) -> int:
|
||
"""
|
||
Add nodes to graph.
|
||
|
||
Args:
|
||
nodes: List of nodes to add (dicts with id, type, properties)
|
||
|
||
Returns:
|
||
Number of nodes added
|
||
"""
|
||
count = 0
|
||
with self._lock:
|
||
for raw_node in nodes:
|
||
if not isinstance(raw_node, dict):
|
||
continue
|
||
|
||
node_id = _coerce_node_id(raw_node)
|
||
if node_id is None:
|
||
self.logger.warning("Skipping node without a usable id: %r", raw_node)
|
||
continue
|
||
|
||
node_props = _coerce_metadata_map(
|
||
raw_node.get("metadata"),
|
||
raw_node.get("properties"),
|
||
)
|
||
node_type = _pick_first(
|
||
raw_node.get("type"),
|
||
raw_node.get("node_type"),
|
||
raw_node.get("category"),
|
||
raw_node.get(":LABEL"),
|
||
node_props.get("type"),
|
||
"entity",
|
||
)
|
||
content = _pick_first(
|
||
raw_node.get("content"),
|
||
raw_node.get("text"),
|
||
raw_node.get("label"),
|
||
raw_node.get("name"),
|
||
raw_node.get("title"),
|
||
raw_node.get("pref_label"),
|
||
node_props.get("content"),
|
||
node_props.get("text"),
|
||
node_props.get("label"),
|
||
node_props.get("name"),
|
||
node_props.get("title"),
|
||
node_props.get("pref_label"),
|
||
node_id,
|
||
)
|
||
|
||
valid_from = _pick_first(
|
||
raw_node.get("valid_from"),
|
||
node_props.get("valid_from"),
|
||
)
|
||
valid_until = _pick_first(
|
||
raw_node.get("valid_until"),
|
||
node_props.get("valid_until"),
|
||
)
|
||
metadata = {
|
||
k: v
|
||
for k, v in node_props.items()
|
||
if k not in ("content", "text", "valid_from", "valid_until")
|
||
}
|
||
|
||
internal_node = ContextNode(
|
||
node_id=node_id,
|
||
node_type=str(node_type or "entity"),
|
||
content=str(content or node_id),
|
||
metadata=metadata,
|
||
properties=node_props,
|
||
valid_from=valid_from,
|
||
valid_until=valid_until,
|
||
)
|
||
|
||
if self._add_internal_node(internal_node):
|
||
count += 1
|
||
return count
|
||
|
||
def add_edges(self, edges: List[Dict[str, Any]]) -> int:
|
||
"""
|
||
Add edges to graph.
|
||
|
||
Args:
|
||
edges: List of edges to add (dicts with source_id, target_id, type,
|
||
weight, properties)
|
||
|
||
Returns:
|
||
Number of edges added
|
||
"""
|
||
count = 0
|
||
with self._lock:
|
||
# Keep the SKOS hierarchy invariant at the lowest common write
|
||
# layer so direct graph users cannot bypass API/session checks.
|
||
hierarchy_edges = [edge for edge in edges if is_skos_hierarchy_edge(edge)]
|
||
if hierarchy_edges:
|
||
existing_edges = [
|
||
edge for edge in self.find_edges() if is_skos_hierarchy_edge(edge)
|
||
]
|
||
validate_skos_hierarchy(hierarchy_edges, existing_edges)
|
||
for raw_edge in edges:
|
||
if not isinstance(raw_edge, dict):
|
||
continue
|
||
|
||
source_id = _coerce_edge_endpoint(raw_edge, "source")
|
||
target_id = _coerce_edge_endpoint(raw_edge, "target")
|
||
if source_id is None or target_id is None:
|
||
self.logger.warning("Skipping edge without usable endpoints: %r", raw_edge)
|
||
continue
|
||
|
||
edge_props = _coerce_metadata_map(
|
||
raw_edge.get("metadata"),
|
||
raw_edge.get("properties"),
|
||
)
|
||
edge_type = _pick_first(
|
||
raw_edge.get("type"),
|
||
raw_edge.get("edge_type"),
|
||
raw_edge.get("relationship"),
|
||
raw_edge.get("predicate"),
|
||
raw_edge.get("relation"),
|
||
raw_edge.get(":TYPE"),
|
||
edge_props.get("type"),
|
||
"related_to",
|
||
)
|
||
|
||
valid_from = _pick_first(raw_edge.get("valid_from"), edge_props.get("valid_from"))
|
||
valid_until = _pick_first(raw_edge.get("valid_until"), edge_props.get("valid_until"))
|
||
weight = _coerce_float(_pick_first(raw_edge.get("weight"), edge_props.get("weight")), default=1.0)
|
||
explicit_edge_id = _pick_first(
|
||
raw_edge.get("id"),
|
||
raw_edge.get("edge_id"),
|
||
edge_props.pop("id", None),
|
||
edge_props.pop("edge_id", None),
|
||
)
|
||
explicit_family_id = _pick_first(
|
||
raw_edge.get("familyId"),
|
||
raw_edge.get("family_id"),
|
||
edge_props.pop("familyId", None),
|
||
edge_props.pop("family_id", None),
|
||
)
|
||
edge_id, family_id = _resolve_edge_identity(
|
||
source_id=source_id,
|
||
target_id=target_id,
|
||
edge_type=str(edge_type or "related_to"),
|
||
weight=weight,
|
||
metadata=edge_props,
|
||
valid_from=valid_from,
|
||
valid_until=valid_until,
|
||
edge_id=explicit_edge_id,
|
||
family_id=explicit_family_id,
|
||
)
|
||
internal_edge = ContextEdge(
|
||
edge_id=edge_id,
|
||
source_id=source_id,
|
||
target_id=target_id,
|
||
edge_type=str(edge_type or "related_to"),
|
||
family_id=str(family_id) if family_id is not None else edge_id,
|
||
weight=weight,
|
||
metadata=edge_props,
|
||
valid_from=valid_from,
|
||
valid_until=valid_until,
|
||
)
|
||
|
||
if self._add_internal_edge(internal_edge):
|
||
count += 1
|
||
return count
|
||
|
||
def __contains__(self, node_id: object) -> bool:
|
||
if not isinstance(node_id, str):
|
||
return False
|
||
with self._lock:
|
||
return node_id in self.nodes
|
||
|
||
def has_node(self, node_id: str) -> bool:
|
||
with self._lock:
|
||
return node_id in self.nodes
|
||
|
||
def neighbors(self, node_id: str) -> List[Dict[str, Any]]:
|
||
return self.get_neighbors(node_id, hops=1)
|
||
|
||
def get_neighbor_ids(
|
||
self,
|
||
node_id: str,
|
||
relationship_types: Optional[List[str]] = None,
|
||
) -> List[str]:
|
||
with self._lock:
|
||
if node_id not in self.nodes:
|
||
return []
|
||
|
||
rel_filter = set(relationship_types) if relationship_types else None
|
||
neighbor_ids: List[str] = []
|
||
for edge in self._adjacency.get(node_id, []):
|
||
if rel_filter is None or edge.edge_type in rel_filter:
|
||
neighbor_ids.append(edge.target_id)
|
||
return neighbor_ids
|
||
|
||
def get_nodes_by_label(self, label: str) -> List[Dict[str, Any]]:
|
||
result = []
|
||
with self._lock:
|
||
for nid in self.node_type_index.get(label, set()):
|
||
node = self.nodes.get(nid)
|
||
if node:
|
||
result.append({
|
||
"id": node.node_id,
|
||
"content": node.content,
|
||
"type": node.node_type,
|
||
"metadata": (node.properties or {}).copy(),
|
||
})
|
||
return result
|
||
|
||
def get_node_property(
|
||
self,
|
||
node_id: str,
|
||
property_name: str,
|
||
default: Any = None,
|
||
) -> Any:
|
||
"""Return the value of *property_name* on *node_id*.
|
||
|
||
Returns *default* when the node does not exist or when the property is
|
||
not set on the node. Both failure modes return the same *default*, so
|
||
a sentinel can identify *any not-found result* as distinct from a
|
||
property whose value is legitimately ``None``::
|
||
|
||
_MISSING = object()
|
||
val = graph.get_node_property(node_id, "score", default=_MISSING)
|
||
if val is _MISSING:
|
||
... # node absent or property not set
|
||
|
||
To distinguish a missing node from a missing property specifically,
|
||
call ``find_node()`` first to check node existence.
|
||
|
||
Args:
|
||
node_id: ID of the node to look up.
|
||
property_name: Name of the property to retrieve.
|
||
default: Value returned when the node or property is absent.
|
||
Defaults to ``None`` (backward-compatible).
|
||
|
||
Returns:
|
||
The property value, or *default* if not found.
|
||
"""
|
||
with self._lock:
|
||
node = self.nodes.get(node_id)
|
||
if node is None:
|
||
return default
|
||
return node.properties.get(property_name, default)
|
||
|
||
def get_node_attributes(
|
||
self,
|
||
node_id: str,
|
||
default: Any = _ATTRS_MISSING,
|
||
) -> Any:
|
||
"""Return a copy of all properties on *node_id*.
|
||
|
||
Returns *default* when the node does not exist. The historical
|
||
default is ``{}`` (an empty dict), preserved for backward
|
||
compatibility. Pass a private sentinel as *default* to detect a
|
||
missing node unambiguously::
|
||
|
||
_MISSING = object()
|
||
attrs = graph.get_node_attributes(node_id, default=_MISSING)
|
||
if attrs is _MISSING:
|
||
... # node does not exist
|
||
|
||
Args:
|
||
node_id: ID of the node to look up.
|
||
default: Value returned when the node is absent.
|
||
Defaults to ``{}`` (backward-compatible).
|
||
|
||
Returns:
|
||
A shallow copy of the node's properties dict, or *default*.
|
||
"""
|
||
with self._lock:
|
||
node = self.nodes.get(node_id)
|
||
if node is None:
|
||
return {} if default is _ATTRS_MISSING else default
|
||
return node.properties.copy()
|
||
|
||
def add_node_attribute(self, node_id: str, attributes: Dict[str, Any]) -> None:
|
||
with self._lock:
|
||
node = self.nodes.get(node_id)
|
||
if not node:
|
||
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
|
||
):
|
||
try:
|
||
self.mutation_callback("UPDATE_NODE", node_id, node.to_dict())
|
||
except Exception as e:
|
||
self.logger.warning(f"Audit trail callback failed for node {node_id}: {e}")
|
||
|
||
def get_edge_data(self, source_id: str, target_id: str) -> Dict[str, Any]:
|
||
"""Return metadata for the edge between *source_id* and *target_id*.
|
||
|
||
Returns an empty dict ``{}`` when no edge exists between the two nodes
|
||
or when either node is absent.
|
||
|
||
Args:
|
||
source_id: ID of the source node.
|
||
target_id: ID of the target node.
|
||
|
||
Returns:
|
||
A dict containing edge metadata (``id``, ``familyId``, ``type``,
|
||
``weight``, plus any custom metadata), or ``{}`` if not found.
|
||
"""
|
||
with self._lock:
|
||
for edge in self._adjacency.get(source_id, []):
|
||
if edge.target_id == target_id:
|
||
data = edge.metadata.copy()
|
||
data["id"] = edge.edge_id
|
||
data["familyId"] = edge.family_id or edge.edge_id
|
||
data["type"] = edge.edge_type
|
||
data["weight"] = edge.weight
|
||
return data
|
||
return {}
|
||
|
||
def get_neighbors(
|
||
self,
|
||
node_id: str,
|
||
hops: int = 1,
|
||
relationship_types: Optional[List[str]] = None,
|
||
min_weight: float = 0.0,
|
||
skip: int = 0,
|
||
limit: Optional[int] = None,
|
||
include_distance_metadata: bool = False,
|
||
) -> List[Dict[str, Any]]:
|
||
"""
|
||
Get neighbors of a node.
|
||
|
||
Args:
|
||
node_id: Starting node ID.
|
||
hops: Maximum number of hops to traverse (BFS depth).
|
||
relationship_types: Optional whitelist of edge types to follow.
|
||
min_weight: Minimum edge weight required to traverse an edge (default 0.0
|
||
means all edges pass). Use e.g. ``min_weight=0.5`` to follow only
|
||
strong/high-confidence relationships.
|
||
skip: Number of items to skip for pagination.
|
||
limit: Maximum items to return.
|
||
|
||
Returns:
|
||
List of dicts with neighbor info (id, type, content, relationship, weight, hop).
|
||
"""
|
||
with self._lock:
|
||
if node_id not in self.nodes:
|
||
return []
|
||
|
||
neighbors: List[Dict[str, Any]] = []
|
||
visited = {node_id}
|
||
queue = deque([(node_id, 0, [node_id], 1.0)])
|
||
rel_filter = set(relationship_types) if relationship_types else None
|
||
|
||
while queue:
|
||
current_id, current_hop, path_so_far, decay_so_far = queue.popleft()
|
||
if current_hop >= hops:
|
||
continue
|
||
|
||
outgoing_edges = self._adjacency.get(current_id, [])
|
||
for edge in outgoing_edges:
|
||
if rel_filter is not None and edge.edge_type not in rel_filter:
|
||
continue
|
||
if edge.weight < min_weight:
|
||
continue
|
||
neighbor_id = edge.target_id
|
||
if neighbor_id in visited:
|
||
continue
|
||
visited.add(neighbor_id)
|
||
next_hop = current_hop + 1
|
||
next_decay = decay_so_far * edge.weight
|
||
next_path = path_so_far + [neighbor_id]
|
||
queue.append((neighbor_id, next_hop, next_path, next_decay))
|
||
|
||
node = self.nodes.get(neighbor_id)
|
||
if not node:
|
||
continue
|
||
entry: Dict[str, Any] = {
|
||
"id": node.node_id,
|
||
"type": node.node_type,
|
||
"content": node.content,
|
||
"relationship": edge.edge_type,
|
||
"weight": edge.weight,
|
||
"hop": next_hop,
|
||
}
|
||
if include_distance_metadata:
|
||
entry["distance_band"] = classify_path_distance(next_hop)
|
||
entry["confidence_decay"] = next_decay
|
||
entry["path_to_anchor"] = next_path
|
||
neighbors.append(entry)
|
||
|
||
if limit is not None:
|
||
return neighbors[skip: skip + limit]
|
||
return neighbors[skip:]
|
||
|
||
def get_neighbor_distances(
|
||
self,
|
||
node_id: str,
|
||
hops: int = 3,
|
||
relationship_types: Optional[List[str]] = None,
|
||
min_confidence: float = 0.0,
|
||
) -> List[Dict[str, Any]]:
|
||
"""
|
||
Return neighbors with distance metadata, filtered by confidence decay.
|
||
|
||
Results are ordered by nearest hop first, then by strongest path confidence.
|
||
"""
|
||
neighbors = self.get_neighbors(
|
||
node_id,
|
||
hops=hops,
|
||
relationship_types=relationship_types,
|
||
include_distance_metadata=True,
|
||
)
|
||
filtered = [
|
||
item for item in neighbors
|
||
if item.get("confidence_decay", 0.0) >= min_confidence
|
||
]
|
||
return sorted(
|
||
filtered,
|
||
key=lambda item: (item.get("hop", 0), -item.get("confidence_decay", 0.0)),
|
||
)
|
||
|
||
def query(
|
||
self, query: str, skip: int = 0, limit: Optional[int] = None
|
||
) -> List[Dict[str, Any]]:
|
||
"""
|
||
Execute a simple keyword search query on the graph nodes.
|
||
|
||
Args:
|
||
query: Keyword query string
|
||
skip: Items to skip
|
||
limit: Max items to return
|
||
|
||
Returns:
|
||
List of matching node dicts
|
||
"""
|
||
results = []
|
||
query_lower = query.lower().split()
|
||
|
||
with self._lock:
|
||
for node in self.nodes.values():
|
||
content_lower = node.content.lower()
|
||
if any(word in content_lower for word in query_lower):
|
||
# Calculate simple score
|
||
overlap = sum(1 for word in query_lower if word in content_lower)
|
||
score = overlap / len(query_lower) if query_lower else 0.0
|
||
|
||
results.append(
|
||
{
|
||
"node": node.to_dict(),
|
||
"score": score,
|
||
"content": node.content,
|
||
}
|
||
)
|
||
|
||
sorted_res = sorted(results, key=lambda x: x["score"], reverse=True)
|
||
if limit is not None:
|
||
return sorted_res[skip: skip + limit]
|
||
return sorted_res[skip:]
|
||
|
||
def add_node(
|
||
self,
|
||
node_id: str,
|
||
node_type: str,
|
||
content: Optional[str] = None,
|
||
**properties,
|
||
) -> bool:
|
||
"""
|
||
Add a single node to the graph.
|
||
|
||
Args:
|
||
node_id: Unique identifier
|
||
node_type: Node type (e.g., 'entity', 'concept')
|
||
content: Node content/label
|
||
**properties: Additional properties. Use `valid_from` and `valid_until`
|
||
(ISO datetime strings) to define a temporal validity window.
|
||
"""
|
||
content = content or node_id
|
||
valid_from = properties.pop("valid_from", None)
|
||
valid_until = properties.pop("valid_until", None)
|
||
with self._lock:
|
||
return self._add_internal_node(
|
||
ContextNode(
|
||
node_id=node_id,
|
||
node_type=node_type,
|
||
content=content,
|
||
metadata=properties,
|
||
properties=properties,
|
||
valid_from=valid_from,
|
||
valid_until=valid_until,
|
||
)
|
||
)
|
||
|
||
def add_edge(
|
||
self,
|
||
source_id: str,
|
||
target_id: str,
|
||
edge_type: str = "related_to",
|
||
weight: float = 1.0,
|
||
**properties,
|
||
) -> bool:
|
||
"""
|
||
Add a single edge to the graph.
|
||
|
||
Args:
|
||
source_id: Source node ID
|
||
target_id: Target node ID
|
||
edge_type: Relationship type
|
||
weight: Edge weight
|
||
**properties: Additional properties. Use `valid_from` and `valid_until`
|
||
(ISO datetime strings) to define a temporal validity window.
|
||
"""
|
||
valid_from = properties.pop("valid_from", None)
|
||
valid_until = properties.pop("valid_until", None)
|
||
explicit_edge_id = properties.pop("id", properties.pop("edge_id", None))
|
||
explicit_family_id = properties.pop("familyId", properties.pop("family_id", None))
|
||
edge_id, family_id = _resolve_edge_identity(
|
||
source_id=source_id,
|
||
target_id=target_id,
|
||
edge_type=edge_type,
|
||
weight=weight,
|
||
metadata=properties,
|
||
valid_from=valid_from,
|
||
valid_until=valid_until,
|
||
edge_id=explicit_edge_id,
|
||
family_id=explicit_family_id,
|
||
)
|
||
with self._lock:
|
||
candidate = {"source": source_id, "target": target_id, "type": edge_type}
|
||
if is_skos_hierarchy_edge(candidate):
|
||
existing_edges = [
|
||
edge for edge in self.find_edges() if is_skos_hierarchy_edge(edge)
|
||
]
|
||
validate_skos_hierarchy([candidate], existing_edges)
|
||
return self._add_internal_edge(
|
||
ContextEdge(
|
||
edge_id=edge_id,
|
||
source_id=source_id,
|
||
target_id=target_id,
|
||
edge_type=edge_type,
|
||
family_id=str(family_id) if family_id is not None else edge_id,
|
||
weight=weight,
|
||
metadata=properties,
|
||
valid_from=valid_from,
|
||
valid_until=valid_until,
|
||
)
|
||
)
|
||
|
||
def save_to_file(
|
||
self, path: Union[str, Path], format: str = "json"
|
||
) -> None:
|
||
"""
|
||
Save the context graph in JSON or Markdown format.
|
||
|
||
Args:
|
||
path: JSON file path or Markdown export directory
|
||
format: Persistence format (``json`` or ``markdown``)
|
||
"""
|
||
normalized_format = self._normalize_persistence_format(format)
|
||
if normalized_format == "markdown":
|
||
self._save_markdown_directory(Path(path))
|
||
self.logger.info(f"Saved context graph Markdown to {path}")
|
||
return
|
||
|
||
with self._lock:
|
||
|
||
links_data = []
|
||
for link_id, (other_graph, source_node_id, target_node_id) in self._linked_graphs.items():
|
||
links_data.append(
|
||
{
|
||
"link_id": link_id,
|
||
"source_node_id": source_node_id,
|
||
"target_node_id": target_node_id,
|
||
"other_graph_id": other_graph.graph_id,
|
||
}
|
||
)
|
||
|
||
data = {
|
||
"graph_id": self.graph_id,
|
||
"nodes": [node.to_dict() for node in self.nodes.values()],
|
||
"edges": [edge.to_dict() for edge in self.edges],
|
||
"links": links_data,
|
||
}
|
||
|
||
with open(path, "w", encoding="utf-8") as f:
|
||
json.dump(data, f, indent=2, ensure_ascii=False)
|
||
|
||
self.logger.info(f"Saved context graph to {path}")
|
||
|
||
def load_from_file(
|
||
self, path: Union[str, Path], format: str = "json"
|
||
) -> None:
|
||
"""
|
||
Load the context graph from JSON or Markdown.
|
||
|
||
Args:
|
||
path: JSON file path or Markdown export directory
|
||
format: Persistence format (``json`` or ``markdown``)
|
||
"""
|
||
normalized_format = self._normalize_persistence_format(format)
|
||
if normalized_format == "markdown":
|
||
markdown_path = Path(path)
|
||
linked_component = find_filesystem_link(markdown_path)
|
||
if linked_component is not None:
|
||
raise ValueError(
|
||
"Refusing to import Markdown symbolic link or junction: "
|
||
f"{linked_component}"
|
||
)
|
||
if not markdown_path.exists():
|
||
self.logger.warning(f"File not found: {path}")
|
||
return
|
||
self._load_markdown_directory(markdown_path)
|
||
self.logger.info(f"Loaded context graph Markdown from {path}")
|
||
return
|
||
|
||
if not os.path.exists(path):
|
||
self.logger.warning(f"File not found: {path}")
|
||
return
|
||
|
||
with open(path, "r", encoding="utf-8") as f:
|
||
data = json.load(f)
|
||
|
||
if isinstance(data, list):
|
||
if data and isinstance(data[0], dict) and any(
|
||
key in data[0]
|
||
for key in ("source", "source_id", "target", "target_id", "START_ID", ":START_ID")
|
||
):
|
||
data = {"edges": data, "nodes": []}
|
||
else:
|
||
data = {"nodes": data, "edges": []}
|
||
elif not isinstance(data, dict):
|
||
raise ValueError("Graph file must contain a JSON object or array payload")
|
||
|
||
with self._lock:
|
||
# Clear existing
|
||
self.nodes.clear()
|
||
self.edges.clear()
|
||
self._edge_index.clear()
|
||
self._adjacency.clear()
|
||
self.node_type_index.clear()
|
||
self.edge_type_index.clear()
|
||
self._linked_graphs.clear()
|
||
self._unresolved_links.clear()
|
||
self._analytics_cache.clear()
|
||
# Deletion metadata belongs to the graph being replaced; keeping it
|
||
# would make entities in the loaded graph read as already retracted.
|
||
self._retractions.clear()
|
||
self._tombstones.clear()
|
||
|
||
if "graph_id" in data:
|
||
self.graph_id = data["graph_id"]
|
||
|
||
|
||
nodes = data.get("nodes")
|
||
if nodes is None:
|
||
nodes = data.get("entities")
|
||
if nodes is None:
|
||
nodes = data.get("vertices")
|
||
if nodes is None:
|
||
nodes = []
|
||
self.add_nodes(nodes)
|
||
|
||
edges = data.get("edges")
|
||
if edges is None:
|
||
edges = data.get("relationships")
|
||
if edges is None:
|
||
edges = data.get("links")
|
||
if edges is None:
|
||
edges = []
|
||
self.add_edges(edges)
|
||
|
||
|
||
for link_meta in data.get("links", []):
|
||
link_id = link_meta.get("link_id")
|
||
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
|
||
def _normalize_persistence_format(format: str) -> str:
|
||
if not isinstance(format, str) or not format.strip():
|
||
raise ValueError("Context graph persistence format must be a string.")
|
||
normalized = format.strip().lower()
|
||
if normalized not in {"json", "markdown"}:
|
||
raise ValueError(
|
||
f"Unsupported context graph persistence format: {format!r}. "
|
||
"Expected 'json' or 'markdown'."
|
||
)
|
||
return normalized
|
||
|
||
def _save_markdown_directory(self, destination: Path) -> None:
|
||
if not destination.name:
|
||
raise ValueError("Markdown export destination cannot be a filesystem root.")
|
||
linked_component = find_filesystem_link(destination)
|
||
if linked_component is not None:
|
||
raise ValueError(
|
||
"Refusing to replace Markdown symbolic link or junction: "
|
||
f"{linked_component}"
|
||
)
|
||
if destination.exists() and not destination.is_dir():
|
||
raise ValueError(
|
||
f"Markdown export destination is not a directory: {destination}"
|
||
)
|
||
if destination.exists() and any(destination.iterdir()):
|
||
try:
|
||
self._parse_markdown_directory(
|
||
destination, require_canonical_layout=True
|
||
)
|
||
except (FileNotFoundError, ValueError):
|
||
is_managed = False
|
||
else:
|
||
is_managed = True
|
||
if not is_managed:
|
||
raise ValueError(
|
||
"Refusing to replace a non-empty directory that is not a "
|
||
f"managed ContextGraph export: {destination}"
|
||
)
|
||
|
||
manifest_document, node_documents = self._markdown_documents()
|
||
destination.parent.mkdir(parents=True, exist_ok=True)
|
||
linked_component = find_filesystem_link(destination.parent)
|
||
if linked_component is not None:
|
||
raise ValueError(
|
||
"Refusing to export through Markdown symbolic link or junction: "
|
||
f"{linked_component}"
|
||
)
|
||
staging_path = Path(
|
||
tempfile.mkdtemp(
|
||
dir=str(destination.parent), prefix=f".{destination.name}.staging-"
|
||
)
|
||
)
|
||
backup_path: Optional[Path] = None
|
||
try:
|
||
nodes_path = staging_path / self._MARKDOWN_NODES_DIRECTORY
|
||
nodes_path.mkdir()
|
||
self._write_staged_markdown(
|
||
staging_path / self._MARKDOWN_MANIFEST, manifest_document
|
||
)
|
||
for filename, document in node_documents:
|
||
self._write_staged_markdown(nodes_path / filename, document)
|
||
|
||
if destination.exists():
|
||
backup_path = destination.parent / (
|
||
f".{destination.name}.backup-{uuid.uuid4().hex}"
|
||
)
|
||
os.replace(destination, backup_path)
|
||
try:
|
||
os.replace(staging_path, destination)
|
||
staging_path = None
|
||
except BaseException as publish_error:
|
||
if backup_path is not None and not destination.exists():
|
||
try:
|
||
os.replace(backup_path, destination)
|
||
except BaseException as restore_error:
|
||
self.logger.error(
|
||
"Failed to restore previous ContextGraph Markdown "
|
||
"export from %s after publish failure; preserving "
|
||
"the original publish error",
|
||
backup_path,
|
||
exc_info=(
|
||
type(restore_error),
|
||
restore_error,
|
||
restore_error.__traceback__,
|
||
),
|
||
)
|
||
add_note = getattr(publish_error, "add_note", None)
|
||
if add_note is not None:
|
||
add_note(
|
||
"Restoring the previous ContextGraph Markdown "
|
||
f"export also failed: {restore_error}"
|
||
)
|
||
else:
|
||
backup_path = None
|
||
raise
|
||
|
||
if backup_path is not None:
|
||
shutil.rmtree(backup_path)
|
||
backup_path = None
|
||
finally:
|
||
if staging_path is not None:
|
||
shutil.rmtree(staging_path, ignore_errors=True)
|
||
if backup_path is not None and backup_path.exists():
|
||
self.logger.warning(
|
||
"ContextGraph export left backup directory %s", backup_path
|
||
)
|
||
|
||
@staticmethod
|
||
def _write_staged_markdown(path: Path, document: str) -> None:
|
||
with path.open("x", encoding="utf-8") as output:
|
||
output.write(document)
|
||
output.flush()
|
||
os.fsync(output.fileno())
|
||
|
||
def _markdown_documents(self) -> Tuple[str, List[Tuple[str, str]]]:
|
||
with self._lock:
|
||
nodes = [
|
||
{
|
||
"id": node.node_id,
|
||
"type": node.node_type,
|
||
"properties": copy.deepcopy(node.properties),
|
||
"metadata": copy.deepcopy(node.metadata),
|
||
"valid_from": node.valid_from,
|
||
"valid_until": node.valid_until,
|
||
"content": node.content,
|
||
}
|
||
for node in self.nodes.values()
|
||
]
|
||
edges = [
|
||
{
|
||
"id": edge.edge_id,
|
||
"family_id": edge.family_id or edge.edge_id,
|
||
"source": edge.source_id,
|
||
"target": edge.target_id,
|
||
"type": edge.edge_type,
|
||
"weight": edge.weight,
|
||
"metadata": copy.deepcopy(edge.metadata),
|
||
"valid_from": edge.valid_from,
|
||
"valid_until": edge.valid_until,
|
||
}
|
||
for edge in self.edges
|
||
]
|
||
graph_id = self.graph_id
|
||
links_by_id = {
|
||
link_id: copy.deepcopy(link)
|
||
for link_id, link in self._unresolved_links.items()
|
||
}
|
||
for link_id, (
|
||
other_graph,
|
||
source_node_id,
|
||
target_node_id,
|
||
) in self._linked_graphs.items():
|
||
links_by_id[link_id] = {
|
||
"link_id": link_id,
|
||
"source_node_id": source_node_id,
|
||
"target_node_id": target_node_id,
|
||
"other_graph_id": other_graph.graph_id,
|
||
}
|
||
|
||
edges.sort(
|
||
key=lambda edge: (
|
||
str(edge["id"]),
|
||
str(edge["source"]),
|
||
str(edge["target"]),
|
||
str(edge["type"]),
|
||
)
|
||
)
|
||
seen_edge_ids: Set[str] = set()
|
||
duplicate_edge_ids: Set[str] = set()
|
||
for edge in edges:
|
||
edge_id = edge["id"]
|
||
if not isinstance(edge_id, str) or not edge_id.strip():
|
||
raise ValueError(
|
||
"Cannot export ContextGraph: every edge must have a string ID."
|
||
)
|
||
if edge_id in seen_edge_ids:
|
||
duplicate_edge_ids.add(edge_id)
|
||
seen_edge_ids.add(edge_id)
|
||
if duplicate_edge_ids:
|
||
duplicates = ", ".join(
|
||
repr(edge_id) for edge_id in sorted(duplicate_edge_ids)
|
||
)
|
||
raise ValueError(
|
||
f"Cannot export ContextGraph: duplicate edge ID(s): {duplicates}."
|
||
)
|
||
links = sorted(
|
||
links_by_id.values(), key=lambda link: str(link.get("link_id", ""))
|
||
)
|
||
manifest = {
|
||
"format": self._MARKDOWN_FORMAT,
|
||
"version": self._MARKDOWN_VERSION,
|
||
"graph_id": graph_id,
|
||
"edges": edges,
|
||
"links": links,
|
||
}
|
||
manifest_body = (
|
||
"# Context Graph\n\n"
|
||
"Graph relationships are stored in frontmatter. Node content is in "
|
||
"the `nodes` directory.\n"
|
||
)
|
||
manifest_document = self._render_markdown_document(
|
||
manifest, manifest_body, "graph manifest"
|
||
)
|
||
|
||
node_documents = []
|
||
filenames = set()
|
||
for node in sorted(nodes, key=lambda item: str(item["id"])):
|
||
filename = self._node_markdown_filename(node["id"])
|
||
normalized_filename = filename.casefold()
|
||
if normalized_filename in filenames:
|
||
raise ValueError(
|
||
f"Cannot export ContextGraph: duplicate filename {filename!r}."
|
||
)
|
||
filenames.add(normalized_filename)
|
||
content = node.pop("content")
|
||
node_documents.append(
|
||
(
|
||
filename,
|
||
self._render_markdown_document(
|
||
node, content, f"node {node['id']!r}"
|
||
),
|
||
)
|
||
)
|
||
return manifest_document, node_documents
|
||
|
||
@staticmethod
|
||
def _node_markdown_filename(node_id: Any) -> str:
|
||
if not isinstance(node_id, str) or not node_id.strip():
|
||
raise ValueError("Cannot export a ContextGraph node without a string ID.")
|
||
slug = re.sub(r"[^A-Za-z0-9._-]+", "-", node_id)
|
||
slug = re.sub(r"-+", "-", slug).strip("._-")[:80].rstrip("._-")
|
||
slug = slug or "node"
|
||
digest = hashlib.sha256(node_id.encode("utf-8")).hexdigest()[:12]
|
||
return f"{slug}--{digest}.md"
|
||
|
||
@classmethod
|
||
def _render_markdown_document(
|
||
cls, frontmatter: Dict[str, Any], body: str, source: str
|
||
) -> str:
|
||
if not isinstance(body, str):
|
||
raise ValueError(f"Cannot export {source}: Markdown body must be a string.")
|
||
canonical = cls._canonical_markdown_value(frontmatter, source)
|
||
try:
|
||
yaml_text = yaml.safe_dump(
|
||
canonical,
|
||
sort_keys=False,
|
||
allow_unicode=True,
|
||
default_flow_style=False,
|
||
)
|
||
except yaml.YAMLError as exc:
|
||
raise ValueError(
|
||
f"Cannot export {source}: metadata is not YAML serializable."
|
||
) from exc
|
||
return f"---\n{yaml_text}---\n\n{body}"
|
||
|
||
@classmethod
|
||
def _canonical_markdown_value(
|
||
cls, value: Any, source: str, ancestors: Optional[Set[int]] = None
|
||
) -> Any:
|
||
ancestors = set() if ancestors is None else ancestors
|
||
if isinstance(value, dict):
|
||
if any(not isinstance(key, str) for key in value):
|
||
raise ValueError(
|
||
f"Invalid Markdown metadata in {source}: "
|
||
"mapping keys must be strings."
|
||
)
|
||
identity = id(value)
|
||
if identity in ancestors:
|
||
raise ValueError(
|
||
f"Invalid Markdown metadata in {source}: "
|
||
"values cannot contain cycles."
|
||
)
|
||
ancestors.add(identity)
|
||
try:
|
||
return {
|
||
key: cls._canonical_markdown_value(
|
||
value[key], source, ancestors
|
||
)
|
||
for key in sorted(value)
|
||
}
|
||
finally:
|
||
ancestors.remove(identity)
|
||
if isinstance(value, list):
|
||
identity = id(value)
|
||
if identity in ancestors:
|
||
raise ValueError(
|
||
f"Invalid Markdown metadata in {source}: "
|
||
"values cannot contain cycles."
|
||
)
|
||
ancestors.add(identity)
|
||
try:
|
||
return [
|
||
cls._canonical_markdown_value(item, source, ancestors)
|
||
for item in value
|
||
]
|
||
finally:
|
||
ancestors.remove(identity)
|
||
if isinstance(value, tuple) or isinstance(value, set):
|
||
raise ValueError(
|
||
f"Invalid Markdown metadata in {source}: "
|
||
"tuples and sets are not supported."
|
||
)
|
||
return value
|
||
|
||
def _load_markdown_directory(self, source: Path) -> None:
|
||
parsed_state = self._parse_markdown_directory(source)
|
||
graph_id, nodes_by_id, edges, unresolved_links = parsed_state
|
||
|
||
adjacency: Dict[str, List[ContextEdge]] = defaultdict(list)
|
||
edge_index: Dict[str, ContextEdge] = {}
|
||
node_type_index: Dict[str, Set[str]] = defaultdict(set)
|
||
edge_type_index: Dict[str, List[ContextEdge]] = defaultdict(list)
|
||
for node in nodes_by_id.values():
|
||
node_type_index[node.node_type].add(node.node_id)
|
||
for edge in edges:
|
||
edge_index[edge.edge_id] = edge
|
||
adjacency[edge.source_id].append(edge)
|
||
edge_type_index[edge.edge_type].append(edge)
|
||
|
||
with self._lock:
|
||
self.graph_id = graph_id
|
||
self.nodes.clear()
|
||
self.nodes.update(nodes_by_id)
|
||
self.edges.clear()
|
||
self.edges.extend(edges)
|
||
self._edge_index.clear()
|
||
self._edge_index.update(edge_index)
|
||
self._adjacency.clear()
|
||
self._adjacency.update(adjacency)
|
||
self.node_type_index.clear()
|
||
self.node_type_index.update(node_type_index)
|
||
self.edge_type_index.clear()
|
||
self.edge_type_index.update(edge_type_index)
|
||
self._linked_graphs.clear()
|
||
self._unresolved_links.clear()
|
||
self._unresolved_links.update(unresolved_links)
|
||
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 = [
|
||
("ADD_NODE", node.node_id, node.to_dict())
|
||
for node in nodes_by_id.values()
|
||
]
|
||
mutation_events.extend(
|
||
("ADD_EDGE", edge.edge_id, edge.to_dict()) for edge in edges
|
||
)
|
||
for operation, entity_id, payload in mutation_events:
|
||
try:
|
||
self.mutation_callback(operation, entity_id, payload)
|
||
except Exception as exc:
|
||
self.logger.warning(
|
||
"Audit trail callback failed for Markdown graph load "
|
||
"%s %s: %s",
|
||
operation,
|
||
entity_id,
|
||
exc,
|
||
)
|
||
|
||
def _parse_markdown_directory(
|
||
self, source: Path, require_canonical_layout: bool = False
|
||
) -> Tuple[
|
||
str,
|
||
Dict[str, ContextNode],
|
||
List[ContextEdge],
|
||
Dict[str, Dict[str, str]],
|
||
]:
|
||
manifest_document, node_documents = self._read_markdown_directory(
|
||
source, require_canonical_layout=require_canonical_layout
|
||
)
|
||
manifest, _ = self._parse_markdown_document(
|
||
manifest_document, str(source / self._MARKDOWN_MANIFEST)
|
||
)
|
||
graph_id, edges, links = self._parse_markdown_manifest(manifest, source)
|
||
|
||
nodes_by_id: Dict[str, ContextNode] = {}
|
||
for node_source, document in node_documents:
|
||
frontmatter, body = self._parse_markdown_document(document, node_source)
|
||
node = self._parse_markdown_node(frontmatter, body, node_source)
|
||
if node.node_id in nodes_by_id:
|
||
raise ValueError(
|
||
f"Duplicate Markdown node ID {node.node_id!r} in {node_source}."
|
||
)
|
||
node_filename = Path(node_source).name
|
||
if (
|
||
require_canonical_layout
|
||
and node_filename != self._node_markdown_filename(node.node_id)
|
||
):
|
||
raise ValueError(
|
||
"Invalid managed ContextGraph export: node file "
|
||
f"{node_filename!r} is not the canonical filename "
|
||
f"for node {node.node_id!r}."
|
||
)
|
||
nodes_by_id[node.node_id] = node
|
||
|
||
missing_endpoints = sorted(
|
||
{
|
||
endpoint
|
||
for edge in edges
|
||
for endpoint in (edge.source_id, edge.target_id)
|
||
if endpoint not in nodes_by_id
|
||
}
|
||
)
|
||
if missing_endpoints and require_canonical_layout:
|
||
missing = ", ".join(repr(endpoint) for endpoint in missing_endpoints)
|
||
raise ValueError(
|
||
"Invalid managed ContextGraph export: edge endpoint(s) "
|
||
f"{missing} do not have node files."
|
||
)
|
||
for endpoint in missing_endpoints:
|
||
nodes_by_id[endpoint] = ContextNode(endpoint, "entity", endpoint)
|
||
|
||
hierarchy_edges = [
|
||
{
|
||
"source": edge.source_id,
|
||
"target": edge.target_id,
|
||
"type": edge.edge_type,
|
||
}
|
||
for edge in edges
|
||
if is_skos_hierarchy_edge(edge.to_dict())
|
||
]
|
||
if hierarchy_edges:
|
||
validate_skos_hierarchy(hierarchy_edges, [])
|
||
|
||
unresolved_links = {}
|
||
for link in links:
|
||
link_id = link["link_id"]
|
||
if link_id in unresolved_links:
|
||
raise ValueError(
|
||
f"Duplicate cross-graph link ID {link_id!r} in graph manifest."
|
||
)
|
||
if link["source_node_id"] not in nodes_by_id:
|
||
raise ValueError(
|
||
f"Cross-graph link {link_id!r} references missing source node "
|
||
f"{link['source_node_id']!r}."
|
||
)
|
||
unresolved_links[link_id] = link
|
||
return graph_id, nodes_by_id, edges, unresolved_links
|
||
|
||
def _read_markdown_directory(
|
||
self, source: Path, require_canonical_layout: bool = False
|
||
) -> Tuple[str, List[Tuple[str, str]]]:
|
||
linked_component = find_filesystem_link(source)
|
||
if linked_component is not None:
|
||
raise ValueError(
|
||
"Refusing to import Markdown symbolic link or junction: "
|
||
f"{linked_component}"
|
||
)
|
||
if not source.exists():
|
||
raise FileNotFoundError(
|
||
f"ContextGraph Markdown import path does not exist: {source}"
|
||
)
|
||
if not source.is_dir():
|
||
raise ValueError(
|
||
f"ContextGraph Markdown import path is not a directory: {source}"
|
||
)
|
||
|
||
if require_canonical_layout:
|
||
expected_entries = {
|
||
self._MARKDOWN_MANIFEST,
|
||
self._MARKDOWN_NODES_DIRECTORY,
|
||
}
|
||
actual_entries = {path.name for path in source.iterdir()}
|
||
if actual_entries != expected_entries:
|
||
unexpected = sorted(actual_entries - expected_entries)
|
||
missing = sorted(expected_entries - actual_entries)
|
||
details = []
|
||
if unexpected:
|
||
details.append(f"unexpected entries: {unexpected!r}")
|
||
if missing:
|
||
details.append(f"missing entries: {missing!r}")
|
||
raise ValueError(
|
||
"Invalid managed ContextGraph export layout ("
|
||
+ "; ".join(details)
|
||
+ ")."
|
||
)
|
||
|
||
manifest_path = source / self._MARKDOWN_MANIFEST
|
||
manifest_document = self._read_markdown_file(manifest_path)
|
||
nodes_path = source / self._MARKDOWN_NODES_DIRECTORY
|
||
linked_component = find_filesystem_link(nodes_path)
|
||
if linked_component is not None:
|
||
raise ValueError(
|
||
"Refusing to import Markdown symbolic link or junction: "
|
||
f"{linked_component}"
|
||
)
|
||
if not nodes_path.is_dir():
|
||
raise ValueError(
|
||
f"ContextGraph Markdown nodes directory is missing: {nodes_path}"
|
||
)
|
||
|
||
node_paths = []
|
||
for path in nodes_path.iterdir():
|
||
linked_component = find_filesystem_link(path)
|
||
if linked_component is not None:
|
||
raise ValueError(
|
||
"Refusing to import Markdown symbolic link or junction: "
|
||
f"{linked_component}"
|
||
)
|
||
if path.suffix.lower() not in self._MARKDOWN_EXTENSIONS:
|
||
if require_canonical_layout:
|
||
raise ValueError(
|
||
"Invalid managed ContextGraph export: unexpected node "
|
||
f"entry {path.name!r}."
|
||
)
|
||
continue
|
||
if not path.is_file():
|
||
raise ValueError(f"Markdown node path is not a regular file: {path}")
|
||
node_paths.append(path)
|
||
linked_component = find_filesystem_link(nodes_path)
|
||
if linked_component is not None:
|
||
raise ValueError(
|
||
"Refusing to import Markdown symbolic link or junction: "
|
||
f"{linked_component}"
|
||
)
|
||
node_paths.sort(key=lambda path: (path.name.casefold(), path.name))
|
||
return manifest_document, [
|
||
(str(path), self._read_markdown_file(path)) for path in node_paths
|
||
]
|
||
|
||
@staticmethod
|
||
def _read_markdown_file(path: Path) -> str:
|
||
linked_component = find_filesystem_link(path)
|
||
if linked_component is not None:
|
||
raise ValueError(
|
||
"Refusing to import Markdown symbolic link or junction: "
|
||
f"{linked_component}"
|
||
)
|
||
flags = os.O_RDONLY
|
||
if hasattr(os, "O_NOFOLLOW"):
|
||
flags |= os.O_NOFOLLOW
|
||
try:
|
||
descriptor = os.open(path, flags)
|
||
except OSError as exc:
|
||
linked_component = find_filesystem_link(path)
|
||
if exc.errno == errno.ELOOP or linked_component is not None:
|
||
raise ValueError(
|
||
"Refusing to import Markdown symbolic link or junction: "
|
||
f"{linked_component or path}"
|
||
) from exc
|
||
if exc.errno == errno.ENOENT:
|
||
raise FileNotFoundError(f"Markdown file is missing: {path}") from exc
|
||
raise OSError(
|
||
exc.errno,
|
||
f"Failed to read Markdown file {path}: {exc.strerror or str(exc)}",
|
||
exc.filename or str(path),
|
||
) from exc
|
||
|
||
try:
|
||
linked_component = find_filesystem_link(path)
|
||
if linked_component is not None:
|
||
raise ValueError(
|
||
"Refusing to import Markdown symbolic link or junction: "
|
||
f"{linked_component}"
|
||
)
|
||
if not stat.S_ISREG(os.fstat(descriptor).st_mode):
|
||
raise ValueError(f"Markdown path is not a regular file: {path}")
|
||
with os.fdopen(descriptor, "r", encoding="utf-8") as input_file:
|
||
descriptor = None
|
||
return input_file.read()
|
||
finally:
|
||
if descriptor is not None:
|
||
os.close(descriptor)
|
||
|
||
@staticmethod
|
||
def _parse_markdown_document(
|
||
document: str, source: str
|
||
) -> Tuple[Dict[str, Any], str]:
|
||
lines = document.splitlines(keepends=True)
|
||
if not lines or lines[0].rstrip("\r\n") != "---":
|
||
raise ValueError(
|
||
f"Invalid Markdown frontmatter in {source}: "
|
||
"document must start with '---'."
|
||
)
|
||
closing_index = next(
|
||
(
|
||
index
|
||
for index, line in enumerate(lines[1:], start=1)
|
||
if line.rstrip("\r\n") == "---"
|
||
),
|
||
None,
|
||
)
|
||
if closing_index is None:
|
||
raise ValueError(
|
||
f"Invalid Markdown frontmatter in {source}: missing closing '---'."
|
||
)
|
||
try:
|
||
loaded = yaml.load(
|
||
"".join(lines[1:closing_index]), Loader=_UniqueKeySafeLoader
|
||
)
|
||
except yaml.YAMLError as exc:
|
||
raise ValueError(
|
||
f"Invalid Markdown frontmatter in {source}: {exc}"
|
||
) from exc
|
||
frontmatter = {} if loaded is None else loaded
|
||
if not isinstance(frontmatter, dict):
|
||
raise ValueError(
|
||
f"Invalid Markdown frontmatter in {source}: expected a YAML mapping."
|
||
)
|
||
if any(not isinstance(key, str) for key in frontmatter):
|
||
raise ValueError(
|
||
f"Invalid Markdown frontmatter in {source}: "
|
||
"field names must be strings."
|
||
)
|
||
|
||
body = "".join(lines[closing_index + 1 :])
|
||
if body.startswith("\r\n"):
|
||
body = body[2:]
|
||
elif body.startswith("\n"):
|
||
body = body[1:]
|
||
return frontmatter, body
|
||
|
||
def _parse_markdown_manifest(
|
||
self, manifest: Dict[str, Any], source: Path
|
||
) -> Tuple[str, List[ContextEdge], List[Dict[str, str]]]:
|
||
manifest_source = str(source / self._MARKDOWN_MANIFEST)
|
||
if manifest.get("format") != self._MARKDOWN_FORMAT:
|
||
raise ValueError(
|
||
f"Invalid ContextGraph Markdown manifest in {manifest_source}: "
|
||
f"'format' must be {self._MARKDOWN_FORMAT!r}."
|
||
)
|
||
version = manifest.get("version")
|
||
if isinstance(version, bool) or version != self._MARKDOWN_VERSION:
|
||
raise ValueError(
|
||
f"Unsupported ContextGraph Markdown version {version!r} in "
|
||
f"{manifest_source}; expected {self._MARKDOWN_VERSION}."
|
||
)
|
||
graph_id = self._required_markdown_string(
|
||
manifest.get("graph_id"), "graph_id", manifest_source
|
||
)
|
||
|
||
raw_edges = manifest.get("edges", [])
|
||
if not isinstance(raw_edges, list):
|
||
raise ValueError(
|
||
f"Invalid ContextGraph Markdown manifest in {manifest_source}: "
|
||
"'edges' must be a list."
|
||
)
|
||
edges = []
|
||
edge_ids = set()
|
||
for index, raw_edge in enumerate(raw_edges):
|
||
edge_source = f"{manifest_source} edge[{index}]"
|
||
edge = self._parse_markdown_edge(raw_edge, edge_source)
|
||
if edge.edge_id in edge_ids:
|
||
raise ValueError(
|
||
f"Duplicate Markdown edge ID {edge.edge_id!r} in {edge_source}."
|
||
)
|
||
edge_ids.add(edge.edge_id)
|
||
edges.append(edge)
|
||
|
||
raw_links = manifest.get("links", [])
|
||
if not isinstance(raw_links, list):
|
||
raise ValueError(
|
||
f"Invalid ContextGraph Markdown manifest in {manifest_source}: "
|
||
"'links' must be a list."
|
||
)
|
||
links = [
|
||
self._parse_markdown_link(link, f"{manifest_source} link[{index}]")
|
||
for index, link in enumerate(raw_links)
|
||
]
|
||
return graph_id, edges, links
|
||
|
||
def _parse_markdown_node(
|
||
self, frontmatter: Dict[str, Any], body: str, source: str
|
||
) -> ContextNode:
|
||
node_id = self._required_markdown_string(frontmatter.get("id"), "id", source)
|
||
node_type = self._required_markdown_string(
|
||
frontmatter.get("type"), "type", source
|
||
)
|
||
properties = self._markdown_mapping(
|
||
frontmatter.get("properties", {}), "properties", source
|
||
)
|
||
metadata = self._markdown_mapping(
|
||
frontmatter.get("metadata", {}), "metadata", source
|
||
)
|
||
return ContextNode(
|
||
node_id=node_id,
|
||
node_type=node_type,
|
||
content=body,
|
||
properties=properties,
|
||
metadata=metadata,
|
||
valid_from=self._markdown_temporal_value(
|
||
frontmatter.get("valid_from"), "valid_from", source
|
||
),
|
||
valid_until=self._markdown_temporal_value(
|
||
frontmatter.get("valid_until"), "valid_until", source
|
||
),
|
||
)
|
||
|
||
def _parse_markdown_edge(self, raw_edge: Any, source: str) -> ContextEdge:
|
||
if not isinstance(raw_edge, dict):
|
||
raise ValueError(f"Invalid Markdown edge in {source}: expected a mapping.")
|
||
edge_id = self._required_markdown_string(raw_edge.get("id"), "id", source)
|
||
family_value = raw_edge.get("family_id", raw_edge.get("familyId", edge_id))
|
||
family_id = self._required_markdown_string(family_value, "family_id", source)
|
||
source_id = self._required_markdown_string(
|
||
raw_edge.get("source"), "source", source
|
||
)
|
||
target_id = self._required_markdown_string(
|
||
raw_edge.get("target"), "target", source
|
||
)
|
||
edge_type = self._required_markdown_string(raw_edge.get("type"), "type", source)
|
||
weight = raw_edge.get("weight", 1.0)
|
||
if isinstance(weight, bool) or not isinstance(weight, (int, float)):
|
||
raise ValueError(
|
||
f"Invalid Markdown edge in {source}: 'weight' must be a number."
|
||
)
|
||
metadata = self._markdown_mapping(
|
||
raw_edge.get("metadata", {}), "metadata", source
|
||
)
|
||
return ContextEdge(
|
||
edge_id=edge_id,
|
||
family_id=family_id,
|
||
source_id=source_id,
|
||
target_id=target_id,
|
||
edge_type=edge_type,
|
||
weight=float(weight),
|
||
metadata=metadata,
|
||
valid_from=self._markdown_temporal_value(
|
||
raw_edge.get("valid_from"), "valid_from", source
|
||
),
|
||
valid_until=self._markdown_temporal_value(
|
||
raw_edge.get("valid_until"), "valid_until", source
|
||
),
|
||
)
|
||
|
||
def _parse_markdown_link(self, raw_link: Any, source: str) -> Dict[str, str]:
|
||
if not isinstance(raw_link, dict):
|
||
raise ValueError(
|
||
f"Invalid cross-graph link in {source}: expected a mapping."
|
||
)
|
||
return {
|
||
field_name: self._required_markdown_string(
|
||
raw_link.get(field_name), field_name, source
|
||
)
|
||
for field_name in (
|
||
"link_id",
|
||
"source_node_id",
|
||
"target_node_id",
|
||
"other_graph_id",
|
||
)
|
||
}
|
||
|
||
@staticmethod
|
||
def _required_markdown_string(value: Any, field_name: str, source: str) -> str:
|
||
if not isinstance(value, str) or not value.strip():
|
||
raise ValueError(
|
||
f"Invalid Markdown frontmatter in {source}: "
|
||
f"'{field_name}' must be a non-empty string."
|
||
)
|
||
return value
|
||
|
||
@classmethod
|
||
def _markdown_mapping(
|
||
cls, value: Any, field_name: str, source: str
|
||
) -> Dict[str, Any]:
|
||
if value is None:
|
||
return {}
|
||
if not isinstance(value, dict):
|
||
raise ValueError(
|
||
f"Invalid Markdown frontmatter in {source}: "
|
||
f"'{field_name}' must be a mapping."
|
||
)
|
||
canonical = cls._canonical_markdown_value(value, source)
|
||
return dict(canonical)
|
||
|
||
@staticmethod
|
||
def _markdown_temporal_value(
|
||
value: Any, field_name: str, source: str
|
||
) -> Optional[str]:
|
||
if value is None:
|
||
return None
|
||
if isinstance(value, datetime):
|
||
return _normalize_temporal_input(value)
|
||
if isinstance(value, date):
|
||
return value.isoformat()
|
||
if isinstance(value, str) and value.strip():
|
||
try:
|
||
_normalize_temporal_input(value)
|
||
except ValueError as exc:
|
||
raise ValueError(
|
||
f"Invalid Markdown frontmatter in {source}: "
|
||
f"'{field_name}' must be a valid ISO-8601 string."
|
||
) from exc
|
||
return value
|
||
raise ValueError(
|
||
f"Invalid Markdown frontmatter in {source}: "
|
||
f"'{field_name}' must be a valid ISO-8601 string."
|
||
)
|
||
|
||
|
||
def find_node(self, node_id: str) -> Optional[Dict[str, Any]]:
|
||
"""Return a dict representation of the node identified by *node_id*.
|
||
|
||
Returns ``None`` when the node does not exist.
|
||
|
||
Args:
|
||
node_id: ID of the node to look up.
|
||
|
||
Returns:
|
||
A dict with keys ``id``, ``type``, ``content``, and ``metadata``,
|
||
or ``None`` if the node is not found.
|
||
"""
|
||
with self._lock:
|
||
node = self.nodes.get(node_id)
|
||
if node:
|
||
merged_metadata = {}
|
||
merged_metadata.update(getattr(node, "metadata", {}) or {})
|
||
merged_metadata.update(getattr(node, "properties", {}) or {})
|
||
return {
|
||
"id": node.node_id,
|
||
"type": node.node_type,
|
||
"content": node.content,
|
||
"metadata": merged_metadata,
|
||
}
|
||
return None
|
||
|
||
def find_nodes(
|
||
self, node_type: Optional[str] = None, skip: int = 0, limit: Optional[int] = None
|
||
) -> List[Dict[str, Any]]:
|
||
"""Find nodes lazily"""
|
||
with self._lock:
|
||
if node_type:
|
||
# Sets are unordered, sort IDs for deterministic pagination
|
||
raw_ids = sorted(
|
||
(node_id for node_id in self.node_type_index.get(node_type, set()) if node_id is not None),
|
||
key=lambda value: str(value),
|
||
)
|
||
source = (self.nodes[nid] for nid in raw_ids if nid in self.nodes)
|
||
else:
|
||
source = self.nodes.values()
|
||
|
||
gen = (
|
||
{
|
||
"id": n.node_id,
|
||
"type": n.node_type or "entity",
|
||
"content": n.content or "",
|
||
"metadata": {**(getattr(n, "metadata", {}) or {}), **(getattr(n, "properties", {}) or {})},
|
||
}
|
||
for n in source if n.node_id
|
||
)
|
||
stop = skip + limit if limit is not None else None
|
||
|
||
return list(itertools.islice(gen, skip, stop))
|
||
|
||
def find_active_nodes(
|
||
self,
|
||
node_type: Optional[str] = None,
|
||
at_time: Optional[datetime] = None,
|
||
skip: int = 0,
|
||
limit: Optional[int] = None,
|
||
) -> List[Dict[str, Any]]:
|
||
"""Find active nodes lazily."""
|
||
now = at_time or datetime.utcnow()
|
||
with self._lock:
|
||
if node_type:
|
||
raw_ids = sorted(
|
||
(node_id for node_id in self.node_type_index.get(node_type, set()) if node_id is not None),
|
||
key=lambda value: str(value),
|
||
)
|
||
source = (self.nodes[nid] for nid in raw_ids if nid in self.nodes)
|
||
else:
|
||
source = self.nodes.values()
|
||
|
||
def _active(nodes_iter):
|
||
for n in nodes_iter:
|
||
if n.node_id and n.is_active(now):
|
||
yield {
|
||
"id": n.node_id,
|
||
"type": n.node_type or "entity",
|
||
"content": n.content or "",
|
||
"metadata": {
|
||
**(getattr(n, "metadata", {}) or {}),
|
||
**(getattr(n, "properties", {}) or {}),
|
||
},
|
||
}
|
||
|
||
stop = skip + limit if limit is not None else None
|
||
return list(itertools.islice(_active(source), skip, stop))
|
||
|
||
def link_graph(
|
||
self,
|
||
other_graph: "ContextGraph",
|
||
source_node_id: str,
|
||
target_node_id: str,
|
||
link_type: str = "CROSS_GRAPH",
|
||
) -> str:
|
||
"""
|
||
Create a navigable link from a node in this graph to a node in another graph.
|
||
|
||
This enables cross-graph navigation: separate ContextGraph instances can be
|
||
linked hierarchically, allowing agents to traverse from one problem space into
|
||
a related one without merging the graphs (like "a dream within a dream").
|
||
|
||
Args:
|
||
other_graph: The target ContextGraph instance.
|
||
source_node_id: Node ID in *this* graph that serves as the exit point.
|
||
target_node_id: Node ID in *other_graph* that serves as the entry point.
|
||
link_type: Edge type label for the cross-graph bridge (default "CROSS_GRAPH").
|
||
|
||
Returns:
|
||
A unique link ID that can be passed to :meth:`navigate_to`.
|
||
|
||
Raises:
|
||
KeyError: If source_node_id is not in this graph or target_node_id is not
|
||
in other_graph.
|
||
"""
|
||
with self._lock:
|
||
if source_node_id not in self.nodes:
|
||
raise KeyError(f"Source node '{source_node_id}' not found in this graph")
|
||
if target_node_id not in other_graph.nodes:
|
||
raise KeyError(f"Target node '{target_node_id}' not found in other_graph")
|
||
|
||
link_id = str(uuid.uuid4())
|
||
self._linked_graphs[link_id] = (other_graph, source_node_id, target_node_id)
|
||
|
||
marker_node_id = f"__cross_graph_{link_id}"
|
||
self._add_internal_node(
|
||
ContextNode(
|
||
node_id=marker_node_id,
|
||
node_type="cross_graph_link",
|
||
content=f"Cross-graph link → {target_node_id}",
|
||
metadata={"cross_graph": True, "link_id": link_id, "target_node_id": target_node_id},
|
||
properties={},
|
||
)
|
||
)
|
||
|
||
self._add_internal_edge(
|
||
ContextEdge(
|
||
source_id=source_node_id,
|
||
target_id=marker_node_id,
|
||
edge_type=link_type,
|
||
weight=1.0,
|
||
metadata={"cross_graph": True, "link_id": link_id},
|
||
)
|
||
)
|
||
return link_id
|
||
|
||
def navigate_to(self, link_id: str) -> Tuple["ContextGraph", str]:
|
||
"""
|
||
Navigate to the target graph and entry node for a cross-graph link.
|
||
|
||
Args:
|
||
link_id: Link ID returned by :meth:`link_graph`.
|
||
|
||
Returns:
|
||
Tuple of ``(other_graph, target_node_id)``.
|
||
|
||
Raises:
|
||
KeyError: If link_id is not registered on this graph.
|
||
"""
|
||
if link_id not in self._linked_graphs:
|
||
if link_id in self._unresolved_links:
|
||
meta = self._unresolved_links[link_id]
|
||
raise KeyError(
|
||
f"Cross-graph link '{link_id}' exists but its target graph "
|
||
f"(graph_id={meta.get('other_graph_id')!r}) has not been reconnected. "
|
||
"Call resolve_links({graph_id: graph_instance, ...}) to restore navigation."
|
||
)
|
||
raise KeyError(
|
||
f"No cross-graph link '{link_id}' found. "
|
||
"Call link_graph() first to create the link."
|
||
)
|
||
other_graph, _, target_node_id = self._linked_graphs[link_id]
|
||
return other_graph, target_node_id
|
||
|
||
def cross_graph_path(
|
||
self,
|
||
source_node_id: str,
|
||
target_graph: "ContextGraph",
|
||
target_node_id: str,
|
||
max_hops: int = 10,
|
||
) -> Dict[str, Any]:
|
||
"""
|
||
Find the shortest path across linked ContextGraph instances.
|
||
"""
|
||
start = (self.graph_id, source_node_id)
|
||
goal = (target_graph.graph_id, target_node_id)
|
||
if source_node_id not in self.nodes or target_node_id not in target_graph.nodes:
|
||
return {
|
||
"path": [],
|
||
"hop_count": 0,
|
||
"cross_graph_links_used": 0,
|
||
"confidence_decay": 0.0,
|
||
"distance_band": classify_path_distance(max_hops + 1),
|
||
"reachable": False,
|
||
}
|
||
|
||
queue = deque([(self, source_node_id, [start], 0, 1.0, 0)])
|
||
visited = {start}
|
||
|
||
while queue:
|
||
graph, current_id, path, hop_count, decay, links_used = queue.popleft()
|
||
current_key = (graph.graph_id, current_id)
|
||
if current_key == goal:
|
||
return {
|
||
"path": path,
|
||
"hop_count": hop_count,
|
||
"cross_graph_links_used": links_used,
|
||
"confidence_decay": decay,
|
||
"distance_band": classify_path_distance(hop_count),
|
||
"reachable": True,
|
||
}
|
||
if hop_count >= max_hops:
|
||
continue
|
||
|
||
with graph._lock:
|
||
outgoing_edges = list(graph._adjacency.get(current_id, []))
|
||
|
||
for edge in outgoing_edges:
|
||
marker = graph.nodes.get(edge.target_id)
|
||
link_id = None
|
||
if marker and marker.node_type == "cross_graph_link":
|
||
link_id = marker.metadata.get("link_id")
|
||
|
||
if link_id:
|
||
try:
|
||
next_graph, next_node_id = graph.navigate_to(link_id)
|
||
except KeyError:
|
||
continue
|
||
next_key = (next_graph.graph_id, next_node_id)
|
||
next_links_used = links_used + 1
|
||
else:
|
||
next_graph, next_node_id = graph, edge.target_id
|
||
next_key = (graph.graph_id, edge.target_id)
|
||
next_links_used = links_used
|
||
|
||
if next_key in visited:
|
||
continue
|
||
visited.add(next_key)
|
||
queue.append(
|
||
(
|
||
next_graph,
|
||
next_node_id,
|
||
path + [next_key],
|
||
hop_count + 1,
|
||
decay * edge.weight,
|
||
next_links_used,
|
||
)
|
||
)
|
||
|
||
return {
|
||
"path": [],
|
||
"hop_count": 0,
|
||
"cross_graph_links_used": 0,
|
||
"confidence_decay": 0.0,
|
||
"distance_band": classify_path_distance(max_hops + 1),
|
||
"reachable": False,
|
||
}
|
||
|
||
def resolve_links(self, graphs: Dict[str, "ContextGraph"]) -> int:
|
||
"""
|
||
Reconnect cross-graph links after a :meth:`load_from_file` call.
|
||
|
||
Since ``other_graph`` object references cannot be serialised, links are stored
|
||
as metadata only (``other_graph_id``). Call this method with a mapping of
|
||
``{graph_id: graph_instance}`` to restore live navigation.
|
||
|
||
Args:
|
||
graphs: Mapping of graph_id strings to ContextGraph instances.
|
||
|
||
Returns:
|
||
Number of links successfully resolved.
|
||
|
||
Example::
|
||
|
||
g1.save_to_file("g1.json")
|
||
g2.save_to_file("g2.json")
|
||
|
||
g1b, g2b = ContextGraph(), ContextGraph()
|
||
g1b.load_from_file("g1.json")
|
||
g2b.load_from_file("g2.json")
|
||
resolved = g1b.resolve_links({g2b.graph_id: g2b})
|
||
"""
|
||
resolved = 0
|
||
with self._lock:
|
||
for link_id, meta in list(self._unresolved_links.items()):
|
||
other_graph_id = meta.get("other_graph_id")
|
||
if other_graph_id in graphs:
|
||
other_graph = graphs[other_graph_id]
|
||
source_node_id = meta["source_node_id"]
|
||
target_node_id = meta["target_node_id"]
|
||
# Validate target node still exists in the restored graph
|
||
if target_node_id in other_graph.nodes:
|
||
self._linked_graphs[link_id] = (other_graph, source_node_id, target_node_id)
|
||
del self._unresolved_links[link_id]
|
||
resolved += 1
|
||
else:
|
||
self.logger.warning(
|
||
f"resolve_links: target node '{target_node_id}' not found in "
|
||
f"graph '{other_graph_id}' for link '{link_id}'"
|
||
)
|
||
return resolved
|
||
|
||
def find_edges(
|
||
self, edge_type: Optional[str] = None, skip: int = 0, limit: Optional[int] = None
|
||
) -> List[Dict[str, Any]]:
|
||
"""Find edges lazily."""
|
||
with self._lock:
|
||
source = self.edge_type_index.get(edge_type, []) if edge_type else self.edges
|
||
|
||
gen = (
|
||
{
|
||
"id": e.edge_id,
|
||
"familyId": e.family_id or e.edge_id,
|
||
"source": e.source_id,
|
||
"target": e.target_id,
|
||
"type": e.edge_type,
|
||
"weight": e.weight,
|
||
"metadata": e.metadata,
|
||
"valid_from": e.valid_from,
|
||
"valid_until": e.valid_until,
|
||
}
|
||
for e in source if e.source_id and e.target_id
|
||
)
|
||
stop = skip + limit if limit is not None else None
|
||
return list(itertools.islice(gen, skip, stop))
|
||
|
||
def stats(self) -> Dict[str, Any]:
|
||
"""Get graph statistics."""
|
||
with self._lock:
|
||
# Count only items that find_nodes/find_edges can return, so pagination
|
||
# totals reported to callers match what the methods actually yield.
|
||
node_count = sum(1 for n in self.nodes.values() if n.node_id)
|
||
edge_count = sum(1 for e in self.edges if e.source_id and e.target_id)
|
||
node_types = {
|
||
k: sum(
|
||
1 for nid in v
|
||
if isinstance(nid, str) and nid in self.nodes and self.nodes[nid].node_id
|
||
)
|
||
for k, v in self.node_type_index.items()
|
||
}
|
||
edge_types = {
|
||
k: sum(1 for e in v if e.source_id and e.target_id)
|
||
for k, v in self.edge_type_index.items()
|
||
}
|
||
return {
|
||
"node_count": node_count,
|
||
"edge_count": edge_count,
|
||
"node_types": node_types,
|
||
"edge_types": edge_types,
|
||
"density": self.density(),
|
||
}
|
||
|
||
def density(self) -> float:
|
||
"""Calculate graph density."""
|
||
with self._lock:
|
||
n = len(self.nodes)
|
||
if n < 2:
|
||
return 0.0
|
||
max_edges = n * (n - 1)
|
||
return len(self.edges) / max_edges
|
||
|
||
def retract_node(
|
||
self,
|
||
node_id: str,
|
||
reason: Optional[str] = None,
|
||
at: Optional[Union[str, datetime]] = None,
|
||
cascade: bool = True,
|
||
) -> bool:
|
||
"""Retract a node: no longer active, but still visible in history.
|
||
|
||
Closes the node's validity window rather than deleting it, so
|
||
:meth:`state_at` before ``at`` still returns the node and any decision
|
||
recorded against it remains explainable. Use :meth:`purge_node` when
|
||
the data itself has to be gone.
|
||
|
||
Args:
|
||
node_id: Node to retract.
|
||
reason: Why it was retracted, stored on the retraction record.
|
||
at: When the retraction takes effect (ISO string or datetime).
|
||
Defaults to now, UTC.
|
||
cascade: Also retract every edge touching the node. Leaving edges
|
||
active around an inactive node means :meth:`find_active_nodes`
|
||
drops the node while its relationships still read as current,
|
||
so the default keeps the active view self-consistent.
|
||
|
||
Retraction is expressed through the temporal window, so it is visible
|
||
to the activity-aware views -- :meth:`find_active_nodes`,
|
||
:meth:`state_at`, ``ContextNode.is_active`` -- and not to membership
|
||
checks like :meth:`has_node` or :meth:`stats`, which continue to count
|
||
the retained record. That matches how ``valid_until`` already behaved
|
||
before retraction existed.
|
||
|
||
A node whose ``valid_until`` is already earlier than ``at`` keeps that
|
||
earlier bound: retraction only ever closes a validity window, never
|
||
widens one.
|
||
|
||
Returns:
|
||
True if the node was retracted; False if it does not exist or was
|
||
already retracted.
|
||
|
||
Note:
|
||
Emits ``UPDATE_NODE`` to the audit-trail callback, since retraction
|
||
changes the validity window rather than removing the record.
|
||
"""
|
||
at_iso = _normalize_temporal_input(at) or datetime.now(timezone.utc).isoformat()
|
||
with self._lock:
|
||
node = self.nodes.get(node_id)
|
||
if node is None:
|
||
self.logger.warning("Cannot retract unknown node: %r", node_id)
|
||
return False
|
||
if ("node", node_id) in self._retractions:
|
||
return False
|
||
|
||
node.valid_until = _closing_valid_until(node.valid_until, at_iso)
|
||
record = {
|
||
"entity_id": node_id,
|
||
"entity_kind": "node",
|
||
"retracted_at": at_iso,
|
||
"reason": reason,
|
||
}
|
||
self._retractions[("node", node_id)] = record
|
||
node_payload = {**node.to_dict(), "retraction": dict(record)}
|
||
|
||
cascaded: List[Tuple[str, Dict[str, Any]]] = []
|
||
if cascade:
|
||
# Snapshotted once, before the loop: edge_id is content-derived
|
||
# and not guaranteed unique (#922), so two distinct edge objects
|
||
# can share one id. Checking the live _retractions dict inside
|
||
# the loop would let the first duplicate's record block the
|
||
# second from ever being closed, leaving it active indefinitely
|
||
# while its retraction record claimed otherwise.
|
||
already_retracted_edge_ids = {
|
||
key[1] for key in self._retractions if key[0] == "edge"
|
||
}
|
||
for edge in self._incident_edges(node_id):
|
||
if edge.edge_id in already_retracted_edge_ids:
|
||
continue
|
||
edge.valid_until = _closing_valid_until(edge.valid_until, at_iso)
|
||
edge_record = {
|
||
"entity_id": edge.edge_id,
|
||
"entity_kind": "edge",
|
||
"retracted_at": at_iso,
|
||
"reason": reason,
|
||
"cascaded_from": node_id,
|
||
}
|
||
self._retractions[("edge", edge.edge_id)] = edge_record
|
||
# Payloads are snapshotted here, not read back after the
|
||
# lock is released: a concurrent clear() would otherwise
|
||
# wipe the record out from under the emission below.
|
||
cascaded.append(
|
||
(
|
||
edge.edge_id,
|
||
{**edge.to_dict(), "retraction": dict(edge_record)},
|
||
)
|
||
)
|
||
|
||
self._emit_mutation("UPDATE_NODE", node_id, node_payload)
|
||
for edge_id, edge_payload in cascaded:
|
||
self._emit_mutation("UPDATE_EDGE", edge_id, edge_payload)
|
||
self.logger.info(
|
||
"Retracted node %r at %s (cascaded %d edge(s))",
|
||
node_id,
|
||
at_iso,
|
||
len(cascaded),
|
||
)
|
||
return True
|
||
|
||
def retract_edge(
|
||
self,
|
||
edge_id: str,
|
||
reason: Optional[str] = None,
|
||
at: Optional[Union[str, datetime]] = None,
|
||
) -> bool:
|
||
"""Retract a single edge, leaving its endpoints untouched.
|
||
|
||
An edge whose ``valid_until`` is already earlier than ``at`` keeps that
|
||
earlier bound; retraction never widens a validity window.
|
||
|
||
Args:
|
||
edge_id: Edge to retract.
|
||
reason: Why it was retracted.
|
||
at: When the retraction takes effect. Defaults to now, UTC.
|
||
|
||
Returns:
|
||
True if the edge was retracted; False if it does not exist or was
|
||
already retracted.
|
||
|
||
Note:
|
||
``edge_id`` is content-derived and not guaranteed unique (#922):
|
||
two distinct edge objects can share one id. Every edge matching
|
||
``edge_id`` is closed under a single retraction record, so a
|
||
duplicate can never be left silently active while the record
|
||
claims it was retracted.
|
||
"""
|
||
at_iso = _normalize_temporal_input(at) or datetime.now(timezone.utc).isoformat()
|
||
with self._lock:
|
||
edges = [e for e in self.edges if e.edge_id == edge_id]
|
||
if not edges:
|
||
self.logger.warning("Cannot retract unknown edge: %r", edge_id)
|
||
return False
|
||
if ("edge", edge_id) in self._retractions:
|
||
return False
|
||
|
||
record = {
|
||
"entity_id": edge_id,
|
||
"entity_kind": "edge",
|
||
"retracted_at": at_iso,
|
||
"reason": reason,
|
||
}
|
||
self._retractions[("edge", edge_id)] = record
|
||
for edge in edges:
|
||
edge.valid_until = _closing_valid_until(edge.valid_until, at_iso)
|
||
payload = {**edges[0].to_dict(), "retraction": dict(record)}
|
||
|
||
self._emit_mutation("UPDATE_EDGE", edge_id, payload)
|
||
self.logger.info(
|
||
"Retracted edge %r at %s (%d underlying record(s))",
|
||
edge_id,
|
||
at_iso,
|
||
len(edges),
|
||
)
|
||
return True
|
||
|
||
def purge_node(
|
||
self,
|
||
node_id: str,
|
||
reason: Optional[str] = None,
|
||
at: Optional[Union[str, datetime]] = None,
|
||
cascade: bool = True,
|
||
) -> bool:
|
||
"""Permanently remove a node; history no longer contains it.
|
||
|
||
Unlike :meth:`retract_node` this is destructive: the node disappears
|
||
from :meth:`state_at` as well as from the active view. Only a tombstone
|
||
remains, recording that a purge happened and why -- deliberately
|
||
without the purged content, since retaining it would defeat the point.
|
||
|
||
Scope is this graph only. Copies held elsewhere (``AgentMemory``, a
|
||
bound vector store, an exported file) are not reached, so this is one
|
||
step of an erasure workflow, not the whole of it.
|
||
|
||
Args:
|
||
node_id: Node to purge.
|
||
reason: Why it was purged, e.g. an erasure-request reference.
|
||
at: When the purge takes effect, recorded as the tombstone's
|
||
``purged_at`` (ISO string or datetime). Defaults to now, UTC.
|
||
cascade: Also purge every edge touching the node, and the marker
|
||
node of any cross-graph link it exits through. Defaults to True
|
||
because leaving edges pointing at a removed node produces
|
||
dangling endpoints.
|
||
|
||
Cross-graph links registered by :meth:`link_graph` out of this node are
|
||
deregistered either way -- a link whose source no longer exists would
|
||
still resolve through :meth:`navigate_to` and still be serialized by
|
||
:meth:`save_to_file`.
|
||
|
||
Returns:
|
||
True if the node was purged; False if it does not exist.
|
||
|
||
Note:
|
||
Emits ``REMOVE_NODE``/``REMOVE_EDGE`` to the audit-trail callback.
|
||
"""
|
||
purged_at = (
|
||
_normalize_temporal_input(at) or datetime.now(timezone.utc).isoformat()
|
||
)
|
||
with self._lock:
|
||
if node_id not in self.nodes:
|
||
self.logger.warning("Cannot purge unknown node: %r", node_id)
|
||
return False
|
||
|
||
# The link marker node is scaffolding reachable only from the node
|
||
# being purged, so it goes with the cascade rather than surviving as
|
||
# an orphan. Resolve the markers before deregistering the links they
|
||
# are derived from.
|
||
targets = [node_id]
|
||
if cascade:
|
||
targets.extend(self._cross_graph_marker_nodes(node_id))
|
||
for link_id in self._cross_graph_links_for(node_id):
|
||
self._linked_graphs.pop(link_id, None)
|
||
self._unresolved_links.pop(link_id, None)
|
||
|
||
# Tombstones are snapshotted into locals before the lock is
|
||
# released; reading them back afterwards would race a clear().
|
||
purged_edges: List[Tuple[str, Dict[str, Any]]] = []
|
||
purged_nodes: List[Tuple[str, Dict[str, Any]]] = []
|
||
for target in targets:
|
||
cascaded_from = None if target == node_id else node_id
|
||
if cascade:
|
||
for edge in self._incident_edges(target):
|
||
self._drop_edge_from_indexes(edge)
|
||
edge_record = {
|
||
"entity_id": edge.edge_id,
|
||
"entity_kind": "edge",
|
||
"purged_at": purged_at,
|
||
"reason": reason,
|
||
"cascaded_from": node_id,
|
||
}
|
||
self._tombstones[("edge", edge.edge_id)] = edge_record
|
||
self._retractions.pop(("edge", edge.edge_id), None)
|
||
purged_edges.append((edge.edge_id, dict(edge_record)))
|
||
|
||
self._drop_node_from_indexes(target)
|
||
node_record = {
|
||
"entity_id": target,
|
||
"entity_kind": "node",
|
||
"purged_at": purged_at,
|
||
"reason": reason,
|
||
}
|
||
if cascaded_from is not None:
|
||
node_record["cascaded_from"] = cascaded_from
|
||
self._tombstones[("node", target)] = node_record
|
||
self._retractions.pop(("node", target), None)
|
||
purged_nodes.append((target, dict(node_record)))
|
||
|
||
for edge_id, payload in purged_edges:
|
||
self._emit_mutation("REMOVE_EDGE", edge_id, payload)
|
||
for purged_id, payload in purged_nodes:
|
||
self._emit_mutation("REMOVE_NODE", purged_id, payload)
|
||
self.logger.info(
|
||
"Purged node %r (cascaded %d edge(s), %d node(s))",
|
||
node_id,
|
||
len(purged_edges),
|
||
len(purged_nodes) - 1,
|
||
)
|
||
return True
|
||
|
||
def purge_edge(
|
||
self,
|
||
edge_id: str,
|
||
reason: Optional[str] = None,
|
||
at: Optional[Union[str, datetime]] = None,
|
||
) -> bool:
|
||
"""Permanently remove a single edge, leaving its endpoints in place.
|
||
|
||
If the edge is the bridge of a cross-graph link, the link is also
|
||
deregistered -- :meth:`navigate_to` should not keep resolving a link
|
||
whose bridge is gone. The marker node itself is an endpoint and is left
|
||
in place; purge it directly, or purge the link's source node, to remove
|
||
it too.
|
||
|
||
Args:
|
||
edge_id: Edge to purge.
|
||
reason: Why it was purged.
|
||
at: When the purge takes effect, recorded as the tombstone's
|
||
``purged_at``. Defaults to now, UTC.
|
||
|
||
Returns:
|
||
True if the edge was purged; False if it does not exist.
|
||
|
||
Note:
|
||
``edge_id`` is content-derived and not guaranteed unique (#922):
|
||
two distinct edge objects can share one id. Every edge matching
|
||
``edge_id`` is dropped under a single tombstone, so a duplicate
|
||
can never be left live in the graph while the tombstone claims
|
||
the edge is gone.
|
||
"""
|
||
purged_at = (
|
||
_normalize_temporal_input(at) or datetime.now(timezone.utc).isoformat()
|
||
)
|
||
with self._lock:
|
||
edges = [e for e in self.edges if e.edge_id == edge_id]
|
||
if not edges:
|
||
self.logger.warning("Cannot purge unknown edge: %r", edge_id)
|
||
return False
|
||
for edge in edges:
|
||
self._drop_edge_from_indexes(edge)
|
||
link_id = (edge.metadata or {}).get("link_id")
|
||
if (edge.metadata or {}).get("cross_graph") and link_id:
|
||
self._linked_graphs.pop(link_id, None)
|
||
self._unresolved_links.pop(link_id, None)
|
||
self._retractions.pop(("edge", edge_id), None)
|
||
record = {
|
||
"entity_id": edge_id,
|
||
"entity_kind": "edge",
|
||
"purged_at": purged_at,
|
||
"reason": reason,
|
||
}
|
||
self._tombstones[("edge", edge_id)] = record
|
||
payload = dict(record)
|
||
|
||
self._emit_mutation("REMOVE_EDGE", edge_id, payload)
|
||
self.logger.info(
|
||
"Purged edge %r (%d underlying record(s))", edge_id, len(edges)
|
||
)
|
||
return True
|
||
|
||
def get_retraction(
|
||
self, entity_id: str, entity_kind: Optional[str] = None
|
||
) -> Optional[Dict[str, Any]]:
|
||
"""Return the retraction record for a node or edge, or None.
|
||
|
||
Args:
|
||
entity_id: Node id or edge id.
|
||
entity_kind: ``"node"`` or ``"edge"``. Records are keyed by kind as
|
||
well as id, so pass this when a node id and an edge id could
|
||
collide; without it a node record is preferred over an edge one.
|
||
"""
|
||
with self._lock:
|
||
return self._find_removal_record(self._retractions, entity_id, entity_kind)
|
||
|
||
def get_tombstone(
|
||
self, entity_id: str, entity_kind: Optional[str] = None
|
||
) -> Optional[Dict[str, Any]]:
|
||
"""Return the purge tombstone for a node or edge, or None.
|
||
|
||
The tombstone records that a purge happened, when, and why. It never
|
||
contains the purged content.
|
||
|
||
Args:
|
||
entity_id: Node id or edge id.
|
||
entity_kind: ``"node"`` or ``"edge"``; disambiguates a node id that
|
||
collides with an edge id, as for :meth:`get_retraction`.
|
||
"""
|
||
with self._lock:
|
||
return self._find_removal_record(self._tombstones, entity_id, entity_kind)
|
||
|
||
def list_retractions(self) -> List[Dict[str, Any]]:
|
||
"""Return every retraction record."""
|
||
with self._lock:
|
||
return [dict(record) for record in self._retractions.values()]
|
||
|
||
def list_tombstones(self) -> List[Dict[str, Any]]:
|
||
"""Return every purge tombstone."""
|
||
with self._lock:
|
||
return [dict(record) for record in self._tombstones.values()]
|
||
|
||
def clear(self) -> None:
|
||
"""Fully reset the graph state and indexes."""
|
||
with self._lock:
|
||
self.nodes.clear()
|
||
self.edges.clear()
|
||
self._edge_index.clear()
|
||
self._adjacency.clear()
|
||
self.node_type_index.clear()
|
||
self.edge_type_index.clear()
|
||
self._linked_graphs.clear()
|
||
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 ---
|
||
|
||
def _normalize_timestamp(self, timestamp_value) -> datetime:
|
||
"""
|
||
Normalize timestamp value to datetime object.
|
||
|
||
Handles various timestamp formats:
|
||
- datetime: returns as-is
|
||
- int/float: converts from epoch seconds
|
||
- str: parses ISO format (with optional Z)
|
||
- None/invalid: returns current datetime
|
||
|
||
Args:
|
||
timestamp_value: Timestamp value in various formats
|
||
|
||
Returns:
|
||
datetime: Normalized datetime object
|
||
"""
|
||
from datetime import datetime
|
||
|
||
if isinstance(timestamp_value, datetime):
|
||
return timestamp_value
|
||
elif isinstance(timestamp_value, (int, float)):
|
||
return datetime.fromtimestamp(timestamp_value)
|
||
elif isinstance(timestamp_value, str):
|
||
# Handle ISO format with optional Z suffix
|
||
timestamp_str = timestamp_value.rstrip('Z') #
|
||
try:
|
||
return datetime.fromisoformat(timestamp_str)
|
||
except ValueError:
|
||
# Fallback to current datetime if parsing fails
|
||
return datetime.now()
|
||
else:
|
||
# Fallback for None or other types
|
||
return datetime.now()
|
||
|
||
def _add_internal_node(self, node: ContextNode) -> bool:
|
||
"""Internal method to add a node."""
|
||
if node.node_id is None or (isinstance(node.node_id, str) and not node.node_id.strip()):
|
||
self.logger.warning("Skipping internal node with invalid id: %r", node)
|
||
return False
|
||
with self._lock:
|
||
self.nodes[node.node_id] = node
|
||
# Handle edge case where node_type might be None or not a string
|
||
if hasattr(node, 'node_type') and isinstance(node.node_type, str):
|
||
self.node_type_index[node.node_type].add(node.node_id)
|
||
else:
|
||
# Use 'unknown' as fallback for invalid node_type
|
||
self.node_type_index['unknown'].add(node.node_id)
|
||
|
||
if getattr(self, "mutation_callback", None) and not getattr(
|
||
self, "_suspend_mutation_callback", False
|
||
):
|
||
try:
|
||
self.mutation_callback("ADD_NODE", node.node_id, node.to_dict())
|
||
except Exception as e:
|
||
self.logger.warning(f"Audit trail callback failed for node {node.node_id}: {e}")
|
||
return True
|
||
|
||
def _add_internal_edge(self, edge: ContextEdge) -> bool:
|
||
"""Internal method to add an edge."""
|
||
if edge.source_id is None or edge.target_id is None:
|
||
self.logger.warning("Skipping internal edge with invalid endpoints: %r", edge)
|
||
return False
|
||
with self._lock:
|
||
# Edge identity is content-derived, so an existing edge_id means this
|
||
# exact edge is already stored; re-adding it is a no-op (issue #922).
|
||
if edge.edge_id in self._edge_index:
|
||
return False
|
||
|
||
# Ensure nodes exist
|
||
if edge.source_id not in self.nodes:
|
||
self._add_internal_node(
|
||
ContextNode(edge.source_id, "entity", edge.source_id)
|
||
)
|
||
if edge.target_id not in self.nodes:
|
||
self._add_internal_node(
|
||
ContextNode(edge.target_id, "entity", edge.target_id)
|
||
)
|
||
|
||
self._edge_index[edge.edge_id] = edge
|
||
self.edges.append(edge)
|
||
self.edge_type_index[edge.edge_type].append(edge)
|
||
self._adjacency[edge.source_id].append(edge)
|
||
|
||
if getattr(self, "mutation_callback", None) and not getattr(
|
||
self, "_suspend_mutation_callback", False
|
||
):
|
||
try:
|
||
self.mutation_callback("ADD_EDGE", edge.edge_id, edge.to_dict())
|
||
except Exception as e:
|
||
self.logger.warning(
|
||
f"Audit trail callback failed for edge {edge.edge_id}: {e}"
|
||
)
|
||
return True
|
||
|
||
def _emit_mutation(
|
||
self, operation: str, entity_id: str, payload: Dict[str, Any]
|
||
) -> None:
|
||
"""Fire the audit-trail callback, mirroring the add paths.
|
||
|
||
Kept in one place so retraction and purge record themselves the same
|
||
way ``_add_internal_node``/``_add_internal_edge`` already do, including
|
||
the ``_suspend_mutation_callback`` guard used during restores.
|
||
"""
|
||
if not getattr(self, "mutation_callback", None):
|
||
return
|
||
if getattr(self, "_suspend_mutation_callback", False):
|
||
return
|
||
try:
|
||
self.mutation_callback(operation, entity_id, payload)
|
||
except Exception as e:
|
||
self.logger.warning(
|
||
f"Audit trail callback failed for {operation} {entity_id}: {e}"
|
||
)
|
||
|
||
def _incident_edges(self, node_id: str) -> List[ContextEdge]:
|
||
"""Every edge touching ``node_id``, in either direction.
|
||
|
||
``_adjacency`` is keyed by source only, so incoming edges have to come
|
||
from a scan of ``self.edges``; relying on ``_adjacency`` alone would
|
||
silently leave inbound edges pointing at a removed node.
|
||
"""
|
||
return [
|
||
edge
|
||
for edge in self.edges
|
||
if edge.source_id == node_id or edge.target_id == node_id
|
||
]
|
||
|
||
@staticmethod
|
||
def _find_removal_record(
|
||
store: Dict[Tuple[str, str], Dict[str, Any]],
|
||
entity_id: str,
|
||
entity_kind: Optional[str],
|
||
) -> Optional[Dict[str, Any]]:
|
||
"""Look a retraction/tombstone up by id, optionally narrowed by kind.
|
||
|
||
The caller must hold ``self._lock``. Records are keyed by
|
||
``(entity_kind, entity_id)``; with no kind given, both keyspaces are
|
||
tried so callers that know an id is unambiguous can pass it alone.
|
||
"""
|
||
if entity_kind is not None:
|
||
if entity_kind not in ("node", "edge"):
|
||
raise ValueError(
|
||
f"entity_kind must be 'node', 'edge' or None, got {entity_kind!r}"
|
||
)
|
||
kinds: Tuple[str, ...] = (entity_kind,)
|
||
else:
|
||
kinds = ("node", "edge")
|
||
for kind in kinds:
|
||
record = store.get((kind, entity_id))
|
||
if record is not None:
|
||
return dict(record)
|
||
return None
|
||
|
||
def _cross_graph_links_for(self, node_id: str) -> List[str]:
|
||
"""Link ids that ``node_id`` participates in, as exit point or marker.
|
||
|
||
The caller must hold ``self._lock``. :meth:`link_graph` registers a link
|
||
in three places -- ``_linked_graphs``, a marker node and the bridge edge
|
||
-- so removing only the node would leave :meth:`navigate_to` resolving a
|
||
link whose source is gone.
|
||
"""
|
||
link_ids = [
|
||
link_id
|
||
for link_id, (_, source_node_id, _) in self._linked_graphs.items()
|
||
if source_node_id == node_id
|
||
]
|
||
link_ids.extend(
|
||
link_id
|
||
for link_id, meta in self._unresolved_links.items()
|
||
if meta.get("source_node_id") == node_id
|
||
)
|
||
node = self.nodes.get(node_id)
|
||
metadata = getattr(node, "metadata", None) or {}
|
||
if metadata.get("cross_graph") and metadata.get("link_id"):
|
||
link_ids.append(metadata["link_id"])
|
||
return list(dict.fromkeys(link_ids))
|
||
|
||
def _cross_graph_marker_nodes(self, node_id: str) -> List[str]:
|
||
"""Marker nodes of the cross-graph links ``node_id`` exits through.
|
||
|
||
The caller must hold ``self._lock``.
|
||
"""
|
||
return [
|
||
marker_id
|
||
for marker_id in (
|
||
f"__cross_graph_{link_id}"
|
||
for link_id in self._cross_graph_links_for(node_id)
|
||
)
|
||
if marker_id != node_id and marker_id in self.nodes
|
||
]
|
||
|
||
def _drop_node_from_indexes(self, node_id: str) -> None:
|
||
"""Remove one node from ``nodes``, ``node_type_index`` and ``_adjacency``.
|
||
|
||
The caller must hold ``self._lock``. Incident edges are not touched --
|
||
see :meth:`_drop_edge_from_indexes`.
|
||
"""
|
||
node = self.nodes.pop(node_id, None)
|
||
if node is None:
|
||
return
|
||
bucket = self.node_type_index.get(node.node_type)
|
||
if bucket is not None:
|
||
bucket.discard(node_id)
|
||
if not bucket:
|
||
del self.node_type_index[node.node_type]
|
||
self._adjacency.pop(node_id, None)
|
||
|
||
def _drop_edge_from_indexes(self, edge: ContextEdge) -> None:
|
||
"""Remove one edge from every structure that references it.
|
||
|
||
The caller must hold ``self._lock``. ``edges``, ``edge_type_index`` and
|
||
``_adjacency`` must be updated together or the indexes drift out of
|
||
step with the edge list.
|
||
"""
|
||
try:
|
||
self.edges.remove(edge)
|
||
except ValueError:
|
||
pass
|
||
bucket = self.edge_type_index.get(edge.edge_type)
|
||
if bucket is not None:
|
||
try:
|
||
bucket.remove(edge)
|
||
except ValueError:
|
||
pass
|
||
if not bucket:
|
||
del self.edge_type_index[edge.edge_type]
|
||
adjacent = self._adjacency.get(edge.source_id)
|
||
if adjacent is not None:
|
||
try:
|
||
adjacent.remove(edge)
|
||
except ValueError:
|
||
pass
|
||
if not adjacent:
|
||
del self._adjacency[edge.source_id]
|
||
|
||
# --- Builder Methods (Legacy/Utility) ---
|
||
|
||
def build_from_conversations(
|
||
self,
|
||
conversations: List[Union[str, Dict[str, Any]]],
|
||
link_entities: bool = True,
|
||
extract_intents: bool = False,
|
||
extract_sentiments: bool = False,
|
||
**options,
|
||
) -> Dict[str, Any]:
|
||
"""
|
||
Build context graph from conversations and return dict representation.
|
||
|
||
Args:
|
||
conversations: List of conversation files or dictionaries
|
||
...
|
||
|
||
Returns:
|
||
Graph dictionary (nodes, edges)
|
||
"""
|
||
tracking_id = self.progress_tracker.start_tracking(
|
||
file=None,
|
||
module="context",
|
||
submodule="ContextGraph",
|
||
message=f"Building graph from {len(conversations)} conversations",
|
||
)
|
||
|
||
try:
|
||
for conv in conversations:
|
||
conv_data = (
|
||
conv if isinstance(conv, dict) else self._load_conversation(conv)
|
||
)
|
||
self._process_conversation(
|
||
conv_data,
|
||
extract_intents=extract_intents,
|
||
extract_sentiments=extract_sentiments,
|
||
)
|
||
|
||
if link_entities:
|
||
self._link_entities()
|
||
|
||
self.progress_tracker.stop_tracking(tracking_id, status="completed")
|
||
return self.to_dict()
|
||
|
||
except Exception as e:
|
||
self.progress_tracker.stop_tracking(
|
||
tracking_id, status="failed", message=str(e)
|
||
)
|
||
raise
|
||
|
||
def build_from_entities_and_relationships(
|
||
self,
|
||
entities: List[Dict[str, Any]],
|
||
relationships: List[Dict[str, Any]],
|
||
**kwargs,
|
||
) -> Dict[str, Any]:
|
||
"""
|
||
Build graph from entities and relationships.
|
||
|
||
Args:
|
||
entities: List of entity dictionaries
|
||
relationships: List of relationship dictionaries
|
||
**kwargs: Additional options
|
||
|
||
Returns:
|
||
Graph dictionary (nodes, edges)
|
||
"""
|
||
tracking_id = self.progress_tracker.start_tracking(
|
||
file=None,
|
||
module="context",
|
||
submodule="ContextGraph",
|
||
message=(
|
||
f"Building graph from {len(entities)} entities and "
|
||
f"{len(relationships)} relationships"
|
||
),
|
||
)
|
||
|
||
try:
|
||
# Add entities
|
||
for entity in entities:
|
||
entity_id = entity.get("id") or entity.get("entity_id")
|
||
if entity_id:
|
||
self._add_internal_node(
|
||
ContextNode(
|
||
node_id=entity_id,
|
||
node_type=entity.get("type", "entity"),
|
||
content=entity.get("text")
|
||
or entity.get("label")
|
||
or entity_id,
|
||
metadata=entity,
|
||
properties=entity,
|
||
)
|
||
)
|
||
|
||
# Add relationships
|
||
for rel in relationships:
|
||
source = rel.get("source_id")
|
||
target = rel.get("target_id")
|
||
if source and target:
|
||
self._add_internal_edge(
|
||
ContextEdge(
|
||
source_id=source,
|
||
target_id=target,
|
||
edge_type=rel.get("type", "related_to"),
|
||
weight=rel.get("confidence", 1.0),
|
||
metadata=rel,
|
||
)
|
||
)
|
||
|
||
self.progress_tracker.stop_tracking(tracking_id, status="completed")
|
||
return self.to_dict()
|
||
|
||
except Exception as e:
|
||
self.progress_tracker.stop_tracking(
|
||
tracking_id, status="failed", message=str(e)
|
||
)
|
||
raise
|
||
|
||
def _process_conversation(self, conv_data: Dict[str, Any], **kwargs) -> None:
|
||
"""Process a single conversation."""
|
||
conv_id = conv_data.get("id") or f"conv_{hash(str(conv_data)) % 10000}"
|
||
|
||
# Add conversation node
|
||
self._add_internal_node(
|
||
ContextNode(
|
||
node_id=conv_id,
|
||
node_type="conversation",
|
||
content=conv_data.get("content", "") or conv_data.get("summary", ""),
|
||
metadata={"timestamp": conv_data.get("timestamp")},
|
||
)
|
||
)
|
||
|
||
# Track name to ID mapping for relationship resolution
|
||
name_to_id = {}
|
||
|
||
# Extract entities
|
||
if self.extract_entities:
|
||
for entity in conv_data.get("entities", []):
|
||
entity_id = entity.get("id") or entity.get("entity_id")
|
||
entity_text = (
|
||
entity.get("text")
|
||
or entity.get("label")
|
||
or entity.get("name")
|
||
or entity_id
|
||
)
|
||
entity_type = entity.get("type", "entity")
|
||
|
||
# Generate ID if missing
|
||
if not entity_id and entity_text and self.entity_linker:
|
||
# Use EntityLinker to generate ID
|
||
if hasattr(self.entity_linker, "_generate_entity_id"):
|
||
entity_id = self.entity_linker._generate_entity_id(
|
||
entity_text, entity_type
|
||
)
|
||
else:
|
||
# Fallback ID generation
|
||
import hashlib
|
||
|
||
entity_hash = hashlib.md5( # nosec B324 - deterministic entity ID, not security-sensitive
|
||
f"{entity_text}_{entity_type}".encode()
|
||
).hexdigest()[:12]
|
||
entity_id = f"{entity_type.lower()}_{entity_hash}"
|
||
|
||
if entity_id:
|
||
if entity_text:
|
||
name_to_id[entity_text] = entity_id
|
||
|
||
self._add_internal_node(
|
||
ContextNode(
|
||
node_id=entity_id,
|
||
node_type="entity",
|
||
content=entity_text,
|
||
metadata={"type": entity_type, **entity},
|
||
)
|
||
)
|
||
self._add_internal_edge(
|
||
ContextEdge(
|
||
source_id=conv_id,
|
||
target_id=entity_id,
|
||
edge_type="mentions",
|
||
)
|
||
)
|
||
|
||
# Extract relationships
|
||
if self.extract_relationships:
|
||
for rel in conv_data.get("relationships", []):
|
||
source = rel.get("source_id")
|
||
target = rel.get("target_id")
|
||
|
||
# Resolve IDs from names if missing
|
||
if not source and rel.get("source") and rel.get("source") in name_to_id:
|
||
source = name_to_id[rel.get("source")]
|
||
|
||
if not target and rel.get("target") and rel.get("target") in name_to_id:
|
||
target = name_to_id[rel.get("target")]
|
||
|
||
if source and target:
|
||
self._add_internal_edge(
|
||
ContextEdge(
|
||
source_id=source,
|
||
target_id=target,
|
||
edge_type=rel.get("type", "related_to"),
|
||
weight=rel.get("confidence", 1.0),
|
||
)
|
||
)
|
||
|
||
def _link_entities(self) -> None:
|
||
"""Link similar entities using EntityLinker."""
|
||
if not self.entity_linker:
|
||
return
|
||
|
||
entity_nodes = [n for n in self.nodes.values() if n.node_type == "entity"]
|
||
for i, node1 in enumerate(entity_nodes):
|
||
for node2 in entity_nodes[i + 1 :]:
|
||
similarity = self.entity_linker._calculate_text_similarity(
|
||
node1.content.lower(), node2.content.lower()
|
||
)
|
||
if similarity >= self.entity_linker.similarity_threshold:
|
||
self._add_internal_edge(
|
||
ContextEdge(
|
||
source_id=node1.node_id,
|
||
target_id=node2.node_id,
|
||
edge_type="similar_to",
|
||
weight=similarity,
|
||
)
|
||
)
|
||
|
||
def _load_conversation(self, file_path: str) -> Dict[str, Any]:
|
||
"""Load conversation from file."""
|
||
from pathlib import Path
|
||
|
||
from ..utils.helpers import read_json_file
|
||
|
||
return read_json_file(Path(file_path))
|
||
|
||
def to_dict(self) -> Dict[str, Any]:
|
||
"""Export graph to dictionary format."""
|
||
with self._lock:
|
||
nodes_out = []
|
||
for n in self.nodes.values():
|
||
entry: Dict[str, Any] = {
|
||
"id": n.node_id,
|
||
"type": n.node_type,
|
||
"content": n.content,
|
||
"properties": n.properties,
|
||
"metadata": n.metadata,
|
||
}
|
||
if n.valid_from is not None:
|
||
entry["valid_from"] = n.valid_from
|
||
if n.valid_until is not None:
|
||
entry["valid_until"] = n.valid_until
|
||
nodes_out.append(entry)
|
||
|
||
edges_out = []
|
||
for e in self.edges:
|
||
entry = {
|
||
"id": e.edge_id,
|
||
"familyId": e.family_id or e.edge_id,
|
||
"source": e.source_id,
|
||
"target": e.target_id,
|
||
"type": e.edge_type,
|
||
"weight": e.weight,
|
||
}
|
||
if e.metadata:
|
||
entry["metadata"] = e.metadata
|
||
if e.valid_from is not None:
|
||
entry["valid_from"] = e.valid_from
|
||
if e.valid_until is not None:
|
||
entry["valid_until"] = e.valid_until
|
||
edges_out.append(entry)
|
||
|
||
return {
|
||
"nodes": nodes_out,
|
||
"edges": edges_out,
|
||
"statistics": {
|
||
"node_count": len(self.nodes),
|
||
"edge_count": len(self.edges),
|
||
},
|
||
}
|
||
|
||
def to_kg_dict(self, entities_only: bool = False) -> Dict[str, Any]:
|
||
"""Export graph in the canonical knowledge-graph shape.
|
||
|
||
This is the official adapter that converts the ContextGraph's internal
|
||
``{"nodes", "edges"}`` / ``source`` representation into the
|
||
``{"entities", "relationships"}`` / ``source_id`` shape expected by
|
||
downstream consumers such as
|
||
:class:`~semantica.export.rdf_exporter.RDFExporter` and
|
||
:meth:`~semantica.kg.temporal_query.TemporalGraphQuery.query_time_range`.
|
||
|
||
Users no longer need to hand-map field names between APIs.
|
||
|
||
Args:
|
||
entities_only: If True, only nodes whose ``node_type`` is
|
||
``"entity"`` are exported as entities. When False (default),
|
||
every node is exported. Relationships whose endpoints are not
|
||
in the exported entity set are dropped to avoid dangling
|
||
references in downstream consumers.
|
||
|
||
Returns:
|
||
dict: A knowledge-graph dictionary with:
|
||
- ``entities``: list of ``{"id", "text", "type", "properties",
|
||
"metadata"}`` (plus ``valid_from`` / ``valid_until`` when set)
|
||
- ``relationships``: list of ``{"source_id", "target_id",
|
||
"type", "weight", "id", "familyId"}`` (plus ``metadata`` and
|
||
``valid_from`` / ``valid_until`` when set)
|
||
- ``statistics``: ``{"entity_count", "relationship_count"}``
|
||
"""
|
||
with self._lock:
|
||
entities_out = []
|
||
for n in self.nodes.values():
|
||
if entities_only and n.node_type != "entity":
|
||
continue
|
||
# Normalize the entity id to ``str`` so it matches ContextEdge,
|
||
# which coerces its endpoints to ``str`` in ``__post_init__``.
|
||
# Without this, non-string node ids (e.g. numeric ids loaded via
|
||
# ``from_dict``) would fail the ``valid_ids`` membership check
|
||
# below and silently drop otherwise-valid relationships.
|
||
entity_id = str(n.node_id)
|
||
entity: Dict[str, Any] = {
|
||
"id": entity_id,
|
||
"text": n.content,
|
||
"type": n.node_type,
|
||
# ``properties`` / ``metadata`` may be ``None`` when a node
|
||
# was loaded from JSON containing an explicit ``null``;
|
||
# guard with ``or {}`` so ``dict(...)`` never raises.
|
||
"properties": dict(n.properties or {}),
|
||
"metadata": dict(n.metadata or {}),
|
||
}
|
||
if n.valid_from is not None:
|
||
entity["valid_from"] = n.valid_from
|
||
if n.valid_until is not None:
|
||
entity["valid_until"] = n.valid_until
|
||
entities_out.append(entity)
|
||
|
||
# When only entity nodes are exported, drop relationships whose
|
||
# endpoints were filtered out so downstream consumers never see a
|
||
# source_id/target_id that is absent from ``entities``.
|
||
valid_ids = {e["id"] for e in entities_out} if entities_only else None
|
||
|
||
relationships_out = []
|
||
for e in self.edges:
|
||
if valid_ids is not None and (
|
||
e.source_id not in valid_ids or e.target_id not in valid_ids
|
||
):
|
||
continue
|
||
rel: Dict[str, Any] = {
|
||
"id": e.edge_id,
|
||
"familyId": e.family_id or e.edge_id,
|
||
"source_id": e.source_id,
|
||
"target_id": e.target_id,
|
||
"type": e.edge_type,
|
||
"weight": e.weight,
|
||
}
|
||
if e.metadata:
|
||
rel["metadata"] = dict(e.metadata)
|
||
if e.valid_from is not None:
|
||
rel["valid_from"] = e.valid_from
|
||
if e.valid_until is not None:
|
||
rel["valid_until"] = e.valid_until
|
||
relationships_out.append(rel)
|
||
|
||
return {
|
||
"entities": entities_out,
|
||
"relationships": relationships_out,
|
||
"statistics": {
|
||
"entity_count": len(entities_out),
|
||
"relationship_count": len(relationships_out),
|
||
},
|
||
}
|
||
|
||
def from_dict(self, graph_dict: Dict[str, Any]) -> None:
|
||
"""Load graph from dictionary format."""
|
||
# Clear existing graph
|
||
self.clear()
|
||
|
||
# Add nodes — restore validity windows if present
|
||
for node_data in graph_dict.get("nodes", []):
|
||
node_props = node_data.get("properties", {})
|
||
node = ContextNode(
|
||
node_id=node_data["id"],
|
||
node_type=node_data["type"],
|
||
content=node_data.get("content", ""),
|
||
properties=node_props,
|
||
metadata=node_data.get("metadata", {}),
|
||
valid_from=node_data.get("valid_from") or node_props.get("valid_from"),
|
||
valid_until=node_data.get("valid_until") or node_props.get("valid_until"),
|
||
)
|
||
self._add_internal_node(node)
|
||
|
||
# Add edges — restore validity windows if present
|
||
for edge_data in graph_dict.get("edges", []):
|
||
edge_metadata = edge_data.get("metadata", edge_data.get("properties", {})) or {}
|
||
edge_weight = edge_data.get("weight", 1.0)
|
||
edge_id, family_id = _resolve_edge_identity(
|
||
source_id=edge_data["source"],
|
||
target_id=edge_data["target"],
|
||
edge_type=edge_data["type"],
|
||
weight=edge_weight,
|
||
metadata=edge_metadata,
|
||
valid_from=edge_data.get("valid_from"),
|
||
valid_until=edge_data.get("valid_until"),
|
||
edge_id=edge_data.get("id", edge_data.get("edge_id")),
|
||
family_id=edge_data.get("familyId", edge_data.get("family_id")),
|
||
)
|
||
edge = ContextEdge(
|
||
edge_id=edge_id,
|
||
source_id=edge_data["source"],
|
||
target_id=edge_data["target"],
|
||
edge_type=edge_data["type"],
|
||
weight=edge_weight,
|
||
family_id=family_id,
|
||
metadata=edge_metadata,
|
||
valid_from=edge_data.get("valid_from"),
|
||
valid_until=edge_data.get("valid_until"),
|
||
)
|
||
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)
|
||
with self._lock:
|
||
active_nodes = [node for node in self.nodes.values() if node.is_active(at_time)]
|
||
active_node_ids = {node.node_id for node in active_nodes}
|
||
active_edges = [
|
||
edge for edge in self.edges
|
||
if edge.is_active(at_time)
|
||
and edge.source_id in active_node_ids
|
||
and edge.target_id in active_node_ids
|
||
]
|
||
|
||
nodes_payload = [node.to_dict() for node in active_nodes]
|
||
edges_payload = [edge.to_dict() for edge in active_edges]
|
||
decisions_payload = [
|
||
{
|
||
"id": node.node_id,
|
||
"category": node.properties.get("category", ""),
|
||
"scenario": node.properties.get("scenario", node.content),
|
||
"reasoning": node.properties.get("reasoning", ""),
|
||
"outcome": node.properties.get("outcome", ""),
|
||
"confidence": node.properties.get("confidence", 0.0),
|
||
"timestamp": node.properties.get("timestamp"),
|
||
"decision_maker": node.properties.get("decision_maker"),
|
||
"entities": node.properties.get("entities", []),
|
||
"valid_from": node.valid_from,
|
||
"valid_until": node.valid_until,
|
||
"metadata": dict(node.properties.get("metadata", {}) or {}),
|
||
}
|
||
for node in active_nodes
|
||
if isinstance(node.node_type, str) and node.node_type.lower() == "decision"
|
||
]
|
||
|
||
return {
|
||
"timestamp": at_time.isoformat(),
|
||
"nodes": nodes_payload,
|
||
"edges": edges_payload,
|
||
"entities": nodes_payload,
|
||
"relationships": edges_payload,
|
||
"decisions": decisions_payload,
|
||
}
|
||
|
||
# Decision Support Methods
|
||
def add_decision(
|
||
self,
|
||
decision: "Decision" = None,
|
||
*,
|
||
category: str = None,
|
||
scenario: str = None,
|
||
reasoning: str = None,
|
||
outcome: str = None,
|
||
confidence: float = 0.5,
|
||
entities: Optional[List[str]] = None,
|
||
decision_maker: Optional[str] = "system",
|
||
valid_from=None,
|
||
valid_until=None,
|
||
**kwargs,
|
||
) -> str:
|
||
"""
|
||
Add decision node to graph.
|
||
|
||
Accepts either a Decision object or keyword arguments:
|
||
|
||
# From a Decision object
|
||
graph.add_decision(Decision(category="x", scenario="y", ...))
|
||
|
||
# From keyword arguments (convenience form)
|
||
graph.add_decision(category="x", scenario="y", reasoning="z",
|
||
outcome="o", confidence=0.9)
|
||
|
||
Args:
|
||
decision: Decision object to add (mutually exclusive with kwargs)
|
||
category: Decision category
|
||
scenario: Decision scenario description
|
||
reasoning: Reasoning behind the decision
|
||
outcome: Decision outcome
|
||
confidence: Confidence score (0.0–1.0)
|
||
entities: Related entity labels
|
||
decision_maker: Who made the decision
|
||
valid_from: Start of validity window (ISO string or datetime)
|
||
valid_until: End of validity window (ISO string or datetime)
|
||
**kwargs: Extra metadata stored on the decision node
|
||
|
||
Returns:
|
||
Decision ID
|
||
"""
|
||
from .decision_models import Decision
|
||
|
||
if decision is not None and (
|
||
any(v is not None for v in (
|
||
category, scenario, reasoning, outcome, entities, valid_from, valid_until,
|
||
)) or kwargs
|
||
):
|
||
raise ValueError(
|
||
"Pass either a Decision object or keyword arguments, not both."
|
||
)
|
||
|
||
if decision is None:
|
||
# Build from kwargs — delegate to record_decision which handles ID gen
|
||
return self.record_decision(
|
||
category=category,
|
||
scenario=scenario,
|
||
reasoning=reasoning,
|
||
outcome=outcome,
|
||
confidence=confidence,
|
||
entities=entities,
|
||
decision_maker=decision_maker,
|
||
valid_from=valid_from,
|
||
valid_until=valid_until,
|
||
metadata=kwargs,
|
||
)
|
||
|
||
# Handle empty decision ID by generating UUID for both None and empty string
|
||
# This ensures consistent behavior with Decision model's __post_init__ method
|
||
node_id = decision.decision_id if decision.decision_id else str(uuid.uuid4())
|
||
|
||
# Handle None metadata
|
||
metadata = decision.metadata or {}
|
||
|
||
# Normalize timestamp to ensure consistent storage format
|
||
normalized_timestamp = self._normalize_timestamp(decision.timestamp)
|
||
|
||
node = ContextNode(
|
||
node_id=node_id,
|
||
node_type="Decision",
|
||
content=decision.scenario,
|
||
properties={
|
||
"category": decision.category,
|
||
"reasoning": decision.reasoning,
|
||
"outcome": decision.outcome,
|
||
"confidence": decision.confidence,
|
||
"timestamp": normalized_timestamp.isoformat(),
|
||
"decision_maker": decision.decision_maker,
|
||
"reasoning_embedding": decision.reasoning_embedding,
|
||
"node2vec_embedding": decision.node2vec_embedding,
|
||
**metadata
|
||
},
|
||
valid_from=decision.valid_from,
|
||
valid_until=decision.valid_until,
|
||
)
|
||
self._add_internal_node(node)
|
||
return node_id
|
||
|
||
def add_causal_relationship(
|
||
self,
|
||
source_decision_id: str,
|
||
target_decision_id: str,
|
||
relationship_type: str
|
||
) -> None:
|
||
"""
|
||
Add causal relationship between decisions.
|
||
|
||
Args:
|
||
source_decision_id: Source decision ID
|
||
target_decision_id: Target decision ID
|
||
relationship_type: Type of relationship (CAUSED, INFLUENCED, PRECEDENT_FOR)
|
||
"""
|
||
# Normalize so callers may use either vocabulary's spelling
|
||
# ("causes" from CausalChainAnalyzer, or "CAUSED" from this module's
|
||
# canonical constant); the stored form is always canonical. Invalid
|
||
# inputs keep raising ValueError rather than AttributeError.
|
||
if not isinstance(relationship_type, str):
|
||
raise ValueError(f"Relationship type must be one of: {_CAUSAL_EDGE_TYPES}")
|
||
relationship_type = _CAUSAL_EDGE_ALIASES.get(relationship_type.strip().upper())
|
||
if relationship_type is None:
|
||
raise ValueError(f"Relationship type must be one of: {_CAUSAL_EDGE_TYPES}")
|
||
|
||
# Check if decisions exist - if not, skip adding relationship
|
||
if source_decision_id not in self.nodes or target_decision_id not in self.nodes:
|
||
return
|
||
|
||
# Check if nodes are decision nodes - if not, skip adding relationship
|
||
source_node = self.nodes[source_decision_id]
|
||
target_node = self.nodes[target_decision_id]
|
||
if (not hasattr(source_node, 'node_type') or not isinstance(source_node.node_type, str) or
|
||
not hasattr(target_node, 'node_type') or not isinstance(target_node.node_type, str) or
|
||
source_node.node_type.lower() != "decision" or
|
||
target_node.node_type.lower() != "decision"):
|
||
return
|
||
|
||
edge = ContextEdge(
|
||
source_id=source_decision_id,
|
||
target_id=target_decision_id,
|
||
edge_type=relationship_type,
|
||
weight=1.0,
|
||
metadata={"recorded_at": datetime.utcnow().isoformat()},
|
||
)
|
||
self._add_internal_edge(edge)
|
||
|
||
def get_causal_chain(
|
||
self,
|
||
decision_id: str,
|
||
direction: str = "upstream",
|
||
max_depth: int = 10
|
||
) -> List["Decision"]:
|
||
"""
|
||
Get causal chain from graph.
|
||
|
||
Args:
|
||
decision_id: Starting decision ID
|
||
direction: "upstream" or "downstream"
|
||
max_depth: Maximum traversal depth
|
||
|
||
Returns:
|
||
List of decisions in causal chain
|
||
"""
|
||
from .decision_models import Decision
|
||
|
||
if direction not in ["upstream", "downstream"]:
|
||
raise ValueError("Direction must be 'upstream' or 'downstream'")
|
||
|
||
# BFS traversal
|
||
visited = set()
|
||
queue = deque([(decision_id, 0)])
|
||
decisions = []
|
||
|
||
while queue:
|
||
current_id, depth = queue.popleft()
|
||
|
||
if current_id in visited or depth > max_depth:
|
||
continue
|
||
|
||
visited.add(current_id)
|
||
|
||
# Skip the starting decision - only add connected decisions
|
||
if current_id != decision_id:
|
||
# Get decision node
|
||
if current_id in self.nodes:
|
||
node = self.nodes[current_id]
|
||
if (hasattr(node, 'node_type') and isinstance(node.node_type, str) and
|
||
node.node_type.lower() == "decision"):
|
||
decision_data = node.properties
|
||
timestamp = self._normalize_timestamp(decision_data.get("timestamp"))
|
||
decision = Decision(
|
||
decision_id=current_id,
|
||
category=decision_data.get("category", ""),
|
||
scenario=decision_data.get("scenario", node.content),
|
||
reasoning=decision_data.get("reasoning", ""),
|
||
outcome=decision_data.get("outcome", ""),
|
||
confidence=decision_data.get("confidence", 0.0),
|
||
timestamp=timestamp,
|
||
decision_maker=decision_data.get("decision_maker", ""),
|
||
reasoning_embedding=decision_data.get("reasoning_embedding"),
|
||
node2vec_embedding=decision_data.get("node2vec_embedding"),
|
||
valid_from=node.valid_from,
|
||
valid_until=node.valid_until,
|
||
metadata={k: v for k, v in decision_data.items() if k not in [
|
||
"category", "scenario", "reasoning", "outcome", "confidence",
|
||
"timestamp", "decision_maker", "reasoning_embedding", "node2vec_embedding"
|
||
]}
|
||
)
|
||
decision.metadata["causal_distance"] = depth
|
||
decisions.append(decision)
|
||
|
||
# Find connected decisions
|
||
for edge in self.edges:
|
||
if direction == "upstream":
|
||
if edge.target_id == current_id and edge.edge_type.upper() in _CAUSAL_TRAVERSAL_TYPES:
|
||
if edge.source_id not in visited and depth < max_depth:
|
||
queue.append((edge.source_id, depth + 1))
|
||
else: # downstream
|
||
if edge.source_id == current_id and edge.edge_type.upper() in _CAUSAL_TRAVERSAL_TYPES:
|
||
if edge.target_id not in visited and depth < max_depth:
|
||
queue.append((edge.target_id, depth + 1))
|
||
|
||
# Sort by depth for upstream (most distant first) and downstream (closest first)
|
||
if direction == "upstream":
|
||
decisions.sort(key=lambda d: d.metadata.get("causal_distance", 0), reverse=True)
|
||
else:
|
||
decisions.sort(key=lambda d: d.metadata.get("causal_distance", 0))
|
||
|
||
return decisions
|
||
|
||
def find_precedents(self, decision_id: str, limit: int = 10) -> List["Decision"]:
|
||
"""
|
||
Find precedent decisions.
|
||
|
||
Args:
|
||
decision_id: Decision ID to find precedents for
|
||
limit: Maximum number of results
|
||
|
||
Returns:
|
||
List of precedent decisions
|
||
"""
|
||
# Find decisions connected via PRECEDENT_FOR relationships, accepting
|
||
# the analyzer vocabulary's "precedes" spelling as well (issue #1184).
|
||
precedent_ids = []
|
||
for edge in self.edges:
|
||
if edge.target_id == decision_id and edge.edge_type.upper() in {
|
||
"PRECEDENT_FOR", "PRECEDES",
|
||
}:
|
||
precedent_ids.append(edge.source_id)
|
||
|
||
# Convert to Decision objects
|
||
decisions = []
|
||
for pid in precedent_ids[:limit]:
|
||
if pid in self.nodes:
|
||
node = self.nodes[pid]
|
||
if (hasattr(node, 'node_type') and isinstance(node.node_type, str) and
|
||
node.node_type.lower() == "decision"):
|
||
decision_data = node.properties
|
||
from .decision_models import Decision
|
||
timestamp = self._normalize_timestamp(decision_data.get("timestamp"))
|
||
decision = Decision(
|
||
decision_id=pid,
|
||
category=decision_data.get("category", ""),
|
||
scenario=decision_data.get("scenario", node.content),
|
||
reasoning=decision_data.get("reasoning", ""),
|
||
outcome=decision_data.get("outcome", ""),
|
||
confidence=decision_data.get("confidence", 0.0),
|
||
timestamp=timestamp,
|
||
decision_maker=decision_data.get("decision_maker", ""),
|
||
reasoning_embedding=decision_data.get("reasoning_embedding"),
|
||
node2vec_embedding=decision_data.get("node2vec_embedding"),
|
||
valid_from=node.valid_from,
|
||
valid_until=node.valid_until,
|
||
metadata={k: v for k, v in decision_data.items() if k not in [
|
||
"category", "scenario", "reasoning", "outcome", "confidence",
|
||
"timestamp", "decision_maker", "reasoning_embedding", "node2vec_embedding"
|
||
]}
|
||
)
|
||
decisions.append(decision)
|
||
|
||
return decisions
|
||
|
||
# Enhanced methods for comprehensive context graphs
|
||
def analyze_graph_with_kg(self) -> Dict[str, Any]:
|
||
"""
|
||
Analyze the context graph using advanced KG algorithms.
|
||
|
||
Returns:
|
||
Comprehensive graph analysis results
|
||
"""
|
||
if not self.kg_components:
|
||
self.logger.warning("KG components not available")
|
||
return {"error": "Advanced features not available"}
|
||
|
||
try:
|
||
analysis = {
|
||
"graph_metrics": {},
|
||
"centrality_analysis": {},
|
||
"community_analysis": {},
|
||
"connectivity_analysis": {},
|
||
"node_embeddings": {},
|
||
"timestamp": datetime.now().isoformat()
|
||
}
|
||
|
||
# Convert to KG-compatible format
|
||
kg_graph = self._to_kg_format()
|
||
|
||
# Basic graph metrics
|
||
analysis["graph_metrics"] = {
|
||
"node_count": len(self.nodes),
|
||
"edge_count": len(self.edges),
|
||
"node_types": self._get_node_type_distribution(),
|
||
"edge_types": self._get_edge_type_distribution()
|
||
}
|
||
|
||
# Centrality analysis
|
||
if "centrality_calculator" in self.kg_components:
|
||
centrality = self.kg_components["centrality_calculator"].calculate_all_centrality(kg_graph)
|
||
analysis["centrality_analysis"] = centrality
|
||
|
||
# Community detection
|
||
if "community_detector" in self.kg_components:
|
||
communities = self.kg_components["community_detector"].detect_communities(kg_graph)
|
||
analysis["community_analysis"] = {
|
||
"communities": communities,
|
||
"num_communities": len(communities),
|
||
"modularity": self._calculate_modularity(communities)
|
||
}
|
||
|
||
# Connectivity analysis
|
||
if "connectivity_analyzer" in self.kg_components:
|
||
connectivity = self.kg_components["connectivity_analyzer"].analyze_connectivity(kg_graph)
|
||
analysis["connectivity_analysis"] = connectivity
|
||
|
||
# Node embeddings
|
||
if "node_embedder" in self.kg_components:
|
||
node_labels = list(self.node_type_index.keys())
|
||
relationship_types = list(self.edge_type_index.keys())
|
||
if node_labels:
|
||
embeddings = self.kg_components["node_embedder"].compute_embeddings(
|
||
graph_store=self,
|
||
node_labels=node_labels,
|
||
relationship_types=relationship_types,
|
||
)
|
||
analysis["node_embeddings"] = embeddings
|
||
|
||
self.logger.info("Completed comprehensive graph analysis")
|
||
return analysis
|
||
|
||
except AttributeError as e:
|
||
# A broken internal method call (e.g. calling a method that doesn't
|
||
# exist on one of the kg_components) is a programming error, not a
|
||
# legitimate empty-analysis result. Log it distinctly and re-raise
|
||
# rather than masking it under the generic message below.
|
||
self.logger.error(f"Graph analysis failed due to a broken internal method call: {e}")
|
||
raise
|
||
except Exception as e:
|
||
self.logger.error(f"Failed to analyze graph with KG: {e}")
|
||
return {"error": "Graph analysis failed due to an internal error"}
|
||
|
||
def get_node_centrality(self, node_id: str) -> Dict[str, float]:
|
||
"""
|
||
Get centrality measures for a specific node.
|
||
|
||
Args:
|
||
node_id: Node ID to analyze
|
||
|
||
Returns:
|
||
Dictionary of centrality measures
|
||
"""
|
||
if "centrality_calculator" not in self.kg_components:
|
||
return {"error": "Centrality calculator not available"}
|
||
|
||
if node_id not in self.nodes:
|
||
return {"error": "Node not found"}
|
||
|
||
# Check cache first
|
||
cache_key = f"centrality_{node_id}"
|
||
if cache_key in self._analytics_cache:
|
||
return self._analytics_cache[cache_key]
|
||
|
||
try:
|
||
# Get subgraph around the node
|
||
subgraph = self._get_node_subgraph(node_id, max_depth=2)
|
||
|
||
# Calculate centrality
|
||
centrality = self.kg_components["centrality_calculator"].calculate_all_centrality(subgraph)
|
||
|
||
# Cache result
|
||
self._analytics_cache[cache_key] = centrality.get(node_id, {})
|
||
|
||
return centrality.get(node_id, {})
|
||
|
||
except Exception as e:
|
||
self.logger.error(f"Failed to get node centrality: {e}")
|
||
return {"error": "Node centrality calculation failed due to an internal error"}
|
||
|
||
def find_similar_nodes(
|
||
self, node_id: str, similarity_type: str = "content", top_k: int = 10
|
||
) -> List[Dict[str, Any]]:
|
||
"""
|
||
Find similar nodes using various similarity measures.
|
||
|
||
Args:
|
||
node_id: Reference node ID
|
||
similarity_type: Type of similarity ("embedding", "structural", "content")
|
||
top_k: Number of similar nodes to return
|
||
|
||
Returns:
|
||
List of dicts with node ID, type, content, and similarity score
|
||
"""
|
||
if node_id not in self.nodes:
|
||
return []
|
||
|
||
similar_nodes = []
|
||
reference_node = self.nodes[node_id]
|
||
|
||
try:
|
||
for other_id, other_node in self.nodes.items():
|
||
if other_id != node_id:
|
||
if similarity_type == "content":
|
||
similarity = self._calculate_content_similarity(reference_node, other_node)
|
||
elif similarity_type == "structural":
|
||
similarity = self._calculate_structural_similarity(reference_node, other_node)
|
||
else:
|
||
similarity = self._calculate_content_similarity(reference_node, other_node)
|
||
|
||
similar_nodes.append({
|
||
"id": other_id,
|
||
"content": other_node.content,
|
||
"type": other_node.node_type,
|
||
"score": similarity,
|
||
})
|
||
|
||
# Sort by similarity and return top_k
|
||
similar_nodes.sort(key=lambda x: x["score"], reverse=True)
|
||
return similar_nodes[:top_k]
|
||
|
||
except Exception as e:
|
||
self.logger.error(f"Failed to find similar nodes: {e}")
|
||
return []
|
||
|
||
# Helper methods for KG integration
|
||
def _to_kg_format(self) -> Dict[str, Any]:
|
||
"""Convert context graph to KG-compatible format."""
|
||
nodes = []
|
||
edges = []
|
||
relationships = []
|
||
|
||
# Convert nodes
|
||
for node_id, node in self.nodes.items():
|
||
nodes.append({
|
||
"id": node_id,
|
||
"type": node.node_type,
|
||
"properties": node.properties,
|
||
"content": node.content
|
||
})
|
||
|
||
# Convert edges
|
||
for edge in self.edges:
|
||
edge_data = {
|
||
"source": edge.source_id,
|
||
"target": edge.target_id,
|
||
"type": edge.edge_type,
|
||
"weight": edge.weight,
|
||
"properties": edge.metadata
|
||
}
|
||
edges.append(edge_data)
|
||
relationships.append(edge_data)
|
||
|
||
return {
|
||
"nodes": nodes,
|
||
"edges": edges,
|
||
"relationships": relationships # KG algorithms expect this key
|
||
}
|
||
|
||
def _get_node_type_distribution(self) -> Dict[str, int]:
|
||
"""Get distribution of node types."""
|
||
from collections import defaultdict
|
||
distribution = defaultdict(int)
|
||
for node in self.nodes.values():
|
||
distribution[node.node_type] += 1
|
||
return dict(distribution)
|
||
|
||
def _get_edge_type_distribution(self) -> Dict[str, int]:
|
||
"""Get distribution of edge types."""
|
||
from collections import defaultdict
|
||
distribution = defaultdict(int)
|
||
for edge in self.edges:
|
||
distribution[edge.edge_type] += 1
|
||
return dict(distribution)
|
||
|
||
def _calculate_modularity(self, communities: Dict) -> float:
|
||
"""Calculate modularity for communities (simplified)."""
|
||
# Placeholder for modularity calculation
|
||
return 0.5
|
||
|
||
def _get_node_subgraph(self, node_id: str, max_depth: int = 2) -> Dict[str, Any]:
|
||
"""Get subgraph around a node."""
|
||
neighbors = self.get_neighbors(node_id, hops=max_depth)
|
||
|
||
subgraph_nodes = {node_id}
|
||
subgraph_edges = []
|
||
|
||
for neighbor in neighbors:
|
||
neighbor_id = neighbor["id"]
|
||
subgraph_nodes.add(neighbor_id)
|
||
|
||
# Add edges between nodes in subgraph
|
||
for edge in self.edges:
|
||
if edge.source_id in subgraph_nodes and edge.target_id in subgraph_nodes:
|
||
subgraph_edges.append({
|
||
"source": edge.source_id,
|
||
"target": edge.target_id,
|
||
"type": edge.edge_type,
|
||
"weight": edge.weight
|
||
})
|
||
|
||
return {
|
||
"nodes": [{"id": nid} for nid in subgraph_nodes],
|
||
"edges": subgraph_edges
|
||
}
|
||
|
||
def _calculate_structural_similarity(self, node1: ContextNode, node2: ContextNode) -> float:
|
||
"""Calculate structural similarity between two nodes."""
|
||
# Simple structural similarity based on node types and connections
|
||
if node1.node_type != node2.node_type:
|
||
return 0.0
|
||
|
||
# Count connections
|
||
connections1 = len(self._adjacency.get(node1.node_id, []))
|
||
connections2 = len(self._adjacency.get(node2.node_id, []))
|
||
|
||
# Similarity based on connection count similarity
|
||
max_connections = max(connections1, connections2, 1)
|
||
return 1.0 - abs(connections1 - connections2) / max_connections
|
||
|
||
def _calculate_content_similarity(self, node1: ContextNode, node2: ContextNode) -> float:
|
||
"""Calculate content similarity between two nodes."""
|
||
words1 = set(node1.content.lower().split())
|
||
words2 = set(node2.content.lower().split())
|
||
|
||
intersection = words1.intersection(words2)
|
||
union = words1.union(words2)
|
||
|
||
return len(intersection) / len(union) if union else 0.0
|
||
|
||
# --- Comprehensive Decision Management Features ---
|
||
|
||
def record_decision(
|
||
self,
|
||
category: str,
|
||
scenario: str,
|
||
reasoning: str,
|
||
outcome: str,
|
||
confidence: float,
|
||
entities: Optional[List[str]] = None,
|
||
decision_maker: Optional[str] = None,
|
||
metadata: Optional[Dict[str, Any]] = None,
|
||
valid_from: Optional[Union[str, int, float, datetime]] = None,
|
||
valid_until: Optional[Union[str, int, float, datetime]] = None,
|
||
**kwargs
|
||
) -> str:
|
||
"""
|
||
Record a decision with full context and analytics.
|
||
|
||
Args:
|
||
category: Decision category (e.g., "loan_approval")
|
||
scenario: Decision scenario description
|
||
reasoning: Decision reasoning explanation
|
||
outcome: Decision outcome
|
||
confidence: Confidence score (0.0 to 1.0)
|
||
entities: Related entities
|
||
decision_maker: Who made the decision
|
||
metadata: Additional metadata
|
||
**kwargs: Additional decision data
|
||
|
||
Returns:
|
||
Decision ID for reference
|
||
"""
|
||
import uuid
|
||
from datetime import datetime
|
||
|
||
# Input validation
|
||
if not isinstance(category, str) or not category.strip():
|
||
raise ValueError("Category must be a non-empty string")
|
||
if len(category.strip()) > 100:
|
||
raise ValueError("Category must be 100 characters or less")
|
||
|
||
if not isinstance(scenario, str) or not scenario.strip():
|
||
raise ValueError("Scenario must be a non-empty string")
|
||
if len(scenario.strip()) > 5000:
|
||
raise ValueError("Scenario must be 5000 characters or less")
|
||
|
||
if not isinstance(reasoning, str) or not reasoning.strip():
|
||
raise ValueError("Reasoning must be a non-empty string")
|
||
if len(reasoning.strip()) > 10000:
|
||
raise ValueError("Reasoning must be 10000 characters or less")
|
||
|
||
if not isinstance(outcome, str) or not outcome.strip():
|
||
raise ValueError("Outcome must be a non-empty string")
|
||
if len(outcome.strip()) > 1000:
|
||
raise ValueError("Outcome must be 1000 characters or less")
|
||
|
||
if not isinstance(confidence, (int, float)):
|
||
raise ValueError("Confidence must be a number")
|
||
if not (0.0 <= confidence <= 1.0):
|
||
raise ValueError("Confidence must be between 0.0 and 1.0")
|
||
|
||
if entities is not None:
|
||
if not isinstance(entities, list):
|
||
raise ValueError("Entities must be a list of strings")
|
||
for entity in entities:
|
||
if not isinstance(entity, str) or not entity.strip():
|
||
raise ValueError("Each entity must be a non-empty string")
|
||
if len(entity.strip()) > 200:
|
||
raise ValueError("Each entity must be 200 characters or less")
|
||
|
||
if decision_maker is not None:
|
||
if not isinstance(decision_maker, str) or not decision_maker.strip():
|
||
raise ValueError("Decision maker must be a non-empty string")
|
||
if len(decision_maker.strip()) > 200:
|
||
raise ValueError("Decision maker must be 200 characters or less")
|
||
|
||
if metadata is not None:
|
||
if not isinstance(metadata, dict):
|
||
raise ValueError("Metadata must be a dictionary")
|
||
for key, value in metadata.items():
|
||
if not isinstance(key, str) or not key.strip():
|
||
raise ValueError("Metadata keys must be non-empty strings")
|
||
if len(key.strip()) > 100:
|
||
raise ValueError("Metadata keys must be 100 characters or less")
|
||
if len(str(value)) > 1000:
|
||
raise ValueError("Metadata values must be 1000 characters or less")
|
||
|
||
# Validate kwargs
|
||
for key, value in kwargs.items():
|
||
if not isinstance(key, str) or not key.strip():
|
||
raise ValueError("Additional field names must be non-empty strings")
|
||
if len(key.strip()) > 100:
|
||
raise ValueError("Additional field names must be 100 characters or less")
|
||
if len(str(value)) > 1000:
|
||
raise ValueError("Additional field values must be 1000 characters or less")
|
||
|
||
decision_id = str(uuid.uuid4())
|
||
timestamp = datetime.now().timestamp()
|
||
|
||
# Sanitize inputs
|
||
category = category.strip()
|
||
scenario = scenario.strip()
|
||
reasoning = reasoning.strip()
|
||
outcome = outcome.strip()
|
||
confidence = float(confidence)
|
||
entities = [entity.strip() for entity in (entities or []) if entity.strip()]
|
||
decision_maker = decision_maker.strip() if decision_maker else None
|
||
normalized_valid_from = _normalize_temporal_input(valid_from)
|
||
normalized_valid_until = _normalize_temporal_input(valid_until)
|
||
|
||
# Create decision record
|
||
decision = {
|
||
"id": decision_id,
|
||
"category": category,
|
||
"scenario": scenario,
|
||
"reasoning": reasoning,
|
||
"outcome": outcome,
|
||
"confidence": confidence,
|
||
"entities": entities,
|
||
"decision_maker": decision_maker,
|
||
"timestamp": timestamp,
|
||
"recorded_at": datetime.utcnow().isoformat(),
|
||
"valid_from": normalized_valid_from,
|
||
"valid_until": normalized_valid_until,
|
||
"metadata": metadata or {},
|
||
**kwargs
|
||
}
|
||
|
||
# Store decision in graph
|
||
self._add_decision_to_graph(decision)
|
||
|
||
# Store in internal decision storage
|
||
if not hasattr(self, '_decisions'):
|
||
self._decisions = {}
|
||
self._decision_index = defaultdict(set)
|
||
self._entity_index = defaultdict(set)
|
||
self._temporal_index = []
|
||
|
||
self._decisions[decision_id] = decision
|
||
self._decision_index[category].add(decision_id)
|
||
|
||
for entity in entities or []:
|
||
self._entity_index[entity].add(decision_id)
|
||
|
||
self._temporal_index.append((decision_id, timestamp))
|
||
self._temporal_index.sort(key=lambda x: x[1], reverse=True)
|
||
|
||
self.logger.info(f"Recorded decision {decision_id} in category {category}")
|
||
return decision_id
|
||
|
||
def find_precedents_by_scenario(
|
||
self,
|
||
scenario: str,
|
||
category: Optional[str] = None,
|
||
limit: int = 10,
|
||
similarity_threshold: float = 0.5,
|
||
use_semantic_search: bool = True,
|
||
include_superseded: bool = False,
|
||
as_of: Optional[Union[str, int, float, datetime]] = None,
|
||
**filters
|
||
) -> List[Dict[str, Any]]:
|
||
"""
|
||
Find similar decisions (precedents) using hybrid search.
|
||
|
||
Args:
|
||
scenario: Scenario to find precedents for
|
||
category: Filter by decision category
|
||
limit: Maximum number of precedents
|
||
similarity_threshold: Minimum similarity score
|
||
use_semantic_search: Use vector embeddings for search
|
||
**filters: Additional filters
|
||
|
||
Returns:
|
||
List of similar decisions with similarity scores
|
||
"""
|
||
if not hasattr(self, '_decisions') or not self._decisions:
|
||
return []
|
||
as_of_time = self._normalize_timestamp(as_of) if as_of is not None else None
|
||
|
||
candidates = set()
|
||
|
||
# Get candidates by category
|
||
if category:
|
||
candidates.update(self._decision_index.get(category, set()))
|
||
else:
|
||
candidates.update(self._decisions.keys())
|
||
|
||
# Filter by entities if provided
|
||
if "entities" in filters:
|
||
entity_candidates = set()
|
||
for entity in filters["entities"]:
|
||
entity_candidates.update(self._entity_index.get(entity, set()))
|
||
candidates = candidates.intersection(entity_candidates)
|
||
|
||
# Calculate similarities
|
||
precedents = []
|
||
for decision_id in candidates:
|
||
decision = self._decisions[decision_id]
|
||
if not self._decision_matches_temporal_filters(
|
||
decision,
|
||
include_superseded=include_superseded,
|
||
as_of=as_of_time,
|
||
):
|
||
continue
|
||
|
||
# Content similarity
|
||
content_sim = self._calculate_decision_content_similarity(scenario, decision)
|
||
|
||
# Structural similarity (graph-based)
|
||
structural_sim = 0.0
|
||
if self.config.get("advanced_analytics"):
|
||
structural_sim = self._calculate_structural_similarity_for_decision(decision_id, scenario)
|
||
|
||
# Combined similarity
|
||
combined_sim = 0.7 * content_sim + 0.3 * structural_sim
|
||
|
||
if combined_sim >= similarity_threshold:
|
||
precedents.append({
|
||
"decision": decision,
|
||
"similarity": combined_sim,
|
||
"content_similarity": content_sim,
|
||
"structural_similarity": structural_sim
|
||
})
|
||
|
||
# Sort by similarity and limit
|
||
precedents.sort(key=lambda x: x["similarity"], reverse=True)
|
||
return precedents[:limit]
|
||
|
||
def analyze_decision_influence(
|
||
self,
|
||
decision_id: str,
|
||
max_depth: int = 3,
|
||
include_indirect: bool = True
|
||
) -> Dict[str, Any]:
|
||
"""
|
||
Analyze decision influence and impact.
|
||
|
||
Args:
|
||
decision_id: Decision to analyze
|
||
max_depth: Maximum depth for influence analysis
|
||
include_indirect: Include indirect influences
|
||
|
||
Returns:
|
||
Influence analysis results
|
||
"""
|
||
if not hasattr(self, '_decisions') or decision_id not in self._decisions:
|
||
raise ValueError(f"Decision {decision_id} not found")
|
||
|
||
decision = self._decisions[decision_id]
|
||
|
||
# Direct influence (same entities, category)
|
||
direct_influence = set()
|
||
for entity in decision["entities"]:
|
||
direct_influence.update(self._entity_index.get(entity, set()))
|
||
direct_influence.discard(decision_id)
|
||
direct_influence.update(self._decision_index.get(decision["category"], set()))
|
||
direct_influence.discard(decision_id)
|
||
|
||
# Explicit causal relationships recorded via add_causal_relationship() are
|
||
# ground truth and always count as direct influence, in either direction.
|
||
# The index is keyed by the raw edge_type string ("causes" and "CAUSED"
|
||
# are separate keys), so filter by normalized type instead of iterating
|
||
# a fixed spelling list.
|
||
for edge_type, edges in self.edge_type_index.items():
|
||
if edge_type.upper() not in _CAUSAL_TRAVERSAL_TYPES:
|
||
continue
|
||
for edge in edges:
|
||
if edge.source_id == decision_id and edge.target_id in self._decisions:
|
||
direct_influence.add(edge.target_id)
|
||
elif edge.target_id == decision_id and edge.source_id in self._decisions:
|
||
direct_influence.add(edge.source_id)
|
||
|
||
# Indirect influence (through graph relationships)
|
||
indirect_influence = set()
|
||
if include_indirect and self.config.get("advanced_analytics"):
|
||
indirect_influence = self._find_indirect_decision_influence(decision_id, max_depth) - direct_influence
|
||
|
||
# Calculate influence scores
|
||
influence_scores = {}
|
||
for influenced_id in direct_influence | indirect_influence:
|
||
influence_scores[influenced_id] = self._calculate_decision_influence_score(
|
||
decision_id, influenced_id
|
||
)
|
||
|
||
# Sort by influence score
|
||
sorted_influence = sorted(
|
||
influence_scores.items(),
|
||
key=lambda x: x[1].get("score", 0.0),
|
||
reverse=True
|
||
)
|
||
|
||
def _enrich(did: str) -> Dict[str, Any]:
|
||
dec = self._decisions.get(did, {})
|
||
return {
|
||
"decision_id": did,
|
||
"scenario": dec.get("scenario", ""),
|
||
"outcome": dec.get("outcome", ""),
|
||
"category": dec.get("category", ""),
|
||
}
|
||
|
||
return {
|
||
"decision_id": decision_id,
|
||
"direct_influence": [_enrich(did) for did in direct_influence],
|
||
"indirect_influence": [_enrich(did) for did in indirect_influence],
|
||
"influence_scores": [
|
||
{
|
||
**_enrich(did),
|
||
"score": details.get("score", 0.0),
|
||
"score_breakdown": {
|
||
"entity_overlap": details.get("entity_score", 0.0),
|
||
"category_match": details.get("category_score", 0.0),
|
||
"temporal_proximity": details.get("time_score", 0.0),
|
||
},
|
||
"is_direct": did in direct_influence,
|
||
}
|
||
for did, details in sorted_influence
|
||
],
|
||
"total_influenced": len(influence_scores),
|
||
"max_influence_score": max(
|
||
details.get("score", 0.0) for details in influence_scores.values()
|
||
) if influence_scores else 0.0
|
||
}
|
||
|
||
def get_decision_insights(self) -> Dict[str, Any]:
|
||
"""
|
||
Get comprehensive insights about all decisions.
|
||
|
||
Returns:
|
||
Comprehensive analytics and insights
|
||
"""
|
||
if not hasattr(self, '_decisions') or not self._decisions:
|
||
return {"message": "No decisions recorded yet"}
|
||
|
||
# Basic statistics
|
||
total_decisions = len(self._decisions)
|
||
categories = {}
|
||
outcomes = {}
|
||
confidence_scores = []
|
||
|
||
for decision in self._decisions.values():
|
||
# Category distribution
|
||
categories[decision["category"]] = categories.get(decision["category"], 0) + 1
|
||
|
||
# Outcome distribution
|
||
outcomes[decision["outcome"]] = outcomes.get(decision["outcome"], 0) + 1
|
||
|
||
# Confidence scores
|
||
confidence_scores.append(decision["confidence"])
|
||
|
||
# Advanced analytics (if available)
|
||
advanced_insights = {}
|
||
if self.config.get("advanced_analytics"):
|
||
advanced_insights = self.analyze_graph_with_kg()
|
||
|
||
# Temporal analysis
|
||
temporal_insights = self._get_decision_temporal_analysis()
|
||
|
||
# Entity analysis
|
||
entity_insights = self._get_decision_entity_analysis()
|
||
|
||
return {
|
||
"total_decisions": total_decisions,
|
||
"categories": categories,
|
||
"outcomes": outcomes,
|
||
"confidence_stats": {
|
||
"mean": sum(confidence_scores) / len(confidence_scores),
|
||
"min": min(confidence_scores),
|
||
"max": max(confidence_scores),
|
||
"median": sorted(confidence_scores)[len(confidence_scores) // 2]
|
||
},
|
||
"advanced_analytics": advanced_insights,
|
||
"temporal_analysis": temporal_insights,
|
||
"entity_analysis": entity_insights,
|
||
"graph_metrics": self.get_graph_metrics() if hasattr(self, 'get_graph_metrics') else {}
|
||
}
|
||
|
||
def trace_decision_causality(
|
||
self,
|
||
decision_id: str,
|
||
max_depth: int = 5,
|
||
max_chains: Optional[int] = 10000
|
||
) -> List[Dict[str, Any]]:
|
||
"""
|
||
Trace causal chain for a decision.
|
||
|
||
Args:
|
||
decision_id: Decision to trace
|
||
max_depth: Maximum depth for causal analysis
|
||
max_chains: Maximum number of chains to return. Densely connected
|
||
graphs can contain a combinatorial number of distinct causal
|
||
paths, so the traversal stops once this many chains have been
|
||
collected and appends a ``{"truncated": True, ...}`` marker so
|
||
callers can tell the trace is incomplete. Pass None for no limit.
|
||
|
||
Returns:
|
||
Causal chain as list of decision relationships
|
||
"""
|
||
if not hasattr(self, '_decisions') or decision_id not in self._decisions:
|
||
raise ValueError(f"Decision {decision_id} not found")
|
||
|
||
try:
|
||
# Use graph traversal to find causal relationships
|
||
causal_chain = []
|
||
chain_limit = float("inf") if max_chains is None else max_chains
|
||
truncated = False
|
||
|
||
# Reverse index of explicit causal edges, built once per call so the
|
||
# traversal does not rescan the edge list at every visited node.
|
||
# Edges may reference decision nodes that were never recorded through
|
||
# record_decision() (e.g. a graph restored via from_dict), so only
|
||
# causes with a known decision record are kept.
|
||
incoming_causal_edges = defaultdict(list)
|
||
# The index is keyed by the raw edge_type string ("causes" and
|
||
# "CAUSED" are separate keys), so filter by normalized type.
|
||
for edge_type, edges in self.edge_type_index.items():
|
||
if edge_type.upper() not in _CAUSAL_TRAVERSAL_TYPES:
|
||
continue
|
||
for edge in edges:
|
||
if edge.source_id in self._decisions:
|
||
incoming_causal_edges[edge.target_id].append(edge)
|
||
|
||
def record_chain(cause_path):
|
||
"""Record one chain. Returns False once the cap is reached."""
|
||
nonlocal truncated
|
||
if len(causal_chain) >= chain_limit:
|
||
truncated = True
|
||
return False
|
||
causal_chain.append(
|
||
self._build_causal_chain_report(list(reversed(cause_path)))
|
||
)
|
||
return True
|
||
|
||
def trace_recursive(current_id, depth, path, path_ids):
|
||
# Cycle detection is per-path rather than global: a decision reached
|
||
# through one branch must stay traversable through another, otherwise
|
||
# branching graphs silently lose valid chains. max_depth bounds the
|
||
# traversal.
|
||
if truncated or depth >= max_depth or current_id in path_ids:
|
||
return
|
||
|
||
path_ids = path_ids | {current_id}
|
||
current_decision = self._decisions[current_id]
|
||
|
||
# Explicit causal relationships recorded via add_causal_relationship()
|
||
# take precedence - they are the ground truth the caller recorded.
|
||
# Every edge is traced, so parallel relationships between the same
|
||
# pair of decisions are all reported rather than overwriting.
|
||
explicit_causes = incoming_causal_edges.get(current_id, [])
|
||
explicit_cause_ids = {edge.source_id for edge in explicit_causes}
|
||
|
||
for edge in explicit_causes:
|
||
cause_id = edge.source_id
|
||
cause_dec = self._decisions[cause_id]
|
||
weight = getattr(edge, "weight", None)
|
||
# A stored weight of 0.0 is meaningful and must not be coerced
|
||
# to the 1.0 default.
|
||
edge_weight = 1.0 if weight is None else float(weight)
|
||
hop = {
|
||
"from": cause_id,
|
||
"from_scenario": cause_dec.get("scenario", ""),
|
||
"to": current_id,
|
||
"to_scenario": current_decision.get("scenario", ""),
|
||
"type": edge.edge_type,
|
||
"edge_weight": edge_weight,
|
||
}
|
||
cause_path = path + [hop]
|
||
if not record_chain(cause_path):
|
||
return
|
||
trace_recursive(cause_id, depth + 1, cause_path, path_ids)
|
||
if truncated:
|
||
return
|
||
|
||
# Find potential causes (decisions that influenced this one) via
|
||
# shared entities/timestamps - additive heuristic, skipping anything
|
||
# already covered by an explicit relationship above.
|
||
potential_causes = []
|
||
for entity in current_decision["entities"]:
|
||
for other_decision_id in self._entity_index.get(entity, set()):
|
||
if other_decision_id != current_id and other_decision_id not in explicit_cause_ids:
|
||
other_decision = self._decisions[other_decision_id]
|
||
if other_decision["timestamp"] < current_decision["timestamp"]:
|
||
potential_causes.append(other_decision_id)
|
||
|
||
for cause_id in potential_causes:
|
||
cause_dec = self._decisions.get(cause_id, {})
|
||
edge_weight = float(cause_dec.get("confidence", 1.0))
|
||
hop = {
|
||
"from": cause_id,
|
||
"from_scenario": cause_dec.get("scenario", ""),
|
||
"to": current_id,
|
||
"to_scenario": current_decision.get("scenario", ""),
|
||
"type": "influences",
|
||
"edge_weight": edge_weight,
|
||
}
|
||
cause_path = path + [hop]
|
||
if not record_chain(cause_path):
|
||
return
|
||
trace_recursive(cause_id, depth + 1, cause_path, path_ids)
|
||
if truncated:
|
||
return
|
||
|
||
trace_recursive(decision_id, 0, [], frozenset())
|
||
|
||
if truncated:
|
||
# Never drop chains silently: the caller is told the trace is partial.
|
||
self.logger.warning(
|
||
"Causal trace for %s truncated at %s chains; "
|
||
"raise max_chains or lower max_depth for a complete trace.",
|
||
decision_id,
|
||
max_chains,
|
||
)
|
||
causal_chain.append({
|
||
"truncated": True,
|
||
"max_chains": max_chains,
|
||
"message": (
|
||
f"Causal trace truncated at {max_chains} chains. "
|
||
"The result is incomplete; raise max_chains or lower "
|
||
"max_depth for a complete trace."
|
||
),
|
||
})
|
||
|
||
return causal_chain
|
||
|
||
except Exception as e:
|
||
self.logger.error(f"Causal analysis failed: {e}")
|
||
return [{"error": "Causal analysis failed due to an internal error"}]
|
||
|
||
def enforce_decision_policy(
|
||
self,
|
||
decision_data: Dict[str, Any],
|
||
policy_rules: Optional[Dict[str, Any]] = None
|
||
) -> Dict[str, Any]:
|
||
"""
|
||
Enforce policies on decision data.
|
||
|
||
Args:
|
||
decision_data: Decision data to check
|
||
policy_rules: Policy rules to enforce
|
||
|
||
Returns:
|
||
Policy enforcement results
|
||
"""
|
||
# Simple policy enforcement implementation
|
||
violations = []
|
||
warnings = []
|
||
|
||
# Default policy rules
|
||
default_rules = {
|
||
"min_confidence": 0.7,
|
||
"required_outcomes": ["approved", "rejected", "flagged"],
|
||
"required_metadata": ["decision_maker"],
|
||
"max_reasoning_length": 1000
|
||
}
|
||
|
||
rules = policy_rules or default_rules
|
||
|
||
# Check confidence
|
||
if decision_data.get("confidence", 0) < rules.get("min_confidence", 0.7):
|
||
violations.append(f"Confidence too low: {decision_data.get('confidence', 0)}")
|
||
|
||
# Check outcome
|
||
if decision_data.get("outcome") not in rules.get("required_outcomes", []):
|
||
violations.append(f"Invalid outcome: {decision_data.get('outcome')}")
|
||
|
||
# Check required metadata
|
||
for required_field in rules.get("required_metadata", []):
|
||
if not decision_data.get(required_field):
|
||
violations.append(f"Missing required field: {required_field}")
|
||
|
||
# Check reasoning length
|
||
reasoning = decision_data.get("reasoning", "")
|
||
if len(reasoning) > rules.get("max_reasoning_length", 1000):
|
||
warnings.append(f"Reasoning too long: {len(reasoning)} characters")
|
||
|
||
return {
|
||
"compliant": len(violations) == 0,
|
||
"violations": violations,
|
||
"warnings": warnings,
|
||
"policy_rules": rules
|
||
}
|
||
|
||
# --- Private helper methods for decision management ---
|
||
|
||
def _add_decision_to_graph(self, decision: Dict[str, Any]) -> None:
|
||
"""Add decision to context graph."""
|
||
try:
|
||
protected_properties = {
|
||
"category",
|
||
"scenario",
|
||
"reasoning",
|
||
"outcome",
|
||
"confidence",
|
||
"timestamp",
|
||
"decision_maker",
|
||
}
|
||
safe_metadata = {
|
||
key: value
|
||
for key, value in (decision.get("metadata") or {}).items()
|
||
if key not in protected_properties
|
||
}
|
||
extra_properties = {
|
||
key: value
|
||
for key, value in decision.items()
|
||
if key not in {
|
||
"id",
|
||
"category",
|
||
"scenario",
|
||
"reasoning",
|
||
"outcome",
|
||
"confidence",
|
||
"entities",
|
||
"decision_maker",
|
||
"timestamp",
|
||
"recorded_at",
|
||
"valid_from",
|
||
"valid_until",
|
||
"metadata",
|
||
}
|
||
}
|
||
# Add decision node
|
||
self.add_node(
|
||
decision["id"],
|
||
"decision",
|
||
content=decision["scenario"],
|
||
valid_from=decision.get("valid_from"),
|
||
valid_until=decision.get("valid_until"),
|
||
category=decision["category"],
|
||
outcome=decision["outcome"],
|
||
confidence=decision["confidence"],
|
||
timestamp=decision["timestamp"],
|
||
scenario=decision["scenario"],
|
||
decision_maker=decision.get("decision_maker", ""),
|
||
reasoning=decision["reasoning"],
|
||
**safe_metadata,
|
||
**extra_properties,
|
||
)
|
||
|
||
# Add entity nodes and relationships
|
||
for entity in decision["entities"]:
|
||
# Add entity node if not exists
|
||
if not self.find_node(entity):
|
||
self.add_node(
|
||
entity,
|
||
"entity",
|
||
name=entity
|
||
)
|
||
|
||
# Add relationship
|
||
self.add_edge(
|
||
decision["id"],
|
||
entity,
|
||
"involves",
|
||
confidence=decision["confidence"]
|
||
)
|
||
|
||
# Add category node and relationship
|
||
category_id = f"category_{decision['category']}"
|
||
if not self.find_node(category_id):
|
||
self.add_node(
|
||
category_id,
|
||
"category",
|
||
name=decision["category"]
|
||
)
|
||
|
||
self.add_edge(
|
||
decision["id"],
|
||
category_id,
|
||
"belongs_to"
|
||
)
|
||
|
||
# Add decision maker node if provided
|
||
if decision.get("decision_maker"):
|
||
maker_id = f"maker_{decision['decision_maker']}"
|
||
if not self.find_node(maker_id):
|
||
self.add_node(
|
||
maker_id,
|
||
"decision_maker",
|
||
name=decision["decision_maker"]
|
||
)
|
||
|
||
self.add_edge(
|
||
decision["id"],
|
||
maker_id,
|
||
"made_by"
|
||
)
|
||
|
||
except Exception as e:
|
||
self.logger.exception("Failed to add decision to graph")
|
||
|
||
def _decision_matches_temporal_filters(
|
||
self,
|
||
decision: Dict[str, Any],
|
||
include_superseded: bool = False,
|
||
as_of: Optional[datetime] = None,
|
||
) -> bool:
|
||
"""Return True when a decision matches temporal filter rules."""
|
||
valid_from = _parse_iso_dt(decision.get("valid_from")) if decision.get("valid_from") else None
|
||
valid_until = _parse_iso_dt(decision.get("valid_until")) if decision.get("valid_until") else None
|
||
reference_time = as_of or datetime.utcnow()
|
||
|
||
if as_of is not None:
|
||
if valid_from is not None and reference_time < valid_from:
|
||
return False
|
||
if valid_until is not None and reference_time > valid_until:
|
||
return False
|
||
return True
|
||
|
||
if include_superseded:
|
||
return True
|
||
|
||
if valid_until is not None and reference_time > valid_until:
|
||
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).
|
||
|
||
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.
|
||
|
||
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|/|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_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
|
||
)
|
||
|
||
# --- character-bigram Jaccard (CJK / very-short-query fallback) ---
|
||
scenario_bigrams = self._char_bigrams(scenario)
|
||
decision_bigrams = self._char_bigrams(decision_text)
|
||
|
||
# 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:
|
||
self.logger.exception("Content similarity calculation failed")
|
||
return 0.0
|
||
|
||
def _calculate_structural_similarity_for_decision(self, decision_id: str, scenario: str) -> float:
|
||
"""Calculate structural similarity using graph algorithms."""
|
||
try:
|
||
if not self.config.get("advanced_analytics"):
|
||
return 0.0
|
||
|
||
# Use graph similarity algorithms
|
||
similar_nodes = self.find_similar_nodes(
|
||
decision_id,
|
||
similarity_type="structural",
|
||
top_k=5
|
||
)
|
||
|
||
if similar_nodes:
|
||
return max(
|
||
item.get("score", 0.0)
|
||
for item in similar_nodes
|
||
if isinstance(item, dict)
|
||
)
|
||
|
||
except Exception as e:
|
||
self.logger.exception("Structural similarity calculation failed")
|
||
|
||
return 0.0
|
||
|
||
def _find_indirect_decision_influence(self, decision_id: str, max_depth: int) -> Set[str]:
|
||
"""Find indirect influences using graph traversal."""
|
||
try:
|
||
influenced = set()
|
||
|
||
# Get neighbors in graph
|
||
neighbors = self.get_neighbors(decision_id, hops=max_depth)
|
||
|
||
for neighbor in neighbors:
|
||
if neighbor.get("type") == "decision":
|
||
influenced.add(neighbor["id"])
|
||
|
||
return influenced
|
||
|
||
except Exception as e:
|
||
self.logger.warning(f"Indirect influence analysis failed: {e}")
|
||
return set()
|
||
|
||
def _build_causal_chain_report(self, hops: List[Dict[str, Any]]) -> Dict[str, Any]:
|
||
"""Build an auditable causal-chain response from hop records."""
|
||
hop_count = len(hops)
|
||
confidence_decay = 1.0
|
||
weakest_link = None
|
||
for hop in hops:
|
||
edge_weight = float(hop.get("edge_weight", 1.0))
|
||
confidence_decay *= edge_weight
|
||
if weakest_link is None or edge_weight < float(weakest_link.get("edge_weight", 1.0)):
|
||
weakest_link = hop
|
||
|
||
if hop_count <= 1:
|
||
interpretation = f"Direct influence with confidence {confidence_decay:.2f}."
|
||
elif confidence_decay > 0.7:
|
||
interpretation = (
|
||
f"Mediated through {hop_count - 1} step(s) with high confidence "
|
||
f"({confidence_decay:.2f})."
|
||
)
|
||
elif confidence_decay > 0.4:
|
||
interpretation = (
|
||
f"Mediated through {hop_count - 1} step(s) - confidence decays "
|
||
f"to {confidence_decay:.2f}."
|
||
)
|
||
else:
|
||
interpretation = (
|
||
f"Distal influence across {hop_count} causal steps; confidence "
|
||
f"{confidence_decay:.2f} is weak evidence."
|
||
)
|
||
|
||
return _CausalChain({
|
||
"hops": hops,
|
||
"hop_count": hop_count,
|
||
"confidence_decay": confidence_decay,
|
||
"weakest_link": weakest_link,
|
||
"distance_band": classify_path_distance(hop_count),
|
||
"interpretation": interpretation,
|
||
})
|
||
|
||
def _calculate_decision_influence_score(self, source_id: str, target_id: str) -> Dict[str, float]:
|
||
"""Calculate influence score between two decisions."""
|
||
try:
|
||
if not hasattr(self, '_decisions'):
|
||
return {"score": 0.0, "entity_score": 0.0, "category_score": 0.0, "time_score": 0.0}
|
||
|
||
source_decision = self._decisions[source_id]
|
||
target_decision = self._decisions[target_id]
|
||
|
||
# Base score from shared entities
|
||
shared_entities = set(source_decision["entities"]) & set(target_decision["entities"])
|
||
entity_score = len(shared_entities) / max(len(source_decision["entities"]), 1)
|
||
|
||
# Category similarity
|
||
category_score = 1.0 if source_decision["category"] == target_decision["category"] else 0.0
|
||
|
||
# Temporal proximity (more recent decisions have higher influence)
|
||
time_diff = abs(source_decision["timestamp"] - target_decision["timestamp"])
|
||
time_score = max(0.0, 1.0 - time_diff / (30 * 24 * 3600)) # 30 days window
|
||
|
||
# Combined score
|
||
combined_score = 0.5 * entity_score + 0.3 * category_score + 0.2 * time_score
|
||
|
||
return {
|
||
"score": combined_score,
|
||
"entity_score": entity_score,
|
||
"category_score": category_score,
|
||
"time_score": time_score,
|
||
}
|
||
|
||
except Exception as e:
|
||
self.logger.warning(f"Influence score calculation failed: {e}")
|
||
return {"score": 0.0, "entity_score": 0.0, "category_score": 0.0, "time_score": 0.0}
|
||
|
||
def _get_decision_temporal_analysis(self) -> Dict[str, Any]:
|
||
"""Get temporal analysis of decisions."""
|
||
try:
|
||
if not hasattr(self, '_temporal_index') or not self._temporal_index:
|
||
return {}
|
||
|
||
# Group decisions by time periods
|
||
recent_decisions = [did for did, ts in self._temporal_index[:10]]
|
||
|
||
return {
|
||
"recent_decisions": len(recent_decisions),
|
||
"oldest_decision": min(ts for _, ts in self._temporal_index),
|
||
"newest_decision": max(ts for _, ts in self._temporal_index),
|
||
"time_span": max(ts for _, ts in self._temporal_index) - min(ts for _, ts in self._temporal_index)
|
||
}
|
||
|
||
except Exception as e:
|
||
self.logger.warning(f"Temporal analysis failed: {e}")
|
||
return {}
|
||
|
||
def _get_decision_entity_analysis(self) -> Dict[str, Any]:
|
||
"""Get entity analysis from decisions."""
|
||
try:
|
||
if not hasattr(self, '_decisions'):
|
||
return {}
|
||
|
||
entity_counts = {}
|
||
for decision in self._decisions.values():
|
||
for entity in decision["entities"]:
|
||
entity_counts[entity] = entity_counts.get(entity, 0) + 1
|
||
|
||
# Get top entities
|
||
top_entities = sorted(entity_counts.items(), key=lambda x: x[1], reverse=True)[:10]
|
||
|
||
return {
|
||
"total_entities": len(entity_counts),
|
||
"top_entities": top_entities,
|
||
"avg_entities_per_decision": sum(len(d["entities"]) for d in self._decisions.values()) / len(self._decisions)
|
||
}
|
||
|
||
except Exception as e:
|
||
self.logger.warning(f"Entity analysis failed: {e}")
|
||
return {}
|
||
|
||
# --- Easy-to-Use Convenience Methods ---
|
||
|
||
def add_decision_simple(
|
||
self,
|
||
category: str,
|
||
scenario: str,
|
||
reasoning: str,
|
||
outcome: str,
|
||
confidence: float = 0.5,
|
||
entities: Optional[List[str]] = None,
|
||
decision_maker: Optional[str] = "system",
|
||
**kwargs
|
||
) -> str:
|
||
"""
|
||
Easy way to record a decision.
|
||
|
||
Args:
|
||
category: Decision category (e.g., "loan_approval")
|
||
scenario: What was the situation
|
||
reasoning: Why was this decision made
|
||
outcome: What was decided
|
||
confidence: How confident (0.0 to 1.0)
|
||
entities: Related entities (people, items, etc.)
|
||
decision_maker: Who made the decision
|
||
**kwargs: Additional information
|
||
|
||
Returns:
|
||
Decision ID for reference
|
||
"""
|
||
return self.record_decision(
|
||
category=category,
|
||
scenario=scenario,
|
||
reasoning=reasoning,
|
||
outcome=outcome,
|
||
confidence=confidence,
|
||
entities=entities,
|
||
decision_maker=decision_maker,
|
||
metadata=kwargs
|
||
)
|
||
|
||
def find_similar_decisions(
|
||
self,
|
||
scenario: str,
|
||
category: Optional[str] = None,
|
||
max_results: int = 10,
|
||
min_similarity: float = 0.3
|
||
) -> List[Dict[str, Any]]:
|
||
"""
|
||
Easy way to find similar past decisions.
|
||
|
||
Args:
|
||
scenario: What situation are you looking for
|
||
category: Filter by decision type
|
||
max_results: Maximum results to return
|
||
min_similarity: Minimum similarity score
|
||
|
||
Returns:
|
||
List of similar decisions with similarity scores
|
||
"""
|
||
return self.find_precedents_by_scenario(
|
||
scenario=scenario,
|
||
category=category,
|
||
limit=max_results,
|
||
similarity_threshold=min_similarity
|
||
)
|
||
|
||
def analyze_decision_impact(
|
||
self,
|
||
decision_id: str,
|
||
include_indirect: bool = True
|
||
) -> Dict[str, Any]:
|
||
"""
|
||
Easy way to analyze how a decision impacts others.
|
||
|
||
Args:
|
||
decision_id: Decision to analyze
|
||
include_indirect: Include indirect impacts
|
||
|
||
Returns:
|
||
Impact analysis results
|
||
"""
|
||
return self.analyze_decision_influence(
|
||
decision_id=decision_id,
|
||
max_depth=3,
|
||
include_indirect=include_indirect
|
||
)
|
||
|
||
def get_decision_summary(self) -> Dict[str, Any]:
|
||
"""
|
||
Easy way to get a summary of all decisions.
|
||
|
||
Returns:
|
||
Summary statistics and insights
|
||
"""
|
||
return self.get_decision_insights()
|
||
|
||
def trace_decision_chain(
|
||
self,
|
||
decision_id: str,
|
||
max_steps: int = 5,
|
||
max_chains: Optional[int] = 10000
|
||
) -> List[Dict[str, Any]]:
|
||
"""
|
||
Easy way to trace how decisions are connected.
|
||
|
||
Args:
|
||
decision_id: Starting decision
|
||
max_steps: Maximum steps to trace
|
||
max_chains: Maximum number of chains to return; see
|
||
trace_decision_causality(). Pass None for no limit.
|
||
|
||
Returns:
|
||
Decision chain connections
|
||
"""
|
||
return self.trace_decision_causality(
|
||
decision_id=decision_id,
|
||
max_depth=max_steps,
|
||
max_chains=max_chains
|
||
)
|
||
|
||
def check_decision_rules(
|
||
self,
|
||
decision_data: Dict[str, Any],
|
||
rules: Optional[Dict[str, Any]] = None
|
||
) -> Dict[str, Any]:
|
||
"""
|
||
Easy way to check if a decision follows the rules.
|
||
|
||
Args:
|
||
decision_data: Decision to check
|
||
rules: Custom rules (uses default if None)
|
||
|
||
Returns:
|
||
Compliance check results
|
||
"""
|
||
return self.enforce_decision_policy(
|
||
decision_data=decision_data,
|
||
policy_rules=rules
|
||
)
|
||
|
||
def get_graph_summary(self) -> Dict[str, Any]:
|
||
"""
|
||
Easy way to get graph statistics.
|
||
|
||
Returns:
|
||
Graph summary information
|
||
"""
|
||
if hasattr(self, 'get_graph_metrics'):
|
||
return self.get_graph_metrics()
|
||
else:
|
||
return {
|
||
"nodes": len(self.nodes),
|
||
"edges": len(self.edges),
|
||
"node_types": self._get_node_type_distribution(),
|
||
"edge_types": self._get_edge_type_distribution()
|
||
}
|
||
|
||
def find_related_nodes(
|
||
self,
|
||
node_id: str,
|
||
how_many: int = 10,
|
||
similarity_type: str = "content"
|
||
) -> List[Dict[str, Any]]:
|
||
"""
|
||
Easy way to find nodes similar to a given node.
|
||
|
||
Args:
|
||
node_id: Reference node
|
||
how_many: How many similar nodes to find
|
||
similarity_type: Type of similarity ("content", "structural")
|
||
|
||
Returns:
|
||
List of dicts with node ID, type, content, and similarity score
|
||
"""
|
||
return self.find_similar_nodes(
|
||
node_id=node_id,
|
||
similarity_type=similarity_type,
|
||
top_k=how_many
|
||
)
|
||
|
||
def get_node_importance(
|
||
self,
|
||
node_id: str
|
||
) -> Dict[str, float]:
|
||
"""
|
||
Easy way to get how important a node is in the graph.
|
||
|
||
Args:
|
||
node_id: Node to analyze
|
||
|
||
Returns:
|
||
Centrality measures (importance scores)
|
||
"""
|
||
return self.get_node_centrality(node_id)
|
||
|
||
def analyze_connections(self) -> Dict[str, Any]:
|
||
"""
|
||
Easy way to analyze the entire graph structure.
|
||
|
||
Returns:
|
||
Graph analysis results
|
||
"""
|
||
return self.analyze_graph_with_kg()
|
||
|
||
|
||
# For backward compatibility
|
||
ContextGraphBuilder = ContextGraph
|