mirror of
https://github.com/semantica-agi/semantica.git
synced 2026-08-29 04:26:20 +00:00
Compare commits
23
Commits
v0.3.0-beta
...
v0.3.0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
43a8f823c8 | ||
|
|
7a7e3f9e6b | ||
|
|
c59e33c9d3 | ||
|
|
867ecfda1b | ||
|
|
4103f747c5 | ||
|
|
ad8f24fc6b | ||
|
|
7535e39c56 | ||
|
|
420ccfe45a | ||
|
|
ad72ab9d19 | ||
|
|
a06e029264 | ||
|
|
a4caafbb6d | ||
|
|
e8d0d7a2cf | ||
|
|
8ffaf6001b | ||
|
|
476267f764 | ||
|
|
7ebdbcc62b | ||
|
|
e8e838829d | ||
|
|
a7e43304fc | ||
|
|
b36f6cd9eb | ||
|
|
af93dd29a8 | ||
|
|
dc8c29a87f | ||
|
|
78c52eb099 | ||
|
|
6b847716b1 | ||
|
|
4b1c78372c |
+1
-1
@@ -7,7 +7,7 @@ Check the [docs folder](https://github.com/Hawksight-AI/semantica/tree/main/docs
|
||||
|
||||
### 💬 Community Support
|
||||
- **GitHub Discussions**: [Ask questions](https://github.com/Hawksight-AI/semantica/discussions)
|
||||
- **Discord**: Join our [Discord server](https://discord.gg/ggb7vWeP) for real-time chat
|
||||
- **Discord**: Join our [Discord server](https://discord.gg/sV34vps5hH) for real-time chat
|
||||
|
||||
### 💭 Discussions
|
||||
Join the conversation on [GitHub Discussions](https://github.com/Hawksight-AI/semantica/discussions):
|
||||
|
||||
@@ -7,6 +7,41 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [0.3.0] - 2026-03-10
|
||||
|
||||
- **Context Graph Feature Completeness** (by @KaifAhmad1):
|
||||
- Added `valid_from` / `valid_until` temporal validity fields to `ContextNode` and `ContextEdge` dataclasses — both expose `is_active(at_time=None) -> bool`; nodes/edges without these fields are always considered active
|
||||
- Added `add_node(valid_from=..., valid_until=...)` and `add_edge(valid_from=..., valid_until=...)` support — validity windows are extracted from `**properties` and stored as first-class dataclass fields, not in metadata
|
||||
- Added `ContextGraph.find_active_nodes(node_type=None, at_time=None)` — returns only nodes whose validity window includes the given time (defaults to `datetime.utcnow()`); complements `find_nodes()` with temporal filtering
|
||||
- Added `min_weight: float = 0.0` parameter to `ContextGraph.get_neighbors()` — edges with weight below the threshold are skipped during BFS traversal, enabling weighted/confidence-filtered multi-hop navigation; fully backward-compatible (default 0.0 passes all edges)
|
||||
- Added `ContextGraph.link_graph(other_graph, source_node_id, target_node_id, link_type="CROSS_GRAPH") -> str` — creates a navigable bridge between two separate `ContextGraph` instances; records a marker edge internally and returns a `link_id`
|
||||
- Added `ContextGraph.navigate_to(link_id) -> (other_graph, target_node_id)` — resolves a `link_id` to the target graph and its entry node, enabling hierarchical cross-graph traversal (e.g. agent moving from a high-level decision graph into a domain-specific sub-graph)
|
||||
- Added `ContextGraph.resolve_links(registry)` — reconnects cross-graph links after `load_from_file()`; `save_to_file()` now persists a `links` section with `other_graph_id` so navigation survives the full save/load cycle
|
||||
- Added `graph_id` field to `ContextGraph` — stable UUID per instance, persisted to JSON, so separate graphs can identify each other after reload
|
||||
- Fixed `is_active()` on `ContextNode` and `ContextEdge` — tz-aware `datetime` inputs are now normalised to tz-naive UTC before comparison, preventing `TypeError` when callers pass `datetime.now(timezone.utc)`
|
||||
- Fixed `valid_from` / `valid_until` serialisation — `add_nodes()`, `add_edges()`, `to_dict()`, and `from_dict()` all now preserve and restore validity windows; previously these fields were silently lost
|
||||
- Fixed cross-graph link artifact — `link_graph()` now pre-creates a `"cross_graph_link"` typed `ContextNode` for the marker before inserting the marker edge, preventing `_add_internal_edge()` from auto-creating a phantom `"entity"` node
|
||||
- Added 14 tests in `tests/context/test_cross_graph_navigation.py` covering link creation, phantom-node prevention, and full save/load round-trips with `resolve_links()`
|
||||
- Fixed `pipeline_builder.add_step()` return type annotation from `"PipelineBuilder"` to `"PipelineStep"` — implementation was already correct per 0.3.0-beta changelog, only signature and docstring were stale
|
||||
- Fixed `test_hybrid_search_performance` timing computation — accumulated a real `search_times` list and compute true average; raised threshold to `< 5.0s` to account for real `sentence-transformers` (384-dim) latency
|
||||
|
||||
|
||||
|
||||
- **0.3.0 Bug Fixes & Comprehensive Real-World Tests** (by @KaifAhmad1):
|
||||
- Fixed `ProvenanceTracker` missing from `semantica/kg/__init__.py` exports — `from semantica.kg import ProvenanceTracker` now works correctly
|
||||
- Fixed duplicate relation creation in `_parse_relation_result` — orphaned legacy block was appending every relation twice; removed the duplicate block
|
||||
- Added `extraction_method` parameter to `_parse_relation_result`; typed extraction path now correctly sets `"llm_typed"` instead of `"llm"` in relation metadata
|
||||
- Fixed cross-test cache pollution in `tests/semantic_extract/test_retry_logic.py` — module-level `_result_cache` now cleared in `setUp()` to prevent intermittent failures when tests share input text
|
||||
- Added `tests/test_030_realworld_comprehensive.py`: 85 real-world tests covering all 0.3.0-alpha/beta features with real data (tech companies, CEOs, products, investment chains, healthcare scenarios)
|
||||
- ContextGraph basic operations and decision tracking lifecycle
|
||||
- KG algorithms: centrality, community detection, embeddings, path finding, similarity, link prediction, connectivity
|
||||
- PolicyEngine, DecisionQuery, AgentContext, Decision model serialization
|
||||
- ProvenanceTracker with GraphBuilderWithProvenance and AlgorithmTrackerWithProvenance
|
||||
- Deduplication v2 with blocking strategies, RDF/TTL export, Reasoner inference
|
||||
- Pipeline builder/validator/failure handler with retry policies
|
||||
- Multi-hop investment chain (Microsoft→OpenAI, Google→Anthropic) end-to-end
|
||||
- Healthcare entity extraction and knowledge graph construction E2E
|
||||
|
||||
## [0.3.0-beta] - 2026-03-07
|
||||
|
||||
- **Multi-Founder LLM Extraction & Reasoner Inference Fix** (PR #354 by @KaifAhmad1):
|
||||
|
||||
+6
-6
@@ -2,9 +2,9 @@
|
||||
|
||||
Thank you for your interest in contributing! Every contribution, no matter how small, is valuable. 🎉
|
||||
|
||||
⭐ **Give us a Star** • 🍴 **[Fork Semantica](https://github.com/Hawksight-AI/semantica/fork)** • 💬 **Join our [Discord](https://discord.gg/N7WmAuDH)**
|
||||
⭐ **Give us a Star** • 🍴 **[Fork Semantica](https://github.com/Hawksight-AI/semantica/fork)** • 💬 **Join our [Discord](https://discord.gg/sV34vps5hH)**
|
||||
|
||||
> **New to contributing?** Start with a [`good first issue`](https://github.com/Hawksight-AI/semantica/labels/good%20first%20issue) or join our [Discord](https://discord.gg/N7WmAuDH) community.
|
||||
> **New to contributing?** Start with a [`good first issue`](https://github.com/Hawksight-AI/semantica/labels/good%20first%20issue) or join our [Discord](https://discord.gg/sV34vps5hH) community.
|
||||
|
||||
---
|
||||
|
||||
@@ -15,7 +15,7 @@ Thank you for your interest in contributing! Every contribution, no matter how s
|
||||
3. Make your changes
|
||||
4. Submit a pull request!
|
||||
|
||||
**Need help?** Join [Discord](https://discord.gg/N7WmAuDH) or [GitHub Discussions](https://github.com/Hawksight-AI/semantica/discussions)
|
||||
**Need help?** Join [Discord](https://discord.gg/sV34vps5hH) or [GitHub Discussions](https://github.com/Hawksight-AI/semantica/discussions)
|
||||
|
||||
---
|
||||
|
||||
@@ -108,7 +108,7 @@ Thank you for your interest in contributing! Every contribution, no matter how s
|
||||
|
||||
**What:** Help others in the community
|
||||
|
||||
**Where:** [Discord](https://discord.gg/N7WmAuDH), [GitHub Discussions](https://github.com/Hawksight-AI/semantica/discussions)
|
||||
**Where:** [Discord](https://discord.gg/sV34vps5hH), [GitHub Discussions](https://github.com/Hawksight-AI/semantica/discussions)
|
||||
|
||||
**Examples:** Answer questions, review PRs, share your projects
|
||||
|
||||
@@ -326,7 +326,7 @@ result = instance.method()
|
||||
|
||||
## 🆘 Getting Help
|
||||
|
||||
- 💬 [Discord](https://discord.gg/N7WmAuDH) - Real-time chat
|
||||
- 💬 [Discord](https://discord.gg/sV34vps5hH) - Real-time chat
|
||||
- 💭 [GitHub Discussions](https://github.com/Hawksight-AI/semantica/discussions) - Q&A
|
||||
- 🐛 [GitHub Issues](https://github.com/Hawksight-AI/semantica/issues) - Bug reports
|
||||
|
||||
@@ -363,4 +363,4 @@ This project follows a [Code of Conduct](CODE_OF_CONDUCT.md). Be respectful and
|
||||
|
||||
Every contribution matters - whether it's a single line of code, a typo fix, a helpful answer, or a bug report. We appreciate you! 🙏
|
||||
|
||||
⭐ **Give us a Star** • 🍴 **[Fork Semantica](https://github.com/Hawksight-AI/semantica/fork)** • 💬 **Join our [Discord](https://discord.gg/N7WmAuDH)**
|
||||
⭐ **Give us a Star** • 🍴 **[Fork Semantica](https://github.com/Hawksight-AI/semantica/fork)** • 💬 **Join our [Discord](https://discord.gg/sV34vps5hH)**
|
||||
|
||||
+1
-1
@@ -4,7 +4,7 @@ Thank you to all the people who have contributed to Semantica! 🎉
|
||||
|
||||
This project follows the [all-contributors](https://allcontributors.org) specification. Contributions of any kind are welcome!
|
||||
|
||||
⭐ **Give us a Star** • 🍴 **Fork us** • 💬 **Join our [Discord](https://discord.gg/N7WmAuDH)**
|
||||
⭐ **Give us a Star** • 🍴 **Fork us** • 💬 **Join our [Discord](https://discord.gg/sV34vps5hH)**
|
||||
|
||||
---
|
||||
|
||||
|
||||
+1
-1
@@ -27,7 +27,7 @@ Start with our comprehensive documentation:
|
||||
|
||||
**Best for**: Real-time chat and quick questions
|
||||
|
||||
- [Join Discord](https://discord.gg/N7WmAuDH)
|
||||
- [Join Discord](https://discord.gg/sV34vps5hH)
|
||||
|
||||
#### GitHub Issues
|
||||
|
||||
|
||||
@@ -1018,7 +1018,7 @@ knowledge_graph.apply_resolutions(resolved_data)
|
||||
|
||||
### 💬 Community Support
|
||||
|
||||
- **💬 [Discord Community](https://discord.gg/N7WmAuDH)** - Real-time chat and support
|
||||
- **💬 [Discord Community](https://discord.gg/sV34vps5hH)** - Real-time chat and support
|
||||
- **🐙 [GitHub Discussions](https://github.com/semantica/semantica/discussions)** - Community Q&A
|
||||
- **📧 [Mailing List](https://groups.google.com/g/semantica)** - Announcements and updates
|
||||
- **🐦 [Twitter](https://twitter.com/semantica)** - Latest news and tips
|
||||
@@ -1051,6 +1051,6 @@ This project is licensed under the MIT License - see the [LICENSE](https://githu
|
||||
|
||||
**🚀 Ready to transform your data into intelligent knowledge?**
|
||||
|
||||
[Get Started Now](https://semantica.readthedocs.io/quickstart/) • [View Examples](https://github.com/semantica/examples) • [Join Community](https://discord.gg/N7WmAuDH)
|
||||
[Get Started Now](https://semantica.readthedocs.io/quickstart/) • [View Examples](https://github.com/semantica/examples) • [Join Community](https://discord.gg/sV34vps5hH)
|
||||
|
||||
</div>
|
||||
|
||||
@@ -96,6 +96,6 @@ kg = GraphBuilder().build_graph(entities, relationships)
|
||||
|
||||
## Need Help?
|
||||
|
||||
- **[💬 Discord Community](https://discord.gg/N7WmAuDH)** - Get help from the community
|
||||
- **[💬 Discord Community](https://discord.gg/sV34vps5hH)** - Get help from the community
|
||||
- **[🐛 Issues](https://github.com/Hawksight-AI/semantica/issues)** - Report bugs or request features
|
||||
- **[📖 Documentation](https://semantica.readthedocs.io/)** - Full documentation site
|
||||
|
||||
+2
-2
@@ -9,7 +9,7 @@
|
||||
<a href="https://pypi.org/project/semantica/"><img src="https://img.shields.io/pypi/dm/semantica" alt="Monthly Downloads"></a>
|
||||
<a href="https://pepy.tech/project/semantica"><img src="https://static.pepy.tech/badge/semantica" alt="Total Downloads"></a>
|
||||
<a href="https://semantica.readthedocs.io/"><img src="https://img.shields.io/badge/docs-latest-brightgreen.svg" alt="Documentation"></a>
|
||||
<a href="https://discord.gg/N7WmAuDH"><img src="https://img.shields.io/badge/Discord-Join%20Us-7289da?style=flat&logo=discord&logoColor=white" alt="Discord"></a>
|
||||
<a href="https://discord.gg/sV34vps5hH"><img src="https://img.shields.io/badge/Discord-Join%20Us-7289da?style=flat&logo=discord&logoColor=white" alt="Discord"></a>
|
||||
|
||||
<p><strong>Open-Source Semantic Layer & Knowledge Engineering Framework</strong></p>
|
||||
|
||||
@@ -53,7 +53,7 @@ kg = GraphBuilder().build({"entities": entities, "relationships": []})
|
||||
print(f"Built KG with {len(kg.get('entities', []))} entities")
|
||||
```
|
||||
|
||||
**[📖 Full Quick Start](getting-started.md)** • **[🍳 Cookbook Examples](cookbook.md)** • **[💬 Join Discord](https://discord.gg/N7WmAuDH)** • **[⭐ Star Us](https://github.com/Hawksight-AI/semantica)**
|
||||
**[📖 Full Quick Start](getting-started.md)** • **[🍳 Cookbook Examples](cookbook.md)** • **[💬 Join Discord](https://discord.gg/sV34vps5hH)** • **[⭐ Star Us](https://github.com/Hawksight-AI/semantica)**
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -734,4 +734,4 @@ MIT License - See [LICENSE](../../LICENSE) for details.
|
||||
|
||||
## Support
|
||||
|
||||
For issues or questions, please open an issue on GitHub or join our [Discord](https://discord.gg/N7WmAuDH).
|
||||
For issues or questions, please open an issue on GitHub or join our [Discord](https://discord.gg/sV34vps5hH).
|
||||
|
||||
+4
-4
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "semantica"
|
||||
version = "0.3.0-beta"
|
||||
version = "0.3.0"
|
||||
description = "🧠 Semantica - An Open Source Framework for building Semantic Layers and Knowledge Engineering"
|
||||
readme = "README.md"
|
||||
license = { text = "MIT" }
|
||||
@@ -15,7 +15,7 @@ maintainers = [{ name = "Hawksight AI", email = "semantica-dev@users.noreply.git
|
||||
requires-python = ">=3.8"
|
||||
|
||||
classifiers = [
|
||||
"Development Status :: 4 - Beta",
|
||||
"Development Status :: 5 - Production/Stable",
|
||||
"Intended Audience :: Developers",
|
||||
"Intended Audience :: Science/Research",
|
||||
"License :: OSI Approved :: MIT License",
|
||||
@@ -156,8 +156,8 @@ monitoring = [
|
||||
"prometheus-client>=0.14.0",
|
||||
"opentelemetry-api>=1.30.0,<2.0.0",
|
||||
"opentelemetry-sdk>=1.30.0,<2.0.0",
|
||||
"opentelemetry-semantic-conventions>=0.58b0,<0.61b0",
|
||||
"opentelemetry-instrumentation>=0.58b0,<0.61b0"
|
||||
"opentelemetry-semantic-conventions>=0.58b0,<0.62",
|
||||
"opentelemetry-instrumentation>=0.58b0,<0.62"
|
||||
]
|
||||
|
||||
# ---- Visualization ----
|
||||
|
||||
@@ -10,7 +10,7 @@ Main exports:
|
||||
- Config: Configuration management
|
||||
"""
|
||||
|
||||
__version__ = "0.3.0-beta"
|
||||
__version__ = "0.3.0"
|
||||
__author__ = "Semantica Contributors"
|
||||
__license__ = "MIT"
|
||||
|
||||
|
||||
@@ -1041,4 +1041,4 @@ manager = TemporalVersionManager(storage_path="large_data.db")
|
||||
For questions or issues:
|
||||
- GitHub Issues: https://github.com/Hawksight-AI/semantica/issues
|
||||
- Documentation: https://semantica.readthedocs.io
|
||||
- Community: https://discord.gg/N7WmAuDH
|
||||
- Community: https://discord.gg/sV34vps5hH
|
||||
|
||||
@@ -107,7 +107,7 @@ Production Use Cases:
|
||||
|
||||
from collections import defaultdict, deque
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any, Dict, List, Optional, Set, Tuple, Union
|
||||
import uuid
|
||||
|
||||
@@ -127,6 +127,21 @@ except ImportError:
|
||||
KG_AVAILABLE = False
|
||||
|
||||
|
||||
def _parse_iso_dt(value: str) -> Optional[datetime]:
|
||||
"""Parse an ISO datetime string into a tz-naive UTC datetime.
|
||||
|
||||
Always returns a naive datetime in UTC so callers can compare uniformly
|
||||
without worrying about mixed aware/naive arithmetic.
|
||||
"""
|
||||
try:
|
||||
dt = datetime.fromisoformat(str(value).replace("Z", "+00:00"))
|
||||
if dt.tzinfo is not None:
|
||||
dt = dt.astimezone(timezone.utc).replace(tzinfo=None)
|
||||
return dt
|
||||
except (ValueError, AttributeError):
|
||||
return None
|
||||
|
||||
|
||||
@dataclass
|
||||
class ContextNode:
|
||||
"""Context graph node (Internal implementation)."""
|
||||
@@ -136,12 +151,37 @@ class ContextNode:
|
||||
content: str
|
||||
metadata: Dict[str, Any] = field(default_factory=dict)
|
||||
properties: Dict[str, Any] = field(default_factory=dict)
|
||||
valid_from: Optional[str] = None # ISO datetime string, e.g. "2026-01-01T00:00:00"
|
||||
valid_until: Optional[str] = None # ISO datetime string; None = no expiry
|
||||
|
||||
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}
|
||||
|
||||
|
||||
@@ -154,16 +194,42 @@ class ContextEdge:
|
||||
edge_type: str
|
||||
weight: float = 1.0
|
||||
metadata: Dict[str, Any] = field(default_factory=dict)
|
||||
valid_from: Optional[str] = None # ISO datetime string
|
||||
valid_until: Optional[str] = None # ISO datetime string; None = no expiry
|
||||
|
||||
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."""
|
||||
return {
|
||||
d = {
|
||||
"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
|
||||
|
||||
|
||||
class ContextGraph:
|
||||
@@ -204,6 +270,9 @@ class ContextGraph:
|
||||
|
||||
self.entity_linker = self.config.get("entity_linker") or EntityLinker()
|
||||
|
||||
# Stable identifier so this graph can be referenced after save/load
|
||||
self.graph_id: str = str(uuid.uuid4())
|
||||
|
||||
# Graph structure
|
||||
self.nodes: Dict[str, ContextNode] = {}
|
||||
self.edges: List[ContextEdge] = []
|
||||
@@ -215,6 +284,11 @@ class ContextGraph:
|
||||
self.node_type_index: Dict[str, Set[str]] = defaultdict(set)
|
||||
self.edge_type_index: Dict[str, List[ContextEdge]] = defaultdict(list)
|
||||
|
||||
# Cross-graph navigation: link_id -> (other_graph, source_node_id, target_node_id)
|
||||
self._linked_graphs: Dict[str, Tuple["ContextGraph", str, str]] = {}
|
||||
# Unresolved link metadata (populated after load_from_file, before resolve_links)
|
||||
self._unresolved_links: Dict[str, Dict[str, str]] = {}
|
||||
|
||||
# Progress tracker
|
||||
self.progress_tracker = get_progress_tracker()
|
||||
# Ensure progress tracker is enabled
|
||||
@@ -261,7 +335,20 @@ class ContextGraph:
|
||||
# Extract content from properties if not explicit
|
||||
node_props = node.get("properties", {})
|
||||
content = node_props.get("content", node.get("id"))
|
||||
metadata = {k: v for k, v in node_props.items() if k != "content"}
|
||||
# Restore validity windows from properties (written there by ContextNode.to_dict)
|
||||
# or from top-level keys on the node dict
|
||||
valid_from = (
|
||||
node.get("valid_from")
|
||||
or node_props.get("valid_from")
|
||||
)
|
||||
valid_until = (
|
||||
node.get("valid_until")
|
||||
or node_props.get("valid_until")
|
||||
)
|
||||
metadata = {
|
||||
k: v for k, v in node_props.items()
|
||||
if k not in ("content", "valid_from", "valid_until")
|
||||
}
|
||||
|
||||
internal_node = ContextNode(
|
||||
node_id=node.get("id"),
|
||||
@@ -269,6 +356,8 @@ class ContextGraph:
|
||||
content=content,
|
||||
metadata=metadata,
|
||||
properties=node_props,
|
||||
valid_from=valid_from,
|
||||
valid_until=valid_until,
|
||||
)
|
||||
|
||||
if self._add_internal_node(internal_node):
|
||||
@@ -288,12 +377,18 @@ class ContextGraph:
|
||||
"""
|
||||
count = 0
|
||||
for edge in edges:
|
||||
edge_props = edge.get("properties", {})
|
||||
# Restore validity windows — ContextEdge.to_dict() writes them at top level
|
||||
valid_from = edge.get("valid_from") or edge_props.get("valid_from")
|
||||
valid_until = edge.get("valid_until") or edge_props.get("valid_until")
|
||||
internal_edge = ContextEdge(
|
||||
source_id=edge.get("source_id"),
|
||||
target_id=edge.get("target_id"),
|
||||
edge_type=edge.get("type", "related_to"),
|
||||
weight=edge.get("weight", 1.0),
|
||||
metadata=edge.get("properties", {}),
|
||||
metadata=edge_props,
|
||||
valid_from=valid_from,
|
||||
valid_until=valid_until,
|
||||
)
|
||||
|
||||
if self._add_internal_edge(internal_edge):
|
||||
@@ -362,11 +457,21 @@ class ContextGraph:
|
||||
node_id: str,
|
||||
hops: int = 1,
|
||||
relationship_types: Optional[List[str]] = None,
|
||||
min_weight: float = 0.0,
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
Get neighbors of a node.
|
||||
|
||||
Returns list of dicts with neighbor info.
|
||||
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.
|
||||
|
||||
Returns:
|
||||
List of dicts with neighbor info (id, type, content, relationship, weight, hop).
|
||||
"""
|
||||
if node_id not in self.nodes:
|
||||
return []
|
||||
@@ -385,6 +490,8 @@ class ContextGraph:
|
||||
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
|
||||
@@ -451,9 +558,12 @@ class ContextGraph:
|
||||
node_id: Unique identifier
|
||||
node_type: Node type (e.g., 'entity', 'concept')
|
||||
content: Node content/label
|
||||
**properties: Additional properties
|
||||
**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)
|
||||
return self._add_internal_node(
|
||||
ContextNode(
|
||||
node_id=node_id,
|
||||
@@ -461,6 +571,8 @@ class ContextGraph:
|
||||
content=content,
|
||||
metadata=properties,
|
||||
properties=properties,
|
||||
valid_from=valid_from,
|
||||
valid_until=valid_until,
|
||||
)
|
||||
)
|
||||
|
||||
@@ -480,8 +592,11 @@ class ContextGraph:
|
||||
target_id: Target node ID
|
||||
edge_type: Relationship type
|
||||
weight: Edge weight
|
||||
**properties: Additional properties
|
||||
**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)
|
||||
return self._add_internal_edge(
|
||||
ContextEdge(
|
||||
source_id=source_id,
|
||||
@@ -489,6 +604,8 @@ class ContextGraph:
|
||||
edge_type=edge_type,
|
||||
weight=weight,
|
||||
metadata=properties,
|
||||
valid_from=valid_from,
|
||||
valid_until=valid_until,
|
||||
)
|
||||
)
|
||||
|
||||
@@ -501,9 +618,24 @@ class ContextGraph:
|
||||
"""
|
||||
import json
|
||||
|
||||
# Serialise cross-graph link metadata (object references are not serialisable,
|
||||
# so we store other_graph_id; callers can reconnect with resolve_links()).
|
||||
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:
|
||||
@@ -534,6 +666,12 @@ class ContextGraph:
|
||||
self._adjacency.clear()
|
||||
self.node_type_index.clear()
|
||||
self.edge_type_index.clear()
|
||||
self._linked_graphs.clear()
|
||||
self._unresolved_links.clear()
|
||||
|
||||
# Restore stable graph identity
|
||||
if "graph_id" in data:
|
||||
self.graph_id = data["graph_id"]
|
||||
|
||||
# Load nodes
|
||||
nodes = data.get("nodes", [])
|
||||
@@ -543,6 +681,12 @@ class ContextGraph:
|
||||
edges = data.get("edges", [])
|
||||
self.add_edges(edges)
|
||||
|
||||
# Restore link metadata — object references require resolve_links() to reconnect
|
||||
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]]:
|
||||
@@ -578,6 +722,177 @@ class ContextGraph:
|
||||
for n in nodes
|
||||
]
|
||||
|
||||
def find_active_nodes(
|
||||
self,
|
||||
node_type: Optional[str] = None,
|
||||
at_time: Optional[datetime] = None,
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
Find nodes that are currently active within their validity window.
|
||||
|
||||
Nodes without ``valid_from``/``valid_until`` are always considered active.
|
||||
|
||||
Args:
|
||||
node_type: Optional node type filter.
|
||||
at_time: Point in time to evaluate validity (defaults to ``datetime.utcnow()``).
|
||||
|
||||
Returns:
|
||||
List of active node dicts (same format as :meth:`find_nodes`).
|
||||
"""
|
||||
now = at_time or datetime.utcnow()
|
||||
if node_type:
|
||||
node_ids = self.node_type_index.get(node_type, set())
|
||||
nodes_iter = [self.nodes[nid] for nid in node_ids if nid in self.nodes]
|
||||
else:
|
||||
nodes_iter = list(self.nodes.values())
|
||||
|
||||
result = []
|
||||
for node in nodes_iter:
|
||||
if node.is_active(now):
|
||||
result.append(
|
||||
{
|
||||
"id": node.node_id,
|
||||
"type": node.node_type,
|
||||
"content": node.content,
|
||||
"metadata": {
|
||||
**(getattr(node, "metadata", {}) or {}),
|
||||
**(getattr(node, "properties", {}) or {}),
|
||||
},
|
||||
}
|
||||
)
|
||||
return result
|
||||
|
||||
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.
|
||||
"""
|
||||
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)
|
||||
|
||||
# Create a dedicated marker node so it is clearly typed and does not pollute
|
||||
# the entity namespace. _add_internal_edge auto-creates missing targets as
|
||||
# "entity" nodes — by pre-inserting a "cross_graph_link" node we prevent that.
|
||||
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={},
|
||||
)
|
||||
)
|
||||
# Record a marker edge so the link shows up in graph traversal
|
||||
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 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
|
||||
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) -> List[Dict[str, Any]]:
|
||||
"""Find edges, optionally filtered by type."""
|
||||
if edge_type:
|
||||
@@ -914,26 +1229,38 @@ class ContextGraph:
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
"""Export graph to dictionary format."""
|
||||
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 = {
|
||||
"source": e.source_id,
|
||||
"target": e.target_id,
|
||||
"type": e.edge_type,
|
||||
"weight": e.weight,
|
||||
}
|
||||
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": [
|
||||
{
|
||||
"id": n.node_id,
|
||||
"type": n.node_type,
|
||||
"content": n.content,
|
||||
"properties": n.properties,
|
||||
"metadata": n.metadata,
|
||||
}
|
||||
for n in self.nodes.values()
|
||||
],
|
||||
"edges": [
|
||||
{
|
||||
"source": e.source_id,
|
||||
"target": e.target_id,
|
||||
"type": e.edge_type,
|
||||
"weight": e.weight,
|
||||
}
|
||||
for e in self.edges
|
||||
],
|
||||
"nodes": nodes_out,
|
||||
"edges": edges_out,
|
||||
"statistics": {
|
||||
"node_count": len(self.nodes),
|
||||
"edge_count": len(self.edges),
|
||||
@@ -945,26 +1272,31 @@ class ContextGraph:
|
||||
# Clear existing graph
|
||||
self.nodes.clear()
|
||||
self.edges.clear()
|
||||
|
||||
# Add nodes
|
||||
|
||||
# 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_data.get("properties", {}),
|
||||
metadata=node_data.get("metadata", {})
|
||||
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
|
||||
|
||||
# Add edges — restore validity windows if present
|
||||
for edge_data in graph_dict.get("edges", []):
|
||||
edge = ContextEdge(
|
||||
source_id=edge_data["source"],
|
||||
target_id=edge_data["target"],
|
||||
edge_type=edge_data["type"],
|
||||
weight=edge_data.get("weight", 1.0),
|
||||
metadata=edge_data.get("metadata", {})
|
||||
metadata=edge_data.get("metadata", {}),
|
||||
valid_from=edge_data.get("valid_from"),
|
||||
valid_until=edge_data.get("valid_until"),
|
||||
)
|
||||
self._add_internal_edge(edge)
|
||||
|
||||
|
||||
@@ -117,6 +117,7 @@ from .link_predictor import LinkPredictor
|
||||
from .node_embeddings import NodeEmbedder
|
||||
from .path_finder import PathFinder
|
||||
from .kg_provenance import GraphBuilderWithProvenance, AlgorithmTrackerWithProvenance
|
||||
from .provenance_tracker import ProvenanceTracker
|
||||
from .registry import MethodRegistry, method_registry, AlgorithmRegistry, algorithm_registry
|
||||
from .seed_manager import SeedManager
|
||||
from .similarity_calculator import SimilarityCalculator
|
||||
@@ -137,6 +138,7 @@ __all__ = [
|
||||
"TemporalPatternDetector",
|
||||
"TemporalVersionManager",
|
||||
"AlgorithmTrackerWithProvenance",
|
||||
"ProvenanceTracker",
|
||||
# Enhanced Graph Algorithms
|
||||
"NodeEmbedder",
|
||||
"SimilarityCalculator",
|
||||
|
||||
@@ -116,7 +116,7 @@ class PipelineBuilder:
|
||||
self.step_registry: Dict[str, Callable] = {}
|
||||
self.pipeline_config: Dict[str, Any] = {}
|
||||
|
||||
def add_step(self, step_name: str, step_type: str, **config) -> "PipelineBuilder":
|
||||
def add_step(self, step_name: str, step_type: str, **config) -> "PipelineStep":
|
||||
"""
|
||||
Add step to pipeline.
|
||||
|
||||
@@ -126,7 +126,7 @@ class PipelineBuilder:
|
||||
**config: Step configuration
|
||||
|
||||
Returns:
|
||||
Self for method chaining
|
||||
Created PipelineStep object
|
||||
"""
|
||||
delta_mode = config.pop("delta_mode", False)
|
||||
base_version_id = config.pop("base_version_id", None)
|
||||
|
||||
@@ -1973,8 +1973,8 @@ Entities found in text: {entities_str}"""
|
||||
parsed = result_obj
|
||||
|
||||
# Use common parser to build internal Relation objects
|
||||
relations = _parse_relation_result(parsed, original_entities, text, provider, model)
|
||||
|
||||
relations = _parse_relation_result(parsed, original_entities, text, provider, model, extraction_method="llm_typed")
|
||||
|
||||
# If typed path returned no relations, attempt a structured JSON fallback
|
||||
if not relations:
|
||||
try:
|
||||
@@ -1982,7 +1982,7 @@ Entities found in text: {entities_str}"""
|
||||
import sys
|
||||
print(" [methods.extract_relations_llm] Typed result empty, attempting structured JSON fallback...", flush=True, file=sys.stdout)
|
||||
raw_json = llm.generate_structured(prompt, **call_kwargs)
|
||||
relations = _parse_relation_result(raw_json, original_entities, text, provider, model)
|
||||
relations = _parse_relation_result(raw_json, original_entities, text, provider, model, extraction_method="llm_typed")
|
||||
except Exception as _e:
|
||||
# Keep relations as empty if fallback fails
|
||||
pass
|
||||
@@ -2019,11 +2019,12 @@ Entities found in text: {entities_str}"""
|
||||
|
||||
|
||||
def _parse_relation_result(
|
||||
result: Any,
|
||||
entities: List[Entity],
|
||||
result: Any,
|
||||
entities: List[Entity],
|
||||
text: str,
|
||||
provider: str,
|
||||
model: Optional[str]
|
||||
provider: str,
|
||||
model: Optional[str],
|
||||
extraction_method: str = "llm",
|
||||
) -> List[Relation]:
|
||||
"""Helper to parse raw LLM result into Relation objects."""
|
||||
relations = []
|
||||
@@ -2081,29 +2082,10 @@ def _parse_relation_result(
|
||||
metadata={
|
||||
"provider": provider,
|
||||
"model": model,
|
||||
"extraction_method": "llm",
|
||||
"extraction_method": extraction_method,
|
||||
},
|
||||
)
|
||||
)
|
||||
# Find matching entities using hybrid similarity
|
||||
subject_entity = match_entity(subject_text, entities)
|
||||
object_entity = match_entity(object_text, entities)
|
||||
|
||||
if subject_entity and object_entity:
|
||||
relations.append(
|
||||
Relation(
|
||||
subject=subject_entity,
|
||||
predicate=item.get("predicate", "related_to"),
|
||||
object=object_entity,
|
||||
confidence=item.get("confidence", 0.9),
|
||||
context=text,
|
||||
metadata={
|
||||
"provider": provider,
|
||||
"model": model,
|
||||
"extraction_method": "llm",
|
||||
},
|
||||
)
|
||||
)
|
||||
return relations
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,231 @@
|
||||
"""Tests for cross-graph linking, navigation, and save/load persistence."""
|
||||
|
||||
import json
|
||||
import os
|
||||
import tempfile
|
||||
|
||||
import pytest
|
||||
|
||||
from semantica.context.context_graph import ContextGraph
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _two_graphs():
|
||||
"""Return two graphs each with one node."""
|
||||
g1 = ContextGraph()
|
||||
g2 = ContextGraph()
|
||||
g1.add_node("src", "entity", content="source entity")
|
||||
g2.add_node("dst", "entity", content="destination entity")
|
||||
return g1, g2
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# link_graph / navigate_to — basic contract
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestLinkGraph:
|
||||
def test_returns_link_id(self):
|
||||
g1, g2 = _two_graphs()
|
||||
link_id = g1.link_graph(g2, "src", "dst")
|
||||
assert isinstance(link_id, str) and len(link_id) > 0
|
||||
|
||||
def test_navigate_to_returns_correct_graph_and_node(self):
|
||||
g1, g2 = _two_graphs()
|
||||
link_id = g1.link_graph(g2, "src", "dst")
|
||||
other, entry = g1.navigate_to(link_id)
|
||||
assert other is g2
|
||||
assert entry == "dst"
|
||||
|
||||
def test_navigate_to_unknown_link_raises(self):
|
||||
g1, _ = _two_graphs()
|
||||
with pytest.raises(KeyError):
|
||||
g1.navigate_to("nonexistent-link-id")
|
||||
|
||||
def test_source_not_in_graph_raises(self):
|
||||
g1, g2 = _two_graphs()
|
||||
with pytest.raises(KeyError):
|
||||
g1.link_graph(g2, "missing", "dst")
|
||||
|
||||
def test_target_not_in_other_graph_raises(self):
|
||||
g1, g2 = _two_graphs()
|
||||
with pytest.raises(KeyError):
|
||||
g1.link_graph(g2, "src", "missing")
|
||||
|
||||
def test_marker_node_has_cross_graph_link_type(self):
|
||||
"""link_graph() must NOT pollute graph with phantom 'entity' nodes."""
|
||||
g1, g2 = _two_graphs()
|
||||
link_id = g1.link_graph(g2, "src", "dst")
|
||||
marker_id = f"__cross_graph_{link_id}"
|
||||
assert marker_id in g1.nodes
|
||||
assert g1.nodes[marker_id].node_type == "cross_graph_link"
|
||||
|
||||
def test_no_phantom_entity_nodes(self):
|
||||
"""Only 'src' and the typed marker should exist in g1."""
|
||||
g1, g2 = _two_graphs()
|
||||
link_id = g1.link_graph(g2, "src", "dst")
|
||||
entity_nodes = [n for n in g1.nodes.values() if n.node_type == "entity"]
|
||||
assert len(entity_nodes) == 1 # only 'src'
|
||||
|
||||
def test_multiple_links_from_same_source(self):
|
||||
g1 = ContextGraph()
|
||||
g2 = ContextGraph()
|
||||
g3 = ContextGraph()
|
||||
g1.add_node("hub", "entity")
|
||||
g2.add_node("a", "entity")
|
||||
g3.add_node("b", "entity")
|
||||
lid1 = g1.link_graph(g2, "hub", "a")
|
||||
lid2 = g1.link_graph(g3, "hub", "b")
|
||||
other1, entry1 = g1.navigate_to(lid1)
|
||||
other2, entry2 = g1.navigate_to(lid2)
|
||||
assert other1 is g2 and entry1 == "a"
|
||||
assert other2 is g3 and entry2 == "b"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Persistence: save_to_file / load_from_file + resolve_links
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestCrossGraphPersistence:
|
||||
def test_graph_id_preserved_after_save_load(self):
|
||||
g1, _ = _two_graphs()
|
||||
original_id = g1.graph_id
|
||||
with tempfile.NamedTemporaryFile(suffix=".json", delete=False) as f:
|
||||
path = f.name
|
||||
try:
|
||||
g1.save_to_file(path)
|
||||
g1b = ContextGraph()
|
||||
g1b.load_from_file(path)
|
||||
assert g1b.graph_id == original_id
|
||||
finally:
|
||||
os.unlink(path)
|
||||
|
||||
def test_links_section_written_to_file(self):
|
||||
g1, g2 = _two_graphs()
|
||||
link_id = g1.link_graph(g2, "src", "dst")
|
||||
with tempfile.NamedTemporaryFile(suffix=".json", delete=False, mode="w") as f:
|
||||
path = f.name
|
||||
try:
|
||||
g1.save_to_file(path)
|
||||
with open(path) as fp:
|
||||
data = json.load(fp)
|
||||
assert "links" in data
|
||||
assert len(data["links"]) == 1
|
||||
lk = data["links"][0]
|
||||
assert lk["link_id"] == link_id
|
||||
assert lk["source_node_id"] == "src"
|
||||
assert lk["target_node_id"] == "dst"
|
||||
assert lk["other_graph_id"] == g2.graph_id
|
||||
finally:
|
||||
os.unlink(path)
|
||||
|
||||
def test_navigate_to_raises_helpful_error_before_resolve(self):
|
||||
g1, g2 = _two_graphs()
|
||||
link_id = g1.link_graph(g2, "src", "dst")
|
||||
with tempfile.NamedTemporaryFile(suffix=".json", delete=False) as f:
|
||||
path = f.name
|
||||
try:
|
||||
g1.save_to_file(path)
|
||||
g1b = ContextGraph()
|
||||
g1b.load_from_file(path)
|
||||
with pytest.raises(KeyError, match="resolve_links"):
|
||||
g1b.navigate_to(link_id)
|
||||
finally:
|
||||
os.unlink(path)
|
||||
|
||||
def test_resolve_links_restores_navigation(self):
|
||||
g1, g2 = _two_graphs()
|
||||
link_id = g1.link_graph(g2, "src", "dst")
|
||||
|
||||
with (
|
||||
tempfile.NamedTemporaryFile(suffix=".json", delete=False) as f1,
|
||||
tempfile.NamedTemporaryFile(suffix=".json", delete=False) as f2,
|
||||
):
|
||||
path1, path2 = f1.name, f2.name
|
||||
|
||||
try:
|
||||
g1.save_to_file(path1)
|
||||
g2.save_to_file(path2)
|
||||
|
||||
g1b = ContextGraph()
|
||||
g2b = ContextGraph()
|
||||
g1b.load_from_file(path1)
|
||||
g2b.load_from_file(path2)
|
||||
|
||||
resolved = g1b.resolve_links({g2b.graph_id: g2b})
|
||||
assert resolved == 1
|
||||
|
||||
other, entry = g1b.navigate_to(link_id)
|
||||
assert other is g2b
|
||||
assert entry == "dst"
|
||||
finally:
|
||||
os.unlink(path1)
|
||||
os.unlink(path2)
|
||||
|
||||
def test_resolve_links_returns_count(self):
|
||||
g1 = ContextGraph()
|
||||
g2 = ContextGraph()
|
||||
g3 = ContextGraph()
|
||||
g1.add_node("h", "entity")
|
||||
g2.add_node("a", "entity")
|
||||
g3.add_node("b", "entity")
|
||||
g1.link_graph(g2, "h", "a")
|
||||
g1.link_graph(g3, "h", "b")
|
||||
|
||||
with (
|
||||
tempfile.NamedTemporaryFile(suffix=".json", delete=False) as f1,
|
||||
tempfile.NamedTemporaryFile(suffix=".json", delete=False) as f2,
|
||||
tempfile.NamedTemporaryFile(suffix=".json", delete=False) as f3,
|
||||
):
|
||||
p1, p2, p3 = f1.name, f2.name, f3.name
|
||||
try:
|
||||
g1.save_to_file(p1); g2.save_to_file(p2); g3.save_to_file(p3)
|
||||
g1b, g2b, g3b = ContextGraph(), ContextGraph(), ContextGraph()
|
||||
g1b.load_from_file(p1); g2b.load_from_file(p2); g3b.load_from_file(p3)
|
||||
resolved = g1b.resolve_links({g2b.graph_id: g2b, g3b.graph_id: g3b})
|
||||
assert resolved == 2
|
||||
finally:
|
||||
for p in (p1, p2, p3):
|
||||
os.unlink(p)
|
||||
|
||||
def test_resolve_links_partial_registry_leaves_unresolved(self):
|
||||
"""Passing only one graph to resolve_links should resolve only that link."""
|
||||
g1 = ContextGraph()
|
||||
g2 = ContextGraph()
|
||||
g3 = ContextGraph()
|
||||
g1.add_node("h", "entity")
|
||||
g2.add_node("a", "entity")
|
||||
g3.add_node("b", "entity")
|
||||
lid1 = g1.link_graph(g2, "h", "a")
|
||||
lid2 = g1.link_graph(g3, "h", "b")
|
||||
|
||||
with (
|
||||
tempfile.NamedTemporaryFile(suffix=".json", delete=False) as f1,
|
||||
tempfile.NamedTemporaryFile(suffix=".json", delete=False) as f2,
|
||||
tempfile.NamedTemporaryFile(suffix=".json", delete=False) as f3,
|
||||
):
|
||||
p1, p2, p3 = f1.name, f2.name, f3.name
|
||||
try:
|
||||
g1.save_to_file(p1); g2.save_to_file(p2); g3.save_to_file(p3)
|
||||
g1b, g2b, g3b = ContextGraph(), ContextGraph(), ContextGraph()
|
||||
g1b.load_from_file(p1); g2b.load_from_file(p2); g3b.load_from_file(p3)
|
||||
|
||||
# Only resolve g2
|
||||
resolved = g1b.resolve_links({g2b.graph_id: g2b})
|
||||
assert resolved == 1
|
||||
|
||||
# lid1 navigable
|
||||
other, entry = g1b.navigate_to(lid1)
|
||||
assert other is g2b and entry == "a"
|
||||
|
||||
# lid2 still unresolved — must raise with hint
|
||||
with pytest.raises(KeyError, match="resolve_links"):
|
||||
g1b.navigate_to(lid2)
|
||||
finally:
|
||||
for p in (p1, p2, p3):
|
||||
os.unlink(p)
|
||||
@@ -292,24 +292,27 @@ class TestEndToEndContextIntegration:
|
||||
{"graph_expansion": False, "max_results": 20},
|
||||
]
|
||||
|
||||
search_times = []
|
||||
for i, config in enumerate(search_configs):
|
||||
start_time = time.time()
|
||||
|
||||
|
||||
results = retriever.retrieve(
|
||||
query="Test document search",
|
||||
**config
|
||||
)
|
||||
|
||||
|
||||
search_time = time.time() - start_time
|
||||
search_times.append(search_time)
|
||||
print(f"[OK] Config {i+1}: {len(results)} results in {search_time:.3f}s")
|
||||
|
||||
|
||||
# Verify results
|
||||
assert len(results) <= config["max_results"], "Should respect max_results"
|
||||
assert all(isinstance(r, RetrievedContext) for r in results), "Should be RetrievedContext"
|
||||
|
||||
# Performance should be reasonable
|
||||
avg_time = sum(time.time() - start_time for _ in range(3)) / 3
|
||||
assert avg_time < 1.0, "Average search time should be under 1 second"
|
||||
|
||||
# Performance should be reasonable on development machines running real
|
||||
# sentence-transformers (384-dim); threshold is 5.0s per config on average
|
||||
avg_time = sum(search_times) / len(search_times)
|
||||
assert avg_time < 5.0, f"Average search time {avg_time:.3f}s should be under 5 seconds"
|
||||
|
||||
def test_multi_hop_reasoning(self):
|
||||
"""Test multi-hop reasoning capabilities."""
|
||||
|
||||
@@ -37,11 +37,14 @@ class EntitiesResponse(BaseModel):
|
||||
entities: List[dict]
|
||||
|
||||
class TestRetryLogic(unittest.TestCase):
|
||||
|
||||
|
||||
def setUp(self):
|
||||
self.mock_provider = MagicMock()
|
||||
self.mock_provider.is_available.return_value = True
|
||||
self.mock_provider.generate_typed.return_value = MagicMock(entities=[])
|
||||
# Clear the module-level extraction cache to avoid cross-test interference
|
||||
from semantica.semantic_extract.methods import _result_cache
|
||||
_result_cache.clear()
|
||||
|
||||
def test_ner_extractor_init_default(self):
|
||||
"""Test default max_retries in NERExtractor"""
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user