Files
semantica/semantica/context/context_graph.py
T
hsd2514andZohaib Hassnain 8a4ebafb9a fix(context): honor explicit causal edges in decision tracing (#983)
* fix(context): honor explicit causal edges in decision tracing

trace_decision_causality() inferred causes purely from shared NER entities
plus timestamp ordering, so relationships recorded through
add_causal_relationship() never affected the trace. When entity extraction
returned nothing, trace_decision_chain() came back empty even though an
explicit CAUSED edge was stored in the graph.

Traverse the explicit CAUSED/INFLUENCED/PRECEDENT_FOR edges first, since
they are the ground truth the caller recorded, and keep the entity and
timestamp inference as an additive fallback for pairs with no explicit
link. Edges whose source has no decision record (for example a graph
restored via from_dict) are skipped so a stale edge cannot abort the trace.

analyze_decision_influence() now reports explicitly linked decisions as
direct influence rather than surfacing them only as indirect, and no
longer lists the same decision under both direct and indirect.

Closes #975

* fix(context): address review feedback on causal edge tracing

Follow-up to the explicit causal edge fix, covering the issues raised in
review.

A stored edge weight of 0.0 was coerced to the 1.0 default by a truthiness
check, inflating confidence_decay in the causal chain report. add_edge() is
public and can create causal edges with any weight, so use an explicit None
check instead.

Explicit causes were collected into a dict keyed by source_id, so multiple
causal edges between the same pair of decisions overwrote each other and
only the last was traced. Collect every edge instead, keeping a separate set
of source ids for the entity fallback exclusion.

Cycle detection used a single traversal-wide visited set, so a decision
reached through one branch became unreachable through another and branching
graphs silently lost valid chains. Detect cycles per path instead; max_depth
still bounds the traversal.

Build a reverse index of causal edges once per call rather than scanning the
edge list at every visited node, and use edge_type_index in the influence
analysis. The three causal edge types are now a shared constant.

Adds regression tests for zero weights, parallel edges, branching graphs and
cycle termination.

* fix(context): bound causal trace and report truncation

Per-path cycle detection keeps branching graphs correct but makes the
traversal combinatorial in max_depth: on a densely connected graph the
number of distinct causal paths grows by roughly the branching factor per
level, so a raised max_depth could return hundreds of thousands of chain
reports and take seconds of CPU.

Add a max_chains bound, defaulting to 10000. Rather than dropping chains
silently, which is the exact failure this fix set out to eliminate, the
traversal stops at the bound and appends a {"truncated": True, ...} marker
so callers can always tell the trace is incomplete. A warning is logged with
the same detail. Pass max_chains=None for the previous unbounded behaviour.

Graphs that fit within the bound are unaffected.

---------

Co-authored-by: Zohaib Hassnain <109234410+ZohaibHassan16@users.noreply.github.com>
2026-08-14 21:51:04 +05:00

3620 lines
138 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
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", type="language", properties={"popularity": "high"})
>>> graph.add_node("Programming", type="concept")
>>> graph.add_edge("Python", "Programming", type="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("loan_approval", 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
"""
from collections import defaultdict, deque
from dataclasses import dataclass, field
from datetime import datetime, timezone
import json
import threading
import itertools
from typing import Any, Dict, List, Optional, Set, Tuple, Union
import uuid
from ..utils.logging import get_logger
from ..utils.progress_tracker import get_progress_tracker
from ..utils.helpers import classify_path_distance
from ..utils.skos import is_skos_hierarchy_edge, validate_skos_hierarchy
from .entity_linker import EntityLinker
# Optional imports for advanced features
try:
from ..kg import (
GraphBuilder, GraphAnalyzer, CentralityCalculator, CommunityDetector,
PathFinder, NodeEmbedder, SimilarityCalculator, LinkPredictor,
ConnectivityAnalyzer
)
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 _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")
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!
"""
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._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]] = {}
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)
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: str) -> None:
"""
Save context graph to file (JSON format).
Args:
path: File path to save to
"""
import json
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: str) -> None:
"""
Load context graph from file (JSON format).
Args:
path: File path to load from
"""
import json
import os
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._adjacency.clear()
self.node_type_index.clear()
self.edge_type_index.clear()
self._linked_graphs.clear()
self._unresolved_links.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
self.logger.info(f"Loaded context graph from {path}")
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 clear(self) -> None:
"""Fully reset the graph state and indexes."""
with self._lock:
self.nodes.clear()
self.edges.clear()
self._adjacency.clear()
self.node_type_index.clear()
self.edge_type_index.clear()
self._linked_graphs.clear()
self._unresolved_links.clear()
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:
# 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.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
# --- 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 ..utils.helpers import read_json_file
from pathlib import Path
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 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)
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.01.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)
"""
valid_types = ["CAUSED", "INFLUENCED", "PRECEDENT_FOR"]
if relationship_type not in valid_types:
raise ValueError(f"Relationship type must be one of: {valid_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 in ["CAUSED", "INFLUENCED", "PRECEDENT_FOR"]:
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 in ["CAUSED", "INFLUENCED", "PRECEDENT_FOR"]:
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
precedent_ids = []
for edge in self.edges:
if edge.target_id == decision_id and edge.edge_type == "PRECEDENT_FOR":
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.
for edge_type in _CAUSAL_EDGE_TYPES:
for edge in self.edge_type_index.get(edge_type, []):
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)
for edge_type in _CAUSAL_EDGE_TYPES:
for edge in self.edge_type_index.get(edge_type, []):
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
def _calculate_decision_content_similarity(self, scenario: str, decision: Dict[str, Any]) -> float:
"""Calculate content similarity between scenario and decision."""
try:
# Simple word-based similarity
scenario_words = set(scenario.lower().split())
decision_text = f"{decision['scenario']} {decision['reasoning']} {' '.join(decision['entities'])}"
decision_words = set(decision_text.lower().split())
intersection = scenario_words.intersection(decision_words)
union = scenario_words.union(decision_words)
return len(intersection) / len(union) if union else 0.0
except Exception as e:
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