mirror of
https://github.com/semantica-agi/semantica.git
synced 2026-09-03 04:00:18 +00:00
Compare commits
108
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
19ff5bf200 | ||
|
|
4bf525d409 | ||
|
|
8858beb6d9 | ||
|
|
d3183d0ab3 | ||
|
|
da642f12fa | ||
|
|
5376f046ca | ||
|
|
56d9e9a857 | ||
|
|
ecb33a5b7d | ||
|
|
100e95a098 | ||
|
|
cce5ea177c | ||
|
|
e12eec40a1 | ||
|
|
4da27c38bb | ||
|
|
7f928f9f8e | ||
|
|
65e6dcfef5 | ||
|
|
0775b0114e | ||
|
|
f4c3064571 | ||
|
|
b2dc633796 | ||
|
|
13b287b974 | ||
|
|
cec9bee099 | ||
|
|
36ced4e826 | ||
|
|
6032b4e0bc | ||
|
|
8db95f00c6 | ||
|
|
23baf21d5a | ||
|
|
5d54919804 | ||
|
|
c9c777993b | ||
|
|
9cec305a75 | ||
|
|
3d0ce55fd7 | ||
|
|
b13cc1cca2 | ||
|
|
f187d4b5da | ||
|
|
8e79c65542 | ||
|
|
91ea31b460 | ||
|
|
c49e77d059 | ||
|
|
af3308ad06 | ||
|
|
59af023447 | ||
|
|
f4692eea80 | ||
|
|
2de029ac8d | ||
|
|
1ce76055f5 | ||
|
|
8cc5d364db | ||
|
|
d76bff9ab0 | ||
|
|
1e5ad49dc3 | ||
|
|
f0aa581318 | ||
|
|
8a990c8bf5 | ||
|
|
47c7ff5df8 | ||
|
|
92b8aa6993 | ||
|
|
970d3552d4 | ||
|
|
88d73189dd | ||
|
|
599729f2c0 | ||
|
|
84ccc7c0e3 | ||
|
|
fa6d645eea | ||
|
|
97f7154220 | ||
|
|
551b94c524 | ||
|
|
50468f9c90 | ||
|
|
c7d608570c | ||
|
|
5e8caadcb4 | ||
|
|
d05ef9d09f | ||
|
|
06a4b2c9aa | ||
|
|
e2fc76cea0 | ||
|
|
a1a72cdd50 | ||
|
|
2075eca0f3 | ||
|
|
4217f23df2 | ||
|
|
2f63896fb4 | ||
|
|
58aad80d56 | ||
|
|
1452dab5fa | ||
|
|
f454c48929 | ||
|
|
b06a4f0748 | ||
|
|
7da3519ca7 | ||
|
|
b388e936fd | ||
|
|
93281859c8 | ||
|
|
45ce682e6b | ||
|
|
c415d57d16 | ||
|
|
b6c8563cb0 | ||
|
|
6dad69cdb4 | ||
|
|
9b30c8af94 | ||
|
|
703b40a116 | ||
|
|
08d6390521 | ||
|
|
7109040984 | ||
|
|
220fb10e5c | ||
|
|
fb02c868f8 | ||
|
|
58ec7639fb | ||
|
|
ac16042f67 | ||
|
|
346f98bdbf | ||
|
|
595f08ee30 | ||
|
|
0468a603ae | ||
|
|
b9cb524514 | ||
|
|
f4c6be158f | ||
|
|
cf4750ebf0 | ||
|
|
95b6d952e6 | ||
|
|
49db007691 | ||
|
|
4c997b5017 | ||
|
|
de31b43663 | ||
|
|
e41993a6bd | ||
|
|
92ad7bc2df | ||
|
|
db81136b0a | ||
|
|
1a220da477 | ||
|
|
d06434ae31 | ||
|
|
eb7427d12c | ||
|
|
241ff8e481 | ||
|
|
988ff609cf | ||
|
|
cd2d11a2e7 | ||
|
|
1c27a0ae7e | ||
|
|
9e2f349221 | ||
|
|
4a451f410d | ||
|
|
aee6e5ad9c | ||
|
|
21edb700b2 | ||
|
|
0e40639930 | ||
|
|
778ff51162 | ||
|
|
b2d54a6683 | ||
|
|
ea7790a5bf |
@@ -33,15 +33,29 @@ jobs:
|
||||
- name: Install Explorer frontend dependencies
|
||||
working-directory: explorer
|
||||
run: npm ci
|
||||
- name: Install Playwright Chromium
|
||||
working-directory: explorer
|
||||
run: npx playwright install --with-deps chromium
|
||||
- name: Test Explorer frontend
|
||||
working-directory: explorer
|
||||
run: |
|
||||
npm run test:graph-store
|
||||
npm run test:graph-workspace
|
||||
npm run test:plugin-registry
|
||||
npm run test:deterministic-e2e
|
||||
- name: Build Explorer frontend
|
||||
working-directory: explorer
|
||||
run: npm run build
|
||||
- name: Install Explorer backend test dependencies
|
||||
run: |
|
||||
# Run the deterministic backend path before the all-extras CI
|
||||
# environment is installed. The Explorer extra supplies the
|
||||
# production API dependencies without importing optional vector
|
||||
# providers such as Pinecone during test collection.
|
||||
pip install -e ".[explorer]" pytest==9.1.1
|
||||
- name: Test deterministic Explorer backend path
|
||||
run: |
|
||||
pytest -q tests/explorer/test_explorer_deterministic_rendering_e2e.py
|
||||
- name: Install pinned Python dependencies
|
||||
run: |
|
||||
pip install -r requirements-ci.txt
|
||||
|
||||
@@ -32,7 +32,7 @@ jobs:
|
||||
# meaningful state carried over from a failed attempt.
|
||||
- name: Initialize CodeQL (attempt 1)
|
||||
id: codeql-init-1
|
||||
uses: github/codeql-action/init@ff2f1c621b7f889edc0d3c761ac2e6a3f8cdb0dd # v4
|
||||
uses: github/codeql-action/init@cdf488f595d80d6e07e03d4674febd5ab45fa938 # v4
|
||||
continue-on-error: true
|
||||
with:
|
||||
languages: python
|
||||
@@ -42,7 +42,7 @@ jobs:
|
||||
- name: Initialize CodeQL (attempt 2)
|
||||
id: codeql-init-2
|
||||
if: steps.codeql-init-1.outcome == 'failure'
|
||||
uses: github/codeql-action/init@ff2f1c621b7f889edc0d3c761ac2e6a3f8cdb0dd # v4
|
||||
uses: github/codeql-action/init@cdf488f595d80d6e07e03d4674febd5ab45fa938 # v4
|
||||
continue-on-error: true
|
||||
with:
|
||||
languages: python
|
||||
@@ -52,17 +52,17 @@ jobs:
|
||||
- name: Initialize CodeQL (attempt 3)
|
||||
id: codeql-init-3
|
||||
if: steps.codeql-init-2.outcome == 'failure'
|
||||
uses: github/codeql-action/init@ff2f1c621b7f889edc0d3c761ac2e6a3f8cdb0dd # v4
|
||||
uses: github/codeql-action/init@cdf488f595d80d6e07e03d4674febd5ab45fa938 # v4
|
||||
with:
|
||||
languages: python
|
||||
queries: security-and-quality
|
||||
config-file: .github/codeql/codeql-config.yml
|
||||
|
||||
- name: Autobuild
|
||||
uses: github/codeql-action/autobuild@ff2f1c621b7f889edc0d3c761ac2e6a3f8cdb0dd # v4
|
||||
uses: github/codeql-action/autobuild@cdf488f595d80d6e07e03d4674febd5ab45fa938 # v4
|
||||
|
||||
- name: Perform CodeQL Analysis
|
||||
uses: github/codeql-action/analyze@ff2f1c621b7f889edc0d3c761ac2e6a3f8cdb0dd # v4
|
||||
uses: github/codeql-action/analyze@cdf488f595d80d6e07e03d4674febd5ab45fa938 # v4
|
||||
with:
|
||||
category: "/language:python"
|
||||
upload: false
|
||||
@@ -72,7 +72,7 @@ jobs:
|
||||
# Uploads results only when Default Setup is not active.
|
||||
# If Default Setup is still enabled, this step skips gracefully
|
||||
# instead of failing the workflow with HTTP 409.
|
||||
uses: github/codeql-action/upload-sarif@ff2f1c621b7f889edc0d3c761ac2e6a3f8cdb0dd # v4
|
||||
uses: github/codeql-action/upload-sarif@cdf488f595d80d6e07e03d4674febd5ab45fa938 # v4
|
||||
with:
|
||||
sarif_file: ${{ steps.codeql.outputs.sarif-output }}
|
||||
category: "/language:python"
|
||||
|
||||
@@ -57,7 +57,7 @@ jobs:
|
||||
# avoiding the guardian.cmd/checkov exit-code bug in the MSDO wrapper.
|
||||
tools: eslint,templateanalyzer,terrascan
|
||||
- name: Upload results to Security tab
|
||||
uses: github/codeql-action/upload-sarif@ff2f1c621b7f889edc0d3c761ac2e6a3f8cdb0dd # v4
|
||||
uses: github/codeql-action/upload-sarif@cdf488f595d80d6e07e03d4674febd5ab45fa938 # v4
|
||||
with:
|
||||
sarif_file: ${{ steps.msdo.outputs.sarifFile }}
|
||||
|
||||
@@ -82,7 +82,7 @@ jobs:
|
||||
}
|
||||
|
||||
- name: Upload Checkov results to Security tab
|
||||
uses: github/codeql-action/upload-sarif@ff2f1c621b7f889edc0d3c761ac2e6a3f8cdb0dd # v4
|
||||
uses: github/codeql-action/upload-sarif@cdf488f595d80d6e07e03d4674febd5ab45fa938 # v4
|
||||
if: always()
|
||||
with:
|
||||
sarif_file: reports/checkov.sarif
|
||||
|
||||
+115
@@ -9,6 +9,111 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [0.6.7] - 2026-08-28
|
||||
|
||||
### Added
|
||||
|
||||
- **First-class LangChain integration** (closes #963; recreates #969)
|
||||
- New `pip install semantica[langchain]` extra (`langchain-core>=0.3.0`), included in the `all` bundle
|
||||
- `integrations/langchain/SemanticaRetriever` — LangChain `BaseRetriever` that seeds from `HybridSearch` then walks graph edges (`hops=2` default) for GraphRAG-style retrieval; falls back to `ContextGraph.query` when hybrid search is unavailable
|
||||
- `integrations/langchain/SemanticaVectorStore` — LangChain `VectorStore` adapter over `HybridSearch` (`add_texts`, `similarity_search`, `similarity_search_with_score`, `from_texts`)
|
||||
- `integrations/langchain/SemanticaKGTool` / `SemanticaDecisionTool` — `BaseTool` subclasses with Pydantic `args_schema` (`semantica_query_graph`, `semantica_query_decisions`); `build()` returns the tool, or `None` when langchain-core is absent
|
||||
- Retriever and VectorStore read HybridSearch nested `metadata` (`content`, `node_id`, `node_type`) rather than top-level fields that HybridSearch does not set
|
||||
- All adapters remain importable without langchain-core (`LANGCHAIN_AVAILABLE` flag)
|
||||
- Docs: `docs/integrations/langchain.md`, README native-integration matrix, and `docs.json` nav entry
|
||||
|
||||
- **SAP OData ingestor** (#1234, closes #1228) by @pkupt
|
||||
- New `SAPODataEntity` / `SAPODataConnector` / `SAPIngestor` (`semantica.ingest`, lazy exports), following the three-layer connector pattern already used for Snowflake/Databricks, to pull master/transactional data (Business Partners, Sales Orders) from SAP OData v2/v4 services into the Context Graph
|
||||
- Dual auth (OAuth2 client-credentials for BTP/S4HANA Cloud, Basic for on-prem NetWeaver); every outbound request, including the token exchange, routes through `request_with_ssrf_guard`
|
||||
- `$metadata` (CSDL XML) is parsed with a hand-rolled `xml.etree` reader rather than pulling in `pyodata`; pagination follows OData v2 `__next`/`__deferred` and v4 `@odata.nextLink`
|
||||
- New `pip install semantica[ingest-sap]` extra (`requests>=2.28.0`)
|
||||
- **Known phase-1 limits** (documented in docstrings): the OAuth2 token is cached but never refreshed, and pagination has no `max_pages` fuse (`top` bounds it when supplied)
|
||||
- New `tests/ingest/test_sap_ingestor.py`: 22 tests (auth, EDMX parsing, v2/v4 pagination, SSRF routing, error paths, service-root normalization)
|
||||
|
||||
- **`ContextGraph` gains deterministic, human-editable Markdown round-trip persistence** (#852) by @SaurabhScripts
|
||||
- `save_to_markdown()`/`load_from_markdown()` write one file per node plus a graph manifest, so a graph can be reviewed and hand-edited outside the application without giving up the existing JSON API or its default behavior
|
||||
- An existing destination is validated as a complete, canonical managed export before atomic replacement, so the loader can't silently clobber an unrelated or manually-extended directory
|
||||
- Import/export paths and their ancestors reject symlinks, Windows junctions, and other reparse points, with pre-open and post-open validation — the same hardening applied to `AgentMemory`'s existing Markdown import in the companion fix below
|
||||
- Dangling edge endpoints import as JSON-compatible entity stubs rather than being rejected outright (matching what the JSON loader already accepts); node/edge indexes, adjacency, and analytics/retraction/tombstone state are rebuilt after a Markdown load, and granular node/edge events are still emitted so temporal audit history stays useful
|
||||
- New `tests/context/test_context_graph_markdown.py`: 29 passed, 1 skipped (the skipped case creates a real Windows junction and runs on Windows CI); full `tests/context/` suite: 614 passed, 1 skipped
|
||||
|
||||
- **Explorer graph inspector gains a read-only Markdown content viewer** (#1078, closes #900) by @sakshi04-ui — Preview (rendered GFM) and Source (exact, whitespace-preserving) tabs for node content, with a copy-to-clipboard action. A URL allowlist restricts links to `http:`/`https:`/`mailto:`/in-document anchors, raw HTML execution is disabled, and external links carry `rel="noopener noreferrer"`. A first, focused step toward human-editable memory (#765); no write path yet. New `explorer/tests/markdownContentViewer.test.ts`: 8 tests
|
||||
- **Follow-up (perf)** (#1195, addresses #1118) by @pravit-amp: `remarkPlugins` and the ~20-entry renderer `components` map were inline literals, so every unrelated re-render (e.g. clicking Copy) re-ran the full remark parse and remounted the whole subtree — up to 1.1s of main-thread block on a 2000-row GFM table. Both are now hoisted to module scope and the rendered element is memoized on content, cutting re-render cost from as much as 1121ms to ~0.1ms across all measured fixtures with no change to rendered output. A separate, upstream `remark-gfm` table-parse cost (~O(n^1.9), not fixed here) is left open on the issue as a product decision
|
||||
- **Follow-up (cleanup)** (#1194, closes #1119) by @pravit-amp: the pure `isSafeUrl` URL-safety helper is extracted out of `MarkdownContentViewer.tsx` into its own `markdownUrlSafety.ts` module (behavior-preserving — moved verbatim), so the component module exports only components and stops tripping `react-refresh/only-export-components`
|
||||
|
||||
- **`reasoning` gains a structured Action layer — rule-driven side effects with optional provenance** (#1096, closes #1095) by @cxzg007 — `AssertAction`/`RetractAction`/`CallAction`/`EmitEventAction` let a matched rule write facts back to a `KnowledgeGraph`, retract facts, call a structured handler (replacing the previously-unused `Rule.handler`), or emit to a sink registered via `Reasoner.on_event`, turning the reasoner from a pure inference engine into a production-rule system. With `provenance=True`, fired actions are recorded to `Reasoner.action_log`. Fully additive — rules without `actions` are unaffected, and the legacy `handler` field still fires (now wrapped internally as a `CallAction`). Also fixes a latent dangling import in `reasoning_provenance.py` (`ReasoningEngine`/`infer` → `Reasoner`/`infer_facts`). New `tests/reasoning/test_rule_actions.py`: 9 tests; full `tests/reasoning/` suite: 54 passed
|
||||
|
||||
- **`run_shacl_validation` is now a public, documented entry point** (#1189, closes #1186) by @mikemikimike — the SHACL guide had documented the private `_run_pyshacl` helper as the canonical API; it's now exposed through `semantica.ontology`, with `_run_pyshacl` kept as a compatibility alias over the same implementation. `tests/ontology/test_ontology_advanced.py`: 33 passed (also fixes a flaky comparison against pySHACL's non-deterministic blank-node shape identifiers by comparing stable report fields instead)
|
||||
|
||||
- **`docs/storage-backends.md`: adapter inventory and RDF/LPG feature matrix** (#899, addresses #888) by @yulinlina — which graph storage backends are built-in vs. bring-your-own, and where provenance/context support is partial
|
||||
- **`docs/guides/shacl-validation.md`: documented that `rdfs:range` + RDFS entailment makes `sh:class` unfalsifiable** (#1182, fixes #1130) by @ALDRIN121 — with entailment on, pyshacl infers the declared range class onto every object, so a `sh:class` constraint can never fail and reports `conforms: True` on non-conforming data; added to Common Pitfalls with the `inference="none"` vs `inference="rdfs"` contrast and guidance to re-run `sh:class` shape sets with entailment off before trusting a pass
|
||||
- **Cookbook: four new module notebooks** — `22_Provenance_Tracking.ipynb` (#989, lineage walks, revision history, invalidation, checksums), `23_Reasoning.ipynb` (#990, `Reasoner`/`DatalogReasoner`/`ExplanationGenerator`), `24_Change_Management.ipynb` (#991, versioned snapshots, named tags, checksum tamper-detection), and `25_Seed_Data.ipynb` (#992, bootstrapping a foundation graph from a trusted CSV source) — all by @LeonSGP43, filling gaps where the corresponding module shipped a usage doc but no runnable tutorial; every cell verified against current module source. `docs/cookbook.md` index entries for all four added in #1225
|
||||
- **README "Cite Us" section and `docs/citation.md` cross-link** (#1210) by @KaifAhmad1 — BibTeX/APA/MLA/Chicago/IEEE citation forms; also corrects the copyright holder in `LICENSE`/`docs/project-license.md` from the stale "Hawksight AI" to "Semantica" and replaces the retired `Hawksight-AI` GitHub org slug with `semantica-agi` across ~40 files (READMEs, issue templates, plugin manifests, cookbook notebooks, docs)
|
||||
|
||||
### Changed
|
||||
|
||||
- **A registered custom method can now refuse, instead of being silently overridden by the default implementation** (#1127, closes #1108) by @fabio-rovai — every module supporting custom methods wrapped the registered callable in a `try`/`except` that logged a warning and ran the built-in default on *any* exception, including one a validator or policy gate raised on purpose to say "do not produce this output." That made every registered gate advisory rather than authoritative. `semantica/utils/custom_methods.py` now centralizes the policy: an exception from a registered method propagates to the caller by default; `fallback_on_custom_error=True` restores the previous warn-and-continue behavior per call. Applied mechanically across all 58 call sites in `export/`, `ingest/`, `normalize/`, `parse/`, `embeddings/`, and `kg/` methods modules. New `tests/utils/test_custom_method_can_refuse.py`: 13 tests, including the reported gate-deletes-and-raises scenario and a guard that no call site still swallows
|
||||
- **Removed 13 confirmed-dead symbols across 9 files** (#1176, closes #1174) by @Vinv-AI — private helpers and Explorer app-layer code with zero callers in code, tests, or docs, none part of the public API or a FastAPI `response_model`; 289 deletions, no behavior change
|
||||
- **Consolidated the two duplicate Turtle/N-Triples literal escapers in `rdf_exporter.py`** (#1221, closes #1218) by @pkupt — `_escape_turtle_literal` (added in #1148) escaped the same five characters in the same order as the older module-level `_escape_literal`; the redundant one is dropped and all four call sites route through the original. Behavior no-op, verified against the full export suite (301 passed, 1 skipped)
|
||||
- **Removed the unreachable `_extract_with_spacy()` method and the unused `self.nlp` attribute from `NERExtractor`** (#1220, fixes #1058) by @yunaremaia — the ML dispatch path has always gone through `methods.py`'s process-level model cache instead; `__init__` still validates the spaCy runtime up front but no longer eagerly loads a model nothing on the instance reads
|
||||
- **Cleaned up an unused `sys` import and import ordering in `semantica/worker.py`** (#1061) by @aoright
|
||||
- **Test-only contributions**: isolated `sys.modules` mock leakage between `tests/visualization/` files so the suite passes in any collection order (#897, closes #859, by @luantaraschi); added coverage for 4 previously-untested `ConflictResolver` strategies and 3 `ConflictDetector` conflict types (#902, fixes #865, by @Devansh070); added a regression test tracking relationship provenance through `ProvenanceManager` (#1071, closes #1055, by @dex0shubham); added `max_tokens`-propagation regression coverage for LLM extraction methods, later folded into the cache-key fix below (#925, by @saiganesh47)
|
||||
|
||||
### Fixed
|
||||
|
||||
- **`SPARQLReasoner.execute_query()` claimed to run a query but always returned an empty result** (#1087, fixes #1083) by @ALDRIN121 — both the store-configured and unconfigured branches returned an empty `SPARQLQueryResult` with no real execution behind it, so a caller trusting "no matches" (e.g. a compliance check) could draw a false-negative conclusion from a method that never actually queried anything. Until a real triplet-store execution path lands, it now raises `NotImplementedError` explaining why, and the dead cache/inference scaffolding after the unreachable execution point is removed. 3 new regression tests
|
||||
- **`DuplicateDetector` merged entities that share no identifier, type, or name** (#1149, fixes #1137) by @pkupt — `_create_duplicate_candidate()` only ever boosted confidence for matching types and never penalized a mismatch, so two sparse, differently-typed entities (e.g. a `Person` and an `Organization`) could land above the merge threshold and collapse into one node, silently dropping the second. Two non-empty, differing types are now never a duplicate candidate. `tests/deduplication/`: 92 passed
|
||||
- **`TemporalGraphQuery.analyze_evolution()`'s `stability` metric was a hardcoded placeholder** (#1143, closes #1142) by @cxzg007 — every bounded relationship contributed a constant `1`, so `stability` was always `1.0` or `0` regardless of how long relationships actually stayed valid. Now computes the mean valid-time duration in seconds across relationships with both `valid_from`/`valid_until` set; unbounded/half-open intervals are skipped and negative intervals clamp to zero. 3 new tests in `tests/kg/test_kg.py`
|
||||
- **CodeQL false-positive on a JSON-LD test's URL check** (#1183) by @KaifAhmad1 — `"https://schema.org/" in flattened` pattern-matched CodeQL's substring-sanitization heuristic even though `flattened` is always a `list` (exact membership, no sanitization or SSRF path involved); rewritten as an explicit `any(entry == ... for entry in flattened)` with identical behavior
|
||||
- **HuggingFace NER extraction crashed on `huggingface_model` being forwarded as an unexpected pipeline loader kwarg** (#1188, fixes #1063) by @shahzaib-ahmadcs — while preserving genuinely supported pipeline kwargs like `aggregation_strategy`. 5 tests pass
|
||||
- **JSON-LD document/graph `@id` was minted from the wall clock, so re-exporting an unchanged graph produced a new subject every time** (#1181, closes #1147) by @reddynitish — merging repeated exports duplicated graph identity instead of recognizing them as the same graph. The `@id` is now content-derived, with optional `graph_uri`/`document_uri` overrides for callers with a stable graph name; `semantica:exportedAt` still records export time separately. Applies to both JSON-LD export paths
|
||||
- **`ContextGraph.get_causal_chain()` only matched the canonical uppercase causal-edge spellings, silently missing edges recorded in `CausalChainAnalyzer`'s present-tense vocabulary** (#1187, fixes #1184) by @ALDRIN121 — `causes`/`influences`/`precedes` differ from `CAUSED`/`INFLUENCED`/`PRECEDENT_FOR` in word form, not just case, so an edge recorded with the analyzer's spelling produced an empty audit chain — silent, and in the dangerous direction for a compliance trace. `add_causal_relationship()` now normalizes through an alias map before storing the canonical form; traversal accepts the union vocabulary. 2 new regression tests, full `tests/context/` suite: 587 passed
|
||||
- **`semantica embed generate` corrupted its own output and could recurse into a stack overflow** (#996/#1004/#1005, closes #994) by @varunsahni18, @yzxcj797 — three compounding defects in one pipeline. (1) `generate_embeddings`/`embed_text`/`calculate_similarity`/`pool_embeddings` all registered themselves as their own custom-method-registry default, so an unqualified call (exactly what the CLI does) re-entered the same wrapper until Python's recursion limit; each of the four dispatch sites now guards on registry identity before recursing (#996, #1005). A second self-recursion in `EmbeddingGeneratorWithProvenance.__getattr__` (re-entering itself when `_generator` is unset, e.g. during a `deepcopy` probe) now raises a normal `AttributeError` for private names instead (#1005). (2) `--output embeddings.parquet` wrote `json.dumps(result, default=str)` regardless of extension, turning a numpy array into its plain-text `repr()` — a file `embed index` then failed to open as Parquet; the writer now detects `.parquet`/`.json`/`.jsonl` and produces real Parquet/JSON, rejecting any other extension with a clear message (#996, #1004). (3) `pyarrow` was only in optional extras despite being required by the documented quick-start flow; promoted to a core dependency (#996)
|
||||
- **`AgentMemory`'s existing Markdown import accepted symbolic links, NTFS junctions, and other Windows reparse points** (#851) by @SaurabhScripts — a direct linked import path is now rejected with an actionable error, and a linked entry found inside an otherwise-valid directory is skipped rather than aborting the whole import; hardened with pre-open/post-open checks, `O_NOFOLLOW` where available, and `fstat`-based regular-file validation. `tests/context/`: 595 passed, 1 skipped (Windows-junction test, runs on Windows CI)
|
||||
- **A caught vector-similarity scoring exception left stale partial state behind, risking a misleading match on the next call** (#885, fixes #875) by @ArmanGrewal007 — the exception is now logged at debug level and `vector_score`/`vector_idx` reset to neutral values before the remaining matching stages continue
|
||||
- **`RDFExporter` could write invalid or unintended relative IRIs for `GraphBuilder`-default entity/relationship identifiers** (#1112, closes #1099) by @mikemikimike — normalization is now applied at the RDF export boundary across Turtle (including temporal Turtle), RDF/XML, and N-Triples: bare/relative identifiers are minted under the Semantica namespace with safe percent-encoding, absolute IRIs pass through unchanged, and configured/input-context prefixes expand through the effective namespace mapping. 38 focused regression tests; 175 export tests plus 46 subtests pass
|
||||
- **`RDF4JStore`'s `repository_id` constructor argument had no effect** (#1192, closes #1191) by @Freakz2z — the explicit id is now honored when selecting the repository; stale documentation caveats claiming otherwise are removed. 65 tests pass across the affected triplet-store suites
|
||||
- **Non-interactive stdout (piped/redirected output, CI logs) was flooded with progress-bar escape sequences** (#1193, fixes #1185) by @ALDRIN121 — a plain `python demo.py > out.txt` captured 173 bytes of progress noise around 10 bytes of real output. `ProgressTracker` now attaches its console display only for an interactive terminal, Jupyter, or the new `SEMANTICA_FORCE_PROGRESS` opt-in (following the `NO_COLOR`/`FORCE_COLOR` convention); file-based progress logging is untouched. Both switches are now documented in the README and `docs/reference/utils.md`. 11 tests pass (6 new)
|
||||
- **Entity `metadata` was dropped by every RDF serializer except the JSON-LD path**, so an entity kept its confidence but lost its source document, page, extractor, and reviewer on Turtle/N-Triples/RDF/XML/`RDFExporter`'s own JSON-LD (#1165, closes #1154) by @fabio-rovai — Semantica's own metadata keys (`num_entities`, `snapshot_time`, Neo4j loader fields, etc.) are now mapped to declared vocabulary terms and carried through on every path; a caller-supplied key with no mapped term is skipped with an explicit warning (rather than silently vanishing) naming the override needed, pending the caller-key namespace decision tracked in #1146. 21 new tests in `tests/export/test_metadata_passthrough.py`; `tests/export`+`tests/ontology`: 274 pass
|
||||
- **`extract_relations_llm` silently dropped caller-supplied generation parameters** (`max_tokens`, `top_p`, `seed`, etc.), and the extraction cache didn't distinguish calls made with different generation settings (#1213, with test coverage from #925) by @Sameer6305 — a small hardcoded allowlist forwarded only `temperature`/`verbose` to `generate_typed`, discarding the rest; fixed by forwarding all caller kwargs. Once forwarded, those parameters also needed to enter the cache key, since two calls differing only in `max_tokens` previously shared one cache entry and the second could silently reuse a result generated under the first's settings — now applied consistently across entity, relation, and triplet LLM extraction. New regression tests for cache bypass/reuse under differing `max_tokens`/`temperature`
|
||||
- **`OxigraphStore` silently ignored the `storage_path` constructor argument and never flushed writes before a reopen**, both causing silent on-disk data loss (#970) by @logan-jl-cc — `__init__`'s parameter is named `path`, so the project-conventional `storage_path` landed in `**config` and was ignored, degrading a supposedly-persistent store to in-memory with no error; `storage_path` is now accepted as an alias. Separately, pyoxigraph's background flush can lag behind a write, so a reopen immediately after `add_triplets` could observe fewer triples than were written; writes to an on-disk store now call `flush()` explicitly. 2 new regression tests, full suite: 9 passed
|
||||
- **MCP server's `export_graph` tool was broken on every output format** (#1151) by @Arasz — the `json` branch called `JSONExporter().export()` without the `file_path` it requires, and every RDF branch passed a `ContextGraph` object where the exporters expect the canonical kg dict, both surfacing as a raw exception string. A third bug compounded both: the RDF export path's progress bar wrote to stdout, which over stdio MCP *is* the JSON-RPC framing, corrupting the protocol and hanging the client (a 300s timeout on an empty graph). Fixed by converting through `ContextGraph.to_kg_dict()`, serializing the JSON branch to match the RDF branches' string contract, and forcing `SEMANTICA_DISABLE_PROGRESS=1` for the server process. 5 new tests, verified failing against 0.6.6 beforehand
|
||||
- **`OntologyIngestor` dropped every class and property from a JSON-LD document using a named graph** (#1156, fixes #1129) by @13g4d0 — a top-level `@id` beside `@graph` names the graph, and `rdflib.Graph.parse()` silently loads only the default graph, discarding the rest; `POST /api/ontology/load` returned `status: "success"` with `class_count: 0`. Now parses into a `Dataset` and flattens all quads into the working graph (the same `Graph`→`Dataset` migration #757 made for `JenaStore`, extended to the ingest path). On the PR's real-world reproduction: 25 triples/1 subject before, 719 triples/45 classes/40 object properties after. 4 new tests including a default-graph canary so the fix can't trade one blind spot for another
|
||||
- **Turtle and N-Triples RDF export interpolated entity `text` into string literals with no escaping**, so a `"`, backslash, newline, CR, or tab in the source text emitted invalid RDF other parsers rejected (#1148, closes #1098) by @pkupt — a shared `_escape_turtle_literal()` (later consolidated in #1221) now escapes per the RDF 1.1 Turtle grammar and is reused for the N-Triples path, which previously escaped only quotes and newlines. `tests/export/`: 161 passed, 1 skipped
|
||||
- **`PipelineSerializer` round trips dropped step dependencies and delta-processing metadata, and could rehydrate a legacy stringified handler as a non-callable string** (#1217, fixes #1216) by @cxzg007 — step dependencies, delta mode, and base/target version IDs are now restored from the serialized schema; runtime handler callables are treated as process-local state and excluded from serialized business configuration rather than (mis)serialized. 52 tests pass
|
||||
- **`PipelineBuilder` never actually dispatched to a handler registered by `step_type`**, and a serialize/deserialize round trip could leak `handler`/`dependencies` into a step's business config (#1215, fixes #1214) by @cxzg007 — a registered handler is now resolved by `step_type` when no explicit `handler=` is supplied (explicit handlers still take precedence), and the two builder-control fields are kept out of `PipelineStep.config` so a strict handler signature can't receive them as unexpected kwargs. `tests/core`+`tests/pipeline`: 50 passed
|
||||
- **`PipelineBuilder.set_parallelism()` was accepted and stored but never read — pipeline steps always ran strictly sequentially**, and the setting didn't survive a serialize/deserialize round trip (#1226, fixes #1223) by @cxzg007 — wired through builder → serializer → execution engine, plus a new opt-in `PipelineStep.parallel_safe` flag. A dependency layer now runs in parallel only when every step in it is marked `parallel_safe`, the layer has more than one step, the input is dict-typed, and no step is in delta mode; otherwise it falls back to sequential execution. Each parallel step's input is deep-copied for isolation, execution is bounded by `ThreadPoolExecutor(max_workers=min(configured parallelism, max_workers))`, a failure cancels pending futures in the layer, and layer results merge back in declaration order (a same-key conflict raises `ProcessingError`). 22 new tests in `tests/pipeline/test_pipeline_parallel.py`
|
||||
- **`Config.get()` silently dropped boolean environment-variable overrides** (#1038, fixes #1035) by @Kyou12138 — the type dispatch checked `isinstance(default, int)` before `isinstance(default, bool)`, and since `bool` subclasses `int` in Python, the bool branch was unreachable: `CONFLICT_ZZTESTFLAG=true` with a `False` default returned `False`, and `=1` returned the int `1` rather than `True`. Bool is now checked first (with whitespace stripped before parsing truthy/falsy spellings), fixed across all ten affected config modules (`conflicts`, `deduplication`, `split`, `embeddings`, `export`, `ingest`, `kg`, `parse`, `ontology`, `normalize`). 12 new tests plus 6 existing conflicts tests and 131 related module tests pass
|
||||
- **Scanned (image-only) PDFs parsed with no error and no warning, returning empty text with a "completed" status** (#1021, closes #1020) by @shanyu910 — `PDFParser._parse_page` swallowed a missing text layer via `page.extract_text() or ""`, so the failure only surfaced far downstream as zero extracted entities. A warning now fires when every parsed page yields no text with `extract_text` enabled, pointing at `parse_pdf(..., method="docling", enable_ocr=True)`. Also fixes a separate `import semantica.parse` failure on a fresh interpreter (`email_parser.py` used `email.message.Message` without importing `email.message`) that was blocking the parse test suite from even collecting. 25 tests pass in `tests/parse/`
|
||||
- **`GET /api/decisions` returned HTTP 422 for any graph containing real decisions**, breaking the Explorer Decisions workspace entirely (#937) by @logan-jl-cc — `record_decision()` stores the timestamp as a POSIX float, but `DecisionResponse.timestamp` is typed `Optional[str]` and Pydantic's strict mode rejected the coercion. Fixed by coercing to `str` (preserving `None`) at the response-adapter boundary
|
||||
- **Decision persistence/query bugs, CJK text handling, and three missing MCP graph tools** (#967) by @toratto — `mcp_server`'s `_get_graph` called a non-existent `graph.load` instead of `load_from_file`, so `SEMANTICA_KG_PATH` was silently ignored and the server always started with an empty graph; `query_decisions` read `category` from the wrong field, always returning nothing for a category filter; `find_precedents`/`query_decisions(query=)`'s similarity threshold was too high for short CJK queries, which also failed outright because `_calculate_decision_content_similarity`'s whitespace-Jaccard fallback is always zero for languages with no whitespace tokenization (now falls back further to a character-bigram overlap coefficient); `load_from_file` didn't rebuild the in-memory decision/entity/temporal indexes after loading, breaking `find_precedents_by_scenario` and decision counts post-reload; `extract_entities`/`extract_relations` returned the spaCy type label as `text` and dropped the actual entity text, and had no way to select a non-English NER model. Also adds three new MCP tools (`query_graph`, `update_node`, `delete_node`, the latter two persisting back to `SEMANTICA_KG_PATH`)
|
||||
- **`sqlalchemy.text` was used but never imported in two `DBIngestor`/`DataExporter` methods**, raising `NameError` on every call before any query reached the database (#1017, closes #1015) by @pravit-amp — `connect()`/`test_connection()` imported `text` function-locally, so the binding never reached `export_table_data()` or `execute_query()`, which called it anyway; both raised immediately, re-wrapped by an `except Exception` into a `ProcessingError` that read like a database fault rather than a missing import. `docs/guides/ontology.md` documents `DBIngestor().execute_query()` as a supported entry point, so documented usage walked straight into it. 5 new tests against a temporary SQLite database, also repairing a previously-failing `tests/ingest/test_notebook_02.py` case
|
||||
- **Ontology generation resolved relationship endpoint types incorrectly, producing wrong object-property domains/ranges** (#1170, closes #1168) by @T1mn — endpoint types are now resolved from the canonical `source_id`/`target_id` fields and supported aliases instead of defaulting to the first entity when a field was missing, preventing e.g. a `Person -> Organization` relationship from generating a `Person -> Person` property. 80 tests pass, 1 skipped
|
||||
- **Ontology property generation dropped data properties when a raw entity type was normalized into a class name** (#1171, closes #1169) by @T1mn — e.g. `software engineer` → `SoftwareEngineer` lost its `email` property; attributes are now grouped by matching raw, normalized, and recorded class names, so the normalized class stays each property's domain. 79 tests pass, 1 skipped
|
||||
- **`flatten_dict()` silently dropped data when a top-level key already containing the separator collided with a key produced by flattening a nested dict** (#1012, fixes #1010) by @yzxcj797 — `{"a.b": 1, "a": {"b": 2}}` flattened to `{"a.b": 2}` with no error, the `1` simply gone; collisions are now detected (unique-key count vs. item count) and raise `ValueError` naming the colliding key before data is lost. 6 new tests
|
||||
- **Creating relationships after `GraphStore.add_edges`/`build_from_entities_and_relationships` silently produced zero edges against ID-minting backends** (#1173, fixes #1136) by @yzxcj797 — an id-space mismatch across three layers: `add_edges` reads application-level string ids and passes them to `create_relationship`, which is a pure passthrough into `Neo4jStore.create_relationship`'s `MATCH ... WHERE id(a) = $start_id` — a Neo4j-internal integer id. Every node was created and every relationship silently failed with one easily-missed warning per edge. `GraphStore` now keeps an application-id→internal-id map, populated by `add_nodes`/`create_node` from the backend's own creation results and consulted by `create_relationship`; unknown ids and identity-mapped backends are unaffected. `tests/graph_store/`: 100 passed
|
||||
- **RDF export left `semantica:text`/`rdfs:label` empty for entities that only carry a `name` field**, across all four RDF formats (#1113, fixes #1097) by @cxzg007 — `RDFSerializer.convert_kg_to_rdf()` already implemented the `name`→`label`/`text` normalization, but `export_to_rdf()` never called it. Now called once at the export boundary (idempotent, non-destructive, falls back to a label derived from the id suffix). 7 new tests, `tests/export/test_rdf_exporter.py`: 17 passed
|
||||
- **Docker Explorer image failed to build on Python 3.14** — `gensim` has no prebuilt wheel for it and the slim base has no `gcc` to build from source (#1172, closes #1025) by @DwitiThaker — runtime pinned to `python:3.13-slim`, where `gensim` installs from a prebuilt wheel
|
||||
- **Unit normalization rejected common aliases before conversion** — `kg`, `g`, and other abbreviated/plural unit spellings failed category validation and the conversion-factor lookup ahead of it (#939) by @Mr-Neutr0n — aliases now normalize first; canonical aliases added for feet, yards, miles, and gallons. 7 tests pass
|
||||
- **An oversized, caller-controlled mapping key could blow up a `ValidationError` message to megabyte scale**, and equally inflate application logs on repeated malformed input (#1088, fixes #1001) by @ALDRIN121 — follow-up to the graph-payload validation added in #958. The displayed key is now truncated at 64 characters with an ellipsis; the underlying input and validation decisions are unchanged. 4 new tests
|
||||
- **`SeedDataManager.load_from_api()` mislabeled genuine connection failures as a missing `requests` dependency** (#972, closes #949) by @pravit-amp — `requests.exceptions.RequestException` (connection errors, timeouts, `raise_for_status()` failures) subclasses `OSError`, so an `except (ImportError, OSError)` block written to guard a lazy import that no longer existed (`requests` is a core dependency) caught real failures too and told users to reinstall an already-installed library while dropping the original exception chain. The block is removed; genuine failures now surface through the existing `Failed to load from API: {e}` path with `from e` intact. 5 new regression tests
|
||||
- **`SHACLGenerator` produced shapes that matched nothing, and pySHACL reported `conforms: True` on data that plainly violated them** (#1124, closes #1104, closes #1105) by @fabio-rovai — `base_uri` was used both as where shape resources live and to expand every `sh:targetClass`/`sh:path`, so with the default shapes namespace, generated shapes targeted classes no data graph in the package actually uses; a shape with zero matching focus nodes is vacuously satisfied, so validation silently passed regardless of real violations. The target namespace now resolves independently (explicit argument → ontology's declared namespace → an existing absolute class/property IRI → ontology `uri` → the vocabulary namespace), never the shapes namespace. Separately, `_attach_property_shapes` attached a domain-less property's constraint to *every* shape ("no domain declared, attach to all"), asserting a constraint the ontology never stated; a domain-less property is now left unattached by default, with `attach_domainless_properties=True` to restore the old behavior. 17 new tests validate real data through pySHACL rather than reading shape text; `tests/ontology`+`tests/export`: 239 passed
|
||||
- **OWL export dropped every generated property and collapsed distinct classes onto one node** (#1123, closes #1103) by @fabio-rovai — `OWLExporter` reads `object_properties`/`data_properties`, but `OntologyGenerator` emits one combined `properties` list, so every property was silently discarded; separately, a class built without a namespace manager gets `"uri": None`, which a `"uri" not in cls"` guard never catches (the key is present), so the exporter wrote a relative `<>` IRI for it — resolved by rdflib against the current working directory, meaning two classes could collapse onto one subject and that subject's identity changed with the export's working directory. Both dict shapes are now merged and classified correctly, and a class/property IRI resolves through `uri`→`iri`→`id`→a name joined onto the ontology base, skipping (with a warning) a term with none of those instead of minting `<>`. 10 new regression tests parse the real output with rdflib and Oxigraph; `tests/export`+`tests/ontology`: 231 passed
|
||||
- **Confidence scores serialized as four different, mutually-disagreeing RDF terms depending on export format, and one non-numeric confidence value could break an entire Turtle export** (#1125, closes #1100, closes #1102) by @fabio-rovai — Turtle wrote a bare `xsd:decimal`, N-Triples an explicit `xsd:float`, RDF/XML an untyped plain literal, and JSON-LD's native number expanded to `xsd:double`; loading a Turtle and an N-Triples export of the same graph into one store gave the same entity two different confidence values. Separately, an unparseable confidence (e.g. the string `"high"`) was interpolated into Turtle with no validation, producing a syntax error that dropped every entity from the export. All four paths now write one canonical `xsd:decimal` lexical form (matching the pre-existing Turtle behavior and the only exact representation of the four); an unusable value is omitted with a warning instead of corrupting the document. The vocabulary's `sem:confidence` now declares `xsd:decimal` (previously left undeclared to avoid contradicting the disagreeing exporters). 20 new tests compare parsed graphs across all four formats; `tests/export`+`tests/ontology`: 240 passed
|
||||
- **An OWL-Time validity interval was reified onto a relationship IRI the graph never actually referenced**, making it unreachable from the edge it described (#1126, closes #1106) by @fabio-rovai — a relationship serializes as a single triple with no node of its own, so `include_temporal=True` minted a well-formed `time:Interval` with zero inbound arcs to its subject. Turtle now also emits the `sem:Relationship`/`sem:source`/`sem:target`/`sem:type` reification the JSON-LD path already produced, but only when there's temporal data to attach — default and `include_temporal=False` output are byte-for-byte unchanged. 7 new tests include a SPARQL walk from the edge to its interval, the path the dangling node made impossible; `tests/export`+`tests/ontology`: 228 passed
|
||||
- **JSON-LD exports were unreadable by Semantica's own default parser** (#1145, fixes #1144) by @fabio-rovai — every export was written as a named graph (a top-level `@id` beside `@graph`), which a plain `rdflib.Graph.parse()` silently discards in favor of the (empty) default graph; a two-entity graph parsed as 2 triples instead of 20. Compounded by `export_knowledge_graph` converting its payload to JSON-LD and then handing the *already-converted* document to `export()`, which converted it again, producing two `@context` blocks and two document nodes. Metadata now attaches beside `@graph` rather than naming it, and a payload that already declares `@context` is merged rather than re-wrapped. 9 new tests parse with both `Graph()` and `Dataset()` and assert identical counts; full-suite failure set unchanged before/after (539/539)
|
||||
- **`GraphBuilder` didn't propagate entity-resolution's merged ids into the `source_id`/`target_id` relationship aliases**, only `source`/`target` (#1115, closes #1110) by @T1mn — a relationship's alias fields could still point at a pre-merge id after resolution. Both alias pairs are now kept in sync. 9 tests pass
|
||||
- **`GraphValidator` indexed entities only by `id`, rejecting graphs that use the `entity_id` alias as invalid even when their relationships were fine** (#1116, closes #1111) by @T1mn — validation and endpoint checks now go through the shared `get_entity_id()` helper, accepting both fields consistently. 5 tests pass
|
||||
- **Broken star history chart in README** (#1057) by @OctoBored — the embedded chart used the GitHub stargazer API, now access-restricted; switched to a token-free alternative data source
|
||||
|
||||
### Security
|
||||
|
||||
- **Agno's `AgnoKnowledgeGraph.load_urls()` made outbound requests with no SSRF protection beyond a scheme check** (#1212) by @Sameer6305 — caller-supplied URLs went straight to `urllib.request.urlopen()`, unguarded against loopback/private addresses, cloud metadata endpoints (`169.254.169.254`), IPv6-internal addresses, hostnames resolving to private space, or redirects into any of the above. Found during a project-wide SSRF audit following #936/#959. Now routed through the shared `request_with_ssrf_guard()`; an unsafe URL is skipped rather than aborting the rest of the ingestion batch. `OpenClawKGTool` (operator-configured, intentionally allowed to target `localhost` for local deployments) gains scheme/malformed-URL validation as defense in depth, without restricting its legitimate private-network use case. 29 new Agno tests, 26 new OpenClaw tests, all passing alongside the 15 pre-existing Agno integration tests
|
||||
|
||||
### Dependencies
|
||||
|
||||
- Routine version bumps with no application-facing behavior change: `anthropic` 0.121.0→0.122.0 (#1045), `botocore` 1.43.69→1.43.73 (#1047), `agno` 2.8.7→2.9.0 (#1050), `google-genai` 2.17.0→2.18.1→2.19.0 (#1163, #1205), `lxml` 6.1.1→6.1.2 (#1197), `charset-normalizer` 3.5.0→3.5.1 (#1201), `pypickle` 2.0.1→2.0.2 (#1203)
|
||||
|
||||
## [0.6.6] - 2026-08-20
|
||||
|
||||
### Added
|
||||
@@ -108,6 +213,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
### Fixed
|
||||
|
||||
- **KG provenance tests asserted on generated ID strings instead of stored records, and `kg_provenance.py` was missed by the `utcnow` sweep** (closes #946) by @pravit-amp
|
||||
- The KG workflow and integration suites checked that a tracker call returned an ID matching a prefix (`assert cent_id.startswith("centrality_")`) without ever reading the record back, so an ID generator that returned a well-formed string and wrote nothing would have passed. Worse, some of those calls named tracker methods that do not exist anywhere in `semantica/` (`track_layer_analysis`, `track_centrality_score`), so the assertions were satisfied with no real interaction behind them
|
||||
- Those tests now read provenance back through `get_provenance()` and assert on algorithm metadata, and call the methods that actually persist records. Verified by mutation rather than by a green run alone: neutering the manager's storage write (`self.storage.store(...)` → no-op) fails 10 tests
|
||||
- `GraphBuilderWithProvenance` in `semantica/kg/kg_provenance.py` still stamped `activity_started_at_time`/`activity_ended_at_time` with the deprecated `datetime.utcnow()`; it was outside the `export/`+`provenance/` scope of the #1114 sweep below and now uses the same `utc_now_iso()` helper. `docs/guides/provenance.md` and `docs/reference/provenance.md` were still documenting `utcnow()` and a naive timestamp example, and now show the helper and the offset-bearing form
|
||||
- 16 tests across the affected suites ended in `return <value>` instead of asserting, which pytest reports as `PytestReturnNotNoneWarning`; now zero
|
||||
|
||||
- **The temporal-evolution `stability` metric was a hardcoded placeholder, not a duration**
|
||||
- `TemporalGraphQuery.analyze_evolution()` documents `stability` as a "relationship duration/stability measure", but the implementation appended a constant `1` for every relationship with both `valid_from` and `valid_until` set (`durations.append(1) # Placeholder`). The reported stability was therefore always `1.0` when any bounded relationship existed and `0` otherwise — it never reflected how long relationships actually stayed valid, so it could not distinguish a graph of decade-long relationships from one of one-second relationships
|
||||
- `stability` now computes the mean valid-time duration in seconds (`(valid_until - valid_from).total_seconds()`) across relationships that have both bounds set. Relationships with a missing or open `valid_from`/`valid_until` are skipped (their duration is unbounded), and non-positive intervals are clamped to `0`; an empty set still reports `0`
|
||||
@@ -121,6 +232,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
- New `tests/export/test_timestamp_timezones.py` and `tests/provenance/test_timestamp_timezones.py`: offset presence on every export and provenance path, PROV-O literals valid as `xsd:dateTimeStamp`, comparison against a timezone-aware instant without `TypeError`, the Oxigraph filter that dropped the naive value (with a bound inside the indeterminate window, so the test cannot pass by accident), and the document `@id` remaining a valid IRI with `+00:00` in it. 11 of the 13 fail on the parent commit
|
||||
- **Fixed during review** (Qodo): once new entries carry `+00:00` and stored ones do not, `ProvenanceManager.query_recorded_between` and `audit_log` compared ISO timestamps as raw strings, so they ordered by spelling rather than by instant — an inclusive naive bound naming a stored offset-bearing timestamp sorted *below* it and dropped the record, and a bound written in another offset landed wherever its digits fell (`19:45+05:30` is 14:15Z, but sorted after 14:19Z). Both now compare instants through a new `to_utc_datetime()` helper that reads a missing offset as UTC, which is what the values written before this change actually were; a bound that cannot be read as a timestamp keeps the historical string comparison rather than raising on a call that used to work
|
||||
- The remaining 147 naive call sites are in `context/`, `vector_store/`, `seed/` and elsewhere, where timestamps are compared against values parsed from previously stored naive strings. Converting those without a read-side migration would raise `TypeError: can't compare offset-naive and offset-aware datetimes` on existing data, so they are deliberately left for a separate change
|
||||
- **`SHACLGenerator` mangles `#`-terminated namespaces into `#/`, so generated shapes target nothing** (#1082) by @changshenhan
|
||||
- `__init__` normalized `base_uri` with `rstrip("/") + "/"`, which turns `http://example.org/manufacturing#` into `...manufacturing#/` — the most common RDF namespace convention. Every generated URI (`sh:targetClass`, `sh:path`, shape URIs) then landed in a different namespace than the instance data, and SHACL validation silently passed because the shapes targeted nothing
|
||||
- `__init__` now preserves a namespace already ending in `/` or `#`, matching the `#`-aware normalization `generate()` already applies; `shapes_uri` inherits the fix
|
||||
- New `test_hash_namespace_base_uri_is_not_mangled` in `tests/ontology/test_ontology_advanced.py` fails on the pre-fix normalization and passes with it; full ontology suite (76 tests) green
|
||||
|
||||
- **`split`/chunking paths bypassed the centralized spaCy model cache, reloading the model on every call** (#1042, closes #998) by @Accute9, reviewed by @Sameer6305
|
||||
- `semantica/split/methods.py`'s `split_by_sentences()` and `semantica/split/semantic_chunker.py`'s `SemanticChunker.__init__` each called `spacy.load()` directly instead of reusing the process-level cache added in #889/`semantic_extract/methods.py`'s `load_spacy_model()` — every call/construction re-paid the ~120ms model-load cost independently of `NERExtractor`, which already used the cache
|
||||
|
||||
@@ -87,7 +87,7 @@ Semantica sits underneath your LLM, vector store, and agent framework as a deter
|
||||
- **Graph Analytics:** Centrality, community detection, link prediction, and shortest-path queries over the graph you just built
|
||||
- **Polyglot Graph Storage:** Native RDF (embedded Oxigraph, Blazegraph, Apache Jena, Eclipse RDF4J via SPARQL) and Labeled Property Graphs (Neo4j, FalkorDB, Apache AGE, AWS Neptune via Cypher), plus vector stores, all swappable without touching your code
|
||||
- **Visualization:** Explore any graph, ontology, or timeline in an interactive browser workbench
|
||||
- **Drop-in Integrations:** Native Agno and CrewAI support, a full-featured MCP server, a comprehensive CLI, a REST API, and plugins across major editors
|
||||
- **Drop-in Integrations:** Native Agno, CrewAI, and LangChain support, a full-featured MCP server, a comprehensive CLI, a REST API, and plugins across major editors
|
||||
|
||||
---
|
||||
|
||||
@@ -142,11 +142,13 @@ compliant = graph.check_decision_rules({"category": "vendor_selection"}) # poli
|
||||
```bash
|
||||
semantica doctor
|
||||
# Python 3.11.9 pass
|
||||
# semantica 0.6.6 pass
|
||||
# semantica 0.6.7 pass
|
||||
# faiss vector store pass
|
||||
# Config file pass ~/.semantica/config.yaml
|
||||
```
|
||||
|
||||
**Running in a script or CI?** Progress bars are written only when stdout is an interactive terminal (or a Jupyter notebook), so piping and redirecting stay clean by default. Override with `SEMANTICA_DISABLE_PROGRESS=1` to silence progress everywhere, or `SEMANTICA_FORCE_PROGRESS=1` to keep it when stdout is redirected. `SEMANTICA_DISABLE_PROGRESS` takes precedence.
|
||||
|
||||
<div align="center">
|
||||
|
||||
If Semantica solves a real problem for you, a star helps others find it.
|
||||
@@ -1186,7 +1188,7 @@ Start with `semantica`, verify with `doctor`, build a graph, and explore the com
|
||||
|
||||
## Integrations
|
||||
|
||||
Native plugin bundles for Claude Code, Cursor, Codex, Windsurf, Cline, Continue, VS Code, and OpenClaw; a full-featured MCP server for any MCP-compatible client; a comprehensive REST API; and first-class Agno and CrewAI support for agentic frameworks. Every major LLM provider is already supported via `semantica.llms` and LiteLLM: OpenAI, Anthropic, Gemini, Mistral, Llama, Groq, Cohere, Azure, Bedrock, Ollama, DeepSeek, HuggingFace, and more.
|
||||
Native plugin bundles for Claude Code, Cursor, Codex, Windsurf, Cline, Continue, VS Code, and OpenClaw; a full-featured MCP server for any MCP-compatible client; a comprehensive REST API; and first-class Agno, CrewAI, and LangChain support for agentic frameworks. Every major LLM provider is already supported via `semantica.llms` and LiteLLM: OpenAI, Anthropic, Gemini, Mistral, Llama, Groq, Cohere, Azure, Bedrock, Ollama, DeepSeek, HuggingFace, and more.
|
||||
|
||||
MCP setup takes 30 seconds — see [MCP Server](#mcp-server) below.
|
||||
|
||||
@@ -1305,17 +1307,17 @@ MCP setup takes 30 seconds — see [MCP Server](#mcp-server) below.
|
||||
<strong>CrewAI</strong><br/>
|
||||
<sub>First-class · <code>pip install semantica[crewai]</code></sub>
|
||||
</td>
|
||||
<td align="center" width="12.5%">
|
||||
<a href="https://github.com/langchain-ai/langchain"><img src="https://github.com/langchain-ai.png?size=120" alt="LangChain" width="48" height="48" /></a><br/>
|
||||
<strong>LangChain</strong><br/>
|
||||
<sub>First-class · <code>pip install semantica[langchain]</code></sub>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th colspan="8" align="left">Already Supported via REST API & MCP</th>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center" width="12.5%">
|
||||
<a href="https://github.com/langchain-ai/langchain"><img src="https://github.com/langchain-ai.png?size=120" alt="LangChain" width="48" height="48" /></a><br/>
|
||||
<strong>LangChain</strong><br/>
|
||||
<sub>REST API · MCP</sub>
|
||||
</td>
|
||||
<td align="center" width="12.5%">
|
||||
<a href="https://github.com/langchain-ai/langgraph"><img src="https://github.com/langchain-ai.png?size=120" alt="LangGraph" width="48" height="48" /></a><br/>
|
||||
<strong>LangGraph</strong><br/>
|
||||
<sub>REST API · MCP</sub>
|
||||
@@ -1346,11 +1348,6 @@ MCP setup takes 30 seconds — see [MCP Server](#mcp-server) below.
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center" width="12.5%">
|
||||
<a href="https://github.com/langchain-ai/langchain"><img src="https://github.com/langchain-ai.png?size=120" alt="LangChain" width="48" height="48" /></a><br/>
|
||||
<strong>LangChain</strong><br/>
|
||||
<sub>Dedicated toolkit</sub>
|
||||
</td>
|
||||
<td align="center" width="12.5%">
|
||||
<a href="https://github.com/run-llama/llama_index"><img src="https://github.com/run-llama.png?size=120" alt="LlamaIndex" width="48" height="48" /></a><br/>
|
||||
<strong>LlamaIndex</strong><br/>
|
||||
<sub>Dedicated toolkit</sub>
|
||||
@@ -1466,18 +1463,18 @@ For contributor / dev-server setup: **[explorer/README.md: Local Setup Guide](ex
|
||||
|
||||
---
|
||||
|
||||
## What's New in v0.6.6
|
||||
## What's New in v0.6.7
|
||||
|
||||
**Security release — upgrading is strongly recommended.** Fixes for a privately disclosed batch of vulnerabilities spanning backup/restore, database export, outbound requests, and triplet-store backends, plus SSRF hardening across ingestion:
|
||||
**Feature release**, plus one SSRF hardening fix and a large batch of correctness fixes across the RDF/ontology export pipeline:
|
||||
|
||||
- **Tarball restore path traversal**: `semantica backup restore` now validates every archive member for path containment and rejects symlink/hardlink escapes before extraction
|
||||
- **Latent SQL injection in `DataExporter.export_table_data()`**: table/schema names are now identifier-allowlisted and `where`/`order_by` fragments are blocklist-checked
|
||||
- **DNS-rebinding TOCTOU in the shared SSRF guard**: the resolved IP that passes validation is now the one the connection is pinned to, closing the check-then-use race (also closes the `100.64.0.0/10` CGNAT gap)
|
||||
- **Stored XSS in HTML report generation** and **unvalidated SPARQL object IRIs in AnzoStore** (SPARQL injection): both now escape/validate before interpolation
|
||||
- **`Authorization`/`Proxy-Authorization` credential leakage across redirects**, plus **SSRF gaps in `FeedIngestor`/`FeedMonitor`, `RepoIngestor`, and the MCP/public-API ingest paths**: all now route through the shared, redirect-safe SSRF guard
|
||||
- **HTTP response header injection and an unbounded-memory DoS** in the Explorer API, and a **`fastapi`/`python-multipart` ReDoS** (PYSEC-2024-38): floors raised, inputs sanitized, candidate pools capped
|
||||
- **First-class LangChain integration** (`semantica[langchain]`): a `BaseRetriever` and `VectorStore` over `HybridSearch`, plus graph/decision-query tools
|
||||
- **SAP OData ingestor** (`semantica[ingest-sap]`): OAuth2/Basic-auth, SSRF-guarded ingestion for Business Partners and Sales Orders, following the existing Snowflake/Databricks connector pattern
|
||||
- **`ContextGraph` gains deterministic, human-editable Markdown round-trip persistence** alongside the existing JSON API, and the Explorer graph inspector gains a read-only Markdown content viewer
|
||||
- **`reasoning` gains a structured Action layer**: rule-driven `Assert`/`Retract`/`Call`/`EmitEvent` actions with optional provenance, turning the reasoner into a production-rule system
|
||||
- **`run_shacl_validation` is now a public, documented API**, and a dozen ontology/RDF export correctness fixes land: OWL property/class export, SHACL target-namespace resolution, one canonical confidence datatype across all four RDF formats, reachable OWL-Time reification, JSON-LD default-graph and content-derived document identity, and full metadata passthrough on every RDF serializer
|
||||
- **Security**: Agno's `AgnoKnowledgeGraph.load_urls()` and OpenClaw's MCP tool now route outbound requests through the shared SSRF guard
|
||||
|
||||
Also ships: **first-class CrewAI integration** (`semantica[crewai]`, extraction/decision tools + a knowledge source), **`ContextGraph` retraction and purge** (GDPR-style erasure without a full `clear()`), a declared **Semantica RDF vocabulary with deterministic entity/relationship IRIs** (stable, diffable exports), and **timezone-aware timestamps** across `export/` and `provenance/`.
|
||||
Also fixes: `PipelineBuilder.set_parallelism()` now actually parallelizes independent pipeline steps, `flatten_dict()` no longer silently drops data on a key collision, `Config.get()` honors boolean environment overrides, and the MCP server's `export_graph` tool works again on every format.
|
||||
|
||||
→ [Full release notes](RELEASE_NOTES.md) · [Changelog](CHANGELOG.md)
|
||||
|
||||
@@ -1509,6 +1506,7 @@ pip install semantica[all] # everything
|
||||
```bash
|
||||
pip install semantica[agno] # Agno multi-agent integration
|
||||
pip install semantica[crewai] # CrewAI integration
|
||||
pip install semantica[langchain] # LangChain / LangGraph integration
|
||||
pip install semantica[llm-litellm] # OpenAI, Anthropic, Gemini, Mistral, Llama, Groq, Cohere, Bedrock, Ollama, DeepSeek, and more
|
||||
pip install semantica[graph-neo4j] # Neo4j graph store (LPG)
|
||||
pip install semantica[graph-falkordb] # FalkorDB graph store (LPG)
|
||||
@@ -1561,11 +1559,11 @@ On-premises deployment · Private cloud · Custom domain implementations · SLA-
|
||||
|
||||
## Star History
|
||||
|
||||
<a href="https://www.star-history.com/?repos=semantica-agi%2Fsemantica&type=date&legend=top-left">
|
||||
<a href="https://star-history.dera.page/#semantica-agi/semantica&type=date&legend=top-left">
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/chart?repos=semantica-agi/semantica&type=date&theme=dark&legend=top-left" />
|
||||
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/chart?repos=semantica-agi/semantica&type=date&legend=top-left" />
|
||||
<img alt="Star History Chart" src="https://api.star-history.com/chart?repos=semantica-agi/semantica&type=date&legend=top-left" />
|
||||
<source media="(prefers-color-scheme: dark)" srcset="https://star-history.dera.page/svg?repos=semantica-agi/semantica&type=date&theme=dark&legend=top-left" />
|
||||
<source media="(prefers-color-scheme: light)" srcset="https://star-history.dera.page/svg?repos=semantica-agi/semantica&type=date&legend=top-left" />
|
||||
<img alt="Star History Chart" src="https://star-history.dera.page/svg?repos=semantica-agi/semantica&type=date&legend=top-left" />
|
||||
</picture>
|
||||
</a>
|
||||
|
||||
|
||||
@@ -0,0 +1,253 @@
|
||||
{
|
||||
"cells": [
|
||||
{
|
||||
"cell_type": "markdown",
|
||||
"metadata": {},
|
||||
"source": [
|
||||
"# Provenance Tracking (W3C PROV-O)\n",
|
||||
"\n",
|
||||
"## Overview\n",
|
||||
"\n",
|
||||
"In high-stakes domains — healthcare, legal, finance, research — a Knowledge Graph is only as trustworthy as its ability to answer **\"where did this fact come from?\"**. Semantica's `provenance` module provides audit-grade, W3C PROV-O-aligned tracking for every entity, relationship and chunk that flows through your pipeline.\n",
|
||||
"\n",
|
||||
"In this cookbook you will learn how to:\n",
|
||||
"\n",
|
||||
"- Track entities and relationships with **source details** (DOI, page, verbatim quote, confidence)\n",
|
||||
"- Walk the full **lineage** of a fact (document → chunk → entity → KG)\n",
|
||||
"- Audit **revision history** and **all sources** behind an entity\n",
|
||||
"- **Invalidate** a fact without deleting it (prov:Invalidation) — corrections stay provable\n",
|
||||
"- Verify **tamper-evidence** with chained SHA-256 checksums\n",
|
||||
"\n",
|
||||
"**The Scenario:** a research team ingests findings from two scientific papers (with DOIs) into a Knowledge Graph. A regulator later asks: *\"Which paper, which figure, and which exact sentence supports the claim that fish biomass increased by 463%? And was that fact ever corrected?\"*"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": null,
|
||||
"metadata": {},
|
||||
"outputs": [],
|
||||
"source": [
|
||||
"!pip install -q semantica"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": null,
|
||||
"metadata": {},
|
||||
"outputs": [],
|
||||
"source": [
|
||||
"import json\n",
|
||||
"from semantica.provenance import (\n",
|
||||
" ProvenanceManager,\n",
|
||||
" compute_checksum,\n",
|
||||
" verify_checksum,\n",
|
||||
")\n",
|
||||
"\n",
|
||||
"# In-memory storage for this demo; pass storage_path=\"provenance.db\"\n",
|
||||
"# (or a config with provenance.storage_path) for a persistent SQLite backend.\n",
|
||||
"prov = ProvenanceManager()\n",
|
||||
"print(\"ProvenanceManager ready (in-memory storage)\")"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "markdown",
|
||||
"metadata": {},
|
||||
"source": [
|
||||
"## Step 1: Track Entities with Audit-Grade Source Details\n",
|
||||
"\n",
|
||||
"Every fact we ingest carries its evidence with it: the **source identifier** (a DOI here), the **location** inside the source (a figure), the **verbatim quote**, and the extractor's **confidence**."
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": null,
|
||||
"metadata": {},
|
||||
"outputs": [],
|
||||
"source": [
|
||||
"# Finding from paper #1\n",
|
||||
"entry_biomass = prov.track_entity(\n",
|
||||
" entity_id=\"claim_biomass_increase\",\n",
|
||||
" source=\"DOI:10.1371/journal.pone.0023601\",\n",
|
||||
" confidence=0.92,\n",
|
||||
" source_location=\"Figure 2\",\n",
|
||||
" source_quote=\"Total fish biomass increased by 463% ...\",\n",
|
||||
")\n",
|
||||
"\n",
|
||||
"# Supporting entity from paper #2\n",
|
||||
"entry_reserve = prov.track_entity(\n",
|
||||
" entity_id=\"marine_reserve_1\",\n",
|
||||
" source=\"DOI:10.1126/science.1088121\",\n",
|
||||
" confidence=0.88,\n",
|
||||
" source_location=\"Table 1\",\n",
|
||||
" source_quote=\"... no-take marine reserve at Cabo Pulmo ...\",\n",
|
||||
")\n",
|
||||
"\n",
|
||||
"print(\"Tracked:\", entry_biomass.entity_id, \"|\", entry_reserve.entity_id)"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "markdown",
|
||||
"metadata": {},
|
||||
"source": [
|
||||
"## Step 2: Track the Relationship Between Facts\n",
|
||||
"\n",
|
||||
"Facts rarely stand alone. The claim about biomass increase is *about* the marine reserve — that relationship is a first-class provenance-tracked object too.\n",
|
||||
"\n",
|
||||
"`track_relationship()` has no dedicated subject/object fields, so by convention we record which two entities it connects inside `metadata`."
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": null,
|
||||
"metadata": {},
|
||||
"outputs": [],
|
||||
"source": [
|
||||
"rel = prov.track_relationship(\n",
|
||||
" relationship_id=\"rel_biomass_about_reserve\",\n",
|
||||
" source=\"DOI:10.1371/journal.pone.0023601\",\n",
|
||||
" metadata={\n",
|
||||
" \"type\": \"measured_at\",\n",
|
||||
" # No dedicated endpoint fields on track_relationship() yet -- record\n",
|
||||
" # which entities this relationship connects here by convention.\n",
|
||||
" \"subject_entity_id\": \"claim_biomass_increase\",\n",
|
||||
" \"object_entity_id\": \"marine_reserve_1\",\n",
|
||||
" },\n",
|
||||
")\n",
|
||||
"\n",
|
||||
"print(\"Relationship tracked:\", rel.entity_id, \"|\", rel.metadata[\"subject_entity_id\"], \"->\", rel.metadata[\"object_entity_id\"])"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "markdown",
|
||||
"metadata": {},
|
||||
"source": [
|
||||
"## Step 3: Walk the Lineage\n",
|
||||
"\n",
|
||||
"`get_lineage` reconstructs everything known about a fact; `trace_lineage` returns the ordered chain of `ProvenanceEntry` records — every version, every activity, every agent that touched it."
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": null,
|
||||
"metadata": {},
|
||||
"outputs": [],
|
||||
"source": [
|
||||
"lineage = prov.get_lineage(\"claim_biomass_increase\")\n",
|
||||
"print(json.dumps(lineage, indent=2, default=str)[:800])\n",
|
||||
"\n",
|
||||
"print(\"\\n--- ordered chain ---\")\n",
|
||||
"for e in prov.trace_lineage(\"claim_biomass_increase\"):\n",
|
||||
" print(f\"{e.entity_id} | seq#{e.sequence_id} | {e.activity_id}\")"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "markdown",
|
||||
"metadata": {},
|
||||
"source": [
|
||||
"## Step 4: Audit Sources and Revision History\n",
|
||||
"\n",
|
||||
"When the regulator asks *\"has this fact ever been corrected?\"*, `revision_history` answers with the full version chain, and `get_all_sources` lists every source document that ever supported the entity."
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": null,
|
||||
"metadata": {},
|
||||
"outputs": [],
|
||||
"source": [
|
||||
"revisions = prov.revision_history(\"claim_biomass_increase\")\n",
|
||||
"print(f\"{len(revisions)} revision(s) on record\")\n",
|
||||
"\n",
|
||||
"for s in prov.get_all_sources(\"claim_biomass_increase\"):\n",
|
||||
" print(\"source:\", s)"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "markdown",
|
||||
"metadata": {},
|
||||
"source": [
|
||||
"## Step 5: Invalidate — Correct Without Deleting\n",
|
||||
"\n",
|
||||
"Suppose paper #1 is retracted in part. An audit trail must **not** silently delete the fact: `invalidate` archives the pre-invalidation state and appends a fresh `prov:Invalidation` entry naming **who** retracted it and **why**."
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": null,
|
||||
"metadata": {},
|
||||
"outputs": [],
|
||||
"source": [
|
||||
"invalidated = prov.invalidate(\n",
|
||||
" entity_id=\"claim_biomass_increase\",\n",
|
||||
" agent_id=\"reviewer_dr_chen\",\n",
|
||||
" reason=\"Partial retraction: Figure 2 statistics corrected by publisher (see erratum).\",\n",
|
||||
")\n",
|
||||
"print(\"Invalidated:\", invalidated.entity_id, \"| invalidated flag:\", getattr(invalidated, \"invalidated\", True))\n",
|
||||
"\n",
|
||||
"stats = prov.get_statistics()\n",
|
||||
"print(\"\\nStorage statistics:\", json.dumps(stats, indent=2, default=str))"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "markdown",
|
||||
"metadata": {},
|
||||
"source": [
|
||||
"## Step 6: Verify Tamper-Evidence\n",
|
||||
"\n",
|
||||
"Each entry carries a deterministic SHA-256 checksum chained to the previous entry. Recompute and compare to detect any after-the-fact corruption of the provenance record."
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": null,
|
||||
"metadata": {},
|
||||
"outputs": [],
|
||||
"source": [
|
||||
"# entry_biomass was returned by track_entity in Step 1\n",
|
||||
"ok = verify_checksum(entry_biomass)\n",
|
||||
"print(\"Checksum verified:\", ok)\n",
|
||||
"\n",
|
||||
"print(\"Computed:\", compute_checksum(entry_biomass)[:16], \"...\")\n",
|
||||
"print(\"Stored: \", entry_biomass.checksum[:16] if getattr(entry_biomass, 'checksum', None) else \"(see entry fields)\")\n",
|
||||
"chain = prov.verify_chain()\n",
|
||||
"print(\"Chain verification:\", json.dumps(chain, default=str)[:200])\n"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "markdown",
|
||||
"metadata": {},
|
||||
"source": [
|
||||
"## Summary\n",
|
||||
"\n",
|
||||
"| Need | Call |\n",
|
||||
"|---|---|\n",
|
||||
"| Record a fact's evidence | `prov.track_entity(entity_id, source, confidence=..., source_location=..., source_quote=...)` |\n",
|
||||
"| Record a relationship | `prov.track_relationship(relationship_id, source, metadata=...)` |\n",
|
||||
"| Full lineage of a fact | `prov.get_lineage(entity_id)` / `prov.trace_lineage(entity_id)` |\n",
|
||||
"| \"Was it ever corrected?\" | `prov.revision_history(entity_id)` |\n",
|
||||
"| \"Which sources support it?\" | `prov.get_all_sources(entity_id)` |\n",
|
||||
"| Retract without deleting | `prov.invalidate(entity_id, agent_id, reason=...)` |\n",
|
||||
"| Tamper check | `verify_checksum(entry)` |\n",
|
||||
"\n",
|
||||
"### Where to go next\n",
|
||||
"\n",
|
||||
"- **Conflict Detection and Resolution** (notebook 17) — what happens when two sources disagree.\n",
|
||||
"- **Your First Knowledge Graph** (notebook 08) — plug `provenance=True` into extractors so tracking happens automatically during ingestion.\n",
|
||||
"- The module docstring (`help(semantica.provenance)`) documents opt-in integration with `kg`, `split` and `conflicts` trackers."
|
||||
]
|
||||
}
|
||||
],
|
||||
"metadata": {
|
||||
"kernelspec": {
|
||||
"display_name": "Python 3",
|
||||
"language": "python",
|
||||
"name": "python3"
|
||||
},
|
||||
"language_info": {
|
||||
"name": "python",
|
||||
"version": "3.11"
|
||||
}
|
||||
},
|
||||
"nbformat": 4,
|
||||
"nbformat_minor": 5
|
||||
}
|
||||
@@ -0,0 +1,383 @@
|
||||
{
|
||||
"cells": [
|
||||
{
|
||||
"cell_type": "markdown",
|
||||
"id": "b76a5997",
|
||||
"metadata": {},
|
||||
"source": [
|
||||
"[](https://colab.research.google.com/github/semantica-agi/semantica/blob/main/cookbook/introduction/23_Reasoning.ipynb)\n",
|
||||
"\n",
|
||||
"# Reasoning Module — Practical Guide\n",
|
||||
"\n",
|
||||
"Semantica's `reasoning` module derives new knowledge from existing facts and knowledge graphs. It ships several strategies behind one facade:\n",
|
||||
"\n",
|
||||
"- **`Reasoner`** — unified facade with forward chaining, backward chaining, and one-shot `infer_facts`\n",
|
||||
"- **`DatalogReasoner`** — semi-naive Datalog fixpoint evaluation with variable queries\n",
|
||||
"- **`ExplanationGenerator`** — human-readable explanations and reasoning paths for inferred conclusions\n",
|
||||
"- Plus lower-level engines: `ReteEngine`, `SPARQLReasoner`, `GraphReasoner`, temporal reasoning\n",
|
||||
"\n",
|
||||
"This notebook walks through the facade, the Datalog engine, and explanations. All APIs are verified against `semantica/reasoning/`."
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": 1,
|
||||
"id": "52073af7",
|
||||
"metadata": {
|
||||
"execution": {
|
||||
"iopub.execute_input": "2026-08-26T18:45:55.427457Z",
|
||||
"iopub.status.busy": "2026-08-26T18:45:55.427247Z",
|
||||
"iopub.status.idle": "2026-08-26T18:45:57.266607Z",
|
||||
"shell.execute_reply": "2026-08-26T18:45:57.264783Z"
|
||||
}
|
||||
},
|
||||
"outputs": [],
|
||||
"source": [
|
||||
"!pip install -q semantica"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "markdown",
|
||||
"id": "06deb916",
|
||||
"metadata": {},
|
||||
"source": [
|
||||
"## 1) Forward chaining with the `Reasoner` facade\n",
|
||||
"\n",
|
||||
"Facts are simple `Predicate(args)` strings. Rules use `IF <conditions> THEN <conclusion>` with `?x`-style variables. `forward_chain()` derives everything possible and returns a list of `InferenceResult` objects."
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": 2,
|
||||
"id": "519ca92d",
|
||||
"metadata": {
|
||||
"execution": {
|
||||
"iopub.execute_input": "2026-08-26T18:45:57.270791Z",
|
||||
"iopub.status.busy": "2026-08-26T18:45:57.270352Z",
|
||||
"iopub.status.idle": "2026-08-26T18:45:59.991941Z",
|
||||
"shell.execute_reply": "2026-08-26T18:45:59.990678Z"
|
||||
}
|
||||
},
|
||||
"outputs": [
|
||||
{
|
||||
"data": {
|
||||
"text/html": [
|
||||
"<div style='font-family: monospace;'><h4>🧠 Semantica - 📊 Current Progress</h4><table style='width: 100%; border-collapse: collapse;'><tr><th>Status</th><th>Action</th><th>Module</th><th>Submodule</th><th>Progress</th><th>ETA</th><th>Rate</th><th>Time</th><th>Extracted</th></tr><tr><td>✅</td><td>Semantica is reasoning</td><td>🤔 reasoning</td><td>Reasoner</td><td>100.0%</td><td>-</td><td>-</td><td>0.00s</td><td>-</td></tr><tr><td>✅</td><td>Semantica is reasoning</td><td>🤔 reasoning</td><td>DatalogReasoner</td><td>100.0%</td><td>-</td><td>-</td><td>0.00s</td><td>-</td></tr><tr><td>✅</td><td>Semantica is reasoning</td><td>🤔 reasoning</td><td>ExplanationGenerator</td><td>100.0%</td><td>-</td><td>-</td><td>0.00s</td><td>-</td></tr></table></div>"
|
||||
],
|
||||
"text/plain": [
|
||||
"<IPython.core.display.HTML object>"
|
||||
]
|
||||
},
|
||||
"metadata": {},
|
||||
"output_type": "display_data"
|
||||
},
|
||||
{
|
||||
"name": "stdout",
|
||||
"output_type": "stream",
|
||||
"text": [
|
||||
"🔄 Semantica is reasoning: Performing forward chaining 🤔 reasoning Reasoner |░░░░░░░░░░░░░░░| 0.0% ETA: - Rate: - Time: 0.00s Extracted: -"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "stdout",
|
||||
"output_type": "stream",
|
||||
"text": [
|
||||
"Inferred 2 new facts\n",
|
||||
" Human(Jane) (rule: Rule 1, confidence: 1.0)\n",
|
||||
" Human(John) (rule: Rule 1, confidence: 1.0)\n"
|
||||
]
|
||||
}
|
||||
],
|
||||
"source": [
|
||||
"from semantica.reasoning import Reasoner\n",
|
||||
"\n",
|
||||
"reasoner = Reasoner()\n",
|
||||
"\n",
|
||||
"reasoner.add_fact(\"Person(John)\")\n",
|
||||
"reasoner.add_fact(\"Person(Jane)\")\n",
|
||||
"reasoner.add_rule(\"IF Person(?x) THEN Human(?x)\")\n",
|
||||
"\n",
|
||||
"results = reasoner.forward_chain()\n",
|
||||
"print(f\"Inferred {len(results)} new facts\")\n",
|
||||
"for res in results:\n",
|
||||
" print(f\" {res.conclusion} (rule: {res.rule_used.name}, confidence: {res.confidence})\")"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "markdown",
|
||||
"id": "c1131c45",
|
||||
"metadata": {},
|
||||
"source": [
|
||||
"## 2) One-shot inference with `infer_facts`\n",
|
||||
"\n",
|
||||
"`infer_facts(facts, rules)` **adds** the given facts and rules to this `Reasoner` instance, runs forward chaining to fixpoint, and returns the derived facts as strings. It does not reset the instance's existing state — create a fresh `Reasoner()` first if you need isolation between runs."
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": 3,
|
||||
"id": "26249990",
|
||||
"metadata": {
|
||||
"execution": {
|
||||
"iopub.execute_input": "2026-08-26T18:45:59.995447Z",
|
||||
"iopub.status.busy": "2026-08-26T18:45:59.995069Z",
|
||||
"iopub.status.idle": "2026-08-26T18:46:00.004107Z",
|
||||
"shell.execute_reply": "2026-08-26T18:46:00.002873Z"
|
||||
}
|
||||
},
|
||||
"outputs": [
|
||||
{
|
||||
"data": {
|
||||
"text/plain": [
|
||||
"['Employee(Jane, Acme)', 'Employee(John, Acme)']"
|
||||
]
|
||||
},
|
||||
"execution_count": 3,
|
||||
"metadata": {},
|
||||
"output_type": "execute_result"
|
||||
}
|
||||
],
|
||||
"source": [
|
||||
"from semantica.reasoning import Reasoner\n",
|
||||
"\n",
|
||||
"derived = Reasoner().infer_facts(\n",
|
||||
" facts=[\"WorksFor(John, Acme)\", \"WorksFor(Jane, Acme)\"],\n",
|
||||
" rules=[\"IF WorksFor(?x, ?y) THEN Employee(?x, ?y)\"],\n",
|
||||
")\n",
|
||||
"derived"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "markdown",
|
||||
"id": "d5504a38",
|
||||
"metadata": {},
|
||||
"source": [
|
||||
"## 3) Backward chaining: proving a goal\n",
|
||||
"\n",
|
||||
"`backward_chain(goal)` works backwards from a conclusion through the rules. It returns the `InferenceResult` that proves the goal, or `None`."
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": 4,
|
||||
"id": "c4ef85dd",
|
||||
"metadata": {
|
||||
"execution": {
|
||||
"iopub.execute_input": "2026-08-26T18:46:00.007740Z",
|
||||
"iopub.status.busy": "2026-08-26T18:46:00.007346Z",
|
||||
"iopub.status.idle": "2026-08-26T18:46:00.015561Z",
|
||||
"shell.execute_reply": "2026-08-26T18:46:00.014145Z"
|
||||
}
|
||||
},
|
||||
"outputs": [
|
||||
{
|
||||
"name": "stdout",
|
||||
"output_type": "stream",
|
||||
"text": [
|
||||
"Human(John)\n",
|
||||
"premises: ['Person(John)']\n"
|
||||
]
|
||||
}
|
||||
],
|
||||
"source": [
|
||||
"from semantica.reasoning import Reasoner\n",
|
||||
"\n",
|
||||
"reasoner = Reasoner()\n",
|
||||
"reasoner.add_fact(\"Person(John)\")\n",
|
||||
"reasoner.add_rule(\"IF Person(?x) THEN Human(?x)\")\n",
|
||||
"\n",
|
||||
"proof = reasoner.backward_chain(\"Human(John)\")\n",
|
||||
"print(proof.conclusion if proof else \"not provable\")\n",
|
||||
"print(\"premises:\", proof.premises if proof else None)"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "markdown",
|
||||
"id": "b245581d",
|
||||
"metadata": {},
|
||||
"source": [
|
||||
"## 4) Re-run safety\n",
|
||||
"\n",
|
||||
"`add_rule` deduplicates rules with identical conditions and conclusion, so re-executing a setup cell (the common Jupyter re-run) does not duplicate rules — see issue #732."
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": 5,
|
||||
"id": "fb2aeb39",
|
||||
"metadata": {
|
||||
"execution": {
|
||||
"iopub.execute_input": "2026-08-26T18:46:00.019091Z",
|
||||
"iopub.status.busy": "2026-08-26T18:46:00.018881Z",
|
||||
"iopub.status.idle": "2026-08-26T18:46:00.024042Z",
|
||||
"shell.execute_reply": "2026-08-26T18:46:00.022836Z"
|
||||
}
|
||||
},
|
||||
"outputs": [
|
||||
{
|
||||
"name": "stderr",
|
||||
"output_type": "stream",
|
||||
"text": [
|
||||
"Skipping duplicate rule (same conditions/conclusion as 'rule_1'): IF Person(?x) THEN Human(?x)\n"
|
||||
]
|
||||
},
|
||||
{
|
||||
"data": {
|
||||
"text/plain": [
|
||||
"1"
|
||||
]
|
||||
},
|
||||
"execution_count": 5,
|
||||
"metadata": {},
|
||||
"output_type": "execute_result"
|
||||
}
|
||||
],
|
||||
"source": [
|
||||
"from semantica.reasoning import Reasoner\n",
|
||||
"\n",
|
||||
"reasoner = Reasoner()\n",
|
||||
"reasoner.add_fact(\"Person(John)\")\n",
|
||||
"\n",
|
||||
"# Simulate a Jupyter cell re-run: add the same rule twice\n",
|
||||
"r1 = reasoner.add_rule(\"IF Person(?x) THEN Human(?x)\")\n",
|
||||
"r2 = reasoner.add_rule(\"IF Person(?x) THEN Human(?x)\")\n",
|
||||
"\n",
|
||||
"len(reasoner.rules)"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "markdown",
|
||||
"id": "ba2e5c4a",
|
||||
"metadata": {},
|
||||
"source": [
|
||||
"## 5) Datalog reasoning\n",
|
||||
"\n",
|
||||
"`DatalogReasoner` uses classic Datalog syntax (`head :- body.`) and semi-naive fixpoint evaluation. Queries return variable bindings as a list of dicts — use uppercase variables to ask *which* facts hold."
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": 6,
|
||||
"id": "9ec5c0c4",
|
||||
"metadata": {
|
||||
"execution": {
|
||||
"iopub.execute_input": "2026-08-26T18:46:00.026769Z",
|
||||
"iopub.status.busy": "2026-08-26T18:46:00.026588Z",
|
||||
"iopub.status.idle": "2026-08-26T18:46:00.034963Z",
|
||||
"shell.execute_reply": "2026-08-26T18:46:00.032672Z"
|
||||
}
|
||||
},
|
||||
"outputs": [
|
||||
{
|
||||
"data": {
|
||||
"text/plain": [
|
||||
"[{'X': 'tom', 'Z': 'ann'}]"
|
||||
]
|
||||
},
|
||||
"execution_count": 6,
|
||||
"metadata": {},
|
||||
"output_type": "execute_result"
|
||||
}
|
||||
],
|
||||
"source": [
|
||||
"from semantica.reasoning import DatalogReasoner\n",
|
||||
"\n",
|
||||
"datalog = DatalogReasoner()\n",
|
||||
"datalog.add_fact(\"parent(tom, mary)\")\n",
|
||||
"datalog.add_fact(\"parent(mary, ann)\")\n",
|
||||
"datalog.add_rule(\"grandparent(X, Z) :- parent(X, Y), parent(Y, Z)\")\n",
|
||||
"\n",
|
||||
"datalog.derive_all()\n",
|
||||
"datalog.query(\"grandparent(X, Z)\")"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "markdown",
|
||||
"id": "d4f0689b",
|
||||
"metadata": {},
|
||||
"source": [
|
||||
"## 6) Explanations for inferred conclusions\n",
|
||||
"\n",
|
||||
"`ExplanationGenerator` turns `InferenceResult` objects into structured `Explanation` and `ReasoningPath` records, so agents can show *why* they believe a derived fact."
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": 7,
|
||||
"id": "19dcd3a7",
|
||||
"metadata": {
|
||||
"execution": {
|
||||
"iopub.execute_input": "2026-08-26T18:46:00.038649Z",
|
||||
"iopub.status.busy": "2026-08-26T18:46:00.038396Z",
|
||||
"iopub.status.idle": "2026-08-26T18:46:00.059188Z",
|
||||
"shell.execute_reply": "2026-08-26T18:46:00.057805Z"
|
||||
}
|
||||
},
|
||||
"outputs": [
|
||||
{
|
||||
"data": {
|
||||
"text/plain": [
|
||||
"('Explanation', 'ReasoningPath')"
|
||||
]
|
||||
},
|
||||
"execution_count": 7,
|
||||
"metadata": {},
|
||||
"output_type": "execute_result"
|
||||
}
|
||||
],
|
||||
"source": [
|
||||
"from semantica.reasoning import Reasoner, ExplanationGenerator\n",
|
||||
"\n",
|
||||
"reasoner = Reasoner()\n",
|
||||
"reasoner.add_fact(\"Person(John)\")\n",
|
||||
"reasoner.add_rule(\"IF Person(?x) THEN Human(?x)\")\n",
|
||||
"results = reasoner.forward_chain()\n",
|
||||
"\n",
|
||||
"gen = ExplanationGenerator()\n",
|
||||
"explanation = gen.generate_explanation(results[0])\n",
|
||||
"path = gen.show_reasoning_path(results[0])\n",
|
||||
"\n",
|
||||
"type(explanation).__name__, type(path).__name__"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "markdown",
|
||||
"id": "fb882ee4",
|
||||
"metadata": {},
|
||||
"source": [
|
||||
"## Summary\n",
|
||||
"\n",
|
||||
"| Task | API |\n",
|
||||
"|---|---|\n",
|
||||
"| Derive all new facts | `Reasoner.forward_chain()` |\n",
|
||||
"| One-shot inference | `Reasoner.infer_facts(facts, rules)` |\n",
|
||||
"| Prove a goal | `Reasoner.backward_chain(goal)` |\n",
|
||||
"| Datalog fixpoint | `DatalogReasoner.derive_all()` + `query(\"p(X, Y)\")` |\n",
|
||||
"| Explain a conclusion | `ExplanationGenerator.generate_explanation(result)` |\n",
|
||||
"\n",
|
||||
"See also `semantica/reasoning/reasoning_usage.md` and the module docstrings for `ReteEngine`, `SPARQLReasoner`, and temporal reasoning."
|
||||
]
|
||||
}
|
||||
],
|
||||
"metadata": {
|
||||
"kernelspec": {
|
||||
"display_name": "Python 3",
|
||||
"language": "python",
|
||||
"name": "python3"
|
||||
},
|
||||
"language_info": {
|
||||
"codemirror_mode": {
|
||||
"name": "ipython",
|
||||
"version": 3
|
||||
},
|
||||
"file_extension": ".py",
|
||||
"mimetype": "text/x-python",
|
||||
"name": "python",
|
||||
"nbconvert_exporter": "python",
|
||||
"pygments_lexer": "ipython3",
|
||||
"version": "3.13.12"
|
||||
}
|
||||
},
|
||||
"nbformat": 4,
|
||||
"nbformat_minor": 5
|
||||
}
|
||||
@@ -0,0 +1,299 @@
|
||||
{
|
||||
"cells": [
|
||||
{
|
||||
"cell_type": "markdown",
|
||||
"id": "8d7096ea",
|
||||
"metadata": {},
|
||||
"source": [
|
||||
"[](https://colab.research.google.com/github/semantica-agi/semantica/blob/main/cookbook/introduction/24_Change_Management.ipynb)\n",
|
||||
"\n",
|
||||
"# Change Management — Practical Guide\n",
|
||||
"\n",
|
||||
"Semantica's `change_management` module provides versioning, audit trails, and data-integrity checks for knowledge graphs and ontologies:\n",
|
||||
"\n",
|
||||
"- **`ChangeLogEntry`** — standardized change metadata (validated timestamp/author)\n",
|
||||
"- **`InMemoryVersionStorage` / `SQLiteVersionStorage`** — version snapshot storage with named tags\n",
|
||||
"- **`compute_checksum` / `verify_checksum`** — SHA-256 integrity verification\n",
|
||||
"\n",
|
||||
"This notebook runs a complete save → tag → verify → tamper-detect cycle. All outputs are real executed results verified against the repository's `semantica/change_management/` source at the time of writing (the `pip install` cell may fetch a newer release with slightly different behavior)."
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": 1,
|
||||
"id": "7bdffec1",
|
||||
"metadata": {
|
||||
"execution": {
|
||||
"iopub.execute_input": "2026-08-26T18:46:37.171333Z",
|
||||
"iopub.status.busy": "2026-08-26T18:46:37.171183Z",
|
||||
"iopub.status.idle": "2026-08-26T18:46:39.060860Z",
|
||||
"shell.execute_reply": "2026-08-26T18:46:39.059594Z"
|
||||
}
|
||||
},
|
||||
"outputs": [],
|
||||
"source": [
|
||||
"!pip install -q semantica"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "markdown",
|
||||
"id": "169efee1",
|
||||
"metadata": {},
|
||||
"source": [
|
||||
"## 1) A `ChangeLogEntry` records *who* changed *what*, *when*\n",
|
||||
"\n",
|
||||
"`author` must be a valid email — the dataclass validates on construction (`ValidationError` otherwise), which keeps audit trails clean."
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": 2,
|
||||
"id": "5b17acdb",
|
||||
"metadata": {
|
||||
"execution": {
|
||||
"iopub.execute_input": "2026-08-26T18:46:39.064077Z",
|
||||
"iopub.status.busy": "2026-08-26T18:46:39.063818Z",
|
||||
"iopub.status.idle": "2026-08-26T18:46:39.321881Z",
|
||||
"shell.execute_reply": "2026-08-26T18:46:39.321036Z"
|
||||
}
|
||||
},
|
||||
"outputs": [
|
||||
{
|
||||
"data": {
|
||||
"text/plain": [
|
||||
"ChangeLogEntry(timestamp='2026-08-15T09:00:00Z', author='demo@example.com', description='initial version', change_id=None, related_changes=[])"
|
||||
]
|
||||
},
|
||||
"execution_count": 2,
|
||||
"metadata": {},
|
||||
"output_type": "execute_result"
|
||||
}
|
||||
],
|
||||
"source": [
|
||||
"from semantica.change_management import ChangeLogEntry\n",
|
||||
"\n",
|
||||
"entry = ChangeLogEntry(\n",
|
||||
" timestamp=\"2026-08-15T09:00:00Z\",\n",
|
||||
" author=\"demo@example.com\",\n",
|
||||
" description=\"initial version\",\n",
|
||||
")\n",
|
||||
"entry"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "markdown",
|
||||
"id": "53d8df5c",
|
||||
"metadata": {},
|
||||
"source": [
|
||||
"## 2) Save a versioned snapshot\n",
|
||||
"\n",
|
||||
"A snapshot is a dict with a required `label` plus your payload. Here we attach the KG data, the change log, and a SHA-256 `checksum` computed over everything except the checksum field itself."
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": 3,
|
||||
"id": "fec16f24",
|
||||
"metadata": {
|
||||
"execution": {
|
||||
"iopub.execute_input": "2026-08-26T18:46:39.325528Z",
|
||||
"iopub.status.busy": "2026-08-26T18:46:39.325140Z",
|
||||
"iopub.status.idle": "2026-08-26T18:46:39.331480Z",
|
||||
"shell.execute_reply": "2026-08-26T18:46:39.330586Z"
|
||||
}
|
||||
},
|
||||
"outputs": [
|
||||
{
|
||||
"data": {
|
||||
"text/plain": [
|
||||
"True"
|
||||
]
|
||||
},
|
||||
"execution_count": 3,
|
||||
"metadata": {},
|
||||
"output_type": "execute_result"
|
||||
}
|
||||
],
|
||||
"source": [
|
||||
"from semantica.change_management import InMemoryVersionStorage, compute_checksum\n",
|
||||
"\n",
|
||||
"storage = InMemoryVersionStorage()\n",
|
||||
"\n",
|
||||
"snapshot = {\n",
|
||||
" \"label\": \"v1.0.0\",\n",
|
||||
" \"data\": {\"entities\": {\"acme\": {\"type\": \"Company\"}}},\n",
|
||||
" \"change_log\": {\n",
|
||||
" \"timestamp\": entry.timestamp,\n",
|
||||
" \"author\": entry.author,\n",
|
||||
" \"description\": entry.description,\n",
|
||||
" },\n",
|
||||
"}\n",
|
||||
"snapshot[\"checksum\"] = compute_checksum({k: v for k, v in snapshot.items() if k != \"checksum\"})\n",
|
||||
"\n",
|
||||
"storage.save(snapshot)\n",
|
||||
"storage.exists(\"v1.0.0\")"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "markdown",
|
||||
"id": "0f1c603b",
|
||||
"metadata": {},
|
||||
"source": [
|
||||
"## 3) Named tags pin a version for releases\n",
|
||||
"\n",
|
||||
"`save_tag` / `get_tag` map stable names (e.g. `release`) to version labels, decoupling consumers from label churn."
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": 4,
|
||||
"id": "62f7643e",
|
||||
"metadata": {
|
||||
"execution": {
|
||||
"iopub.execute_input": "2026-08-26T18:46:39.335182Z",
|
||||
"iopub.status.busy": "2026-08-26T18:46:39.334886Z",
|
||||
"iopub.status.idle": "2026-08-26T18:46:39.339586Z",
|
||||
"shell.execute_reply": "2026-08-26T18:46:39.338568Z"
|
||||
}
|
||||
},
|
||||
"outputs": [
|
||||
{
|
||||
"data": {
|
||||
"text/plain": [
|
||||
"('v1.0.0', ['v1.0.0'])"
|
||||
]
|
||||
},
|
||||
"execution_count": 4,
|
||||
"metadata": {},
|
||||
"output_type": "execute_result"
|
||||
}
|
||||
],
|
||||
"source": [
|
||||
"storage.save_tag(\"release\", \"v1.0.0\")\n",
|
||||
"\n",
|
||||
"storage.get_tag(\"release\"), [s[\"label\"] for s in storage.list_all()]"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "markdown",
|
||||
"id": "96df12da",
|
||||
"metadata": {},
|
||||
"source": [
|
||||
"## 4) Verify integrity — and catch tampering\n",
|
||||
"\n",
|
||||
"`verify_checksum(snapshot)` recomputes the SHA-256 over the snapshot (minus its `checksum` field) and compares. A single mutated character in the data flips the result to `False`."
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": 5,
|
||||
"id": "26d0de85",
|
||||
"metadata": {
|
||||
"execution": {
|
||||
"iopub.execute_input": "2026-08-26T18:46:39.342653Z",
|
||||
"iopub.status.busy": "2026-08-26T18:46:39.342466Z",
|
||||
"iopub.status.idle": "2026-08-26T18:46:39.346714Z",
|
||||
"shell.execute_reply": "2026-08-26T18:46:39.345623Z"
|
||||
}
|
||||
},
|
||||
"outputs": [
|
||||
{
|
||||
"name": "stdout",
|
||||
"output_type": "stream",
|
||||
"text": [
|
||||
"intact: True\n",
|
||||
"tampered: False\n"
|
||||
]
|
||||
}
|
||||
],
|
||||
"source": [
|
||||
"from semantica.change_management import verify_checksum\n",
|
||||
"\n",
|
||||
"stored = storage.get(\"v1.0.0\")\n",
|
||||
"print(\"intact:\", verify_checksum(stored))\n",
|
||||
"\n",
|
||||
"tampered = storage.get(\"v1.0.0\")\n",
|
||||
"tampered[\"data\"][\"entities\"][\"acme\"][\"note\"] = \"mutated after the fact\"\n",
|
||||
"print(\"tampered:\", verify_checksum(tampered))"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "markdown",
|
||||
"id": "bd14c3e4",
|
||||
"metadata": {},
|
||||
"source": [
|
||||
"## 5) Retiring a version\n",
|
||||
"\n",
|
||||
"`delete(label)` removes a snapshot; tags pointing at it are your responsibility to update."
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": 6,
|
||||
"id": "de9fe3e5",
|
||||
"metadata": {
|
||||
"execution": {
|
||||
"iopub.execute_input": "2026-08-26T18:46:39.349814Z",
|
||||
"iopub.status.busy": "2026-08-26T18:46:39.349513Z",
|
||||
"iopub.status.idle": "2026-08-26T18:46:39.354710Z",
|
||||
"shell.execute_reply": "2026-08-26T18:46:39.353669Z"
|
||||
}
|
||||
},
|
||||
"outputs": [
|
||||
{
|
||||
"data": {
|
||||
"text/plain": [
|
||||
"False"
|
||||
]
|
||||
},
|
||||
"execution_count": 6,
|
||||
"metadata": {},
|
||||
"output_type": "execute_result"
|
||||
}
|
||||
],
|
||||
"source": [
|
||||
"storage.delete(\"v1.0.0\")\n",
|
||||
"storage.exists(\"v1.0.0\")"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "markdown",
|
||||
"id": "ab667b32",
|
||||
"metadata": {},
|
||||
"source": [
|
||||
"## Summary\n",
|
||||
"\n",
|
||||
"| Task | API |\n",
|
||||
"|---|---|\n",
|
||||
"| Record audit metadata | `ChangeLogEntry(timestamp, author=email, description)` |\n",
|
||||
"| Persist a version | `InMemoryVersionStorage().save({\"label\": ..., ...})` |\n",
|
||||
"| Pin a release name | `save_tag(\"release\", \"v1.0.0\")` / `get_tag(\"release\")` |\n",
|
||||
"| Integrity check | `compute_checksum(snap)` / `verify_checksum(snap)` |\n",
|
||||
"| Persistent backend | `SQLiteVersionStorage(path)` — same interface |\n",
|
||||
"\n",
|
||||
"See also `semantica/change_management/change_management_usage.md` for the manager classes (`TemporalVersionManager`, `OntologyVersionManager`)."
|
||||
]
|
||||
}
|
||||
],
|
||||
"metadata": {
|
||||
"kernelspec": {
|
||||
"display_name": "Python 3",
|
||||
"language": "python",
|
||||
"name": "python3"
|
||||
},
|
||||
"language_info": {
|
||||
"codemirror_mode": {
|
||||
"name": "ipython",
|
||||
"version": 3
|
||||
},
|
||||
"file_extension": ".py",
|
||||
"mimetype": "text/x-python",
|
||||
"name": "python",
|
||||
"nbconvert_exporter": "python",
|
||||
"pygments_lexer": "ipython3",
|
||||
"version": "3.13.12"
|
||||
}
|
||||
},
|
||||
"nbformat": 4,
|
||||
"nbformat_minor": 5
|
||||
}
|
||||
@@ -0,0 +1,314 @@
|
||||
{
|
||||
"cells": [
|
||||
{
|
||||
"cell_type": "markdown",
|
||||
"id": "6eb4dfba",
|
||||
"metadata": {},
|
||||
"source": [
|
||||
"[](https://colab.research.google.com/github/semantica-agi/semantica/blob/main/cookbook/introduction/25_Seed_Data.ipynb)\n",
|
||||
"\n",
|
||||
"# Seed Data — Practical Guide\n",
|
||||
"\n",
|
||||
"The `seed` module bootstraps a knowledge graph from **trusted, pre-known data** (CSV/JSON/database/API sources) before any extraction runs. This gives extraction a foundation to link against instead of starting from an empty graph.\n",
|
||||
"\n",
|
||||
"Key pieces:\n",
|
||||
"\n",
|
||||
"- **`SeedDataManager`** — registers data sources and builds foundation graphs\n",
|
||||
"- **`create_foundation_graph()`** — turns registered sources into `entities` + `relationships` + `metadata`\n",
|
||||
"- **`validate_quality()`** — checks a foundation graph before you commit it\n",
|
||||
"\n",
|
||||
"All examples below were executed against `semantica/seed/seed_manager.py`."
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": 1,
|
||||
"id": "32f80cc6",
|
||||
"metadata": {
|
||||
"execution": {
|
||||
"iopub.execute_input": "2026-08-26T18:51:18.716466Z",
|
||||
"iopub.status.busy": "2026-08-26T18:51:18.716264Z",
|
||||
"iopub.status.idle": "2026-08-26T18:51:20.533828Z",
|
||||
"shell.execute_reply": "2026-08-26T18:51:20.531402Z"
|
||||
}
|
||||
},
|
||||
"outputs": [],
|
||||
"source": [
|
||||
"!pip install -q semantica"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "markdown",
|
||||
"id": "75136e5f",
|
||||
"metadata": {},
|
||||
"source": [
|
||||
"## 1) Prepare a seed CSV and register the source\n",
|
||||
"\n",
|
||||
"`register_source(name, format, location, entity_type=...)` records where trusted data lives. `verified=True` (the default) marks the source as pre-validated."
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": 2,
|
||||
"id": "a8089e1f",
|
||||
"metadata": {
|
||||
"execution": {
|
||||
"iopub.execute_input": "2026-08-26T18:51:20.538772Z",
|
||||
"iopub.status.busy": "2026-08-26T18:51:20.538323Z",
|
||||
"iopub.status.idle": "2026-08-26T18:51:20.675403Z",
|
||||
"shell.execute_reply": "2026-08-26T18:51:20.674060Z"
|
||||
}
|
||||
},
|
||||
"outputs": [
|
||||
{
|
||||
"data": {
|
||||
"text/plain": [
|
||||
"True"
|
||||
]
|
||||
},
|
||||
"execution_count": 2,
|
||||
"metadata": {},
|
||||
"output_type": "execute_result"
|
||||
}
|
||||
],
|
||||
"source": [
|
||||
"import csv\n",
|
||||
"import tempfile\n",
|
||||
"from pathlib import Path\n",
|
||||
"from semantica.seed import SeedDataManager\n",
|
||||
"\n",
|
||||
"# Write the sample CSV into a session-scoped temp directory so we never\n",
|
||||
"# clobber a companies.csv that might exist in the user's working directory.\n",
|
||||
"seed_csv = Path(tempfile.mkdtemp(prefix=\"semantica-seed-\")) / \"companies.csv\"\n",
|
||||
"with open(seed_csv, \"w\", newline=\"\") as f:\n",
|
||||
" writer = csv.DictWriter(f, fieldnames=[\"id\", \"name\", \"type\", \"industry\"])\n",
|
||||
" writer.writeheader()\n",
|
||||
" writer.writerow({\"id\": \"c1\", \"name\": \"Acme\", \"type\": \"Company\", \"industry\": \"robotics\"})\n",
|
||||
" writer.writerow({\"id\": \"c2\", \"name\": \"Globex\", \"type\": \"Company\", \"industry\": \"energy\"})\n",
|
||||
"\n",
|
||||
"manager = SeedDataManager()\n",
|
||||
"manager.register_source(\"companies\", format=\"csv\", location=str(seed_csv), entity_type=\"Company\")\n"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "markdown",
|
||||
"id": "e87221ba",
|
||||
"metadata": {},
|
||||
"source": [
|
||||
"## 2) Load records from a registered source\n",
|
||||
"\n",
|
||||
"`load_source(name)` reads the source and enriches each record with `entity_type` and `source` provenance keys."
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": 3,
|
||||
"id": "f932e550",
|
||||
"metadata": {
|
||||
"execution": {
|
||||
"iopub.execute_input": "2026-08-26T18:51:20.679424Z",
|
||||
"iopub.status.busy": "2026-08-26T18:51:20.679156Z",
|
||||
"iopub.status.idle": "2026-08-26T18:51:20.690659Z",
|
||||
"shell.execute_reply": "2026-08-26T18:51:20.688812Z"
|
||||
}
|
||||
},
|
||||
"outputs": [
|
||||
{
|
||||
"data": {
|
||||
"text/html": [
|
||||
"<div style='font-family: monospace;'><h4>🧠 Semantica - 📊 Current Progress</h4><table style='width: 100%; border-collapse: collapse;'><tr><th>Status</th><th>Action</th><th>Module</th><th>Submodule</th><th>Progress</th><th>ETA</th><th>Rate</th><th>Time</th><th>Extracted</th></tr><tr><td>✅</td><td>Semantica is seeding</td><td>🌱 seed</td><td>SeedDataManager</td><td>100.0%</td><td>-</td><td>-</td><td>0.00s</td><td>-</td></tr></table></div>"
|
||||
],
|
||||
"text/plain": [
|
||||
"<IPython.core.display.HTML object>"
|
||||
]
|
||||
},
|
||||
"metadata": {},
|
||||
"output_type": "display_data"
|
||||
},
|
||||
{
|
||||
"name": "stdout",
|
||||
"output_type": "stream",
|
||||
"text": [
|
||||
"🔄 Semantica is seeding: Loading seed data from CSV: /var/folders/7s/bvvstgs10y963tz6_4bbnklr0000gn/T/semantica-seed-eu9__ep1/companies.csv 🌱 seed SeedDataManager |░░░░░░░░░░░░░░░| 0.0% ETA: - Rate: - Time: 0.00s Extracted: -"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "stdout",
|
||||
"output_type": "stream",
|
||||
"text": [
|
||||
"loaded 2 records\n"
|
||||
]
|
||||
},
|
||||
{
|
||||
"data": {
|
||||
"text/plain": [
|
||||
"{'id': 'c1',\n",
|
||||
" 'name': 'Acme',\n",
|
||||
" 'type': 'Company',\n",
|
||||
" 'industry': 'robotics',\n",
|
||||
" 'entity_type': 'Company',\n",
|
||||
" 'source': 'companies'}"
|
||||
]
|
||||
},
|
||||
"execution_count": 3,
|
||||
"metadata": {},
|
||||
"output_type": "execute_result"
|
||||
}
|
||||
],
|
||||
"source": [
|
||||
"records = manager.load_source(\"companies\")\n",
|
||||
"print(f\"loaded {len(records)} records\")\n",
|
||||
"records[0]"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "markdown",
|
||||
"id": "f2ebce64",
|
||||
"metadata": {},
|
||||
"source": [
|
||||
"## 3) Build the foundation graph\n",
|
||||
"\n",
|
||||
"`create_foundation_graph()` converts every registered source into graph-ready entities and relationships. Entities carry `confidence: 1.0` — seed data is trusted by definition."
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": 4,
|
||||
"id": "09388c31",
|
||||
"metadata": {
|
||||
"execution": {
|
||||
"iopub.execute_input": "2026-08-26T18:51:20.695259Z",
|
||||
"iopub.status.busy": "2026-08-26T18:51:20.694928Z",
|
||||
"iopub.status.idle": "2026-08-26T18:51:20.708595Z",
|
||||
"shell.execute_reply": "2026-08-26T18:51:20.707072Z"
|
||||
}
|
||||
},
|
||||
"outputs": [
|
||||
{
|
||||
"data": {
|
||||
"text/plain": [
|
||||
"['entities', 'metadata', 'relationships']"
|
||||
]
|
||||
},
|
||||
"execution_count": 4,
|
||||
"metadata": {},
|
||||
"output_type": "execute_result"
|
||||
}
|
||||
],
|
||||
"source": [
|
||||
"foundation = manager.create_foundation_graph()\n",
|
||||
"sorted(foundation.keys())"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": 5,
|
||||
"id": "4610a59f",
|
||||
"metadata": {
|
||||
"execution": {
|
||||
"iopub.execute_input": "2026-08-26T18:51:20.713136Z",
|
||||
"iopub.status.busy": "2026-08-26T18:51:20.712795Z",
|
||||
"iopub.status.idle": "2026-08-26T18:51:20.718637Z",
|
||||
"shell.execute_reply": "2026-08-26T18:51:20.716835Z"
|
||||
}
|
||||
},
|
||||
"outputs": [
|
||||
{
|
||||
"data": {
|
||||
"text/plain": [
|
||||
"{'id': 'c1',\n",
|
||||
" 'text': 'Acme',\n",
|
||||
" 'type': 'Company',\n",
|
||||
" 'confidence': 1.0,\n",
|
||||
" 'metadata': {'industry': 'robotics', 'source': 'companies'}}"
|
||||
]
|
||||
},
|
||||
"execution_count": 5,
|
||||
"metadata": {},
|
||||
"output_type": "execute_result"
|
||||
}
|
||||
],
|
||||
"source": [
|
||||
"foundation[\"entities\"][0]"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "markdown",
|
||||
"id": "f3a52dc7",
|
||||
"metadata": {},
|
||||
"source": [
|
||||
"## 4) Validate quality before committing\n",
|
||||
"\n",
|
||||
"`validate_quality(foundation_graph)` returns `valid`, `errors`, `warnings`, and `metrics` so you can gate bad seed data before it pollutes the graph."
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": 6,
|
||||
"id": "4eb7e664",
|
||||
"metadata": {
|
||||
"execution": {
|
||||
"iopub.execute_input": "2026-08-26T18:51:20.722674Z",
|
||||
"iopub.status.busy": "2026-08-26T18:51:20.722118Z",
|
||||
"iopub.status.idle": "2026-08-26T18:51:20.732003Z",
|
||||
"shell.execute_reply": "2026-08-26T18:51:20.730170Z"
|
||||
}
|
||||
},
|
||||
"outputs": [
|
||||
{
|
||||
"data": {
|
||||
"text/plain": [
|
||||
"True"
|
||||
]
|
||||
},
|
||||
"execution_count": 6,
|
||||
"metadata": {},
|
||||
"output_type": "execute_result"
|
||||
}
|
||||
],
|
||||
"source": [
|
||||
"quality = manager.validate_quality(foundation)\n",
|
||||
"quality[\"valid\"]"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "markdown",
|
||||
"id": "b534be89",
|
||||
"metadata": {},
|
||||
"source": [
|
||||
"## Summary\n",
|
||||
"\n",
|
||||
"| Task | API |\n",
|
||||
"|---|---|\n",
|
||||
"| Register a trusted source | `register_source(name, format, location, entity_type=...)` |\n",
|
||||
"| Load records | `load_source(name)` — adds `entity_type` / `source` keys |\n",
|
||||
"| Direct file load | `load_from_csv(path)` / `load_from_json(path)` |\n",
|
||||
"| Build the graph | `create_foundation_graph()` → `entities` / `relationships` / `metadata` |\n",
|
||||
"| Gate bad data | `validate_quality(graph)` → `valid` / `errors` / `warnings` / `metrics` |\n",
|
||||
"\n",
|
||||
"See also `semantica/seed/seed_usage.md` for `load_from_database`, `load_from_api`, and `integrate_with_extracted`."
|
||||
]
|
||||
}
|
||||
],
|
||||
"metadata": {
|
||||
"kernelspec": {
|
||||
"display_name": "Python 3",
|
||||
"language": "python",
|
||||
"name": "python3"
|
||||
},
|
||||
"language_info": {
|
||||
"codemirror_mode": {
|
||||
"name": "ipython",
|
||||
"version": 3
|
||||
},
|
||||
"file_extension": ".py",
|
||||
"mimetype": "text/x-python",
|
||||
"name": "python",
|
||||
"nbconvert_exporter": "python",
|
||||
"pygments_lexer": "ipython3",
|
||||
"version": "3.13.12"
|
||||
}
|
||||
},
|
||||
"nbformat": 4,
|
||||
"nbformat_minor": 5
|
||||
}
|
||||
@@ -35,6 +35,7 @@ Essential guides to master the Semantica framework.
|
||||
- **[Vector Store](https://github.com/semantica-agi/semantica/blob/main/cookbook/introduction/13_Vector_Store.ipynb)** — Setting up vector stores for similarity search and retrieval. *Intermediate*
|
||||
- **[Graph Store](https://github.com/semantica-agi/semantica/blob/main/cookbook/introduction/09_Graph_Store.ipynb)** — Persisting knowledge graphs in Neo4j or FalkorDB. Topics: Neo4j, Cypher, Persistence · *Intermediate*
|
||||
- **[Ontology](https://github.com/semantica-agi/semantica/blob/main/cookbook/introduction/14_Ontology.ipynb)** — Defining domain schemas and ontologies to structure your data. Topics: OWL, RDF, Schema Design · *Intermediate*
|
||||
- **[Seed Data](https://github.com/semantica-agi/semantica/blob/main/cookbook/introduction/25_Seed_Data.ipynb)** — Bootstrapping a knowledge graph from trusted CSV, JSON, database, and API sources before extraction runs. Topics: SeedDataManager, Foundation Graphs · *Intermediate*
|
||||
|
||||
|
||||
## Advanced Concepts
|
||||
@@ -50,6 +51,9 @@ Deep dive into advanced features, customization, and complex workflows.
|
||||
- **[Multi-Source Integration](https://github.com/semantica-agi/semantica/blob/main/cookbook/advanced/06_Multi_Source_Data_Integration.ipynb)** — Merging data from disparate sources into a unified graph. Topics: Entity Resolution, Merging, Fusion · *Advanced*
|
||||
- **[Reasoning and Inference](https://github.com/semantica-agi/semantica/blob/main/cookbook/advanced/08_Reasoning_and_Inference.ipynb)** — Using logical reasoning to infer new knowledge from existing facts. Topics: Logic Rules, Inference Engines · *Advanced*
|
||||
- **[Temporal Knowledge Graphs](https://github.com/semantica-agi/semantica/blob/main/cookbook/advanced/10_Temporal_Knowledge_Graphs.ipynb)** — Modeling and querying data that changes over time. Topics: Time Series, Temporal Logic, Allen Algebra · *Advanced*
|
||||
- **[Provenance Tracking](https://github.com/semantica-agi/semantica/blob/main/cookbook/introduction/22_Provenance_Tracking.ipynb)** — Audit-grade, W3C PROV-O-aligned tracking of where every entity, relationship, and chunk came from. Topics: PROV-O, Lineage, Checksums, Invalidation · *Advanced*
|
||||
- **[Reasoning Module](https://github.com/semantica-agi/semantica/blob/main/cookbook/introduction/23_Reasoning.ipynb)** — Deriving new knowledge from existing facts with forward chaining, backward chaining, and Datalog strategies. Topics: Reasoner, Datalog, Explanations · *Advanced*
|
||||
- **[Change Management](https://github.com/semantica-agi/semantica/blob/main/cookbook/introduction/24_Change_Management.ipynb)** — Versioning, audit trails, and data-integrity checks for knowledge graphs and ontologies. Topics: ChangeLogEntry, Version Storage, Data Integrity · *Advanced*
|
||||
|
||||
|
||||
## How to Run
|
||||
|
||||
+3
-1
@@ -103,9 +103,11 @@
|
||||
"pages": [
|
||||
"integrations/agno",
|
||||
"integrations/crewai",
|
||||
"integrations/langchain",
|
||||
"integrations/docling",
|
||||
"integrations/snowflake",
|
||||
"integrations/databricks"
|
||||
"integrations/databricks",
|
||||
"integrations/salesforce"
|
||||
]
|
||||
},
|
||||
{
|
||||
|
||||
+1
-1
@@ -17,7 +17,7 @@ icon: "circle-question"
|
||||
| API key required? | Optional: pattern extraction works with no keys |
|
||||
| Works with LangChain / LlamaIndex? | Yes: Semantica is a layer on top, not a replacement |
|
||||
| Production-ready? | Yes: 1,000+ tests, v0.5.0 ships with 12 security fixes |
|
||||
| Latest version? | **v0.6.6** (August 2026) |
|
||||
| Latest version? | **v0.6.7** (August 2026) |
|
||||
| Local LLMs? | Yes: Ollama via LiteLLM, HuggingFaceLLM for air-gapped |
|
||||
|
||||
|
||||
|
||||
@@ -42,7 +42,7 @@ icon: "rocket"
|
||||
Verify installation:
|
||||
```python
|
||||
import semantica
|
||||
print(semantica.__version__) # 0.6.6
|
||||
print(semantica.__version__) # 0.6.7
|
||||
```
|
||||
</Check>
|
||||
</Step>
|
||||
|
||||
@@ -382,6 +382,45 @@ For authentication details (PAT vs. OAuth M2M for Databricks; password vs. key-p
|
||||
|
||||
> **Security Note:** Never hardcode credentials (`token`, `password`, `private_key`) in production code; pass them via environment variables (e.g., `DATABRICKS_TOKEN`, `SNOWFLAKE_PASSWORD`) or a secrets manager.
|
||||
|
||||
## Source 7 — SAP OData
|
||||
|
||||
`SAPIngestor` ingests an Entity Set from a SAP OData service (S/4HANA Cloud, SuccessFactors, or an on-prem NetWeaver Gateway over its REST surface). It speaks OData v2 and v4, follows server-driven pagination automatically, and flattens each record into a document dict via `export_as_documents()` — the same structured "transform to text, then store" pattern as the other sources.
|
||||
|
||||
```python
|
||||
from semantica.ingest import SAPIngestor
|
||||
|
||||
ing = SAPIngestor(
|
||||
base_url="https://my-sap.example.com/sap/opu/odata/sap/API_BUSINESS_PARTNER",
|
||||
client_id="...", client_secret="...",
|
||||
token_url="https://my-sap.example.com/oauth/token", # OAuth2 client-credentials (BTP/S/4HANA Cloud)
|
||||
# On-prem NetWeaver often uses Basic auth instead — swap the block above for:
|
||||
# username="erp_user", password="...",
|
||||
)
|
||||
|
||||
# 1. Discover an unfamiliar service: entity sets + field types from $metadata
|
||||
sets = ing.discover_service() # [{"name": "A_BusinessPartnerSet", "fields": [...]}, ...]
|
||||
|
||||
# 2. Pull a page-walked Entity Set (v2/v4 next links handled for you)
|
||||
partners = ing.ingest_entity_set(
|
||||
entity_set="A_BusinessPartnerSet",
|
||||
select="BusinessPartner,BusinessPartnerFullName", # $select
|
||||
top=1000, # cap on total rows
|
||||
)
|
||||
|
||||
# 3. Flatten to document dicts, then build text for the graph
|
||||
docs = ing.export_as_documents(partners)
|
||||
partner_texts = [
|
||||
f"Business Partner {d['BusinessPartner']}: {d['BusinessPartnerFullName']}"
|
||||
for d in docs
|
||||
]
|
||||
```
|
||||
|
||||
- Use `expand="to_Item"` (e.g. on a sales-order header set) to pull nested line items in one request — handy for modeling order → line-item → material relationships.
|
||||
- Every outbound request, including the OAuth2 token exchange, is routed through the SSRF guard, so a user-supplied SAP URL can never reach private/loopback/link-local address space.
|
||||
- Install with `pip install 'semantica[ingest-sap]'`.
|
||||
|
||||
> **Security Note:** Never hardcode credentials (`client_secret`, `password`) in code; pass them via environment variables (e.g., `SAP_CLIENT_SECRET`, `SAP_PASSWORD`) or a secrets manager.
|
||||
|
||||
## Combining All Five Sources
|
||||
|
||||
Once you have text from each source, `AgentContext.store()` accepts a flat list of strings. Semantica embeds and indexes them together — the context graph has no concept of which string came from which source unless you add metadata explicitly.
|
||||
|
||||
+16
-5
@@ -222,10 +222,10 @@ builder.register_step_handler("ner_extract", run_ner)
|
||||
builder.register_step_handler("triplet_extract", run_triplets)
|
||||
builder.register_step_handler("kg_merge", merge_into_graph)
|
||||
|
||||
builder.add_step("ingest", "file_ingest", handler=ingest_stix_bundles, path="./stix_bundles/")
|
||||
builder.add_step("ner", "ner_extract", handler=run_ner, confidence_threshold=0.75)
|
||||
builder.add_step("triplets", "triplet_extract", handler=run_triplets, include_temporal=True)
|
||||
builder.add_step("store", "kg_merge", handler=merge_into_graph, output_path="./cti_output/")
|
||||
builder.add_step("ingest", "file_ingest", path="./stix_bundles/")
|
||||
builder.add_step("ner", "ner_extract", confidence_threshold=0.75)
|
||||
builder.add_step("triplets", "triplet_extract", include_temporal=True)
|
||||
builder.add_step("store", "kg_merge", output_path="./cti_output/")
|
||||
|
||||
# ingest feeds both ner and triplets in parallel
|
||||
builder.connect_steps("ingest", "ner")
|
||||
@@ -241,7 +241,18 @@ engine = ExecutionEngine(max_workers=2, retry_on_failure=True)
|
||||
result = engine.execute_pipeline(pipeline)
|
||||
```
|
||||
|
||||
`set_parallelism(n)` tells the engine how many steps it may run simultaneously. The topological sort guarantees that only steps whose dependencies are all completed are eligible for concurrent execution — you cannot accidentally run a step before its inputs are ready.
|
||||
`set_parallelism(n)` tells the engine how many steps it may run simultaneously; `n` must be a positive integer. The topological sort guarantees that only steps whose dependencies are all completed are eligible for concurrent execution — you cannot accidentally run a step before its inputs are ready. The effective concurrency is capped at `min(n, max_workers)`, so the engine's `max_workers` setting remains a hard resource ceiling.
|
||||
|
||||
Concurrency is opt-in per step. A dependency layer only runs in parallel when every step in that layer is marked `parallel_safe`, the layer has more than one step, and the data flowing into the layer is a dict:
|
||||
|
||||
```python
|
||||
builder.add_step("ner", "ner_extract", parallel_safe=True, confidence_threshold=0.75)
|
||||
builder.add_step("triplets", "triplet_extract", parallel_safe=True, include_temporal=True)
|
||||
```
|
||||
|
||||
If any step in a layer is not marked `parallel_safe`, or if a step runs in delta mode, the entire layer falls back to sequential execution — parallelism never silently bypasses a step that was not declared safe. `parallel_safe` is a control field: like `dependencies`, it is consumed by the builder and never reaches your handler's config.
|
||||
|
||||
Parallel-safe handlers must return a dict. Each step in a parallel layer receives an isolated deep copy of the layer's input, so steps cannot see each other's mutations. The per-step results are merged key by key in step declaration order: a key written by one step is added to the merged output, a key written by several steps with equal values is kept, and two steps writing different values for the same key fail the pipeline with a `ProcessingError` naming the conflicting key and both steps. Handlers that touch shared mutable resources — database connections, in-memory stores, global caches — should not be marked `parallel_safe`.
|
||||
|
||||
## Common Pitfalls
|
||||
|
||||
|
||||
@@ -639,7 +639,7 @@ Every `ProvenanceEntry` maps directly to W3C PROV-O terms. If your compliance te
|
||||
| — | `previous_version_id` | This entry corrects/replaces a prior version of the *same* fact |
|
||||
| `prov:wasDerivedFrom` | `derived_from_id` | This entry was derived from a *different* source entity |
|
||||
| `prov:used` | `used_entities` | Entity IDs consumed to produce this one |
|
||||
| `prov:generatedAtTime` | `timestamp` | ISO datetime, auto-set to `datetime.utcnow()` at write time |
|
||||
| `prov:generatedAtTime` | `timestamp` | ISO datetime, auto-set to `utc_now_iso()` at write time |
|
||||
| `prov:qualifiedInvalidation` | `invalidated`, `invalidated_at_time`, `invalidated_by`, `invalidation_reason` | A retraction/correction recorded as a tombstone via `ProvenanceManager.invalidate()`, never a hard delete |
|
||||
| `prov:startedAtTime` / `prov:endedAtTime` | `activity_started_at_time`, `activity_ended_at_time` | Typed Activity timing — pass an `ActivityRecord` via the `activity=` kwarg to set these together with `activity_id` |
|
||||
| `prov:qualifiedGeneration`/`Generation`, `qualifiedUsage`/`Usage`, `qualifiedDerivation`/`Derivation` | (derived from the fields above) | Additive qualified forms of `wasGeneratedBy`/`used`/`wasDerivedFrom`, emitted automatically alongside the plain triples |
|
||||
|
||||
@@ -150,6 +150,12 @@ HighRiskSupplier(DELTA-3) conf=100% rule=Rule 3
|
||||
|
||||
DELTA-3 is flagged even though no document described it that way — the system traced: DELTA-3 supplied GAMMA-7, and GAMMA-7 exploits critical CVEs. For rules that need priority ordering or graded confidence, use the `Rule` dataclass:
|
||||
|
||||
If a rule has side-effecting actions, one concrete activation runs those
|
||||
actions at most once on a Reasoner instance. Re-running `forward_chain()` is
|
||||
therefore safe: already-attempted actions are not repeated. Use
|
||||
`reasoner.reset_action_history()` when you intentionally want to replay them;
|
||||
`reasoner.clear()` and `reasoner.reset()` also clear the history.
|
||||
|
||||
```python
|
||||
# Higher priority rules fire first; confidence propagates into InferenceResult.confidence
|
||||
reasoner.add_rule(Rule(
|
||||
@@ -360,6 +366,13 @@ engine.reset()
|
||||
|
||||
The rule network is compiled once by `build_network()`. Each subsequent `add_fact()` call propagates incrementally through only the nodes whose conditions it satisfies — not the full rule set — which keeps evaluation cost proportional to the number of new activations rather than the total rule count.
|
||||
|
||||
With a Reasoner bound, Rete action side effects are attempted once per rule,
|
||||
bindings, and matched fact identity. Passing the same match to
|
||||
`execute_matches()` again still returns the same conclusion, but does not repeat
|
||||
its actions. Call `engine.reset_action_history()` to replay actions without
|
||||
clearing working memory. `engine.reset()` and `engine.build_network()` also
|
||||
clear the action history.
|
||||
|
||||
## Step 7 — Temporal interval reasoning
|
||||
|
||||
`TemporalReasoningEngine` computes Allen interval relations between time windows, letting you identify whether two events overlap, one contains the other, they meet at a boundary, and so on across your graph:
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
---
|
||||
title: "LangChain Integration"
|
||||
description: "Drop Semantica into LangChain / LangGraph pipelines via a GraphRAG retriever, VectorStore adapter, and agent tools."
|
||||
icon: "link"
|
||||
---
|
||||
|
||||
> Three drop-in adapters that bring Semantica's context graph and hybrid search into LangChain chains and LangGraph agents.
|
||||
|
||||
## Installation
|
||||
|
||||
```bash
|
||||
pip install "semantica[langchain]"
|
||||
```
|
||||
|
||||
Requires `langchain-core >= 0.3`. If langchain-core is not installed, the integration still imports — every class carries the full Semantica API and degrades gracefully (`build()` returns `None`; branch on `LANGCHAIN_AVAILABLE`).
|
||||
|
||||
## Components at a Glance
|
||||
|
||||
- **SemanticaRetriever** — `BaseRetriever`: hybrid-search seeds retrieval, then graph edges are walked `hops` steps (default 2) for GraphRAG-style results.
|
||||
- **SemanticaVectorStore** — `VectorStore`: `add_texts` / `similarity_search` / `similarity_search_with_score` / `from_texts` over `HybridSearch`.
|
||||
- **SemanticaKGTool** / **SemanticaDecisionTool** — `BaseTool` subclasses: `semantica_query_graph` and `semantica_query_decisions` for LangGraph / tool-calling agents.
|
||||
|
||||
## Component Details
|
||||
|
||||
<Tabs>
|
||||
<Tab title="SemanticaRetriever">
|
||||
Hybrid search seeds retrieval; then graph edges are walked `hops` steps so results go beyond flat vector similarity. If hybrid search is omitted or fails, the retriever falls back to a `ContextGraph.query` keyword scan.
|
||||
|
||||
```python
|
||||
from integrations.langchain import SemanticaRetriever
|
||||
from semantica.context import ContextGraph
|
||||
from semantica.vector_store import HybridSearch
|
||||
|
||||
graph = ContextGraph()
|
||||
hybrid = HybridSearch()
|
||||
|
||||
retriever = SemanticaRetriever(graph=graph, hybrid=hybrid, hops=2, top_k=10)
|
||||
|
||||
from langchain.chains import RetrievalQA
|
||||
|
||||
qa = RetrievalQA.from_chain_type(llm=llm, retriever=retriever)
|
||||
```
|
||||
</Tab>
|
||||
<Tab title="SemanticaVectorStore">
|
||||
Drop-in `VectorStore` for RetrievalQA / LCEL chains. `from_texts` requires a pre-configured `hybrid` instance.
|
||||
|
||||
```python
|
||||
from integrations.langchain import SemanticaVectorStore
|
||||
|
||||
store = SemanticaVectorStore(hybrid=hybrid)
|
||||
store.add_texts(
|
||||
["document one", "document two"],
|
||||
metadatas=[{"source": "a"}, {"source": "b"}],
|
||||
)
|
||||
docs = store.similarity_search("document", k=2)
|
||||
docs, scores = store.similarity_search_with_score("document", k=2)
|
||||
```
|
||||
|
||||
`add_texts` delegates to a Semantica vector store with `add_documents` (pass `vector_store=` to `HybridSearch` or to `SemanticaVectorStore`).
|
||||
</Tab>
|
||||
<Tab title="Agent tools">
|
||||
Instances are LangChain `BaseTool`s and can be passed to an agent directly.
|
||||
`.build()` returns the tool, or `None` when langchain-core is absent.
|
||||
|
||||
```python
|
||||
from integrations.langchain import SemanticaKGTool, SemanticaDecisionTool
|
||||
from langgraph.prebuilt import create_react_agent
|
||||
|
||||
tools = [
|
||||
SemanticaKGTool(graph),
|
||||
SemanticaDecisionTool(graph),
|
||||
]
|
||||
agent = create_react_agent(model, tools)
|
||||
```
|
||||
|
||||
| Tool | Description |
|
||||
| :------ | :------------- |
|
||||
| `semantica_query_graph` | Keyword / NL query over the shared context graph |
|
||||
| `semantica_query_decisions` | Search the recorded decision log |
|
||||
</Tab>
|
||||
</Tabs>
|
||||
@@ -0,0 +1,376 @@
|
||||
---
|
||||
title: "Salesforce Integration"
|
||||
description: "Ingest CRM records from Salesforce sObjects and SOQL queries into Semantica's KG pipeline."
|
||||
icon: "cloud"
|
||||
---
|
||||
|
||||
> Extract Accounts, Contacts, Opportunities, and custom objects from Salesforce into Semantica with username/password/security-token, JWT bearer, or session-based authentication.
|
||||
|
||||
|
||||
## Installation
|
||||
|
||||
```bash
|
||||
# Install with Salesforce support
|
||||
pip install "semantica[db-salesforce]"
|
||||
|
||||
# Or install the connector separately
|
||||
pip install simple-salesforce>=1.12.0
|
||||
```
|
||||
|
||||
|
||||
## Basic Usage
|
||||
|
||||
```python
|
||||
from semantica.ingest import SalesforceIngestor
|
||||
import os
|
||||
|
||||
ingestor = SalesforceIngestor(
|
||||
username=os.getenv("SALESFORCE_USERNAME"),
|
||||
password=os.getenv("SALESFORCE_PASSWORD"),
|
||||
security_token=os.getenv("SALESFORCE_SECURITY_TOKEN"),
|
||||
domain=os.getenv("SALESFORCE_DOMAIN", "login"), # "test" for sandbox
|
||||
)
|
||||
|
||||
data = ingestor.ingest_sobject("Account", fields=["Id", "Name", "Industry"], limit=1000)
|
||||
print(f"Retrieved {data.row_count} of {data.total_size} matching records")
|
||||
print(f"Columns: {data.columns}")
|
||||
```
|
||||
|
||||
<Tip>
|
||||
Use environment variables (or a `.env` file with `python-dotenv`) to keep credentials out of source code. `SalesforceIngestor()` with no arguments reads from `SALESFORCE_*` environment variables automatically.
|
||||
</Tip>
|
||||
|
||||
|
||||
## Authentication Methods
|
||||
|
||||
<Tabs>
|
||||
<Tab title="Username / Password / Security Token">
|
||||
```python
|
||||
import os
|
||||
from semantica.ingest import SalesforceIngestor
|
||||
|
||||
ingestor = SalesforceIngestor(
|
||||
username=os.getenv("SALESFORCE_USERNAME"),
|
||||
password=os.getenv("SALESFORCE_PASSWORD"),
|
||||
security_token=os.getenv("SALESFORCE_SECURITY_TOKEN"),
|
||||
domain="login", # production; use "test" for sandbox
|
||||
)
|
||||
```
|
||||
Set the required environment variables before running:
|
||||
```bash
|
||||
export SALESFORCE_USERNAME="your-username@example.com"
|
||||
export SALESFORCE_PASSWORD="your-password"
|
||||
export SALESFORCE_SECURITY_TOKEN="your-security-token"
|
||||
```
|
||||
The standard server-side flow. The security token is appended to the
|
||||
password during Salesforce SOAP login. Generate or reset it under
|
||||
**Settings → My Personal Information → Reset My Security Token**.
|
||||
</Tab>
|
||||
<Tab title="JWT Bearer (Recommended for CI/CD)">
|
||||
```python
|
||||
import os
|
||||
from semantica.ingest import SalesforceIngestor
|
||||
|
||||
ingestor = SalesforceIngestor(
|
||||
username=os.getenv("SALESFORCE_USERNAME"),
|
||||
consumer_key=os.getenv("SALESFORCE_CONSUMER_KEY"),
|
||||
privatekey_file=os.getenv("SALESFORCE_PRIVATE_KEY_FILE"),
|
||||
domain="login", # or "test" for sandbox
|
||||
)
|
||||
```
|
||||
```bash
|
||||
export SALESFORCE_USERNAME="your-username@example.com"
|
||||
export SALESFORCE_CONSUMER_KEY="your-connected-app-consumer-key"
|
||||
export SALESFORCE_PRIVATE_KEY_FILE="/path/to/server.key"
|
||||
```
|
||||
The JWT bearer flow authenticates with a signed token — no password
|
||||
is transmitted. Ideal for server-to-server integrations and CI/CD
|
||||
pipelines. Requires a Salesforce connected app configured with
|
||||
**Use digital signatures** and the pre-authorised user listed under
|
||||
**Manage → Profiles / Permission Sets**.
|
||||
|
||||
If you prefer to pass the key material as a string instead of a file
|
||||
path, use `SALESFORCE_PRIVATE_KEY` (the PEM contents) in place of
|
||||
`SALESFORCE_PRIVATE_KEY_FILE`.
|
||||
</Tab>
|
||||
<Tab title="Session ID + Instance URL">
|
||||
```python
|
||||
ingestor = SalesforceIngestor(
|
||||
session_id=os.getenv("SALESFORCE_SESSION_ID"),
|
||||
instance_url=os.getenv("SALESFORCE_INSTANCE_URL"),
|
||||
)
|
||||
```
|
||||
Use this when your environment already manages the OAuth token
|
||||
lifecycle (e.g. a connected app obtaining tokens via the web-server
|
||||
or device flow). Pass the access token as `session_id` and the full
|
||||
instance URL (e.g. `https://myorg.my.salesforce.com`) as
|
||||
`instance_url`.
|
||||
</Tab>
|
||||
<Tab title="Sandbox">
|
||||
```python
|
||||
import os
|
||||
from semantica.ingest import SalesforceIngestor
|
||||
|
||||
ingestor = SalesforceIngestor(
|
||||
username=os.getenv("SALESFORCE_USERNAME"),
|
||||
password=os.getenv("SALESFORCE_PASSWORD"),
|
||||
security_token=os.getenv("SALESFORCE_SECURITY_TOKEN"),
|
||||
domain="test", # routes to test.salesforce.com
|
||||
)
|
||||
```
|
||||
```bash
|
||||
export SALESFORCE_USERNAME="your-sandbox-username@example.com.sandbox"
|
||||
export SALESFORCE_PASSWORD="your-password"
|
||||
export SALESFORCE_SECURITY_TOKEN="your-security-token"
|
||||
export SALESFORCE_DOMAIN="test"
|
||||
```
|
||||
Replace `domain="login"` with `domain="test"` (or set
|
||||
`SALESFORCE_DOMAIN=test` in your environment) to connect to a
|
||||
developer or full sandbox.
|
||||
</Tab>
|
||||
</Tabs>
|
||||
|
||||
### Environment variables
|
||||
|
||||
All constructor parameters have environment-variable fallbacks:
|
||||
|
||||
| Variable | Parameter | Default |
|
||||
|---|---|---|
|
||||
| `SALESFORCE_USERNAME` | `username` | — |
|
||||
| `SALESFORCE_PASSWORD` | `password` | — |
|
||||
| `SALESFORCE_SECURITY_TOKEN` | `security_token` | — |
|
||||
| `SALESFORCE_DOMAIN` | `domain` | `"login"` |
|
||||
| `SALESFORCE_INSTANCE_URL` | `instance_url` | — |
|
||||
| `SALESFORCE_SESSION_ID` | `session_id` | — |
|
||||
| `SALESFORCE_CONSUMER_KEY` | `consumer_key` | — |
|
||||
| `SALESFORCE_PRIVATE_KEY_FILE` | `privatekey_file` | — |
|
||||
| `SALESFORCE_PRIVATE_KEY` | `privatekey` | — |
|
||||
| `SALESFORCE_API_VERSION` | `api_version` | library default (`59.0`) |
|
||||
|
||||
|
||||
## Object Ingestion
|
||||
|
||||
### Ingest a standard object
|
||||
|
||||
```python
|
||||
data = ingestor.ingest_sobject(
|
||||
"Account",
|
||||
fields=["Id", "Name", "Industry", "AnnualRevenue", "BillingCity"],
|
||||
where="Type = 'Customer' AND AnnualRevenue > 1000000",
|
||||
order_by="Name ASC",
|
||||
limit=5000,
|
||||
)
|
||||
print(f"Retrieved {data.row_count} of {data.total_size} matching records")
|
||||
```
|
||||
|
||||
<Note>
|
||||
`data.row_count` is the number of records in `data.data` (i.e. what was actually returned after any `limit`). `data.total_size` is Salesforce's `totalSize` — the number of records matching the query *before* the limit. Compare them to know whether you got all results.
|
||||
</Note>
|
||||
|
||||
### Ingest a custom object
|
||||
|
||||
Custom objects end with `__c` in their API name:
|
||||
|
||||
```python
|
||||
data = ingestor.ingest_sobject(
|
||||
"My_Custom_Object__c",
|
||||
fields=["Id", "Name", "Custom_Field__c"],
|
||||
)
|
||||
```
|
||||
|
||||
Relationship traversal fields (`Owner.Name`) are also supported:
|
||||
|
||||
```python
|
||||
data = ingestor.ingest_sobject(
|
||||
"Contact",
|
||||
fields=["Id", "Name", "Email", "Account.Name", "Owner.Name"],
|
||||
limit=10000,
|
||||
)
|
||||
```
|
||||
|
||||
### Let Semantica choose the fields
|
||||
|
||||
When `fields` is omitted, all selectable fields are fetched via `describe()`
|
||||
(one extra API call). Compound address and geolocation fields (`type=address`,
|
||||
`type=location`) are automatically excluded — select their components
|
||||
(`BillingStreet`, `BillingCity`, `Location__Latitude__s`, …) individually if
|
||||
you need them.
|
||||
|
||||
```python
|
||||
data = ingestor.ingest_sobject("Opportunity")
|
||||
```
|
||||
|
||||
|
||||
## Raw SOQL Ingestion
|
||||
|
||||
Pass any valid SOQL query verbatim — pagination is handled automatically:
|
||||
|
||||
```python
|
||||
data = ingestor.ingest_query("""
|
||||
SELECT Id, Name, StageName, Amount, CloseDate,
|
||||
Account.Name, Owner.Name
|
||||
FROM Opportunity
|
||||
WHERE IsClosed = false
|
||||
ORDER BY CloseDate ASC
|
||||
""")
|
||||
print(f"Open opportunities: {data.row_count}")
|
||||
```
|
||||
|
||||
The query is passed to the Salesforce REST API unchanged. The caller is
|
||||
responsible for SOQL correctness and safety.
|
||||
|
||||
<Warning>
|
||||
`ingest_query` does not validate or sanitise the SOQL string. Use
|
||||
`ingest_sobject` (which validates sObject names, field names, and WHERE/ORDER
|
||||
BY fragments) when building queries from application-controlled inputs.
|
||||
</Warning>
|
||||
|
||||
|
||||
## Document Export
|
||||
|
||||
Convert ingested records to the Semantica document format for use with
|
||||
`GraphBuilder`:
|
||||
|
||||
```python
|
||||
documents = ingestor.export_as_documents(
|
||||
data,
|
||||
id_field="Id", # default; Salesforce 18-char record Id
|
||||
text_fields=["Name", "Description"], # omit to join all string fields
|
||||
)
|
||||
|
||||
print(f"Created {len(documents)} documents")
|
||||
# Each document:
|
||||
# {
|
||||
# "id": "001xx000003GYk2AAG",
|
||||
# "text": "Acme Corp Enterprise software company",
|
||||
# "metadata": {
|
||||
# "source": "salesforce",
|
||||
# "sobject": "Account",
|
||||
# "instance_url": "https://myorg.my.salesforce.com",
|
||||
# "row_data": { ... full cleaned record ... }
|
||||
# }
|
||||
# }
|
||||
```
|
||||
|
||||
Feed the documents directly into `GraphBuilder`:
|
||||
|
||||
```python
|
||||
from semantica.kg import GraphBuilder
|
||||
|
||||
builder = GraphBuilder()
|
||||
kg = builder.build(documents)
|
||||
```
|
||||
|
||||
|
||||
## Object and Schema Discovery
|
||||
|
||||
```python
|
||||
# List all accessible sObjects
|
||||
sobject_names = ingestor.list_sobjects()
|
||||
print(sobject_names[:10]) # ["Account", "Case", "Contact", ...]
|
||||
|
||||
# Inspect fields for a specific sObject
|
||||
schema = ingestor.get_sobject_schema("Account")
|
||||
for field in schema["fields"]:
|
||||
print(f"{field['name']}: {field['type']} (nillable={field['nillable']})")
|
||||
```
|
||||
|
||||
|
||||
## Context Manager
|
||||
|
||||
Prefer the context manager for long-running jobs — it opens one connection on
|
||||
entry and closes it on exit, so every ingestion call inside the `with` block
|
||||
reuses the same authenticated session:
|
||||
|
||||
```python
|
||||
with SalesforceIngestor(
|
||||
username=os.getenv("SALESFORCE_USERNAME"),
|
||||
password=os.getenv("SALESFORCE_PASSWORD"),
|
||||
security_token=os.getenv("SALESFORCE_SECURITY_TOKEN"),
|
||||
) as sf:
|
||||
accounts = sf.ingest_sobject("Account", limit=10000)
|
||||
contacts = sf.ingest_sobject("Contact", limit=10000)
|
||||
sobjects = sf.list_sobjects()
|
||||
```
|
||||
|
||||
|
||||
## Convenience Function
|
||||
|
||||
Use `ingest_salesforce()` for one-liner ingestion:
|
||||
|
||||
```python
|
||||
from semantica.ingest import ingest_salesforce
|
||||
|
||||
# Fetch records
|
||||
data = ingest_salesforce(
|
||||
method="sobject",
|
||||
sobject_name="Account",
|
||||
fields=["Id", "Name", "Industry"],
|
||||
limit=500,
|
||||
)
|
||||
|
||||
# Execute raw SOQL (credentials from environment variables)
|
||||
data = ingest_salesforce(
|
||||
method="query",
|
||||
soql="SELECT Id, Name FROM Contact WHERE IsActive = true",
|
||||
)
|
||||
|
||||
# Ingest + export to documents in one step
|
||||
docs = ingest_salesforce(
|
||||
method="documents",
|
||||
sobject_name="Account",
|
||||
text_fields=["Name", "Description"],
|
||||
limit=1000,
|
||||
)
|
||||
|
||||
# List accessible sObjects
|
||||
sobject_names = ingest_salesforce(method="list_sobjects")
|
||||
```
|
||||
|
||||
Or use the unified `ingest()` dispatcher:
|
||||
|
||||
```python
|
||||
from semantica.ingest import ingest
|
||||
|
||||
result = ingest(
|
||||
None,
|
||||
source_type="salesforce",
|
||||
method="sobject",
|
||||
sobject_name="Account",
|
||||
fields=["Id", "Name"],
|
||||
limit=500,
|
||||
)
|
||||
data = result["data"] # SalesforceData
|
||||
```
|
||||
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
```python
|
||||
import os
|
||||
from semantica.ingest import SalesforceConnector
|
||||
|
||||
connector = SalesforceConnector(
|
||||
username=os.getenv("SALESFORCE_USERNAME"),
|
||||
password=os.getenv("SALESFORCE_PASSWORD"),
|
||||
security_token=os.getenv("SALESFORCE_SECURITY_TOKEN"),
|
||||
)
|
||||
if not connector.test_connection():
|
||||
print("Connection failed: check username, password, security token, and domain")
|
||||
```
|
||||
|
||||
Common causes of authentication failures:
|
||||
|
||||
- **Wrong domain**: production orgs use `domain="login"`; sandboxes use `domain="test"`.
|
||||
- **Stale security token**: reset it under **Settings → Reset My Security Token**. The new token is emailed to you.
|
||||
- **IP restriction**: your org's trusted IP ranges may block the originating IP. Check **Setup → Network Access**.
|
||||
- **API access disabled**: ensure the connected profile has the **API Enabled** permission.
|
||||
|
||||
|
||||
## See Also
|
||||
|
||||
- [Ingest Module](../reference/ingest) — Full `SalesforceIngestor` API and all other ingestors.
|
||||
- [Snowflake Integration](snowflake) — Relational warehouse connector with a similar design.
|
||||
- [Databricks Integration](databricks) — Lakehouse connector.
|
||||
- [Installation](../installation) — All optional dependency extras.
|
||||
- [Knowledge Graph](../reference/kg) — Build a KG from ingested Salesforce data.
|
||||
@@ -28,6 +28,7 @@ icon: "database"
|
||||
| `DBIngestor` | SQL databases via SQLAlchemy: tables, views, and custom queries |
|
||||
| `SnowflakeIngestor` | Snowflake data warehouse queries and table exports |
|
||||
| `DatabricksIngestor` | Databricks Unity Catalog metadata, Delta table queries, and lineage |
|
||||
| `SAPIngestor` | SAP OData services (S/4HANA Cloud, SuccessFactors, NetWeaver Gateway): entity-set discovery and ingestion with v2/v4 pagination |
|
||||
| `ParquetIngestor` | Apache Parquet files and partitioned datasets with column selection |
|
||||
| `ArrowIngestor` | Apache Arrow IPC and Feather file processing |
|
||||
| `XMLIngestor` | XXE-safe XML parsing with optional XSD schema validation |
|
||||
|
||||
@@ -250,7 +250,7 @@ entry = ProvenanceEntry(
|
||||
source_document="report.pdf", # str: default ""
|
||||
source_location="Page 4", # Optional[str]: default None
|
||||
source_quote="Relevant text...", # Optional[str]: default None
|
||||
timestamp="2024-01-01T12:00:00", # str: auto-set to utcnow()
|
||||
timestamp="2024-01-01T12:00:00+00:00", # str: auto-set to utc_now_iso()
|
||||
first_seen=None, # Optional[str]: ISO timestamp
|
||||
last_updated=None, # Optional[str]: ISO timestamp
|
||||
confidence=0.9, # float: default 1.0
|
||||
|
||||
@@ -127,9 +127,19 @@ conclusions = reasoner.infer_facts(
|
||||
| `forward_chain()` | `List[InferenceResult]` | Derive all possible conclusions iteratively until fixpoint |
|
||||
| `backward_chain(goal, max_depth)` | `InferenceResult \| None` | Prove a specific goal string, returns `None` if unprovable |
|
||||
| `infer_facts(facts, rules)` | `List[str]` | Load facts and rules then run `forward_chain()`, returns conclusion strings |
|
||||
| `clear()` | `None` | Clear all facts and rules |
|
||||
| `reset_action_history()` | `None` | Allow actions for previously fired activations to run again |
|
||||
| `clear()` | `None` | Clear all facts, rules, and action activation history |
|
||||
| `reset()` | `None` | Alias for `clear()` |
|
||||
|
||||
Rules with actions use at-most-once attempt semantics per concrete activation
|
||||
(rule ID, bindings, and matched facts). Calling `forward_chain()` again on the
|
||||
same instance does not repeat side effects for an activation that was already
|
||||
attempted, even when an action raised an exception. Call
|
||||
`reset_action_history()` to deliberately retry without clearing facts or rules;
|
||||
`clear()` and `reset()` also clear this history. Replacing a rule's actions in
|
||||
place does not invalidate an existing activation; reset the history explicitly
|
||||
when the replacement should be replayed.
|
||||
|
||||
### Rule and Fact dataclass fields
|
||||
|
||||
```python
|
||||
@@ -230,9 +240,16 @@ engine.reset()
|
||||
| `add_fact(fact)` | `None` | Add a `Fact` to working memory and propagate through the network |
|
||||
| `match_patterns(facts)` | `List[Match]` | Match all patterns; optionally add facts before matching |
|
||||
| `execute_matches(matches)` | `List[Any]` | Execute matched rules and return their conclusion values |
|
||||
| `reset()` | `None` | Clear facts and all node activation state |
|
||||
| `reset_action_history()` | `None` | Allow actions for previously executed activations to run again |
|
||||
| `reset()` | `None` | Clear facts, node activation state, and action activation history |
|
||||
| `get_network_stats()` | `dict` | Return counts of alpha, beta, terminal nodes and facts |
|
||||
|
||||
When a Reasoner is bound, `execute_matches()` deduplicates action side effects
|
||||
by rule ID, bindings, and matched fact identity. Re-executing a match still
|
||||
returns its conclusion for compatibility, but its actions are skipped after the
|
||||
first attempt. `reset_action_history()`, `reset()`, and `build_network()` allow
|
||||
those actions to run again.
|
||||
|
||||
|
||||
## SPARQLReasoner
|
||||
|
||||
|
||||
@@ -182,7 +182,7 @@ for row in result.bindings:
|
||||
store = TripletStore(
|
||||
backend="rdf4j",
|
||||
endpoint="http://localhost:8080/rdf4j-server",
|
||||
repository_id="semantica", # passed through **config
|
||||
repository_id="semantica", # selects the remote repository
|
||||
)
|
||||
```
|
||||
|
||||
|
||||
@@ -77,7 +77,17 @@ Most users won't call utils directly: it's the **shared foundation** for all mod
|
||||
export SEMANTICA_LOG_LEVEL=DEBUG
|
||||
export SEMANTICA_LOG_FORMAT=json # "json" | "text"
|
||||
export SEMANTICA_DISABLE_PROGRESS=true
|
||||
export SEMANTICA_FORCE_PROGRESS=true
|
||||
```
|
||||
|
||||
<Tip>
|
||||
**Progress bars follow your terminal.** Console progress is written only when
|
||||
stdout is an interactive terminal (or a Jupyter notebook), so piping or
|
||||
redirecting output no longer fills logs with progress bars and escape
|
||||
sequences. Set `SEMANTICA_DISABLE_PROGRESS` to silence progress even in a
|
||||
terminal, or `SEMANTICA_FORCE_PROGRESS` to keep it when stdout is redirected.
|
||||
`SEMANTICA_DISABLE_PROGRESS` wins if both are set.
|
||||
</Tip>
|
||||
</Step>
|
||||
</Steps>
|
||||
|
||||
|
||||
@@ -35,7 +35,7 @@ This page is intentionally conservative: it distinguishes between an adapter exi
|
||||
| FalkorDB | LPG | Yes | Yes | Partial | Partial | Redis-based; provenance depends on node/edge properties, and multi-graph isolation depends on the selected graph name. |
|
||||
| Amazon Neptune | LPG | Yes | Yes | Partial | Partial | Use the property-graph endpoint; AWS auth, VPC, and endpoint configuration can affect local tests. Provenance depends on node/edge properties. |
|
||||
| Apache AGE | LPG | Yes | Yes | Partial | Partial | Runs through PostgreSQL/AGE; Cypher compatibility and property handling can differ from standalone LPG engines. |
|
||||
| RDF4J | RDF | Yes | Partial | Partial | Partial | Context separation relies on named graphs; triple-level provenance may require reification or graph-level metadata. `RDF4JStore(repository_id=...)` currently has no effect — the constructor always connects to the `"default"` repository regardless of the value passed; track a fix separately. |
|
||||
| RDF4J | RDF | Yes | Partial | Partial | Partial | Context separation relies on named graphs; triple-level provenance may require reification or graph-level metadata. |
|
||||
| Apache Jena | RDF | Yes | Partial | Partial | Partial | Named graphs are needed for context separation; backend configuration and transaction behavior matter. |
|
||||
| Blazegraph | RDF | Yes | Partial | Partial | Partial | Use quads/named graphs for context; IRI stability and graph naming matter for provenance. |
|
||||
| Anzo | RDF | Yes | Partial | Partial | Partial | Anzo deployments are environment-specific; validate `dataset_uri`/graphmart naming, named-graph support, and provenance mapping. |
|
||||
@@ -107,7 +107,7 @@ from semantica.triplet_store import RDF4JStore
|
||||
|
||||
store = RDF4JStore(
|
||||
endpoint='http://localhost:8080/rdf4j-server',
|
||||
repository_id='semantica' # currently has no effect; connects to "default" (see Known limitations)
|
||||
repository_id='semantica'
|
||||
)
|
||||
```
|
||||
|
||||
|
||||
@@ -0,0 +1,195 @@
|
||||
"""
|
||||
Deterministic Explorer Rendering E2E Example.
|
||||
|
||||
Demonstrates building, serializing, and reloading a deterministic 4-node,
|
||||
3-edge knowledge graph baseline for visual inspection in Semantica Explorer (#1037).
|
||||
|
||||
Graph topology:
|
||||
Alice (Person, #63E6FF) --WORKS_AT--> Acme (Organization, #A78BFA)
|
||||
Bob (Person, #63E6FF) --KNOWS--> Alice (Person, #63E6FF)
|
||||
Acme (Organization, #A78BFA) --LOCATED_IN--> New York (Location, #34D399)
|
||||
|
||||
Clean Checkout Prerequisites:
|
||||
1. Python backend dependencies:
|
||||
pip install -e ".[explorer]"
|
||||
2. Frontend workspace dependencies:
|
||||
cd explorer && npm install && cd ..
|
||||
|
||||
Usage:
|
||||
# 1. Generate the deterministic graph baseline:
|
||||
python examples/explorer_deterministic_rendering_example.py
|
||||
|
||||
# 2. Launch Explorer with local dev authentication (Option A - Dev mode):
|
||||
# Terminal 1 (Backend API):
|
||||
SEMANTICA_ALLOW_ANONYMOUS=true python -m semantica.explorer --graph explorer_e2e_test_graph.json --port 8000 --no-browser
|
||||
# Terminal 2 (Frontend UI):
|
||||
cd explorer && npm run dev
|
||||
# Open http://localhost:5173
|
||||
|
||||
# 2. Launch Explorer (Option B - Standalone CLI server):
|
||||
SEMANTICA_ALLOW_ANONYMOUS=true python -m semantica.explorer --graph explorer_e2e_test_graph.json --port 8000
|
||||
# Open http://localhost:8000
|
||||
|
||||
# Secure authentication alternative:
|
||||
export SEMANTICA_API_KEY="your-secret-api-key"
|
||||
python -m semantica.explorer --graph explorer_e2e_test_graph.json --port 8000
|
||||
# Send HTTP header: X-API-Key: your-secret-api-key
|
||||
|
||||
Verification Checklist:
|
||||
- Exactly 4 nodes visible on canvas:
|
||||
* Alice (Person, #63E6FF)
|
||||
* Bob (Person, #63E6FF)
|
||||
* Acme (Organization, #A78BFA)
|
||||
* New York (Location, #34D399)
|
||||
- Exactly 3 directed edges with canonical relationship labels:
|
||||
* Alice -> Acme (WORKS_AT)
|
||||
* Bob -> Alice (KNOWS)
|
||||
* Acme -> New York (LOCATED_IN)
|
||||
- Zoom behavior:
|
||||
* Zoom in to Inspection tier (ratio <= 0.5): directional arrows and node labels scale clearly.
|
||||
* Zoom out to Overview tier (ratio > 1.2): layout remains stable and non-colliding.
|
||||
- Hover & Selection interactions:
|
||||
* Hover over 'Alice': node halo triggers; incident edges (WORKS_AT, KNOWS) highlight in local context.
|
||||
* Click an edge: Inspector panel confirms edgeType ('WORKS_AT', 'KNOWS', or 'LOCATED_IN').
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
from semantica.context.context_graph import ContextGraph
|
||||
from semantica.explorer.session import GraphSession
|
||||
|
||||
|
||||
def build_deterministic_graph() -> ContextGraph:
|
||||
"""Build the exact 4-node, 3-edge graph specified in #1037."""
|
||||
graph = ContextGraph(advanced_analytics=False)
|
||||
|
||||
# 1. Add exactly 4 nodes
|
||||
graph.add_node(
|
||||
"alice",
|
||||
node_type="Person",
|
||||
content="Alice",
|
||||
color="#63E6FF",
|
||||
)
|
||||
graph.add_node(
|
||||
"bob",
|
||||
node_type="Person",
|
||||
content="Bob",
|
||||
color="#63E6FF",
|
||||
)
|
||||
graph.add_node(
|
||||
"acme",
|
||||
node_type="Organization",
|
||||
content="Acme",
|
||||
color="#A78BFA",
|
||||
)
|
||||
graph.add_node(
|
||||
"new_york",
|
||||
node_type="Location",
|
||||
content="New York",
|
||||
color="#34D399",
|
||||
)
|
||||
|
||||
# 2. Add exactly 3 directed edges
|
||||
graph.add_edge("alice", "acme", edge_type="WORKS_AT", weight=1.0)
|
||||
graph.add_edge("bob", "alice", edge_type="KNOWS", weight=1.0)
|
||||
graph.add_edge("acme", "new_york", edge_type="LOCATED_IN", weight=1.0)
|
||||
|
||||
return graph
|
||||
|
||||
|
||||
def main() -> None:
|
||||
print("=" * 75)
|
||||
print("Semantica Explorer Deterministic Graph Generator (#1037)")
|
||||
print("=" * 75)
|
||||
|
||||
print("1. Building deterministic ContextGraph...")
|
||||
graph = build_deterministic_graph()
|
||||
print(
|
||||
f" ✓ Graph built with {len(graph.nodes)} nodes "
|
||||
f"and {len(graph.edges)} edges."
|
||||
)
|
||||
|
||||
output_path = Path("explorer_e2e_test_graph.json").resolve()
|
||||
print(f"2. Persisting graph to '{output_path.name}'...")
|
||||
graph.save_to_file(str(output_path))
|
||||
print(f" ✓ Graph saved to {output_path}")
|
||||
|
||||
# Verify JSON format
|
||||
with open(output_path, "r", encoding="utf-8") as f:
|
||||
data = json.load(f)
|
||||
assert len(data.get("nodes", [])) == 4
|
||||
assert len(data.get("edges", [])) == 3
|
||||
|
||||
print("3. Verifying reload via GraphSession.from_file()...")
|
||||
session = GraphSession.from_file(str(output_path))
|
||||
stats = session.get_stats()
|
||||
nodes, total_nodes = session.get_nodes()
|
||||
edges, total_edges = session.get_edges()
|
||||
|
||||
assert stats["node_count"] == 4
|
||||
assert stats["edge_count"] == 3
|
||||
assert total_nodes == 4
|
||||
assert total_edges == 3
|
||||
|
||||
print(
|
||||
f" ✓ Graph reloaded successfully without mutation "
|
||||
f"(nodes: {total_nodes}, edges: {total_edges}).\n"
|
||||
)
|
||||
|
||||
print("=" * 75)
|
||||
print("Clean Checkout Prerequisites:")
|
||||
print("=" * 75)
|
||||
print(" pip install -e '.[explorer]'")
|
||||
print(" cd explorer && npm install && cd ..\n")
|
||||
|
||||
print("=" * 75)
|
||||
print("Reproduction instructions to view in Semantica Explorer:")
|
||||
print("=" * 75)
|
||||
print("Option A (Frontend dev server + API backend — recommended for development):")
|
||||
print(
|
||||
f" 1. Backend: SEMANTICA_ALLOW_ANONYMOUS=true python -m semantica.explorer "
|
||||
f"--graph {output_path} --port 8000 --no-browser"
|
||||
)
|
||||
print(" 2. Frontend: cd explorer && npm run dev")
|
||||
print(" 3. Open http://localhost:5173 to inspect the graph canvas.\n")
|
||||
|
||||
print("Option B (Standalone Explorer CLI server):")
|
||||
print(
|
||||
f" SEMANTICA_ALLOW_ANONYMOUS=true python -m semantica.explorer "
|
||||
f"--graph {output_path} --port 8000"
|
||||
)
|
||||
print(" Open http://localhost:8000\n")
|
||||
|
||||
print("Secure Authentication Alternative:")
|
||||
print(" export SEMANTICA_API_KEY='your-secret-api-key'")
|
||||
print(
|
||||
f" python -m semantica.explorer --graph {output_path} --port 8000"
|
||||
)
|
||||
print(" Send header: 'X-API-Key: your-secret-api-key'\n")
|
||||
|
||||
print("=" * 75)
|
||||
print("Verification Checklist:")
|
||||
print("=" * 75)
|
||||
print(" 1. Nodes (4 total):")
|
||||
print(" - Alice (Person, #63E6FF)")
|
||||
print(" - Bob (Person, #63E6FF)")
|
||||
print(" - Acme (Organization, #A78BFA)")
|
||||
print(" - New York (Location, #34D399)")
|
||||
print(" 2. Directed Edges & Canonical Labels (3 total):")
|
||||
print(" - Alice -> Acme [WORKS_AT]")
|
||||
print(" - Bob -> Alice [KNOWS]")
|
||||
print(" - Acme -> New York [LOCATED_IN]")
|
||||
print(" 3. Zoom Interactions:")
|
||||
print(" - Inspection tier (zoom in): directional arrows & labels remain legible.")
|
||||
print(" - Overview tier (zoom out): nodes and edges maintain layout integrity.")
|
||||
print(" 4. Hover & Selection Interactions:")
|
||||
print(" - Hover Alice: node halo triggers and incident edges (WORKS_AT, KNOWS) highlight.")
|
||||
print(" - Click edge: Inspector panel displays edgeType label ('WORKS_AT', 'KNOWS', 'LOCATED_IN').")
|
||||
print("=" * 75)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -9,7 +9,8 @@
|
||||
"lint": "eslint .",
|
||||
"preview": "vite preview",
|
||||
"test:graph-store": "node --test tests/graphStore.multi-edge.test.mjs",
|
||||
"test:graph-workspace": "node --import tsx --test tests/markdownContentViewer.test.ts tests/graphSceneState.display.test.ts tests/temporalLifecycle.test.ts",
|
||||
"test:graph-workspace": "node --import tsx --test tests/markdownContentViewer.test.ts tests/graphSceneState.display.test.ts tests/temporalLifecycle.test.ts tests/deterministicExplorerRendering.test.ts",
|
||||
"test:deterministic-e2e": "node --import tsx --test tests/deterministicExplorerRendering.e2e.ts",
|
||||
"test:plugin-registry": "node --import tsx --test tests/pluginRegistry.temporal.test.mjs"
|
||||
},
|
||||
"dependencies": {
|
||||
|
||||
@@ -41,6 +41,7 @@ import {
|
||||
} from "./plugins";
|
||||
import { explorationEffectsShouldLoad, neighborhoodPanelShouldLoad, temporalOverlayShouldLoad } from "./pluginRegistryPredicates";
|
||||
import { shouldFetchTemporalBounds, shouldFetchTemporalSnapshot } from "./temporalLifecyclePredicates";
|
||||
import { createTemporalSnapshotGuards, type TemporalSnapshotResponse } from "./temporalSnapshotGuards";
|
||||
import type { LinkPrediction, PathResponse } from "./GraphInspectorPanel";
|
||||
import type { GraphSceneHandle, GraphSceneRuntime } from "./scene";
|
||||
import type {
|
||||
@@ -1479,6 +1480,23 @@ export function GraphWorkspace({ externalFocusNodeId, externalFocusToken }: Grap
|
||||
summary?.edgeCount,
|
||||
]);
|
||||
|
||||
// Guards the snapshot lifecycle: at most one in-flight request per scrubber
|
||||
// position (identical-`at` polls are deduplicated, breaking the idle/play
|
||||
// polling loop), applied snapshots are cached and re-applied on revisit, and
|
||||
// a response applies only while the scrubber is still on its position
|
||||
// (out-of-order responses cannot clobber the active-node count).
|
||||
const temporalSnapshotGuardsRef = useRef<ReturnType<typeof createTemporalSnapshotGuards> | null>(null);
|
||||
if (temporalSnapshotGuardsRef.current === null) {
|
||||
temporalSnapshotGuardsRef.current = createTemporalSnapshotGuards();
|
||||
}
|
||||
const temporalSnapshotGuards = temporalSnapshotGuardsRef.current;
|
||||
|
||||
// A new graph summary means the graph data was replaced (reload/retry);
|
||||
// snapshots cached against the previous graph are stale, so reset all state.
|
||||
useEffect(() => {
|
||||
temporalSnapshotGuards.reset();
|
||||
}, [summary]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!canFetchTemporalSnapshot) {
|
||||
return;
|
||||
@@ -1488,37 +1506,67 @@ export function GraphWorkspace({ externalFocusNodeId, externalFocusToken }: Grap
|
||||
return;
|
||||
}
|
||||
|
||||
const atMs = debouncedTime.getTime();
|
||||
const { seq, cached } = temporalSnapshotGuards.begin(atMs);
|
||||
if (seq === null) {
|
||||
// An identical request is already in flight: one request per position.
|
||||
return;
|
||||
}
|
||||
|
||||
let cancelled = false;
|
||||
|
||||
const applyData = (data: TemporalSnapshotResponse) => {
|
||||
const nextActiveIds = new Set(data.active_node_ids);
|
||||
requestAnimationFrame(() => {
|
||||
if (cancelled) return;
|
||||
if (!temporalSnapshotGuards.shouldApply(atMs, seq)) {
|
||||
// The scrubber moved on (or this request was superseded): release the
|
||||
// position so a return to it refetches instead of stalling.
|
||||
temporalSnapshotGuards.finish(atMs, seq);
|
||||
return;
|
||||
}
|
||||
const previous = prevActiveIdsRef.current;
|
||||
previous.forEach((id) => {
|
||||
if (!nextActiveIds.has(id) && graph.hasNode(id)) {
|
||||
graph.setNodeAttribute(id, "hidden", true);
|
||||
}
|
||||
});
|
||||
nextActiveIds.forEach((id) => {
|
||||
if (graph.hasNode(id)) {
|
||||
graph.setNodeAttribute(id, "hidden", false);
|
||||
}
|
||||
});
|
||||
prevActiveIdsRef.current = nextActiveIds;
|
||||
setActiveNodeCount(data.active_node_count);
|
||||
setGraphVersion((current) => current + 1);
|
||||
sceneRef.current?.getRuntime()?.requestRender();
|
||||
temporalSnapshotGuards.apply(atMs, seq, data);
|
||||
});
|
||||
};
|
||||
|
||||
if (cached) {
|
||||
// Returning to a position whose snapshot was already applied: re-apply
|
||||
// the cached result without a network request.
|
||||
applyData(cached);
|
||||
return;
|
||||
}
|
||||
|
||||
const applySnapshot = async () => {
|
||||
try {
|
||||
const at = debouncedTime.toISOString();
|
||||
const response = await fetch(`/api/temporal/snapshot?at=${encodeURIComponent(at)}`);
|
||||
if (!response.ok || cancelled) return;
|
||||
|
||||
const data: { active_node_ids: string[]; active_node_count: number } = await response.json();
|
||||
if (!response.ok) {
|
||||
// A failed request must be retryable if the scrubber returns.
|
||||
if (!cancelled) temporalSnapshotGuards.finish(atMs, seq);
|
||||
return;
|
||||
}
|
||||
if (cancelled) return;
|
||||
|
||||
const nextActiveIds = new Set(data.active_node_ids);
|
||||
requestAnimationFrame(() => {
|
||||
if (cancelled) return;
|
||||
const previous = prevActiveIdsRef.current;
|
||||
previous.forEach((id) => {
|
||||
if (!nextActiveIds.has(id) && graph.hasNode(id)) {
|
||||
graph.setNodeAttribute(id, "hidden", true);
|
||||
}
|
||||
});
|
||||
nextActiveIds.forEach((id) => {
|
||||
if (graph.hasNode(id)) {
|
||||
graph.setNodeAttribute(id, "hidden", false);
|
||||
}
|
||||
});
|
||||
prevActiveIdsRef.current = nextActiveIds;
|
||||
setActiveNodeCount(data.active_node_count);
|
||||
setGraphVersion((current) => current + 1);
|
||||
sceneRef.current?.getRuntime()?.requestRender();
|
||||
});
|
||||
const data: TemporalSnapshotResponse = await response.json();
|
||||
if (cancelled) return;
|
||||
applyData(data);
|
||||
} catch (fetchError) {
|
||||
temporalSnapshotGuards.finish(atMs, seq);
|
||||
if (!cancelled) {
|
||||
console.error("[Temporal] Snapshot fetch failed", fetchError);
|
||||
}
|
||||
@@ -1528,6 +1576,8 @@ export function GraphWorkspace({ externalFocusNodeId, externalFocusToken }: Grap
|
||||
applySnapshot();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
// A cancelled request must be retryable when its position is revisited.
|
||||
temporalSnapshotGuards.finish(atMs, seq);
|
||||
};
|
||||
}, [
|
||||
canFetchTemporalSnapshot,
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { useState, useRef, useEffect, type CSSProperties } from "react";
|
||||
import ReactMarkdown from "react-markdown";
|
||||
import { useState, useRef, useEffect, useMemo, type CSSProperties } from "react";
|
||||
import ReactMarkdown, { type Components } from "react-markdown";
|
||||
import remarkGfm from "remark-gfm";
|
||||
import { Check, Copy, Code2, Eye, ExternalLink, Image as ImageIcon } from "lucide-react";
|
||||
import { GRAPH_THEME } from "./graphTheme";
|
||||
import { isSafeUrl } from "./markdownUrlSafety";
|
||||
|
||||
export interface MarkdownContentViewerProps {
|
||||
content?: string | null;
|
||||
@@ -10,25 +11,6 @@ export interface MarkdownContentViewerProps {
|
||||
defaultMode?: "preview" | "source";
|
||||
}
|
||||
|
||||
export function isSafeUrl(url?: string): boolean {
|
||||
if (!url) return false;
|
||||
const trimmed = url.trim();
|
||||
// Reject whitespace-only strings — new URL("", base) would resolve to the base
|
||||
// protocol and produce a false positive. This guards direct callers of the exported
|
||||
// function; markdown parsers normalise whitespace-only destinations to "" which
|
||||
// already fails the !url check above.
|
||||
if (!trimmed) return false;
|
||||
if (trimmed.startsWith("//")) return false;
|
||||
if (trimmed.startsWith("#")) return true;
|
||||
if (trimmed.startsWith("/")) return true;
|
||||
try {
|
||||
const parsed = new URL(trimmed, "http://localhost");
|
||||
return ["http:", "https:", "mailto:"].includes(parsed.protocol);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function MarkdownContentViewer({
|
||||
content,
|
||||
className,
|
||||
@@ -65,6 +47,20 @@ export function MarkdownContentViewer({
|
||||
const rawContent = typeof content === "string" ? content : "";
|
||||
const hasContent = rawContent.trim().length > 0;
|
||||
|
||||
// react-markdown runs the whole remark pipeline synchronously inside its own
|
||||
// render, so without this memo every unrelated re-render of this component --
|
||||
// clicking Copy, toggling Preview/Source -- re-parses the entire document.
|
||||
// Measured at ~364ms per re-render for a 1000-row GFM table (issue #1118).
|
||||
// Keyed on rawContent so a genuine node change still re-parses exactly once.
|
||||
const renderedMarkdown = useMemo(
|
||||
() => (
|
||||
<ReactMarkdown remarkPlugins={REMARK_PLUGINS} components={MARKDOWN_COMPONENTS}>
|
||||
{rawContent}
|
||||
</ReactMarkdown>
|
||||
),
|
||||
[rawContent],
|
||||
);
|
||||
|
||||
const handleCopy = async () => {
|
||||
if (!hasContent) return;
|
||||
try {
|
||||
@@ -130,98 +126,103 @@ export function MarkdownContentViewer({
|
||||
<code style={sourceCodeStyle}>{rawContent}</code>
|
||||
</pre>
|
||||
) : (
|
||||
<div style={previewStyle}>
|
||||
<ReactMarkdown
|
||||
remarkPlugins={[remarkGfm]}
|
||||
components={{
|
||||
// C-1: react-markdown passes a HAST `node` prop (the raw AST
|
||||
// Element) to every custom component override via passNode:true.
|
||||
// In React 19 any unknown prop spreads onto a native element are
|
||||
// serialised as HTML attributes, producing node="[object Object]"
|
||||
// on every rendered link. Fix: destructure `node` by name so it
|
||||
// is explicitly discarded, then spread `...rest` to preserve all
|
||||
// other legitimate HAST/remark-gfm attributes — e.g. the `id`,
|
||||
// `aria-describedby`, `aria-label`, `data-footnote-ref`,
|
||||
// `data-footnote-backref`, and `class` attrs that GFM footnotes
|
||||
// require for correct in-page navigation and accessibility.
|
||||
//
|
||||
// C-2: fragment links (#anchor, GFM footnote backlinks) must
|
||||
// navigate within the current document. External links continue
|
||||
// to use target="_blank" with noopener noreferrer.
|
||||
//
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
a: ({ href, children, title, node: _node, ...rest }) => {
|
||||
if (!isSafeUrl(href)) {
|
||||
return <span style={{ color: GRAPH_THEME.ui.text.muted, textDecoration: "line-through" }}>{children}</span>;
|
||||
}
|
||||
// isSafeUrl returning true guarantees href is a non-empty string.
|
||||
const safeHref = href ?? "";
|
||||
// Fragment links (#section, footnote backlinks like
|
||||
// #user-content-fnref-1) are in-document anchors. Opening them
|
||||
// in a new tab would break GFM footnote back-navigation.
|
||||
const isFragment = safeHref.startsWith("#");
|
||||
if (isFragment) {
|
||||
return (
|
||||
<a href={safeHref} title={title} style={linkStyle} {...rest}>
|
||||
{children}
|
||||
</a>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<a href={safeHref} title={title} target="_blank" rel="noopener noreferrer" style={linkStyle} {...rest}>
|
||||
{children}
|
||||
<ExternalLink size={10} style={{ marginLeft: 3, verticalAlign: "middle", display: "inline" }} />
|
||||
</a>
|
||||
);
|
||||
},
|
||||
img: ({ src, alt }) => (
|
||||
<span style={imageBadgeStyle} title={src || "Image"}>
|
||||
<ImageIcon size={12} style={{ marginRight: 5 }} />
|
||||
<span>Image: {alt || src || "unlabeled"}</span>
|
||||
</span>
|
||||
),
|
||||
h1: ({ children }) => <h1 style={h1Style}>{children}</h1>,
|
||||
h2: ({ children }) => <h2 style={h2Style}>{children}</h2>,
|
||||
h3: ({ children }) => <h3 style={h3Style}>{children}</h3>,
|
||||
h4: ({ children }) => <h4 style={h4Style}>{children}</h4>,
|
||||
p: ({ children }) => <p style={{ margin: "0 0 8px 0" }}>{children}</p>,
|
||||
ul: ({ children }) => <ul style={{ margin: "0 0 8px 0", paddingLeft: 18 }}>{children}</ul>,
|
||||
ol: ({ children }) => <ol style={{ margin: "0 0 8px 0", paddingLeft: 18 }}>{children}</ol>,
|
||||
li: ({ children }) => <li style={{ marginBottom: 3 }}>{children}</li>,
|
||||
blockquote: ({ children }) => <blockquote style={blockquoteStyle}>{children}</blockquote>,
|
||||
hr: () => <hr style={{ border: "none", borderTop: `1px solid ${GRAPH_THEME.ui.surface.panelBorder}`, margin: "10px 0" }} />,
|
||||
table: ({ children }) => (
|
||||
<div style={{ width: "100%", overflowX: "auto", margin: "8px 0", borderRadius: 6, border: `1px solid ${GRAPH_THEME.ui.surface.panelBorder}` }}>
|
||||
<table style={{ width: "100%", borderCollapse: "collapse", fontSize: 12 }}>{children}</table>
|
||||
</div>
|
||||
),
|
||||
thead: ({ children }) => <thead style={{ background: "rgba(255, 255, 255, 0.04)" }}>{children}</thead>,
|
||||
tbody: ({ children }) => <tbody>{children}</tbody>,
|
||||
tr: ({ children }) => <tr style={{ borderBottom: `1px solid ${GRAPH_THEME.ui.surface.panelBorder}` }}>{children}</tr>,
|
||||
th: ({ children }) => <th style={{ padding: "6px 8px", textAlign: "left", fontWeight: 700, color: GRAPH_THEME.ui.text.strong, borderRight: `1px solid ${GRAPH_THEME.ui.surface.panelBorder}` }}>{children}</th>,
|
||||
td: ({ children }) => <td style={{ padding: "6px 8px", color: GRAPH_THEME.ui.text.body, borderRight: `1px solid ${GRAPH_THEME.ui.surface.panelBorder}` }}>{children}</td>,
|
||||
pre: ({ children }) => <pre style={preBlockStyle}>{children}</pre>,
|
||||
// C-1: discard `node` here too — code elements are custom components
|
||||
// and would otherwise receive node="[object Object]" in the DOM.
|
||||
code: ({ className: codeClass, children }) => {
|
||||
const isInline = !codeClass && typeof children === "string" && !children.includes("\n");
|
||||
return (
|
||||
<code style={isInline ? inlineCodeStyle : blockCodeStyle}>
|
||||
{children}
|
||||
</code>
|
||||
);
|
||||
},
|
||||
}}
|
||||
>
|
||||
{rawContent}
|
||||
</ReactMarkdown>
|
||||
</div>
|
||||
<div style={previewStyle}>{renderedMarkdown}</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ─── Markdown rendering config ───────────────────────────────────── */
|
||||
|
||||
// Both props are hoisted to module scope so they keep a stable identity across
|
||||
// renders. As inline literals they allocated a fresh plugin array and ~20 fresh
|
||||
// arrow components on every render, which made React treat every mapped tag as a
|
||||
// new element type and remount the entire rendered subtree instead of updating
|
||||
// it (issue #1118). The arrow bodies only read the style constants below at call
|
||||
// time, so declaring the map before them is safe.
|
||||
const REMARK_PLUGINS = [remarkGfm];
|
||||
|
||||
const MARKDOWN_COMPONENTS: Components = {
|
||||
// C-1: react-markdown passes a HAST `node` prop (the raw AST
|
||||
// Element) to every custom component override via passNode:true.
|
||||
// In React 19 any unknown prop spreads onto a native element are
|
||||
// serialised as HTML attributes, producing node="[object Object]"
|
||||
// on every rendered link. Fix: destructure `node` by name so it
|
||||
// is explicitly discarded, then spread `...rest` to preserve all
|
||||
// other legitimate HAST/remark-gfm attributes — e.g. the `id`,
|
||||
// `aria-describedby`, `aria-label`, `data-footnote-ref`,
|
||||
// `data-footnote-backref`, and `class` attrs that GFM footnotes
|
||||
// require for correct in-page navigation and accessibility.
|
||||
//
|
||||
// C-2: fragment links (#anchor, GFM footnote backlinks) must
|
||||
// navigate within the current document. External links continue
|
||||
// to use target="_blank" with noopener noreferrer.
|
||||
//
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
a: ({ href, children, title, node: _node, ...rest }) => {
|
||||
if (!isSafeUrl(href)) {
|
||||
return <span style={{ color: GRAPH_THEME.ui.text.muted, textDecoration: "line-through" }}>{children}</span>;
|
||||
}
|
||||
// isSafeUrl returning true guarantees href is a non-empty string.
|
||||
const safeHref = href ?? "";
|
||||
// Fragment links (#section, footnote backlinks like
|
||||
// #user-content-fnref-1) are in-document anchors. Opening them
|
||||
// in a new tab would break GFM footnote back-navigation.
|
||||
const isFragment = safeHref.startsWith("#");
|
||||
if (isFragment) {
|
||||
return (
|
||||
<a href={safeHref} title={title} style={linkStyle} {...rest}>
|
||||
{children}
|
||||
</a>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<a href={safeHref} title={title} target="_blank" rel="noopener noreferrer" style={linkStyle} {...rest}>
|
||||
{children}
|
||||
<ExternalLink size={10} style={{ marginLeft: 3, verticalAlign: "middle", display: "inline" }} />
|
||||
</a>
|
||||
);
|
||||
},
|
||||
img: ({ src, alt }) => (
|
||||
<span style={imageBadgeStyle} title={src || "Image"}>
|
||||
<ImageIcon size={12} style={{ marginRight: 5 }} />
|
||||
<span>Image: {alt || src || "unlabeled"}</span>
|
||||
</span>
|
||||
),
|
||||
h1: ({ children }) => <h1 style={h1Style}>{children}</h1>,
|
||||
h2: ({ children }) => <h2 style={h2Style}>{children}</h2>,
|
||||
h3: ({ children }) => <h3 style={h3Style}>{children}</h3>,
|
||||
h4: ({ children }) => <h4 style={h4Style}>{children}</h4>,
|
||||
p: ({ children }) => <p style={{ margin: "0 0 8px 0" }}>{children}</p>,
|
||||
ul: ({ children }) => <ul style={{ margin: "0 0 8px 0", paddingLeft: 18 }}>{children}</ul>,
|
||||
ol: ({ children }) => <ol style={{ margin: "0 0 8px 0", paddingLeft: 18 }}>{children}</ol>,
|
||||
li: ({ children }) => <li style={{ marginBottom: 3 }}>{children}</li>,
|
||||
blockquote: ({ children }) => <blockquote style={blockquoteStyle}>{children}</blockquote>,
|
||||
hr: () => <hr style={{ border: "none", borderTop: `1px solid ${GRAPH_THEME.ui.surface.panelBorder}`, margin: "10px 0" }} />,
|
||||
table: ({ children }) => (
|
||||
<div style={{ width: "100%", overflowX: "auto", margin: "8px 0", borderRadius: 6, border: `1px solid ${GRAPH_THEME.ui.surface.panelBorder}` }}>
|
||||
<table style={{ width: "100%", borderCollapse: "collapse", fontSize: 12 }}>{children}</table>
|
||||
</div>
|
||||
),
|
||||
thead: ({ children }) => <thead style={{ background: "rgba(255, 255, 255, 0.04)" }}>{children}</thead>,
|
||||
tbody: ({ children }) => <tbody>{children}</tbody>,
|
||||
tr: ({ children }) => <tr style={{ borderBottom: `1px solid ${GRAPH_THEME.ui.surface.panelBorder}` }}>{children}</tr>,
|
||||
th: ({ children }) => <th style={{ padding: "6px 8px", textAlign: "left", fontWeight: 700, color: GRAPH_THEME.ui.text.strong, borderRight: `1px solid ${GRAPH_THEME.ui.surface.panelBorder}` }}>{children}</th>,
|
||||
td: ({ children }) => <td style={{ padding: "6px 8px", color: GRAPH_THEME.ui.text.body, borderRight: `1px solid ${GRAPH_THEME.ui.surface.panelBorder}` }}>{children}</td>,
|
||||
pre: ({ children }) => <pre style={preBlockStyle}>{children}</pre>,
|
||||
// C-1: discard `node` here too — code elements are custom components
|
||||
// and would otherwise receive node="[object Object]" in the DOM.
|
||||
code: ({ className: codeClass, children }) => {
|
||||
const isInline = !codeClass && typeof children === "string" && !children.includes("\n");
|
||||
return (
|
||||
<code style={isInline ? inlineCodeStyle : blockCodeStyle}>
|
||||
{children}
|
||||
</code>
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
/* ─── Styles ──────────────────────────────────────────────────────── */
|
||||
|
||||
const viewerContainerStyle: CSSProperties = {
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
/**
|
||||
* URL-safety predicate for the Markdown content viewer.
|
||||
*
|
||||
* Extracted into a pure module so the check can be unit-tested without
|
||||
* importing the MarkdownContentViewer React component, and so the component
|
||||
* module exports only components (react-refresh/only-export-components,
|
||||
* issue #1119). The behaviour is unchanged from the original in-component
|
||||
* implementation: only http, https, mailto, in-document fragments, and
|
||||
* root-relative paths are permitted.
|
||||
*/
|
||||
|
||||
export function isSafeUrl(url?: string): boolean {
|
||||
if (!url) return false;
|
||||
const trimmed = url.trim();
|
||||
// Reject whitespace-only strings — new URL("", base) would resolve to the base
|
||||
// protocol and produce a false positive. This guards direct callers of the exported
|
||||
// function; markdown parsers normalise whitespace-only destinations to "" which
|
||||
// already fails the !url check above.
|
||||
if (!trimmed) return false;
|
||||
if (trimmed.startsWith("//")) return false;
|
||||
if (trimmed.startsWith("#")) return true;
|
||||
if (trimmed.startsWith("/")) return true;
|
||||
try {
|
||||
const parsed = new URL(trimmed, "http://localhost");
|
||||
return ["http:", "https:", "mailto:"].includes(parsed.protocol);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
/**
|
||||
* Guards for the temporal snapshot fetch/apply lifecycle.
|
||||
*
|
||||
* The snapshot effect previously fetched /api/temporal/snapshot with no
|
||||
* idempotency or ordering protection. Upstream churn (timeline recreation
|
||||
* while bounds settle, play ticks resetting the playhead, drag events) could
|
||||
* re-request the same `at` repeatedly, and responses could arrive after the
|
||||
* scrubber had moved on.
|
||||
*
|
||||
* The guards enforce:
|
||||
* - at most one in-flight request per scrubber position (identical `at`
|
||||
* values are deduplicated while a request is pending, breaking the
|
||||
* idle/play polling loop);
|
||||
* - successful snapshots are cached per position and re-applied when the
|
||||
* scrubber returns (play wrap-around, back-scrubbing) without a refetch;
|
||||
* - a response is applied only while the scrubber is still on its position,
|
||||
* so out-of-order responses cannot clobber a newer position's count;
|
||||
* - failed, cancelled, or superseded requests release their position so it
|
||||
* can be fetched again on the next visit;
|
||||
* - `reset()` drops all state when the underlying graph data is replaced
|
||||
* (reload/retry), because cached snapshots describe the previous graph.
|
||||
*
|
||||
* `createTemporalSnapshotGuards()` is stateful by design.
|
||||
*/
|
||||
|
||||
export interface TemporalSnapshotResponse {
|
||||
active_node_ids: string[];
|
||||
active_node_count: number;
|
||||
}
|
||||
|
||||
export interface TemporalSnapshotRequest {
|
||||
/** null when the request was deduplicated because one is already in flight. */
|
||||
seq: number | null;
|
||||
/** The snapshot previously applied for this position, when revisiting it. */
|
||||
cached: TemporalSnapshotResponse | null;
|
||||
}
|
||||
|
||||
export interface TemporalSnapshotGuards {
|
||||
/** Begin (or dedupe) a request for `atMs`; marks it as the current position. */
|
||||
begin(atMs: number): TemporalSnapshotRequest;
|
||||
/** True when the response for `atMs`/`seq` may be applied (scrubber still on `atMs`). */
|
||||
shouldApply(atMs: number, seq: number): boolean;
|
||||
/** Record a successful application and cache its snapshot for revisits. */
|
||||
apply(atMs: number, seq: number, data: TemporalSnapshotResponse): void;
|
||||
/** Release a position whose request failed, was cancelled, or was superseded. */
|
||||
finish(atMs: number, seq: number): void;
|
||||
/** Drop all state; call when the underlying graph data is replaced (reload). */
|
||||
reset(): void;
|
||||
}
|
||||
|
||||
interface SnapshotEntry {
|
||||
seq: number;
|
||||
/** null while the request is in flight (or before the first success). */
|
||||
data: TemporalSnapshotResponse | null;
|
||||
}
|
||||
|
||||
/** Upper bound on cached positions so long scrubbing sessions stay bounded. */
|
||||
const MAX_CACHED_POSITIONS = 256;
|
||||
|
||||
export function createTemporalSnapshotGuards(): TemporalSnapshotGuards {
|
||||
const entries = new Map<number, SnapshotEntry>();
|
||||
let latestRequestSeq = 0;
|
||||
let currentAtMs: number | null = null;
|
||||
|
||||
const evictOldest = () => {
|
||||
while (entries.size > MAX_CACHED_POSITIONS) {
|
||||
const oldestAtMs = entries.keys().next().value;
|
||||
if (oldestAtMs === undefined) return;
|
||||
entries.delete(oldestAtMs);
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
begin(atMs) {
|
||||
const existing = entries.get(atMs);
|
||||
if (existing && existing.data === null) {
|
||||
// Identical request already in flight: dedupe, but the scrubber is here now.
|
||||
currentAtMs = atMs;
|
||||
return { seq: null, cached: null };
|
||||
}
|
||||
latestRequestSeq += 1;
|
||||
const seq = latestRequestSeq;
|
||||
entries.set(atMs, { seq, data: existing?.data ?? null });
|
||||
currentAtMs = atMs;
|
||||
evictOldest();
|
||||
return { seq, cached: existing?.data ?? null };
|
||||
},
|
||||
|
||||
shouldApply(atMs, seq) {
|
||||
return atMs === currentAtMs && entries.get(atMs)?.seq === seq;
|
||||
},
|
||||
|
||||
apply(atMs, seq, data) {
|
||||
const entry = entries.get(atMs);
|
||||
if (entry && entry.seq === seq) {
|
||||
entry.data = data;
|
||||
}
|
||||
},
|
||||
|
||||
finish(atMs, seq) {
|
||||
const entry = entries.get(atMs);
|
||||
if (entry && entry.seq === seq && entry.data === null) {
|
||||
entries.delete(atMs);
|
||||
}
|
||||
},
|
||||
|
||||
reset() {
|
||||
entries.clear();
|
||||
latestRequestSeq = 0;
|
||||
currentAtMs = null;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { spawn, type ChildProcess } from "node:child_process";
|
||||
import { existsSync } from "node:fs";
|
||||
import { setTimeout as delay } from "node:timers/promises";
|
||||
import test from "node:test";
|
||||
import { chromium, type Page } from "playwright";
|
||||
|
||||
const PORT = 4173;
|
||||
const BASE_URL = `http://127.0.0.1:${PORT}`;
|
||||
|
||||
const nodes = [
|
||||
{ id: "alice", type: "Person", content: "Alice", properties: {} },
|
||||
{ id: "bob", type: "Person", content: "Bob", properties: {} },
|
||||
{ id: "acme", type: "Organization", content: "Acme", properties: {} },
|
||||
{ id: "new_york", type: "Location", content: "New York", properties: {} },
|
||||
];
|
||||
|
||||
const edges = [
|
||||
{ id: "edge_alice_acme", familyId: "edge_alice_acme", source: "alice", target: "acme", type: "WORKS_AT", weight: 1, properties: {} },
|
||||
{ id: "edge_bob_alice", familyId: "edge_bob_alice", source: "bob", target: "alice", type: "KNOWS", weight: 1, properties: {} },
|
||||
{ id: "edge_acme_new_york", familyId: "edge_acme_new_york", source: "acme", target: "new_york", type: "LOCATED_IN", weight: 1, properties: {} },
|
||||
];
|
||||
|
||||
let server: ChildProcess | undefined;
|
||||
|
||||
async function startVite(): Promise<void> {
|
||||
server = spawn("npm", ["run", "dev", "--", "--host", "127.0.0.1", "--port", String(PORT)], {
|
||||
cwd: process.cwd(),
|
||||
stdio: "ignore",
|
||||
});
|
||||
|
||||
for (let attempt = 0; attempt < 50; attempt += 1) {
|
||||
try {
|
||||
const response = await fetch(BASE_URL);
|
||||
if (response.ok) return;
|
||||
} catch {
|
||||
// Vite is still starting.
|
||||
}
|
||||
await delay(100);
|
||||
}
|
||||
throw new Error("Vite did not become ready");
|
||||
}
|
||||
|
||||
async function installApiFixture(page: Page): Promise<void> {
|
||||
await page.route("**/api/graph/**", async (route) => {
|
||||
const pathname = new URL(route.request().url()).pathname;
|
||||
if (pathname === "/api/graph/stats") {
|
||||
await route.fulfill({ json: { node_count: 4, edge_count: 3 } });
|
||||
} else if (pathname === "/api/graph/nodes") {
|
||||
await route.fulfill({ json: { nodes, total: nodes.length, skip: 0, limit: 1000, next_cursor: null } });
|
||||
} else if (pathname === "/api/graph/edges") {
|
||||
await route.fulfill({ json: { edges, total: edges.length, skip: 0, limit: 1000, next_cursor: null } });
|
||||
} else {
|
||||
await route.continue();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
test("real Explorer loading path hydrates and renders API edge labels", async (t) => {
|
||||
await startVite();
|
||||
t.after(async () => {
|
||||
server?.kill();
|
||||
});
|
||||
|
||||
const browser = await chromium.launch({
|
||||
headless: true,
|
||||
executablePath: process.env.CHROMIUM_PATH || (existsSync("/usr/bin/chromium") ? "/usr/bin/chromium" : undefined),
|
||||
});
|
||||
t.after(() => browser.close());
|
||||
const page = await browser.newPage({ viewport: { width: 1440, height: 1000 } });
|
||||
|
||||
await page.addInitScript(() => {
|
||||
const captured = (window as Window & { __capturedCanvasText?: string[] }).__capturedCanvasText = [];
|
||||
const originalFillText = CanvasRenderingContext2D.prototype.fillText;
|
||||
CanvasRenderingContext2D.prototype.fillText = function (text: string, ...args: [number, number, number?, number?]) {
|
||||
captured.push(String(text));
|
||||
return originalFillText.call(this, text, ...args);
|
||||
};
|
||||
});
|
||||
await installApiFixture(page);
|
||||
await page.goto(BASE_URL);
|
||||
await page.getByRole("button", { name: /Open Semantica Explorer/ }).click();
|
||||
|
||||
await page.locator("canvas").nth(0).waitFor({ state: "attached" });
|
||||
await page.waitForFunction(() => document.querySelectorAll("canvas").length >= 2);
|
||||
await page.waitForFunction(() => {
|
||||
const labels = (window as Window & { __capturedCanvasText?: string[] }).__capturedCanvasText ?? [];
|
||||
return ["WORKS_AT", "KNOWS", "LOCATED_IN"].every((label) => labels.includes(label));
|
||||
}, undefined, { timeout: 10_000 });
|
||||
|
||||
const capturedLabels = await page.evaluate(() => (window as Window & { __capturedCanvasText?: string[] }).__capturedCanvasText ?? []);
|
||||
for (const label of ["WORKS_AT", "KNOWS", "LOCATED_IN"]) {
|
||||
assert.ok(capturedLabels.includes(label), `Expected rendered edge label ${label}`);
|
||||
}
|
||||
assert.ok(capturedLabels.includes("Alice"));
|
||||
|
||||
await page.getByRole("button", { name: "Zoom In" }).click();
|
||||
await page.waitForTimeout(250);
|
||||
const labelsAfterZoom = await page.evaluate(() => (window as Window & { __capturedCanvasText?: string[] }).__capturedCanvasText ?? []);
|
||||
for (const label of ["WORKS_AT", "KNOWS", "LOCATED_IN"]) {
|
||||
assert.ok(labelsAfterZoom.includes(label), `Expected edge label ${label} after zoom`);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,508 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import {
|
||||
batchMergeEdges,
|
||||
batchMergeNodes,
|
||||
clearGraph,
|
||||
graph,
|
||||
} from "../src/store/graphStore.ts";
|
||||
import {
|
||||
buildStructuralDistanceSnapshot,
|
||||
classifyFullGraphEdge,
|
||||
resolveDisplayGraph,
|
||||
resolveEdgeElementStyle,
|
||||
resolveEdgeVisualState,
|
||||
resolveNodeElementStyle,
|
||||
resolveNodeVisualState,
|
||||
shouldForceNodeLabel,
|
||||
} from "../src/workspaces/GraphWorkspace/graphSceneState.ts";
|
||||
import { GRAPH_THEME, type GraphZoomTier } from "../src/workspaces/GraphWorkspace/graphTheme.ts";
|
||||
|
||||
test.beforeEach(() => {
|
||||
clearGraph();
|
||||
});
|
||||
|
||||
test.after(() => {
|
||||
clearGraph();
|
||||
});
|
||||
|
||||
/**
|
||||
* Loads the canonical 4-node, 3-edge deterministic test graph (Semantica #1037).
|
||||
*
|
||||
* Graph structure:
|
||||
* Alice (Person) --WORKS_AT--> Acme (Organization)
|
||||
* Bob (Person) --KNOWS--> Alice (Person)
|
||||
* Acme (Organization) --LOCATED_IN--> New York (Location)
|
||||
*/
|
||||
function loadDeterministicTestGraph() {
|
||||
batchMergeNodes([
|
||||
{
|
||||
id: "alice",
|
||||
attributes: {
|
||||
label: "Alice",
|
||||
content: "Alice",
|
||||
x: 0,
|
||||
y: 0,
|
||||
size: 8,
|
||||
color: "#63E6FF",
|
||||
baseColor: "#63E6FF",
|
||||
nodeType: "Person",
|
||||
semanticGroup: "Person",
|
||||
properties: {},
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "bob",
|
||||
attributes: {
|
||||
label: "Bob",
|
||||
content: "Bob",
|
||||
x: -50,
|
||||
y: 0,
|
||||
size: 8,
|
||||
color: "#63E6FF",
|
||||
baseColor: "#63E6FF",
|
||||
nodeType: "Person",
|
||||
semanticGroup: "Person",
|
||||
properties: {},
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "acme",
|
||||
attributes: {
|
||||
label: "Acme",
|
||||
content: "Acme",
|
||||
x: 50,
|
||||
y: 0,
|
||||
size: 8,
|
||||
color: "#A78BFA",
|
||||
baseColor: "#A78BFA",
|
||||
nodeType: "Organization",
|
||||
semanticGroup: "Organization",
|
||||
properties: {},
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "new_york",
|
||||
attributes: {
|
||||
label: "New York",
|
||||
content: "New York",
|
||||
x: 100,
|
||||
y: 0,
|
||||
size: 8,
|
||||
color: "#34D399",
|
||||
baseColor: "#34D399",
|
||||
nodeType: "Location",
|
||||
semanticGroup: "Location",
|
||||
properties: {},
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
batchMergeEdges([
|
||||
{
|
||||
id: "edge_alice_acme",
|
||||
source: "alice",
|
||||
target: "acme",
|
||||
attributes: {
|
||||
edgeId: "edge_alice_acme",
|
||||
edgeType: "WORKS_AT",
|
||||
weight: 1.0,
|
||||
visualPriority: 0.8,
|
||||
baseSize: 0.8,
|
||||
properties: {},
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "edge_bob_alice",
|
||||
source: "bob",
|
||||
target: "alice",
|
||||
attributes: {
|
||||
edgeId: "edge_bob_alice",
|
||||
edgeType: "KNOWS",
|
||||
weight: 1.0,
|
||||
visualPriority: 0.8,
|
||||
baseSize: 0.8,
|
||||
properties: {},
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "edge_acme_new_york",
|
||||
source: "acme",
|
||||
target: "new_york",
|
||||
attributes: {
|
||||
edgeId: "edge_acme_new_york",
|
||||
edgeType: "LOCATED_IN",
|
||||
weight: 1.0,
|
||||
visualPriority: 0.8,
|
||||
baseSize: 0.8,
|
||||
properties: {},
|
||||
},
|
||||
},
|
||||
]);
|
||||
}
|
||||
|
||||
test("deterministic graph contains exactly 4 nodes and 3 edges in store", () => {
|
||||
loadDeterministicTestGraph();
|
||||
|
||||
assert.equal(graph.order, 4, "Expected exactly 4 nodes");
|
||||
assert.equal(graph.size, 3, "Expected exactly 3 edges");
|
||||
|
||||
// Verify node identities and labels
|
||||
const alice = graph.getNodeAttributes("alice");
|
||||
const bob = graph.getNodeAttributes("bob");
|
||||
const acme = graph.getNodeAttributes("acme");
|
||||
const newYork = graph.getNodeAttributes("new_york");
|
||||
|
||||
assert.equal(alice.label, "Alice");
|
||||
assert.equal(alice.nodeType, "Person");
|
||||
assert.equal(alice.color, "#63E6FF");
|
||||
|
||||
assert.equal(bob.label, "Bob");
|
||||
assert.equal(bob.nodeType, "Person");
|
||||
assert.equal(bob.color, "#63E6FF");
|
||||
|
||||
assert.equal(acme.label, "Acme");
|
||||
assert.equal(acme.nodeType, "Organization");
|
||||
assert.equal(acme.color, "#A78BFA");
|
||||
|
||||
assert.equal(newYork.label, "New York");
|
||||
assert.equal(newYork.nodeType, "Location");
|
||||
assert.equal(newYork.color, "#34D399");
|
||||
|
||||
// Verify edge connectivity and canonical edgeType labels
|
||||
const edgeAliceAcme = graph.getEdgeAttributes("edge_alice_acme");
|
||||
const edgeBobAlice = graph.getEdgeAttributes("edge_bob_alice");
|
||||
const edgeAcmeNewYork = graph.getEdgeAttributes("edge_acme_new_york");
|
||||
|
||||
assert.equal(edgeAliceAcme.edgeType, "WORKS_AT");
|
||||
assert.equal(graph.source("edge_alice_acme"), "alice");
|
||||
assert.equal(graph.target("edge_alice_acme"), "acme");
|
||||
|
||||
assert.equal(edgeBobAlice.edgeType, "KNOWS");
|
||||
assert.equal(graph.source("edge_bob_alice"), "bob");
|
||||
assert.equal(graph.target("edge_bob_alice"), "alice");
|
||||
|
||||
assert.equal(edgeAcmeNewYork.edgeType, "LOCATED_IN");
|
||||
assert.equal(graph.source("edge_acme_new_york"), "acme");
|
||||
assert.equal(graph.target("edge_acme_new_york"), "new_york");
|
||||
});
|
||||
|
||||
test("display graph resolution preserves all 4 nodes and 3 edges in full view", () => {
|
||||
loadDeterministicTestGraph();
|
||||
|
||||
const { graph: displayGraph } = resolveDisplayGraph("", [], [], "full", { aggregationEnabled: false });
|
||||
|
||||
assert.equal(displayGraph.order, 4);
|
||||
assert.equal(displayGraph.size, 3);
|
||||
assert.ok(displayGraph.hasNode("alice"));
|
||||
assert.ok(displayGraph.hasNode("bob"));
|
||||
assert.ok(displayGraph.hasNode("acme"));
|
||||
assert.ok(displayGraph.hasNode("new_york"));
|
||||
assert.ok(displayGraph.hasEdge("edge_alice_acme"));
|
||||
assert.ok(displayGraph.hasEdge("edge_bob_alice"));
|
||||
assert.ok(displayGraph.hasEdge("edge_acme_new_york"));
|
||||
});
|
||||
|
||||
test("structural distance calculation resolves correct hop counts across the 3-edge chain", () => {
|
||||
loadDeterministicTestGraph();
|
||||
|
||||
// From Bob: Bob (0) -> Alice (1) -> Acme (2) -> New York (3)
|
||||
const distances = buildStructuralDistanceSnapshot(graph, "bob", 3);
|
||||
|
||||
assert.equal(distances.bob, 0);
|
||||
assert.equal(distances.alice, 1);
|
||||
assert.equal(distances.acme, 2);
|
||||
assert.equal(distances.new_york, 3);
|
||||
});
|
||||
|
||||
test("edge rendering and canonical edge labels remain legible across zoom tiers and inspection modes", () => {
|
||||
loadDeterministicTestGraph();
|
||||
|
||||
const canonicalEdges = [
|
||||
{ id: "edge_alice_acme", source: "alice", target: "acme", label: "WORKS_AT" },
|
||||
{ id: "edge_bob_alice", source: "bob", target: "alice", label: "KNOWS" },
|
||||
{ id: "edge_acme_new_york", source: "acme", target: "new_york", label: "LOCATED_IN" },
|
||||
];
|
||||
|
||||
// 1. Edge attributes preserve canonical edgeType labels in graph store:
|
||||
for (const item of canonicalEdges) {
|
||||
const attrs = graph.getEdgeAttributes(item.id);
|
||||
assert.equal(attrs.edgeType, item.label, `Edge ${item.id} must have edgeType ${item.label}`);
|
||||
assert.equal(graph.source(item.id), item.source);
|
||||
assert.equal(graph.target(item.id), item.target);
|
||||
}
|
||||
|
||||
// 2. In active context / neighbor state across all zoom tiers (overview, structure, inspection):
|
||||
const allTiers: GraphZoomTier[] = ["overview", "structure", "inspection"];
|
||||
for (const tier of allTiers) {
|
||||
for (const item of canonicalEdges) {
|
||||
const attrs = graph.getEdgeAttributes(item.id);
|
||||
const contextStyle = resolveEdgeElementStyle(
|
||||
GRAPH_THEME,
|
||||
tier,
|
||||
"neighbor",
|
||||
attrs,
|
||||
item.source,
|
||||
item.target,
|
||||
"full",
|
||||
item.id,
|
||||
);
|
||||
|
||||
assert.equal(
|
||||
contextStyle.hidden,
|
||||
false,
|
||||
`Edge ${item.id} (${item.label}) in context state 'neighbor' must be visible in zoom tier '${tier}'`,
|
||||
);
|
||||
assert.ok(
|
||||
contextStyle.size !== undefined && contextStyle.size > 0,
|
||||
`Edge ${item.id} (${item.label}) must have positive render size in zoom tier '${tier}'`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// 3. In selected state in inspection zoom tier (close examination of edge details and label):
|
||||
for (const item of canonicalEdges) {
|
||||
const attrs = graph.getEdgeAttributes(item.id);
|
||||
const selectedStyle = resolveEdgeElementStyle(
|
||||
GRAPH_THEME,
|
||||
"inspection",
|
||||
"selected",
|
||||
attrs,
|
||||
item.source,
|
||||
item.target,
|
||||
"full",
|
||||
item.id,
|
||||
"selected",
|
||||
);
|
||||
|
||||
assert.equal(
|
||||
selectedStyle.hidden,
|
||||
false,
|
||||
`Selected edge ${item.id} (${item.label}) must be visible in inspection zoom tier`,
|
||||
);
|
||||
assert.ok(
|
||||
selectedStyle.size !== undefined && selectedStyle.size > 0,
|
||||
`Selected edge ${item.id} (${item.label}) must have positive render size`,
|
||||
);
|
||||
}
|
||||
|
||||
// 4. Verify inspection zoom tier camera and arrow rendering settings
|
||||
assert.equal(GRAPH_THEME.zoomTiers.inspection.showContextualArrows, true);
|
||||
assert.equal(GRAPH_THEME.zoomTiers.inspection.showCurves, true);
|
||||
});
|
||||
|
||||
test("node hover interaction preserves edge visibility and highlights canonical incident edge types", () => {
|
||||
loadDeterministicTestGraph();
|
||||
|
||||
// Scenario 1: Hover Alice
|
||||
// Incident edges: Alice -> Acme (WORKS_AT) and Bob -> Alice (KNOWS)
|
||||
const aliceAttrs = graph.getNodeAttributes("alice");
|
||||
const aliceVisual = resolveNodeVisualState("alice", "structure", "alice", "", "", new Set(), new Set(), new Set());
|
||||
assert.equal(aliceVisual, "hovered");
|
||||
|
||||
const aliceStyle = resolveNodeElementStyle(GRAPH_THEME, "structure", "hovered", aliceAttrs, "Alice");
|
||||
assert.equal(aliceStyle.forceLabel, true, "Hovered Alice must force-render label");
|
||||
assert.equal(aliceStyle.label, "Alice");
|
||||
assert.equal(aliceStyle.showHalo, true, "Hovered Alice must show interactive halo");
|
||||
|
||||
const aliceIncidentEdges = new Set(["edge_alice_acme", "edge_bob_alice"]);
|
||||
|
||||
// Edge Alice -> Acme (WORKS_AT) under Alice hover
|
||||
const aliceAcmeAttrs = graph.getEdgeAttributes("edge_alice_acme");
|
||||
assert.equal(aliceAcmeAttrs.edgeType, "WORKS_AT");
|
||||
const aliceAcmeState = resolveEdgeVisualState(
|
||||
"edge_alice_acme",
|
||||
"alice",
|
||||
"acme",
|
||||
"structure",
|
||||
"alice",
|
||||
"",
|
||||
"",
|
||||
new Set(),
|
||||
new Set(),
|
||||
aliceIncidentEdges,
|
||||
);
|
||||
assert.equal(aliceAcmeState, "hovered");
|
||||
const aliceAcmeStyle = resolveEdgeElementStyle(
|
||||
GRAPH_THEME,
|
||||
"structure",
|
||||
"hovered",
|
||||
aliceAcmeAttrs,
|
||||
"alice",
|
||||
"acme",
|
||||
"full",
|
||||
"edge_alice_acme",
|
||||
);
|
||||
assert.equal(aliceAcmeStyle.hidden, false, "Incident edge WORKS_AT must remain visible on hover");
|
||||
assert.ok(aliceAcmeStyle.size !== undefined && aliceAcmeStyle.size > 0);
|
||||
|
||||
// Edge Bob -> Alice (KNOWS) under Alice hover
|
||||
const bobAliceAttrs = graph.getEdgeAttributes("edge_bob_alice");
|
||||
assert.equal(bobAliceAttrs.edgeType, "KNOWS");
|
||||
const bobAliceState = resolveEdgeVisualState(
|
||||
"edge_bob_alice",
|
||||
"bob",
|
||||
"alice",
|
||||
"structure",
|
||||
"alice",
|
||||
"",
|
||||
"",
|
||||
new Set(),
|
||||
new Set(),
|
||||
aliceIncidentEdges,
|
||||
);
|
||||
assert.equal(bobAliceState, "hovered");
|
||||
const bobAliceStyle = resolveEdgeElementStyle(
|
||||
GRAPH_THEME,
|
||||
"structure",
|
||||
"hovered",
|
||||
bobAliceAttrs,
|
||||
"bob",
|
||||
"alice",
|
||||
"full",
|
||||
"edge_bob_alice",
|
||||
);
|
||||
assert.equal(bobAliceStyle.hidden, false, "Incident edge KNOWS must remain visible on hover");
|
||||
|
||||
// Non-incident edge Acme -> New York (LOCATED_IN) under Alice hover
|
||||
const acmeNyAttrs = graph.getEdgeAttributes("edge_acme_new_york");
|
||||
assert.equal(acmeNyAttrs.edgeType, "LOCATED_IN");
|
||||
const acmeNyState = resolveEdgeVisualState(
|
||||
"edge_acme_new_york",
|
||||
"acme",
|
||||
"new_york",
|
||||
"structure",
|
||||
"alice",
|
||||
"",
|
||||
"",
|
||||
new Set(),
|
||||
new Set(),
|
||||
aliceIncidentEdges,
|
||||
);
|
||||
assert.equal(acmeNyState, "muted");
|
||||
|
||||
// Scenario 2: Hover Acme
|
||||
// Incident edges: Alice -> Acme (WORKS_AT) and Acme -> New York (LOCATED_IN)
|
||||
const acmeAttrs = graph.getNodeAttributes("acme");
|
||||
const acmeStyle = resolveNodeElementStyle(GRAPH_THEME, "structure", "hovered", acmeAttrs, "Acme");
|
||||
assert.equal(acmeStyle.forceLabel, true);
|
||||
assert.equal(acmeStyle.label, "Acme");
|
||||
|
||||
const acmeIncidentEdges = new Set(["edge_alice_acme", "edge_acme_new_york"]);
|
||||
const acmeNyHoverState = resolveEdgeVisualState(
|
||||
"edge_acme_new_york",
|
||||
"acme",
|
||||
"new_york",
|
||||
"structure",
|
||||
"acme",
|
||||
"",
|
||||
"",
|
||||
new Set(),
|
||||
new Set(),
|
||||
acmeIncidentEdges,
|
||||
);
|
||||
assert.equal(acmeNyHoverState, "hovered");
|
||||
const acmeNyHoverStyle = resolveEdgeElementStyle(
|
||||
GRAPH_THEME,
|
||||
"structure",
|
||||
"hovered",
|
||||
acmeNyAttrs,
|
||||
"acme",
|
||||
"new_york",
|
||||
"full",
|
||||
"edge_acme_new_york",
|
||||
);
|
||||
assert.equal(acmeNyHoverStyle.hidden, false, "Incident edge LOCATED_IN must remain visible on hover");
|
||||
|
||||
// Scenario 3: Hover Bob
|
||||
// Incident edge: Bob -> Alice (KNOWS)
|
||||
const bobAttrs = graph.getNodeAttributes("bob");
|
||||
const bobStyle = resolveNodeElementStyle(GRAPH_THEME, "structure", "hovered", bobAttrs, "Bob");
|
||||
assert.equal(bobStyle.forceLabel, true);
|
||||
assert.equal(bobStyle.label, "Bob");
|
||||
|
||||
const bobIncidentEdges = new Set(["edge_bob_alice"]);
|
||||
const bobAliceHoverState = resolveEdgeVisualState(
|
||||
"edge_bob_alice",
|
||||
"bob",
|
||||
"alice",
|
||||
"structure",
|
||||
"bob",
|
||||
"",
|
||||
"",
|
||||
new Set(),
|
||||
new Set(),
|
||||
bobIncidentEdges,
|
||||
);
|
||||
assert.equal(bobAliceHoverState, "hovered");
|
||||
});
|
||||
|
||||
test("edge selection maintains canonical edge type labels and active visual state", () => {
|
||||
loadDeterministicTestGraph();
|
||||
|
||||
const edgeCases = [
|
||||
{ id: "edge_alice_acme", source: "alice", target: "acme", label: "WORKS_AT" },
|
||||
{ id: "edge_bob_alice", source: "bob", target: "alice", label: "KNOWS" },
|
||||
{ id: "edge_acme_new_york", source: "acme", target: "new_york", label: "LOCATED_IN" },
|
||||
];
|
||||
|
||||
for (const { id, source, target, label } of edgeCases) {
|
||||
const attrs = graph.getEdgeAttributes(id);
|
||||
assert.equal(attrs.edgeType, label);
|
||||
|
||||
const visualState = resolveEdgeVisualState(
|
||||
id,
|
||||
source,
|
||||
target,
|
||||
"inspection",
|
||||
null,
|
||||
"",
|
||||
id, // selected edge
|
||||
new Set(),
|
||||
new Set(),
|
||||
);
|
||||
assert.equal(visualState, "selected", `Selected edge ${id} must resolve to 'selected' state`);
|
||||
|
||||
const style = resolveEdgeElementStyle(
|
||||
GRAPH_THEME,
|
||||
"inspection",
|
||||
"selected",
|
||||
attrs,
|
||||
source,
|
||||
target,
|
||||
"full",
|
||||
id,
|
||||
"selected",
|
||||
);
|
||||
|
||||
assert.equal(style.hidden, false, `Selected edge ${id} (${label}) must not be hidden`);
|
||||
assert.ok(
|
||||
style.size !== undefined && style.size > 0,
|
||||
`Selected edge ${id} (${label}) must have positive render size`,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test("node labels remain forced visible during hover, selection, and inspection zoom tier", () => {
|
||||
loadDeterministicTestGraph();
|
||||
|
||||
const nodes = ["alice", "bob", "acme", "new_york"];
|
||||
|
||||
for (const nid of nodes) {
|
||||
const attrs = graph.getNodeAttributes(nid);
|
||||
|
||||
// Hover state forces label visibility
|
||||
const hoverForcesLabel = shouldForceNodeLabel(GRAPH_THEME, "structure", "hovered", attrs, 0);
|
||||
assert.equal(hoverForcesLabel, true, `Node ${nid} label must force visible on hover`);
|
||||
|
||||
// Selected state forces label visibility
|
||||
const selectForcesLabel = shouldForceNodeLabel(GRAPH_THEME, "structure", "selected", attrs, 0);
|
||||
assert.equal(selectForcesLabel, true, `Node ${nid} label must force visible on selection`);
|
||||
|
||||
// Resolved style emits actual string label
|
||||
const style = resolveNodeElementStyle(GRAPH_THEME, "inspection", "hovered", attrs, attrs.label);
|
||||
assert.equal(style.forceLabel, true);
|
||||
assert.equal(style.label, attrs.label);
|
||||
}
|
||||
});
|
||||
@@ -5,7 +5,8 @@ import { renderToString } from "react-dom/server";
|
||||
|
||||
(globalThis as any).React = React;
|
||||
|
||||
import { isSafeUrl, MarkdownContentViewer } from "../src/workspaces/GraphWorkspace/MarkdownContentViewer.tsx";
|
||||
import { MarkdownContentViewer } from "../src/workspaces/GraphWorkspace/MarkdownContentViewer.tsx";
|
||||
import { isSafeUrl } from "../src/workspaces/GraphWorkspace/markdownUrlSafety.ts";
|
||||
|
||||
test("isSafeUrl permits safe http, https, and mailto URLs and relative paths", () => {
|
||||
assert.equal(isSafeUrl("https://example.com"), true);
|
||||
|
||||
@@ -0,0 +1,150 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import { createTemporalSnapshotGuards } from "../src/workspaces/GraphWorkspace/temporalSnapshotGuards.ts";
|
||||
|
||||
const POSITION_1 = new Date("2023-07-02T00:00:00Z").getTime();
|
||||
const POSITION_2 = new Date("2024-01-02T00:00:00Z").getTime();
|
||||
const POSITION_3 = new Date("2024-07-02T00:00:00Z").getTime();
|
||||
|
||||
const SNAPSHOT = { active_node_ids: ["n1", "n2"], active_node_count: 2 };
|
||||
|
||||
// ── begin: one request per scrubber position ─────────────────────────────────
|
||||
|
||||
test("begin: a new position returns a fresh request sequence", () => {
|
||||
const guards = createTemporalSnapshotGuards();
|
||||
assert.deepEqual(guards.begin(POSITION_1), { seq: 1, cached: null });
|
||||
});
|
||||
|
||||
test("begin: an identical in-flight request is deduplicated (no duplicate fetch)", () => {
|
||||
const guards = createTemporalSnapshotGuards();
|
||||
guards.begin(POSITION_1);
|
||||
assert.deepEqual(guards.begin(POSITION_1), { seq: null, cached: null });
|
||||
});
|
||||
|
||||
test("begin: distinct positions request independently", () => {
|
||||
const guards = createTemporalSnapshotGuards();
|
||||
assert.equal(guards.begin(POSITION_1).seq, 1);
|
||||
assert.equal(guards.begin(POSITION_2).seq, 2);
|
||||
});
|
||||
|
||||
test("begin: revisiting an applied position returns its cached snapshot", () => {
|
||||
const guards = createTemporalSnapshotGuards();
|
||||
const { seq } = guards.begin(POSITION_1);
|
||||
guards.apply(POSITION_1, seq, SNAPSHOT);
|
||||
const revisit = guards.begin(POSITION_1);
|
||||
assert.equal(revisit.seq, 2);
|
||||
assert.deepEqual(revisit.cached, SNAPSHOT);
|
||||
});
|
||||
|
||||
test("begin: a failed position (finished) can be requested again", () => {
|
||||
const guards = createTemporalSnapshotGuards();
|
||||
const { seq } = guards.begin(POSITION_1);
|
||||
guards.finish(POSITION_1, seq);
|
||||
const retry = guards.begin(POSITION_1);
|
||||
assert.equal(retry.seq, 2);
|
||||
assert.equal(retry.cached, null);
|
||||
});
|
||||
|
||||
test("finish: does not clear a position whose snapshot was already applied", () => {
|
||||
const guards = createTemporalSnapshotGuards();
|
||||
const { seq } = guards.begin(POSITION_1);
|
||||
guards.apply(POSITION_1, seq, SNAPSHOT);
|
||||
guards.finish(POSITION_1, seq);
|
||||
assert.deepEqual(guards.begin(POSITION_1).cached, SNAPSHOT);
|
||||
});
|
||||
|
||||
test("finish: a stale sequence cannot release a newer request's position", () => {
|
||||
const guards = createTemporalSnapshotGuards();
|
||||
const first = guards.begin(POSITION_1);
|
||||
guards.finish(POSITION_1, first.seq);
|
||||
guards.begin(POSITION_1); // seq 2, in flight again
|
||||
guards.finish(POSITION_1, first.seq); // stale seq: must not release seq 2
|
||||
assert.deepEqual(guards.begin(POSITION_1), { seq: null, cached: null });
|
||||
});
|
||||
|
||||
// ── shouldApply: applied only while the scrubber is on that position ─────────
|
||||
|
||||
test("shouldApply: the current position's response is applied", () => {
|
||||
const guards = createTemporalSnapshotGuards();
|
||||
const { seq } = guards.begin(POSITION_1);
|
||||
assert.equal(guards.shouldApply(POSITION_1, seq), true);
|
||||
});
|
||||
|
||||
test("shouldApply: a response for a position the scrubber left is discarded", () => {
|
||||
const guards = createTemporalSnapshotGuards();
|
||||
const { seq: seq1 } = guards.begin(POSITION_1);
|
||||
guards.begin(POSITION_2);
|
||||
assert.equal(guards.shouldApply(POSITION_1, seq1), false);
|
||||
assert.equal(guards.shouldApply(POSITION_2, 2), true);
|
||||
});
|
||||
|
||||
test("shouldApply: a late response for the position the scrubber returned to is applied", () => {
|
||||
const guards = createTemporalSnapshotGuards();
|
||||
const { seq: seq1 } = guards.begin(POSITION_1);
|
||||
const { seq: seq2 } = guards.begin(POSITION_2);
|
||||
guards.begin(POSITION_1); // back to 1: deduplicated, no new request
|
||||
assert.equal(guards.shouldApply(POSITION_1, seq1), true);
|
||||
assert.equal(guards.shouldApply(POSITION_2, seq2), false);
|
||||
});
|
||||
|
||||
test("shouldApply: an unknown sequence is discarded", () => {
|
||||
const guards = createTemporalSnapshotGuards();
|
||||
guards.begin(POSITION_1);
|
||||
assert.equal(guards.shouldApply(POSITION_1, 99), false);
|
||||
});
|
||||
|
||||
test("shouldApply: after a reset no pre-reset response applies", () => {
|
||||
const guards = createTemporalSnapshotGuards();
|
||||
const { seq } = guards.begin(POSITION_1);
|
||||
guards.reset();
|
||||
assert.equal(guards.shouldApply(POSITION_1, seq), false);
|
||||
});
|
||||
|
||||
// ── apply: caching for revisits ─────────────────────────────────────────────
|
||||
|
||||
test("apply: stores the snapshot so a revisit re-applies it without a request", () => {
|
||||
const guards = createTemporalSnapshotGuards();
|
||||
const { seq } = guards.begin(POSITION_1);
|
||||
guards.apply(POSITION_1, seq, SNAPSHOT);
|
||||
guards.begin(POSITION_2);
|
||||
assert.deepEqual(guards.begin(POSITION_1).cached, SNAPSHOT);
|
||||
});
|
||||
|
||||
test("apply: play wrap-around re-applies the wrapped-to position's snapshot", () => {
|
||||
const guards = createTemporalSnapshotGuards();
|
||||
const { seq } = guards.begin(POSITION_1);
|
||||
guards.apply(POSITION_1, seq, SNAPSHOT);
|
||||
guards.begin(POSITION_2);
|
||||
guards.begin(POSITION_3);
|
||||
const wrap = guards.begin(POSITION_1);
|
||||
assert.deepEqual(wrap.cached, SNAPSHOT);
|
||||
assert.equal(guards.shouldApply(POSITION_1, wrap.seq), true);
|
||||
});
|
||||
|
||||
// ── reset: graph reload ─────────────────────────────────────────────────────
|
||||
|
||||
test("reset: clears requested and cached state so positions refetch", () => {
|
||||
const guards = createTemporalSnapshotGuards();
|
||||
const { seq } = guards.begin(POSITION_1);
|
||||
guards.apply(POSITION_1, seq, SNAPSHOT);
|
||||
guards.reset();
|
||||
const fresh = guards.begin(POSITION_1);
|
||||
assert.equal(fresh.seq, 1);
|
||||
assert.equal(fresh.cached, null);
|
||||
});
|
||||
|
||||
// ── cache bound ─────────────────────────────────────────────────────────────
|
||||
|
||||
test("cache: oldest positions are evicted when the cache is full", () => {
|
||||
const guards = createTemporalSnapshotGuards();
|
||||
const count = 300;
|
||||
for (let i = 0; i < count; i++) {
|
||||
const { seq } = guards.begin(POSITION_1 + i * 1000);
|
||||
guards.apply(POSITION_1 + i * 1000, seq, SNAPSHOT);
|
||||
}
|
||||
const oldest = guards.begin(POSITION_1);
|
||||
assert.equal(oldest.cached, null); // evicted: must refetch on revisit
|
||||
const newest = guards.begin(POSITION_1 + (count - 1) * 1000);
|
||||
assert.deepEqual(newest.cached, SNAPSHOT); // still cached
|
||||
});
|
||||
@@ -1,7 +1,7 @@
|
||||
"""
|
||||
Semantica Framework Integrations
|
||||
|
||||
Optional integration packages for agentic frameworks (Google ADK, Claude Agent SDK, Agno, etc.).
|
||||
Optional integration packages for agentic frameworks (Google ADK, Claude Agent SDK, Agno, CrewAI, LangChain, etc.).
|
||||
Each integration is self-contained, independently installable via extras_require, and maintains
|
||||
zero impact on core Semantica - keeping the semantic layer lean while maximizing ecosystem reach.
|
||||
"""
|
||||
|
||||
@@ -277,25 +277,22 @@ class AgnoKnowledgeGraph(_KnowledgeBase): # type: ignore[misc]
|
||||
def load_urls(self, urls: List[str]) -> None:
|
||||
"""Fetch each URL and ingest the response body.
|
||||
|
||||
Only ``http`` and ``https`` schemes are permitted to prevent SSRF.
|
||||
Uses the shared SSRF guard so that ``http`` and ``https`` are the only
|
||||
permitted schemes, private/loopback/link-local/cloud-metadata addresses
|
||||
are blocked by default, DNS resolution is validated, and every redirect
|
||||
hop is re-checked before being followed.
|
||||
"""
|
||||
import urllib.request
|
||||
from urllib.parse import urlparse
|
||||
from semantica.ingest.ssrf import request_with_ssrf_guard
|
||||
from semantica.utils.exceptions import ValidationError
|
||||
|
||||
for url in urls:
|
||||
parsed = urlparse(url)
|
||||
if parsed.scheme not in ("http", "https"):
|
||||
logger.warning(
|
||||
"Skipping URL with disallowed scheme '%s': %s",
|
||||
parsed.scheme,
|
||||
url,
|
||||
)
|
||||
continue
|
||||
try:
|
||||
with urllib.request.urlopen(url, timeout=10) as resp: # noqa: S310
|
||||
text = resp.read().decode("utf-8", errors="replace")
|
||||
response = request_with_ssrf_guard("GET", url, timeout=10)
|
||||
text = response.text
|
||||
self._ingest_text(text, source=url)
|
||||
logger.info("Loaded URL: %s", url)
|
||||
except ValidationError as exc:
|
||||
logger.warning("Skipping URL (SSRF check failed) %s: %s", url, exc)
|
||||
except Exception as exc:
|
||||
logger.warning("Failed to fetch %s: %s", url, exc)
|
||||
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
# Semantica × LangChain
|
||||
|
||||
Drop Semantica into existing LangChain / LangGraph pipelines: GraphRAG-style
|
||||
retrieval, a `VectorStore` adapter, and agent tools.
|
||||
|
||||
## Install
|
||||
|
||||
```bash
|
||||
pip install semantica[langchain]
|
||||
# or just the core adapter dependency:
|
||||
pip install langchain-core
|
||||
```
|
||||
|
||||
## Retriever (GraphRAG)
|
||||
|
||||
```python
|
||||
from integrations.langchain import SemanticaRetriever
|
||||
from semantica.context import ContextGraph
|
||||
from semantica.vector_store import HybridSearch
|
||||
|
||||
graph = ContextGraph()
|
||||
hybrid = HybridSearch()
|
||||
|
||||
retriever = SemanticaRetriever(graph=graph, hybrid=hybrid, hops=2, top_k=10)
|
||||
|
||||
# Use with any LangChain chain that accepts a retriever:
|
||||
from langchain.chains import RetrievalQA
|
||||
|
||||
qa = RetrievalQA.from_chain_type(llm=llm, retriever=retriever)
|
||||
```
|
||||
|
||||
Hybrid search seeds retrieval; then graph edges are walked `hops` steps so
|
||||
results go beyond flat vector similarity.
|
||||
|
||||
## VectorStore
|
||||
|
||||
```python
|
||||
from integrations.langchain import SemanticaVectorStore
|
||||
|
||||
store = SemanticaVectorStore(hybrid=hybrid)
|
||||
store.add_texts(["document one", "document two"], metadatas=[{"source": "a"}, {"source": "b"}])
|
||||
docs = store.similarity_search("document", k=2)
|
||||
docs, scores = store.similarity_search_with_score("document", k=2)
|
||||
```
|
||||
|
||||
## Agent tools (LangGraph / tool-calling agents)
|
||||
|
||||
```python
|
||||
from integrations.langchain import SemanticaKGTool, SemanticaDecisionTool
|
||||
from langgraph.prebuilt import create_react_agent
|
||||
|
||||
tools = [
|
||||
SemanticaKGTool(graph),
|
||||
SemanticaDecisionTool(graph),
|
||||
]
|
||||
agent = create_react_agent(model, tools)
|
||||
```
|
||||
|
||||
- `semantica_query_graph` — query the shared context graph (keyword / NL)
|
||||
- `semantica_query_decisions` — search the recorded decision log
|
||||
|
||||
## Compatibility
|
||||
|
||||
- Requires `langchain-core >= 0.3`.
|
||||
- All classes degrade gracefully when `langchain-core` is absent: they remain
|
||||
importable (carrying the full Semantica API), and `build()` returns `None`,
|
||||
so agents can branch on `LANGCHAIN_AVAILABLE`.
|
||||
@@ -0,0 +1,48 @@
|
||||
"""
|
||||
Semantica × LangChain Integration
|
||||
=================================
|
||||
|
||||
First-class integration between the Semantica semantic intelligence stack and
|
||||
the `LangChain <https://github.com/langchain-ai/langchain>`_ / LangGraph
|
||||
ecosystem.
|
||||
|
||||
Public surface
|
||||
--------------
|
||||
SemanticaRetriever — ``BaseRetriever`` with multi-hop GraphRAG (walks graph
|
||||
edges from hybrid-search hits)
|
||||
SemanticaVectorStore — ``VectorStore`` adapter over Semantica's hybrid search
|
||||
(drop-in for RetrievalQA / LCEL chains)
|
||||
SemanticaKGTool — ``BaseTool`` for querying the context graph
|
||||
SemanticaDecisionTool — ``BaseTool`` exposing the recorded decision log
|
||||
|
||||
Quick start
|
||||
-----------
|
||||
pip install semantica[langchain]
|
||||
|
||||
>>> from integrations.langchain import (
|
||||
... SemanticaRetriever,
|
||||
... SemanticaVectorStore,
|
||||
... SemanticaKGTool,
|
||||
... SemanticaDecisionTool,
|
||||
... )
|
||||
|
||||
Compatibility
|
||||
-------------
|
||||
Requires ``langchain-core >= 0.3``. All classes degrade gracefully when
|
||||
``langchain-core`` is not installed — they are still importable and carry the
|
||||
full Semantica API, but cannot be bound to LangChain chains/agents.
|
||||
"""
|
||||
|
||||
from .retriever import LANGCHAIN_AVAILABLE, SemanticaRetriever
|
||||
from .tools import SemanticaDecisionTool, SemanticaKGTool
|
||||
from .vectorstore import SemanticaVectorStore
|
||||
|
||||
__all__ = [
|
||||
"SemanticaRetriever",
|
||||
"SemanticaVectorStore",
|
||||
"SemanticaKGTool",
|
||||
"SemanticaDecisionTool",
|
||||
"LANGCHAIN_AVAILABLE",
|
||||
]
|
||||
|
||||
__version__ = "0.1.0"
|
||||
@@ -0,0 +1,216 @@
|
||||
"""
|
||||
SemanticaRetriever — LangChain ``BaseRetriever`` with multi-hop GraphRAG.
|
||||
|
||||
Hybrid search seeds the retrieval, then graph edges are walked for ``hops``
|
||||
steps so results go beyond flat vector similarity.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Dict, List, Optional, Tuple
|
||||
|
||||
from semantica.utils.logging import get_logger
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Optional: LangChain core
|
||||
# ---------------------------------------------------------------------------
|
||||
LANGCHAIN_AVAILABLE = False
|
||||
LANGCHAIN_IMPORT_ERROR: Optional[str] = None
|
||||
|
||||
_BaseRetriever: Any = object
|
||||
_Document: Any = None
|
||||
|
||||
|
||||
def _get_document(**kwargs: Any) -> Any:
|
||||
"""Instantiate a langchain Document lazily (keeps the import optional)."""
|
||||
if _Document is None: # pragma: no cover - exercised only with langchain
|
||||
raise RuntimeError(LANGCHAIN_IMPORT_ERROR or "langchain-core not installed")
|
||||
return _Document(**kwargs)
|
||||
|
||||
|
||||
try:
|
||||
from langchain_core.documents import Document as _Document # type: ignore
|
||||
from langchain_core.retrievers import (
|
||||
BaseRetriever as _BaseRetriever, # type: ignore
|
||||
)
|
||||
|
||||
LANGCHAIN_AVAILABLE = True
|
||||
except ImportError: # pragma: no cover - exercised only without langchain
|
||||
LANGCHAIN_IMPORT_ERROR = (
|
||||
"langchain-core is not installed. Install with: pip install langchain-core"
|
||||
)
|
||||
logger.debug(LANGCHAIN_IMPORT_ERROR)
|
||||
|
||||
|
||||
def _hit_layers(hit: Dict[str, Any]) -> Tuple[Dict[str, Any], Dict[str, Any]]:
|
||||
"""Nested HybridSearch metadata and ContextGraph.query node, if present."""
|
||||
metadata = hit.get("metadata") if isinstance(hit.get("metadata"), dict) else {}
|
||||
node = hit.get("node") if isinstance(hit.get("node"), dict) else {}
|
||||
return metadata, node
|
||||
|
||||
|
||||
def _hit_id(hit: Dict[str, Any]) -> Optional[str]:
|
||||
"""Graph node id, preferring metadata over a HybridSearch vector id."""
|
||||
metadata, node = _hit_layers(hit)
|
||||
return (
|
||||
hit.get("node_id")
|
||||
or metadata.get("node_id")
|
||||
or node.get("id")
|
||||
or node.get("node_id")
|
||||
or hit.get("id")
|
||||
)
|
||||
|
||||
|
||||
def _hit_content(hit: Dict[str, Any], fallback: str = "") -> str:
|
||||
metadata, node = _hit_layers(hit)
|
||||
props = node.get("properties") if isinstance(node.get("properties"), dict) else {}
|
||||
return (
|
||||
hit.get("content")
|
||||
or hit.get("text")
|
||||
or metadata.get("content")
|
||||
or metadata.get("text")
|
||||
or props.get("content")
|
||||
or fallback
|
||||
)
|
||||
|
||||
|
||||
def _hit_type(hit: Dict[str, Any]) -> str:
|
||||
metadata, node = _hit_layers(hit)
|
||||
return (
|
||||
hit.get("node_type")
|
||||
or hit.get("type")
|
||||
or metadata.get("node_type")
|
||||
or metadata.get("type")
|
||||
or node.get("type")
|
||||
or node.get("node_type")
|
||||
or "node"
|
||||
)
|
||||
|
||||
|
||||
def _hit_score(hit: Dict[str, Any], default: float = 1.0) -> float:
|
||||
return float(hit.get("score") if hit.get("score") is not None else hit.get("distance") or default)
|
||||
|
||||
|
||||
class SemanticaRetriever(_BaseRetriever): # type: ignore[misc]
|
||||
"""GraphRAG-style retriever over a Semantica ``ContextGraph``.
|
||||
|
||||
Args:
|
||||
graph: A semantica.context.ContextGraph instance.
|
||||
hybrid: A semantica.vector_store.HybridSearch instance used to seed
|
||||
retrieval. If omitted, a best-effort keyword search on the graph
|
||||
is used.
|
||||
hops: Number of graph-edge expansion hops (default 2).
|
||||
top_k: Number of seed hits (default 10).
|
||||
"""
|
||||
|
||||
graph: Any
|
||||
hybrid: Any = None
|
||||
hops: int = 2
|
||||
top_k: int = 10
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
graph: Any,
|
||||
hybrid: Any = None,
|
||||
hops: int = 2,
|
||||
top_k: int = 10,
|
||||
**kwargs: Any,
|
||||
) -> None:
|
||||
"""Explicit init so the retriever works with and without langchain."""
|
||||
if LANGCHAIN_AVAILABLE:
|
||||
# BaseRetriever is a Pydantic model: pass the declared fields
|
||||
# through so validation succeeds.
|
||||
super().__init__(
|
||||
graph=graph,
|
||||
hybrid=hybrid,
|
||||
hops=hops,
|
||||
top_k=top_k,
|
||||
**kwargs,
|
||||
)
|
||||
else:
|
||||
# Without langchain-core, BaseRetriever is a plain object
|
||||
super().__init__() # type: ignore[call-arg]
|
||||
self.graph = graph
|
||||
self.hybrid = hybrid
|
||||
self.hops = hops
|
||||
self.top_k = top_k
|
||||
|
||||
def _get_relevant_documents(self, query: str, **kwargs: Any) -> List[Any]:
|
||||
"""LangChain BaseRetriever entry point."""
|
||||
seed = self._seed_results(query)
|
||||
if not seed:
|
||||
return []
|
||||
|
||||
# Expand each seed node through the graph
|
||||
expanded: Dict[str, Dict[str, Any]] = {}
|
||||
for hit in seed:
|
||||
node_id = _hit_id(hit)
|
||||
if not node_id:
|
||||
continue
|
||||
metadata, _ = _hit_layers(hit)
|
||||
expanded[node_id] = {
|
||||
"content": _hit_content(hit, fallback=str(node_id)),
|
||||
"node_type": _hit_type(hit),
|
||||
"score": _hit_score(hit),
|
||||
"metadata": metadata,
|
||||
}
|
||||
try:
|
||||
neighbors = self.graph.get_neighbors(node_id, hops=self.hops)
|
||||
for neighbor in neighbors:
|
||||
nid = neighbor.get("node_id") or neighbor.get("id")
|
||||
if nid and nid not in expanded:
|
||||
expanded[nid] = {
|
||||
"content": neighbor.get("content")
|
||||
or neighbor.get("text")
|
||||
or neighbor.get("name")
|
||||
or str(nid),
|
||||
"node_type": neighbor.get("node_type")
|
||||
or neighbor.get("type")
|
||||
or "node",
|
||||
"score": float(neighbor.get("weight") or 0.5),
|
||||
"metadata": {},
|
||||
}
|
||||
except Exception as exc: # graph expansion is best-effort
|
||||
logger.debug("graph expansion failed for %s: %s", node_id, exc)
|
||||
|
||||
# Order: seed hits first (they have real scores), then neighbors.
|
||||
# Keep a deterministic id->payload list (sets are unordered — see Qodo).
|
||||
ordered_pairs: List[tuple] = []
|
||||
seen_ids = set()
|
||||
for hit in seed:
|
||||
nid = _hit_id(hit)
|
||||
if nid and nid in expanded and nid not in seen_ids:
|
||||
ordered_pairs.append((nid, expanded[nid]))
|
||||
seen_ids.add(nid)
|
||||
for nid, item in expanded.items():
|
||||
if nid not in seen_ids:
|
||||
ordered_pairs.append((nid, item))
|
||||
seen_ids.add(nid)
|
||||
|
||||
return [
|
||||
_get_document(
|
||||
page_content=item["content"],
|
||||
metadata={
|
||||
**item["metadata"],
|
||||
"node_id": nid,
|
||||
"node_type": item["node_type"],
|
||||
"score": item["score"],
|
||||
},
|
||||
)
|
||||
for nid, item in ordered_pairs
|
||||
]
|
||||
|
||||
def _seed_results(self, query: str) -> List[Dict[str, Any]]:
|
||||
"""Get seed results from hybrid search or a graph keyword scan."""
|
||||
if self.hybrid is not None:
|
||||
try:
|
||||
return self.hybrid.search(query, k=self.top_k)
|
||||
except Exception as exc:
|
||||
logger.debug("hybrid search failed, falling back: %s", exc)
|
||||
# Best-effort keyword scan over graph nodes (ContextGraph.query)
|
||||
try:
|
||||
return self.graph.query(query, limit=self.top_k)
|
||||
except Exception:
|
||||
return []
|
||||
@@ -0,0 +1,133 @@
|
||||
"""
|
||||
SemanticaKGTool / SemanticaDecisionTool — LangChain ``BaseTool`` adapters
|
||||
for LangChain / LangGraph agents.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from typing import Any, Optional, Type
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
|
||||
from semantica.utils.logging import get_logger
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Optional: LangChain core
|
||||
# ---------------------------------------------------------------------------
|
||||
LANGCHAIN_AVAILABLE = False
|
||||
LANGCHAIN_IMPORT_ERROR: Optional[str] = None
|
||||
|
||||
_BaseTool: Any = object
|
||||
|
||||
|
||||
try:
|
||||
from langchain_core.tools import BaseTool as _BaseTool # type: ignore
|
||||
|
||||
LANGCHAIN_AVAILABLE = True
|
||||
except ImportError: # pragma: no cover
|
||||
LANGCHAIN_IMPORT_ERROR = (
|
||||
"langchain-core is not installed. Install with: pip install langchain-core"
|
||||
)
|
||||
logger.debug(LANGCHAIN_IMPORT_ERROR)
|
||||
|
||||
|
||||
def _json(payload: Any) -> str:
|
||||
return json.dumps(payload, default=str, ensure_ascii=False)
|
||||
|
||||
|
||||
class QueryGraphInput(BaseModel):
|
||||
query: str = Field(..., description="Natural-language or keyword graph query")
|
||||
limit: int = Field(10, description="Maximum matching nodes to return")
|
||||
|
||||
|
||||
class QueryDecisionsInput(BaseModel):
|
||||
category: str = Field(
|
||||
"",
|
||||
description="Keyword to search recorded decisions; empty returns insights",
|
||||
)
|
||||
limit: int = Field(10, description="Maximum results when searching by keyword")
|
||||
|
||||
|
||||
class SemanticaKGTool(_BaseTool): # type: ignore[misc]
|
||||
"""LangChain tool for querying a Semantica ``ContextGraph``.
|
||||
|
||||
Args:
|
||||
graph: A semantica.context.ContextGraph instance.
|
||||
|
||||
Example:
|
||||
>>> tool = SemanticaKGTool(graph)
|
||||
>>> agent = create_react_agent(model, tools=[tool])
|
||||
"""
|
||||
|
||||
model_config = ConfigDict(arbitrary_types_allowed=True)
|
||||
|
||||
name: str = "semantica_query_graph"
|
||||
description: str = (
|
||||
"Query Semantica's shared context graph with a natural-language "
|
||||
"keyword query. Returns matching entities and relationships."
|
||||
)
|
||||
args_schema: Type[BaseModel] = QueryGraphInput
|
||||
graph: Any = None
|
||||
|
||||
def __init__(self, graph: Any = None, **kwargs: Any) -> None:
|
||||
if LANGCHAIN_AVAILABLE:
|
||||
super().__init__(graph=graph, **kwargs)
|
||||
else:
|
||||
super().__init__()
|
||||
self.graph = graph
|
||||
|
||||
def build(self) -> Any:
|
||||
"""Return this tool, or None if langchain-core is missing."""
|
||||
return self if LANGCHAIN_AVAILABLE else None
|
||||
|
||||
def _run(self, query: str, limit: int = 10, **kwargs: Any) -> str:
|
||||
try:
|
||||
return _json(self.graph.query(query, limit=limit))
|
||||
except Exception as exc:
|
||||
return _json({"error": str(exc)})
|
||||
|
||||
async def _arun(self, query: str, limit: int = 10, **kwargs: Any) -> str:
|
||||
return self._run(query, limit=limit)
|
||||
|
||||
|
||||
class SemanticaDecisionTool(_BaseTool): # type: ignore[misc]
|
||||
"""LangChain tool for searching Semantica's recorded decision log.
|
||||
|
||||
Args:
|
||||
graph: A semantica.context.ContextGraph instance.
|
||||
"""
|
||||
|
||||
model_config = ConfigDict(arbitrary_types_allowed=True)
|
||||
|
||||
name: str = "semantica_query_decisions"
|
||||
description: str = (
|
||||
"Search Semantica's recorded decision log with a keyword query. "
|
||||
"Returns decisions, rationale, and context."
|
||||
)
|
||||
args_schema: Type[BaseModel] = QueryDecisionsInput
|
||||
graph: Any = None
|
||||
|
||||
def __init__(self, graph: Any = None, **kwargs: Any) -> None:
|
||||
if LANGCHAIN_AVAILABLE:
|
||||
super().__init__(graph=graph, **kwargs)
|
||||
else:
|
||||
super().__init__()
|
||||
self.graph = graph
|
||||
|
||||
def build(self) -> Any:
|
||||
"""Return this tool, or None if langchain-core is missing."""
|
||||
return self if LANGCHAIN_AVAILABLE else None
|
||||
|
||||
def _run(self, category: str = "", limit: int = 10, **kwargs: Any) -> str:
|
||||
try:
|
||||
if category:
|
||||
return _json(self.graph.query(category, limit=limit))
|
||||
return _json(self.graph.get_decision_insights())
|
||||
except Exception as exc:
|
||||
return _json({"error": str(exc)})
|
||||
|
||||
async def _arun(self, category: str = "", limit: int = 10, **kwargs: Any) -> str:
|
||||
return self._run(category=category, limit=limit)
|
||||
@@ -0,0 +1,143 @@
|
||||
"""
|
||||
SemanticaVectorStore — LangChain ``VectorStore`` adapter over Semantica's
|
||||
hybrid search (``semantica.vector_store.HybridSearch``).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Dict, Iterable, List, Optional
|
||||
|
||||
from semantica.utils.logging import get_logger
|
||||
|
||||
from .retriever import _hit_content, _hit_id, _hit_score, _hit_type, _hit_layers
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Optional: LangChain core
|
||||
# ---------------------------------------------------------------------------
|
||||
LANGCHAIN_AVAILABLE = False
|
||||
LANGCHAIN_IMPORT_ERROR: Optional[str] = None
|
||||
|
||||
_VectorStoreBase: Any = object
|
||||
_Document: Any = None
|
||||
|
||||
|
||||
def _make_document(**kwargs: Any) -> Any:
|
||||
if _Document is None: # pragma: no cover
|
||||
raise RuntimeError(LANGCHAIN_IMPORT_ERROR or "langchain-core not installed")
|
||||
return _Document(**kwargs)
|
||||
|
||||
|
||||
try:
|
||||
from langchain_core.documents import Document as _Document # type: ignore
|
||||
from langchain_core.vectorstores import (
|
||||
VectorStore as _VectorStoreBase, # type: ignore
|
||||
)
|
||||
|
||||
LANGCHAIN_AVAILABLE = True
|
||||
except ImportError: # pragma: no cover
|
||||
LANGCHAIN_IMPORT_ERROR = (
|
||||
"langchain-core is not installed. Install with: pip install langchain-core"
|
||||
)
|
||||
logger.debug(LANGCHAIN_IMPORT_ERROR)
|
||||
|
||||
|
||||
def _document_from_hit(hit: Dict[str, Any], include_score: bool = True) -> Any:
|
||||
metadata, _ = _hit_layers(hit)
|
||||
node_id = _hit_id(hit)
|
||||
doc_meta = {
|
||||
**metadata,
|
||||
"node_id": node_id,
|
||||
"node_type": _hit_type(hit),
|
||||
}
|
||||
if include_score:
|
||||
doc_meta["score"] = _hit_score(hit, default=0.0)
|
||||
return _make_document(
|
||||
page_content=_hit_content(hit),
|
||||
metadata=doc_meta,
|
||||
)
|
||||
|
||||
|
||||
class SemanticaVectorStore(_VectorStoreBase): # type: ignore[misc]
|
||||
"""Wrap Semantica hybrid search as a LangChain ``VectorStore``.
|
||||
|
||||
Args:
|
||||
hybrid: A semantica.vector_store.HybridSearch instance.
|
||||
vector_store: Optional Semantica vector store passed through to
|
||||
``HybridSearch.add_texts``.
|
||||
"""
|
||||
|
||||
hybrid: Any
|
||||
vector_store: Any = None
|
||||
|
||||
def __init__(self, hybrid: Any, vector_store: Any = None, **kwargs: Any) -> None:
|
||||
if LANGCHAIN_AVAILABLE:
|
||||
super().__init__(**kwargs)
|
||||
else:
|
||||
super().__init__()
|
||||
self.hybrid = hybrid
|
||||
self.vector_store = vector_store
|
||||
|
||||
# -- required VectorStore API ------------------------------------------
|
||||
def add_texts(
|
||||
self,
|
||||
texts: Iterable[str],
|
||||
metadatas: Optional[List[Dict[str, Any]]] = None,
|
||||
**kwargs: Any,
|
||||
) -> List[str]:
|
||||
"""Embed and store texts; return the generated IDs.
|
||||
|
||||
Delegates to the Semantica ``VectorStore.add_documents`` backing the
|
||||
HybridSearch instance (or to ``hybrid.vector_store`` if provided).
|
||||
"""
|
||||
if self.vector_store is not None:
|
||||
return self.vector_store.add_documents(
|
||||
list(texts), metadata=metadatas, **kwargs
|
||||
)
|
||||
vs = getattr(self.hybrid, "vector_store", None)
|
||||
if vs is not None and hasattr(vs, "add_documents"):
|
||||
return vs.add_documents(list(texts), metadata=metadatas, **kwargs)
|
||||
raise ValueError(
|
||||
"SemanticaVectorStore requires a Semantica vector store with "
|
||||
"add_documents (pass vector_store=... to the HybridSearch or to "
|
||||
"SemanticaVectorStore)"
|
||||
)
|
||||
|
||||
def similarity_search(self, query: str, k: int = 4, **kwargs: Any) -> List[Any]:
|
||||
"""Return documents most similar to the query."""
|
||||
return [_document_from_hit(hit) for hit in self.hybrid.search(query, k=k)]
|
||||
|
||||
def similarity_search_with_score(
|
||||
self, query: str, k: int = 4, **kwargs: Any
|
||||
) -> List[Any]:
|
||||
"""Return (document, score) pairs."""
|
||||
return [
|
||||
(
|
||||
_document_from_hit(hit, include_score=False),
|
||||
_hit_score(hit, default=0.0),
|
||||
)
|
||||
for hit in self.hybrid.search(query, k=k)
|
||||
]
|
||||
|
||||
@classmethod
|
||||
def from_texts(
|
||||
cls,
|
||||
texts: List[str],
|
||||
embedding: Any = None,
|
||||
metadatas: Optional[List[Dict[str, Any]]] = None,
|
||||
**kwargs: Any,
|
||||
) -> "SemanticaVectorStore":
|
||||
"""Build a store from a list of texts (LangChain convention).
|
||||
|
||||
Requires a pre-configured ``hybrid`` instance passed via kwargs.
|
||||
"""
|
||||
hybrid = kwargs.pop("hybrid", None)
|
||||
if hybrid is None:
|
||||
raise ValueError(
|
||||
"SemanticaVectorStore.from_texts requires a 'hybrid' "
|
||||
"HybridSearch instance as a keyword argument"
|
||||
)
|
||||
store = cls(hybrid=hybrid, **kwargs)
|
||||
store.add_texts(texts, metadatas=metadatas)
|
||||
return store
|
||||
@@ -116,7 +116,41 @@ class OpenClawKGTool:
|
||||
)
|
||||
|
||||
def __init__(self, base_url: str = "http://localhost:8000", timeout: int = 30) -> None:
|
||||
self.base_url = base_url.rstrip("/")
|
||||
# Validate base_url at construction time so callers get an immediate,
|
||||
# actionable error rather than a cryptic failure on the first request.
|
||||
# allow_private_ips=True because the documented default (localhost:8000)
|
||||
# is intentionally a local Semantica server; the scheme check and
|
||||
# URL-structure check still apply unconditionally.
|
||||
try:
|
||||
from semantica.ingest.ssrf import validate_url_for_request
|
||||
validate_url_for_request(base_url, allow_private_ips=True)
|
||||
except ImportError:
|
||||
# semantica.ingest not installed in minimal openclaw-only environments;
|
||||
# mirror the structural checks that validate_url_for_request performs
|
||||
# unconditionally (before allow_private_ips is consulted), so the
|
||||
# guarantee in the comment above — "scheme check and URL-structure check
|
||||
# still apply unconditionally" — holds in this path too.
|
||||
from urllib.parse import urlparse as _urlparse
|
||||
if not isinstance(base_url, str) or not base_url.strip():
|
||||
raise ValueError("OpenClawKGTool base_url must be a non-empty string.")
|
||||
_parsed = _urlparse(base_url.strip())
|
||||
_scheme = (_parsed.scheme or "").lower()
|
||||
if _scheme not in ("http", "https"):
|
||||
raise ValueError(
|
||||
f"OpenClawKGTool base_url scheme '{_parsed.scheme}' is not permitted. "
|
||||
"Only http and https are allowed."
|
||||
)
|
||||
if not _parsed.netloc:
|
||||
raise ValueError(
|
||||
f"Invalid OpenClawKGTool base_url '{base_url}': "
|
||||
"URL must include a netloc (domain or host)."
|
||||
)
|
||||
if not _parsed.hostname:
|
||||
raise ValueError(
|
||||
f"Invalid OpenClawKGTool base_url '{base_url}': "
|
||||
"URL must include a hostname."
|
||||
)
|
||||
self.base_url = base_url.strip().rstrip("/")
|
||||
self.timeout = timeout
|
||||
self._session: Any = None
|
||||
|
||||
|
||||
@@ -21,6 +21,17 @@ Configure in Claude Desktop, Windsurf, Cline, Continue, VS Code:
|
||||
}
|
||||
"""
|
||||
|
||||
import os
|
||||
|
||||
# MCP stdio framing IS stdout: any progress bar or console renderer that writes
|
||||
# to stdout would interleave with the JSON-RPC stream and corrupt framing for
|
||||
# every client. This package is always used as an MCP stdio server, so force
|
||||
# progress tracking off for the entire process. Set before importing server /
|
||||
# tools so the Semantica progress-tracker singleton is never created with
|
||||
# output enabled (the singleton reads this variable at construction time and
|
||||
# the enabled.setter re-checks it, so later re-enable attempts are also blocked).
|
||||
os.environ["SEMANTICA_DISABLE_PROGRESS"] = "1"
|
||||
|
||||
# `semantica.__version__` is the authoritative package version — see
|
||||
# semantica/mcp_server/__init__.py for why it is used directly rather than
|
||||
# importlib.metadata.version("semantica").
|
||||
|
||||
+6
-1
@@ -80,7 +80,12 @@ def handle_export_graph(args: dict) -> dict:
|
||||
if rdf_fmt:
|
||||
try:
|
||||
from semantica.export import RDFExporter
|
||||
rdf_str = RDFExporter().export_to_rdf(graph, format=rdf_fmt)
|
||||
# RDFExporter.export_to_rdf() expects the canonical kg dict
|
||||
# {"entities": [...], "relationships": [...]}, not a ContextGraph
|
||||
# object. Convert before handing off; passing the raw graph
|
||||
# caused AttributeError: 'ContextGraph' object has no attribute
|
||||
# 'get' on every RDF format.
|
||||
rdf_str = RDFExporter().export_to_rdf(graph.to_kg_dict(), format=rdf_fmt)
|
||||
return {"format": rdf_fmt, "data": rdf_str}
|
||||
except Exception as exc:
|
||||
return {"error": f"RDF export failed: {exc}"}
|
||||
|
||||
@@ -187,15 +187,6 @@ def poc_vuln3():
|
||||
})
|
||||
return nodes
|
||||
|
||||
# Simulate the CSV parser — mirrors export_import.py lines 131-133
|
||||
def parse_import_csv_row(row: dict) -> dict:
|
||||
"""Mirrors export_import.py CSV node ID extraction (no sanitization)."""
|
||||
node_id = row.get("id") or row.get("node_id") or row.get(":ID") or row.get("_id")
|
||||
return {
|
||||
"id": str(node_id), # ← UNSANITIZED
|
||||
"type": row.get("type", "entity"),
|
||||
}
|
||||
|
||||
# Attack payloads
|
||||
payloads = [
|
||||
# Header injection payload (chained with VULN-1)
|
||||
|
||||
+6
-3
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "semantica"
|
||||
version = "0.6.6"
|
||||
version = "0.6.7"
|
||||
description = "Graph-Native Infrastructure for Context and Accountable AI Systems: context graphs, decision intelligence, full provenance tracking, and explainable reasoning engines — every AI decision traceable, every output auditable."
|
||||
readme = "README.md"
|
||||
license = { text = "MIT" }
|
||||
@@ -124,11 +124,13 @@ shacl = ["pyshacl>=0.25.0"]
|
||||
db-snowflake = ["snowflake-connector-python>=4.6.0", "cryptography>=49.0.0"]
|
||||
db-databricks = ["databricks-sdk>=0.60.0", "databricks-sql-connector>=4.0.0"]
|
||||
db-arrow = ["pyarrow>=24.0.0"]
|
||||
db-salesforce = ["simple-salesforce>=1.12.0"]
|
||||
ingest-parquet = ["pyarrow>=24.0.0"]
|
||||
ingest-arrow = ["pyarrow>=24.0.0"]
|
||||
ingest-sap = ["requests>=2.28.0"]
|
||||
|
||||
db-all = [
|
||||
"semantica[db-snowflake,db-databricks,db-arrow]"
|
||||
"semantica[db-snowflake,db-databricks,db-salesforce,db-arrow]"
|
||||
]
|
||||
|
||||
# ---- Embedding / Models ----
|
||||
@@ -206,6 +208,7 @@ agno = ["agno>=1.0.0"]
|
||||
# needed (it pulls vulnerable transitive deps like chromadb) and would only
|
||||
# duplicate the prebuilt tooling users can install separately.
|
||||
crewai = ["crewai>=0.80.0"]
|
||||
langchain = ["langchain-core>=0.3.0"]
|
||||
|
||||
# ---- File Watching ----
|
||||
watch = ["watchdog>=6.0.0"]
|
||||
@@ -253,7 +256,7 @@ explorer-lite = [
|
||||
# dependency-audit/security gates. Install it explicitly via ``semantica[crewai]``.
|
||||
all = [
|
||||
"semantica[dev,viz,infra,cloud,monitoring,watch,llm-all,models-huggingface,split-all,graph-all,tripletstore-oxigraph,vectorstore-all,parse-docling,ingest-parquet,ingest-arrow,shacl,explorer]",
|
||||
"semantica[dev,viz,infra,cloud,monitoring,watch,llm-all,models-huggingface,split-all,graph-all,tripletstore-oxigraph,vectorstore-all,parse-docling,ingest-parquet,ingest-arrow,shacl,agno]"
|
||||
"semantica[dev,viz,infra,cloud,monitoring,watch,llm-all,models-huggingface,split-all,graph-all,tripletstore-oxigraph,vectorstore-all,parse-docling,ingest-parquet,ingest-arrow,shacl,agno,langchain]"
|
||||
]
|
||||
|
||||
# ---------------- ENTRYPOINTS ----------------
|
||||
|
||||
+791
-314
File diff suppressed because it is too large
Load Diff
@@ -10,7 +10,7 @@ Main exports:
|
||||
- Config: Configuration management
|
||||
"""
|
||||
|
||||
__version__ = "0.6.6"
|
||||
__version__ = "0.6.7"
|
||||
__author__ = "Semantica Contributors"
|
||||
__license__ = "MIT"
|
||||
|
||||
|
||||
@@ -31,6 +31,7 @@ import hashlib
|
||||
import json
|
||||
import sqlite3
|
||||
import threading
|
||||
import warnings
|
||||
from abc import ABC, abstractmethod
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
@@ -62,6 +63,12 @@ def create_graph_snapshot_record(
|
||||
"""
|
||||
Creates a standardized snapshot metadata record for a named graph.
|
||||
|
||||
.. deprecated::
|
||||
``create_graph_snapshot_record()`` is deprecated and will be removed in
|
||||
a future major version. It has no callers inside Semantica; build the
|
||||
record inline and checksum it with
|
||||
:func:`semantica.change_management.compute_checksum` instead.
|
||||
|
||||
Args:
|
||||
version_id: Unique identifier for this snapshot
|
||||
graph_uri: The underlying named graph URI in the triplet store
|
||||
@@ -69,6 +76,13 @@ def create_graph_snapshot_record(
|
||||
description: Purpose or context of the snapshot
|
||||
metadata: Additional tags or pipeline context
|
||||
"""
|
||||
warnings.warn(
|
||||
"create_graph_snapshot_record() is deprecated and will be removed in a "
|
||||
"future major version. Build the snapshot record inline and use "
|
||||
"semantica.change_management.compute_checksum() instead.",
|
||||
DeprecationWarning,
|
||||
stacklevel=2,
|
||||
)
|
||||
|
||||
record = {
|
||||
"label": version_id,
|
||||
|
||||
+89
-2
@@ -1732,6 +1732,93 @@ def embed(ctx: click.Context) -> None:
|
||||
click.echo(ctx.get_help())
|
||||
|
||||
|
||||
def _json_default(obj) -> object:
|
||||
"""JSON serialiser that converts NumPy scalars/arrays to native Python types.
|
||||
|
||||
Falls back to ``str()`` for everything else so the writer never crashes on
|
||||
unexpected types (e.g. ``datetime``, custom domain objects).
|
||||
"""
|
||||
try:
|
||||
import numpy as np # local import — only needed when result contains numpy
|
||||
if isinstance(obj, np.ndarray):
|
||||
return obj.tolist()
|
||||
if isinstance(obj, np.generic):
|
||||
return obj.item()
|
||||
except ImportError:
|
||||
pass
|
||||
return str(obj)
|
||||
|
||||
|
||||
def _write_result_output(out_path: Path, result) -> None:
|
||||
"""Serialize a structured CLI result (dict or list) for ``--output``.
|
||||
|
||||
Domain commands like ``deduplicate`` and ``ontology align`` produce dicts
|
||||
and lists, not numeric matrices — routing them through the embeddings
|
||||
writer rejected their shapes and extensions (.csv is documented for
|
||||
deduplicate). JSON-family formats serialize anything; CSV serializes a
|
||||
list of dicts (or a single dict as one row).
|
||||
|
||||
Accepted extensions: .json, .jsonl, .csv (no-extension and .txt are
|
||||
rejected so the path reported to the caller always matches the file
|
||||
actually created, consistent with every other --output in the CLI).
|
||||
"""
|
||||
import json as _json
|
||||
|
||||
suffix = out_path.suffix.lower()
|
||||
|
||||
# ── JSON ────────────────────────────────────────────────────────────────
|
||||
if suffix == ".json":
|
||||
with open(out_path, "w", encoding="utf-8") as fh:
|
||||
_json.dump(result, fh, indent=2, default=_json_default)
|
||||
return
|
||||
|
||||
# ── JSON Lines ──────────────────────────────────────────────────────────
|
||||
# Every record must occupy exactly one line. Wrap a bare dict in a list
|
||||
# so callers never need to know whether their result is singular or plural.
|
||||
if suffix == ".jsonl":
|
||||
items = result if isinstance(result, list) else [result]
|
||||
with open(out_path, "w", encoding="utf-8") as fh:
|
||||
for item in items:
|
||||
fh.write(_json.dumps(item, default=_json_default) + "\n")
|
||||
return
|
||||
|
||||
# ── CSV ─────────────────────────────────────────────────────────────────
|
||||
if suffix == ".csv":
|
||||
import pandas as pd
|
||||
|
||||
rows = result if isinstance(result, list) else [result]
|
||||
if not rows:
|
||||
raise click.ClickException(
|
||||
"No results to write — output file not created."
|
||||
)
|
||||
# Normalise numpy scalars/arrays to Python natives so to_csv() does
|
||||
# not fall back to repr() strings for array-valued cells.
|
||||
def _normalise(row):
|
||||
if not isinstance(row, dict):
|
||||
return row
|
||||
out = {}
|
||||
for k, v in row.items():
|
||||
try:
|
||||
import numpy as np
|
||||
if isinstance(v, np.ndarray):
|
||||
v = v.tolist()
|
||||
elif isinstance(v, np.generic):
|
||||
v = v.item()
|
||||
except ImportError:
|
||||
pass
|
||||
out[k] = v
|
||||
return out
|
||||
|
||||
pd.DataFrame([_normalise(r) for r in rows]).to_csv(out_path, index=False)
|
||||
return
|
||||
|
||||
# ── unsupported ─────────────────────────────────────────────────────────
|
||||
display = suffix if suffix else "(no extension)"
|
||||
raise click.ClickException(
|
||||
f"Unsupported output format '{display}'. Use .json, .jsonl, or .csv"
|
||||
)
|
||||
|
||||
|
||||
@embed.command("generate")
|
||||
@click.argument("input_path")
|
||||
@click.option("--model",
|
||||
@@ -2032,7 +2119,7 @@ def deduplicate(
|
||||
except ImportError as exc:
|
||||
raise click.ClickException(f"Deduplication module not available: {exc}") from exc
|
||||
if output:
|
||||
Path(output).write_text(json.dumps(result, default=str), encoding="utf-8")
|
||||
_write_result_output(Path(output), result)
|
||||
_ok(cli_ctx, f"Wrote {output}")
|
||||
elif _is_json(cli_ctx, local_json):
|
||||
_jecho(result if isinstance(result, (dict, list)) else {"result": str(result)})
|
||||
@@ -3156,7 +3243,7 @@ def ontology_align(cli_ctx: CLIContext, source: str, target: str, strategy: str,
|
||||
except ImportError as exc:
|
||||
raise click.ClickException(f"Ontology module not available: {exc}") from exc
|
||||
if output:
|
||||
Path(output).write_text(json.dumps(result, default=str), encoding="utf-8")
|
||||
_write_result_output(Path(output), result)
|
||||
_ok(cli_ctx, f"Wrote {output}")
|
||||
elif _is_json(cli_ctx, local_json):
|
||||
_jecho(result if isinstance(result, dict) else {"alignments": str(result)})
|
||||
|
||||
@@ -109,7 +109,7 @@ class ConflictsConfig:
|
||||
if value:
|
||||
try:
|
||||
if type_func == bool:
|
||||
self._configs[config_key] = value.lower() in (
|
||||
self._configs[config_key] = value.strip().lower() in (
|
||||
"true",
|
||||
"1",
|
||||
"yes",
|
||||
@@ -136,12 +136,12 @@ class ConflictsConfig:
|
||||
if value:
|
||||
try:
|
||||
# Try to convert to appropriate type
|
||||
if isinstance(default, int):
|
||||
if isinstance(default, bool):
|
||||
return value.strip().lower() in ("true", "1", "yes", "on")
|
||||
elif isinstance(default, int):
|
||||
return int(value)
|
||||
elif isinstance(default, float):
|
||||
return float(value)
|
||||
elif isinstance(default, bool):
|
||||
return value.lower() in ("true", "1", "yes", "on")
|
||||
return value
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
|
||||
@@ -899,6 +899,11 @@ class ContextGraph:
|
||||
return
|
||||
node.properties.update(attributes)
|
||||
node.metadata.update(attributes)
|
||||
# Keep derived decision indexes consistent when a decision node is
|
||||
# mutated so that category / entity / temporal lookups reflect the
|
||||
# new property values without requiring a full graph reload.
|
||||
if (getattr(node, "node_type", None) or "").lower() == "decision":
|
||||
self._sync_decision_from_node(node_id)
|
||||
|
||||
if getattr(self, "mutation_callback", None) and not getattr(
|
||||
self, "_suspend_mutation_callback", False
|
||||
@@ -1291,6 +1296,14 @@ class ContextGraph:
|
||||
if link_id:
|
||||
self._unresolved_links[link_id] = link_meta
|
||||
|
||||
# Rebuild all derived decision indexes from the freshly-loaded
|
||||
# nodes so that find_precedents_by_scenario, find_similar_decisions,
|
||||
# and all decision analytics work correctly after a reload.
|
||||
# _rebuild_decision_indexes() unconditionally clears the old indexes
|
||||
# first, so repeated load_from_file calls never accumulate stale
|
||||
# entries from a previous file.
|
||||
self._rebuild_decision_indexes()
|
||||
|
||||
self.logger.info(f"Loaded context graph from {path}")
|
||||
|
||||
@staticmethod
|
||||
@@ -1633,6 +1646,8 @@ class ContextGraph:
|
||||
self._analytics_cache.clear()
|
||||
self._retractions.clear()
|
||||
self._tombstones.clear()
|
||||
# Rebuild derived decision indexes from the freshly-loaded nodes.
|
||||
self._rebuild_decision_indexes()
|
||||
|
||||
if self.mutation_callback and not self._suspend_mutation_callback:
|
||||
mutation_events = [
|
||||
@@ -2825,6 +2840,12 @@ class ContextGraph:
|
||||
self._unresolved_links.clear()
|
||||
self._retractions.clear()
|
||||
self._tombstones.clear()
|
||||
# Reset derived decision indexes so that decision queries against
|
||||
# a cleared graph return empty results rather than stale data.
|
||||
self._decisions = {}
|
||||
self._decision_index = defaultdict(set)
|
||||
self._entity_index = defaultdict(set)
|
||||
self._temporal_index = []
|
||||
self.logger.debug("Graph state fully cleared.")
|
||||
|
||||
# --- Internal Helpers ---
|
||||
@@ -3482,6 +3503,9 @@ class ContextGraph:
|
||||
)
|
||||
self._add_internal_edge(edge)
|
||||
|
||||
# Rebuild derived decision indexes from the now-populated node store.
|
||||
self._rebuild_decision_indexes()
|
||||
|
||||
def state_at(self, timestamp: Union[str, int, float, datetime]) -> Dict[str, Any]:
|
||||
"""Return a serializable snapshot of graph state valid at the given time."""
|
||||
at_time = self._normalize_timestamp(timestamp)
|
||||
@@ -4707,6 +4731,7 @@ class ContextGraph:
|
||||
scenario=decision["scenario"],
|
||||
decision_maker=decision.get("decision_maker", ""),
|
||||
reasoning=decision["reasoning"],
|
||||
recorded_at=decision.get("recorded_at", ""),
|
||||
**safe_metadata,
|
||||
**extra_properties,
|
||||
)
|
||||
@@ -4788,20 +4813,288 @@ class ContextGraph:
|
||||
return False
|
||||
return True
|
||||
|
||||
def _calculate_decision_content_similarity(self, scenario: str, decision: Dict[str, Any]) -> float:
|
||||
"""Calculate content similarity between scenario and decision."""
|
||||
# ── decision-index helpers ────────────────────────────────────────────────
|
||||
|
||||
# Protected set of node properties whose values are *core* decision fields
|
||||
# so that we can distinguish them from user-supplied metadata when
|
||||
# rebuilding the in-memory indexes from a persisted node.
|
||||
_DECISION_CORE_FIELDS: frozenset = frozenset({
|
||||
"id", "category", "scenario", "reasoning", "outcome", "confidence",
|
||||
"entities", "decision_maker", "timestamp", "recorded_at",
|
||||
"valid_from", "valid_until", "content",
|
||||
})
|
||||
|
||||
def _rebuild_decision_indexes(self) -> None:
|
||||
"""Rebuild all derived decision indexes from the current node store.
|
||||
|
||||
This method is the single authoritative rebuild path. It must be
|
||||
called (under the graph lock) after any operation that wholesale
|
||||
replaces ``self.nodes`` — namely ``load_from_file`` (JSON and Markdown
|
||||
paths) and ``from_dict``.
|
||||
|
||||
Contract:
|
||||
- Unconditionally clears ``_decisions``, ``_decision_index``,
|
||||
``_entity_index``, and ``_temporal_index`` before rebuilding so that
|
||||
repeated calls never accumulate stale entries.
|
||||
- Derives ``_decisions[node_id]["metadata"]`` from the full set of
|
||||
node properties, excluding the protected core fields, so that
|
||||
user-supplied metadata survives the round-trip.
|
||||
- Runs under ``self._lock`` when called from load paths; callers that
|
||||
already hold the lock must invoke ``_rebuild_decision_indexes``
|
||||
inside the lock block.
|
||||
"""
|
||||
# Always start fresh so repeated loads don't accumulate stale entries.
|
||||
self._decisions: Dict[str, Any] = {}
|
||||
self._decision_index: Dict[str, set] = defaultdict(set)
|
||||
self._entity_index: Dict[str, set] = defaultdict(set)
|
||||
self._temporal_index: List[Tuple[str, float]] = []
|
||||
|
||||
for node in self.nodes.values():
|
||||
if (getattr(node, "node_type", None) or "").lower() != "decision":
|
||||
continue
|
||||
|
||||
# Merge metadata and properties; properties win on collision.
|
||||
meta: Dict[str, Any] = {}
|
||||
meta.update(getattr(node, "metadata", {}) or {})
|
||||
meta.update(getattr(node, "properties", {}) or {})
|
||||
|
||||
# Timestamp: keep whatever was stored (float epoch or ISO string).
|
||||
# The temporal index uses it for sorting; downstream code handles
|
||||
# both types via _normalize_timestamp.
|
||||
raw_ts = meta.get("timestamp", 0.0)
|
||||
try:
|
||||
sort_ts = float(raw_ts)
|
||||
except (TypeError, ValueError):
|
||||
sort_ts = 0.0
|
||||
|
||||
# Entities may be stored as a list in meta or inferred from
|
||||
# outgoing "involves" edges if the list field is absent/empty.
|
||||
# _add_decision_to_graph creates entity nodes connected via
|
||||
# "involves" edges; it does NOT store the list as a node property.
|
||||
entities = meta.get("entities") or []
|
||||
if not isinstance(entities, list):
|
||||
entities = []
|
||||
if not entities:
|
||||
# Recover entity list from "involves" edges on this decision node
|
||||
for edge in self._adjacency.get(node.node_id, []):
|
||||
if edge.edge_type == "involves":
|
||||
entities.append(edge.target_id)
|
||||
|
||||
# Everything that isn't a core field is user-supplied metadata.
|
||||
extra_meta = {
|
||||
k: v
|
||||
for k, v in meta.items()
|
||||
if k not in self._DECISION_CORE_FIELDS
|
||||
}
|
||||
|
||||
decision: Dict[str, Any] = {
|
||||
"id": node.node_id,
|
||||
"category": meta.get("category", ""),
|
||||
"scenario": meta.get("scenario", getattr(node, "content", "") or ""),
|
||||
"reasoning": meta.get("reasoning", ""),
|
||||
"outcome": meta.get("outcome", ""),
|
||||
"confidence": float(meta.get("confidence", 0.0) or 0.0),
|
||||
"entities": entities,
|
||||
"decision_maker": meta.get("decision_maker"),
|
||||
"timestamp": raw_ts,
|
||||
"recorded_at": meta.get("recorded_at", ""),
|
||||
"valid_from": getattr(node, "valid_from", None),
|
||||
"valid_until": getattr(node, "valid_until", None),
|
||||
# Preserve all non-core node properties as decision metadata so
|
||||
# that user-supplied fields survive a save → load round-trip.
|
||||
"metadata": extra_meta,
|
||||
}
|
||||
|
||||
self._decisions[node.node_id] = decision
|
||||
|
||||
category = decision["category"]
|
||||
if category:
|
||||
self._decision_index[category].add(node.node_id)
|
||||
|
||||
for entity in entities:
|
||||
self._entity_index[entity].add(node.node_id)
|
||||
|
||||
self._temporal_index.append((node.node_id, sort_ts))
|
||||
|
||||
self._temporal_index.sort(key=lambda x: x[1], reverse=True)
|
||||
|
||||
def _sync_decision_from_node(self, node_id: str) -> None:
|
||||
"""Synchronise a single decision index entry from the node store.
|
||||
|
||||
Called after ``add_node_attribute`` mutates a decision node so that
|
||||
``_decisions`` and the derived indexes stay consistent without
|
||||
requiring a full rebuild of all decisions.
|
||||
"""
|
||||
node = self.nodes.get(node_id)
|
||||
if node is None:
|
||||
return
|
||||
if (getattr(node, "node_type", None) or "").lower() != "decision":
|
||||
return
|
||||
|
||||
if not hasattr(self, "_decisions"):
|
||||
# Indexes don't exist yet — a full rebuild is safer.
|
||||
self._rebuild_decision_indexes()
|
||||
return
|
||||
|
||||
# Remove stale index entries for this decision ID.
|
||||
old = self._decisions.get(node_id)
|
||||
if old:
|
||||
old_cat = old.get("category", "")
|
||||
if old_cat and node_id in self._decision_index.get(old_cat, set()):
|
||||
self._decision_index[old_cat].discard(node_id)
|
||||
for ent in old.get("entities", []):
|
||||
self._entity_index[ent].discard(node_id)
|
||||
self._temporal_index = [
|
||||
(nid, ts) for nid, ts in self._temporal_index if nid != node_id
|
||||
]
|
||||
|
||||
# Rebuild the entry for this node and re-insert index entries.
|
||||
meta: Dict[str, Any] = {}
|
||||
meta.update(getattr(node, "metadata", {}) or {})
|
||||
meta.update(getattr(node, "properties", {}) or {})
|
||||
|
||||
raw_ts = meta.get("timestamp", 0.0)
|
||||
try:
|
||||
# Simple word-based similarity
|
||||
sort_ts = float(raw_ts)
|
||||
except (TypeError, ValueError):
|
||||
sort_ts = 0.0
|
||||
|
||||
entities = meta.get("entities") or []
|
||||
if not isinstance(entities, list):
|
||||
entities = []
|
||||
if not entities:
|
||||
# Recover entity list from "involves" edges
|
||||
for edge in self._adjacency.get(node_id, []):
|
||||
if edge.edge_type == "involves":
|
||||
entities.append(edge.target_id)
|
||||
|
||||
extra_meta = {
|
||||
k: v for k, v in meta.items() if k not in self._DECISION_CORE_FIELDS
|
||||
}
|
||||
|
||||
decision: Dict[str, Any] = {
|
||||
"id": node_id,
|
||||
"category": meta.get("category", ""),
|
||||
"scenario": meta.get("scenario", getattr(node, "content", "") or ""),
|
||||
"reasoning": meta.get("reasoning", ""),
|
||||
"outcome": meta.get("outcome", ""),
|
||||
"confidence": float(meta.get("confidence", 0.0) or 0.0),
|
||||
"entities": entities,
|
||||
"decision_maker": meta.get("decision_maker"),
|
||||
"timestamp": raw_ts,
|
||||
"recorded_at": meta.get("recorded_at", ""),
|
||||
"valid_from": getattr(node, "valid_from", None),
|
||||
"valid_until": getattr(node, "valid_until", None),
|
||||
"metadata": extra_meta,
|
||||
}
|
||||
|
||||
self._decisions[node_id] = decision
|
||||
if decision["category"]:
|
||||
self._decision_index[decision["category"]].add(node_id)
|
||||
for ent in entities:
|
||||
self._entity_index[ent].add(node_id)
|
||||
self._temporal_index.append((node_id, sort_ts))
|
||||
self._temporal_index.sort(key=lambda x: x[1], reverse=True)
|
||||
|
||||
@staticmethod
|
||||
def _char_bigrams(text: str) -> set:
|
||||
"""Character bigrams over whitespace-stripped text (CJK fallback).
|
||||
|
||||
Strips whitespace so CJK characters without word-separating spaces are
|
||||
treated as a contiguous character sequence rather than a single token.
|
||||
"""
|
||||
chars = "".join(text.lower().split())
|
||||
return {chars[i:i + 2] for i in range(len(chars) - 1)}
|
||||
|
||||
@staticmethod
|
||||
def _looks_cjk(text: str) -> bool:
|
||||
"""True if text contains CJK/Japanese/Korean script characters.
|
||||
|
||||
Used to gate the character-bigram similarity fallback so it only
|
||||
activates for scripts where whitespace tokenisation doesn't work.
|
||||
"""
|
||||
for ch in text:
|
||||
code = ord(ch)
|
||||
if (
|
||||
0x4E00 <= code <= 0x9FFF # CJK Unified Ideographs
|
||||
or 0x3400 <= code <= 0x4DBF # CJK Extension A
|
||||
or 0x3040 <= code <= 0x30FF # Hiragana + Katakana
|
||||
or 0xAC00 <= code <= 0xD7A3 # Hangul Syllables
|
||||
or 0x1100 <= code <= 0x11FF # Hangul Jamo
|
||||
):
|
||||
return True
|
||||
return False
|
||||
|
||||
def _calculate_decision_content_similarity(self, scenario: str, decision: Dict[str, Any]) -> float:
|
||||
"""Calculate content similarity between scenario and decision.
|
||||
|
||||
Uses word-level Jaccard for space-separated languages. For text where
|
||||
whitespace tokenisation is unreliable (CJK/Japanese/Korean scripts, or
|
||||
a query with no whitespace at all) a character-bigram Jaccard is
|
||||
computed over the *stripped* character sequences instead.
|
||||
|
||||
The bigram fallback only activates when whitespace tokenisation would
|
||||
not help — i.e. the query is CJK-like or has at most one whitespace
|
||||
token — so it never contributes for ordinary multi-word English
|
||||
queries, where incidental bigram overlap between unrelated sentences
|
||||
would otherwise inflate scores.
|
||||
|
||||
The bigram side uses *Jaccard* (|A∩B|/|A∪B|), not the overlap
|
||||
coefficient, so a 2-character query whose single bigram happens to
|
||||
appear anywhere in a long document does not silently receive a score of
|
||||
1.0. A minimum bigram set size of 3 is required before the bigram
|
||||
signal contributes; this prevents 1- and 2-character English queries
|
||||
from polluting results while still allowing 3-character CJK phrases (2
|
||||
bigrams) to match.
|
||||
"""
|
||||
try:
|
||||
decision_text = (
|
||||
f"{decision['scenario']} {decision['reasoning']} "
|
||||
f"{' '.join(decision['entities'])}"
|
||||
)
|
||||
|
||||
# --- word-level Jaccard (primary metric for Latin/space-delimited) ---
|
||||
scenario_words = set(scenario.lower().split())
|
||||
decision_text = f"{decision['scenario']} {decision['reasoning']} {' '.join(decision['entities'])}"
|
||||
decision_words = set(decision_text.lower().split())
|
||||
|
||||
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:
|
||||
word_union = scenario_words | decision_words
|
||||
word_sim = (
|
||||
len(scenario_words & decision_words) / len(word_union)
|
||||
if word_union
|
||||
else 0.0
|
||||
)
|
||||
|
||||
# --- character-bigram Jaccard (CJK / very-short-query fallback) ---
|
||||
# Only used when whitespace tokenisation can't do the job: CJK-like
|
||||
# scripts, or a query that is a single whitespace token (no spaces
|
||||
# to split on). Ordinary multi-word English queries rely on
|
||||
# word_sim alone, so incidental bigram overlap between unrelated
|
||||
# sentences can never inflate their score.
|
||||
bigram_sim = 0.0
|
||||
needs_bigram_fallback = (
|
||||
self._looks_cjk(scenario) or len(scenario.split()) <= 1
|
||||
)
|
||||
if needs_bigram_fallback:
|
||||
scenario_bigrams = self._char_bigrams(scenario)
|
||||
decision_bigrams = self._char_bigrams(decision_text)
|
||||
|
||||
# Require at least 3 bigrams in the query before the bigram
|
||||
# signal is used. A 2-char query produces only 1 bigram; that
|
||||
# single bigram is far too likely to appear as a substring of
|
||||
# any English word and would produce a spuriously high overlap
|
||||
# coefficient. 3 bigrams correspond to a 4-char stripped query
|
||||
# (e.g. two CJK characters produce 1 bigram each → need ≥3
|
||||
# chars stripped).
|
||||
if len(scenario_bigrams) >= 3 and decision_bigrams:
|
||||
bigram_union = scenario_bigrams | decision_bigrams
|
||||
bigram_sim = (
|
||||
len(scenario_bigrams & decision_bigrams) / len(bigram_union)
|
||||
if bigram_union
|
||||
else 0.0
|
||||
)
|
||||
|
||||
return max(word_sim, bigram_sim)
|
||||
|
||||
except Exception:
|
||||
self.logger.exception("Content similarity calculation failed")
|
||||
return 0.0
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ including node labels, relationship types, and indexes for graph databases.
|
||||
"""
|
||||
|
||||
import json
|
||||
import warnings
|
||||
from typing import Dict, Any, List
|
||||
|
||||
from ..graph_store import GraphStore
|
||||
@@ -460,11 +461,25 @@ def drop_decision_schema(graph_store: GraphStore) -> None:
|
||||
"""
|
||||
Drop decision tracking schema (for cleanup/testing).
|
||||
|
||||
.. deprecated::
|
||||
``drop_decision_schema()`` is deprecated and will be removed in a future
|
||||
major version. It has no callers inside Semantica; issue the DROP
|
||||
CONSTRAINT / DROP INDEX / DETACH DELETE statements directly against your
|
||||
:class:`~semantica.graph_store.GraphStore` instead.
|
||||
|
||||
Args:
|
||||
graph_store: Graph database instance
|
||||
"""
|
||||
logger = get_logger(__name__)
|
||||
|
||||
warnings.warn(
|
||||
"drop_decision_schema() is deprecated and will be removed in a future "
|
||||
"major version. Issue the DROP CONSTRAINT / DROP INDEX / DETACH DELETE "
|
||||
"statements directly against your GraphStore instead.",
|
||||
DeprecationWarning,
|
||||
stacklevel=2,
|
||||
)
|
||||
|
||||
try:
|
||||
# Drop constraints
|
||||
constraints = [
|
||||
|
||||
@@ -110,7 +110,7 @@ class DeduplicationConfig:
|
||||
if value:
|
||||
try:
|
||||
if type_func == bool:
|
||||
self._configs[config_key] = value.lower() in (
|
||||
self._configs[config_key] = value.strip().lower() in (
|
||||
"true",
|
||||
"1",
|
||||
"yes",
|
||||
@@ -137,12 +137,12 @@ class DeduplicationConfig:
|
||||
if value:
|
||||
try:
|
||||
# Try to convert to appropriate type
|
||||
if isinstance(default, int):
|
||||
if isinstance(default, bool):
|
||||
return value.strip().lower() in ("true", "1", "yes", "on")
|
||||
elif isinstance(default, int):
|
||||
return int(value)
|
||||
elif isinstance(default, float):
|
||||
return float(value)
|
||||
elif isinstance(default, bool):
|
||||
return value.lower() in ("true", "1", "yes", "on")
|
||||
return value
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
|
||||
@@ -147,9 +147,12 @@ def calculate_similarity(
|
||||
>>> result = calculate_similarity(entity1, entity2, method="levenshtein")
|
||||
>>> print(f"Similarity: {result.score:.2f}")
|
||||
"""
|
||||
# Check for custom method in registry
|
||||
# Check for custom method in registry, skip self-referential wrappers.
|
||||
# _multi_factor_similarity is registered under "multi_factor" and calls back
|
||||
# into calculate_similarity(method="multi_factor"), creating indirect
|
||||
# infinite recursion. The identity guard short-circuits that loop.
|
||||
custom_method = method_registry.get("similarity", method)
|
||||
if custom_method:
|
||||
if custom_method and custom_method is not calculate_similarity:
|
||||
return custom_method(entity1, entity2, **kwargs)
|
||||
|
||||
# Use default SimilarityCalculator
|
||||
@@ -235,9 +238,11 @@ def detect_duplicates(
|
||||
>>> duplicates = detect_duplicates(entities, method="pairwise", similarity_threshold=0.8)
|
||||
>>> print(f"Found {len(duplicates)} duplicate candidates")
|
||||
"""
|
||||
# Check for custom method in registry
|
||||
# Check for custom method in registry, skip self-referential wrappers.
|
||||
# _pairwise_detection is registered under "pairwise" and calls back into
|
||||
# detect_duplicates(method="pairwise"), creating indirect infinite recursion.
|
||||
custom_method = method_registry.get("detection", method)
|
||||
if custom_method:
|
||||
if custom_method and custom_method is not detect_duplicates:
|
||||
return custom_method(
|
||||
entities, similarity_threshold=similarity_threshold, **kwargs
|
||||
)
|
||||
@@ -282,9 +287,10 @@ def dedup_triplets(
|
||||
List of duplicate relationship piars (rel1, rel2).
|
||||
"""
|
||||
|
||||
# Check for custom method in registry (but not ourself)
|
||||
# Check for custom method in registry (but not ourself — identity guard
|
||||
# consistent with the other dispatch functions in this module).
|
||||
custom_method = method_registry.get("detection", "triplets")
|
||||
if custom_method and custom_method.__name__ != "dedup_triplets":
|
||||
if custom_method and custom_method is not dedup_triplets:
|
||||
return custom_method(relationships, mode=mode, threshold=threshold, **kwargs)
|
||||
|
||||
detector = DuplicateDetector(**kwargs)
|
||||
@@ -328,9 +334,11 @@ def merge_entities(
|
||||
>>> operations = merge_entities(duplicate_entities, method="keep_most_complete")
|
||||
>>> print(f"Performed {len(operations)} merge operations")
|
||||
"""
|
||||
# Check for custom method in registry
|
||||
# Check for custom method in registry, skip self-referential registration.
|
||||
# merge_entities is now registered directly under its default method name;
|
||||
# the identity guard prevents a direct recursion loop.
|
||||
custom_method = method_registry.get("merging", method)
|
||||
if custom_method:
|
||||
if custom_method and custom_method is not merge_entities:
|
||||
return custom_method(
|
||||
entities, preserve_provenance=preserve_provenance, **kwargs
|
||||
)
|
||||
@@ -374,9 +382,12 @@ def build_clusters(
|
||||
>>> result = build_clusters(entities, method="graph_based", similarity_threshold=0.8)
|
||||
>>> print(f"Found {len(result.clusters)} clusters")
|
||||
"""
|
||||
# Check for custom method in registry
|
||||
# Check for custom method in registry, skip self-referential wrappers.
|
||||
# _graph_based_clustering is registered under "graph_based" and calls back
|
||||
# into build_clusters(method="graph_based"), creating indirect infinite
|
||||
# recursion.
|
||||
custom_method = method_registry.get("clustering", method)
|
||||
if custom_method:
|
||||
if custom_method and custom_method is not build_clusters:
|
||||
return custom_method(
|
||||
entities, similarity_threshold=similarity_threshold, **kwargs
|
||||
)
|
||||
@@ -546,25 +557,12 @@ def list_available_methods(task: Optional[str] = None) -> Dict[str, List[str]]:
|
||||
return result
|
||||
|
||||
|
||||
# Register default methods with registry
|
||||
def _multi_factor_similarity(e1, e2, **kw):
|
||||
return calculate_similarity(e1, e2, method="multi_factor", **kw)
|
||||
|
||||
|
||||
def _pairwise_detection(entities, **kw):
|
||||
return detect_duplicates(entities, method="pairwise", **kw)
|
||||
|
||||
|
||||
def _keep_most_complete_merging(entities, **kw):
|
||||
return merge_entities(entities, method="keep_most_complete", **kw)
|
||||
|
||||
|
||||
def _graph_based_clustering(entities, **kw):
|
||||
return build_clusters(entities, method="graph_based", **kw)
|
||||
|
||||
|
||||
method_registry.register("similarity", "multi_factor", _multi_factor_similarity)
|
||||
method_registry.register("detection", "pairwise", _pairwise_detection)
|
||||
method_registry.register("merging", "keep_most_complete", _keep_most_complete_merging)
|
||||
method_registry.register("clustering", "graph_based", _graph_based_clustering)
|
||||
# Register default methods with registry.
|
||||
# The public dispatch functions are registered directly so the identity guard
|
||||
# in each function short-circuits the self-reference rather than going through
|
||||
# an intermediate wrapper that re-enters the same dispatch path.
|
||||
method_registry.register("similarity", "multi_factor", calculate_similarity)
|
||||
method_registry.register("detection", "pairwise", detect_duplicates)
|
||||
method_registry.register("merging", "keep_most_complete", merge_entities)
|
||||
method_registry.register("clustering", "graph_based", build_clusters)
|
||||
method_registry.register("detection", "triplets", dedup_triplets)
|
||||
|
||||
@@ -106,7 +106,7 @@ class EmbeddingsConfig:
|
||||
if value:
|
||||
try:
|
||||
if type_func == bool:
|
||||
self._configs[config_key] = value.lower() in (
|
||||
self._configs[config_key] = value.strip().lower() in (
|
||||
"true",
|
||||
"1",
|
||||
"yes",
|
||||
@@ -123,8 +123,8 @@ class EmbeddingsConfig:
|
||||
if key.startswith(env_prefix) and key not in env_mappings:
|
||||
config_key = key[len(env_prefix) :].lower()
|
||||
# Try to convert to appropriate type
|
||||
if value.lower() in ("true", "false"):
|
||||
self._configs[config_key] = value.lower() == "true"
|
||||
if value.strip().lower() in ("true", "false"):
|
||||
self._configs[config_key] = value.strip().lower() == "true"
|
||||
elif value.isdigit():
|
||||
self._configs[config_key] = int(value)
|
||||
else:
|
||||
@@ -149,12 +149,12 @@ class EmbeddingsConfig:
|
||||
if value:
|
||||
try:
|
||||
# Try to convert to appropriate type
|
||||
if isinstance(default, int):
|
||||
if isinstance(default, bool):
|
||||
return value.strip().lower() in ("true", "1", "yes", "on")
|
||||
elif isinstance(default, int):
|
||||
return int(value)
|
||||
elif isinstance(default, float):
|
||||
return float(value)
|
||||
elif isinstance(default, bool):
|
||||
return value.lower() in ("true", "1", "yes", "on")
|
||||
return value
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
|
||||
@@ -2,8 +2,9 @@
|
||||
Semantica Explorer : FastAPI Dependencies
|
||||
|
||||
Provides ``Depends()``-compatible callables for injecting the
|
||||
current ``GraphSession`` and ``ConnectionManager`` into route handlers,
|
||||
and for enforcing API-key authentication on protected routes.
|
||||
current ``GraphSession`` into route handlers, and for enforcing API-key
|
||||
authentication on protected routes. WebSocket manager access is handled
|
||||
directly via ``app.state.ws_manager``.
|
||||
"""
|
||||
|
||||
import hmac
|
||||
@@ -14,7 +15,6 @@ from fastapi import Request, HTTPException, Security, status
|
||||
from fastapi.security.api_key import APIKeyHeader
|
||||
|
||||
from .session import GraphSession
|
||||
from .ws import ConnectionManager
|
||||
|
||||
_api_key_header = APIKeyHeader(name="X-API-Key", auto_error=False)
|
||||
|
||||
@@ -80,13 +80,3 @@ def get_session(request: Request) -> GraphSession:
|
||||
detail="GraphSession not initialized."
|
||||
)
|
||||
return request.app.state.session
|
||||
|
||||
|
||||
def get_ws_manager(request: Request) -> ConnectionManager:
|
||||
"""Retrieve the ConnectionManager stored on ``app.state``."""
|
||||
if not hasattr(request.app.state, "ws_manager") or request.app.state.ws_manager is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||
detail="WebSocket manager not initialized.",
|
||||
)
|
||||
return request.app.state.ws_manager
|
||||
|
||||
@@ -78,66 +78,6 @@ def _parse_bbox(raw_bbox: Optional[str]) -> Optional[tuple[float, float, float,
|
||||
return min_x, min_y, max_x, max_y
|
||||
|
||||
|
||||
def _coerce_embedding_vector(value: object) -> Optional[List[float]]:
|
||||
if isinstance(value, dict):
|
||||
# Probe keys in priority order: generic first, then framework-specific.
|
||||
# Must stay aligned with the top-level keys in _extract_node_embeddings.
|
||||
for key in ("embedding", "embeddings", "vector", "values", "node2vec", "semantic"):
|
||||
nested = _coerce_embedding_vector(value.get(key))
|
||||
if nested is not None:
|
||||
return nested
|
||||
return None
|
||||
|
||||
if not isinstance(value, (list, tuple)):
|
||||
return None
|
||||
|
||||
vector: List[float] = []
|
||||
for item in value:
|
||||
try:
|
||||
vector.append(float(item))
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
return vector if vector else None
|
||||
|
||||
|
||||
def _extract_node_embeddings(graph_dict: dict) -> dict[str, List[float]]:
|
||||
"""Extract embeddings from graph dictionary."""
|
||||
# Top-level keys to probe on each entity (and its metadata/properties dicts).
|
||||
# Priority: generic names first, then KG-extras-specific names.
|
||||
# Must stay aligned with the inner probe list in _coerce_embedding_vector.
|
||||
embedding_keys = (
|
||||
"embedding",
|
||||
"embeddings",
|
||||
"vector",
|
||||
"node_embedding",
|
||||
"node2vec_embedding",
|
||||
"semantic_embedding",
|
||||
"reasoning_embedding",
|
||||
)
|
||||
|
||||
embeddings: dict[str, List[float]] = {}
|
||||
for entity in graph_dict.get("entities") or graph_dict.get("nodes") or []:
|
||||
if not isinstance(entity, dict):
|
||||
continue
|
||||
node_id = entity.get("id") or entity.get("node_id")
|
||||
if not node_id:
|
||||
continue
|
||||
|
||||
metadata = entity.get("metadata") if isinstance(entity.get("metadata"), dict) else {}
|
||||
properties = entity.get("properties") if isinstance(entity.get("properties"), dict) else {}
|
||||
|
||||
for key in embedding_keys:
|
||||
vector = _coerce_embedding_vector(
|
||||
entity.get(key, metadata.get(key, properties.get(key)))
|
||||
)
|
||||
if vector is not None:
|
||||
embeddings[str(node_id)] = vector
|
||||
break
|
||||
|
||||
return embeddings
|
||||
|
||||
|
||||
def _get_cached_embeddings(session: GraphSession) -> dict[str, List[float]]:
|
||||
"""Get embeddings from session cache for optimal performance."""
|
||||
return session.get_cached_embeddings()
|
||||
|
||||
@@ -456,10 +456,6 @@ class DraftResponse(BaseModel):
|
||||
updated_at: str
|
||||
|
||||
|
||||
class ProposalState(BaseModel):
|
||||
state: Literal["draft", "proposed", "approved", "published", "rejected"]
|
||||
|
||||
|
||||
class ProposalRequest(BaseModel):
|
||||
draft_id: str
|
||||
ontology_uri: str
|
||||
|
||||
@@ -8,11 +8,6 @@ from typing import Any, Dict, List, Literal, Optional, Tuple
|
||||
from pydantic import BaseModel, Field, field_validator
|
||||
|
||||
|
||||
class ErrorResponse(BaseModel):
|
||||
detail: str
|
||||
status_code: int = 500
|
||||
|
||||
|
||||
class NodeResponse(BaseModel):
|
||||
id: str
|
||||
type: str
|
||||
@@ -187,12 +182,6 @@ class ComplianceResponse(BaseModel):
|
||||
violations: List[Dict[str, Any]] = Field(default_factory=list)
|
||||
|
||||
|
||||
class TemporalSnapshotResponse(BaseModel):
|
||||
timestamp: str
|
||||
active_nodes: List[NodeResponse]
|
||||
active_node_count: int
|
||||
|
||||
|
||||
class TemporalDiffResponse(BaseModel):
|
||||
from_time: str
|
||||
to_time: str
|
||||
@@ -256,13 +245,6 @@ class ExportRequest(BaseModel):
|
||||
node_ids: Optional[List[str]] = None
|
||||
|
||||
|
||||
class ExportResponse(BaseModel):
|
||||
format: str
|
||||
content_type: str
|
||||
filename: str
|
||||
size_bytes: int = 0
|
||||
|
||||
|
||||
class ImportResponse(BaseModel):
|
||||
status: str = "success"
|
||||
message: str = "Import successful"
|
||||
@@ -272,11 +254,6 @@ class ImportResponse(BaseModel):
|
||||
edges_imported: Optional[int] = None
|
||||
|
||||
|
||||
class StandardMessageResponse(BaseModel):
|
||||
status: str
|
||||
message: str
|
||||
|
||||
|
||||
class AnnotationCreate(BaseModel):
|
||||
node_id: str
|
||||
content: str
|
||||
|
||||
@@ -105,7 +105,7 @@ class ExportConfig:
|
||||
if value:
|
||||
try:
|
||||
if type_func == bool:
|
||||
self._configs[config_key] = value.lower() in (
|
||||
self._configs[config_key] = value.strip().lower() in (
|
||||
"true",
|
||||
"1",
|
||||
"yes",
|
||||
@@ -122,8 +122,8 @@ class ExportConfig:
|
||||
if key.startswith(env_prefix) and key not in env_mappings:
|
||||
config_key = key[len(env_prefix) :].lower()
|
||||
# Try to convert to appropriate type
|
||||
if value.lower() in ("true", "false"):
|
||||
self._configs[config_key] = value.lower() == "true"
|
||||
if value.strip().lower() in ("true", "false"):
|
||||
self._configs[config_key] = value.strip().lower() == "true"
|
||||
elif value.isdigit():
|
||||
self._configs[config_key] = int(value)
|
||||
else:
|
||||
@@ -148,12 +148,12 @@ class ExportConfig:
|
||||
if value:
|
||||
try:
|
||||
# Try to convert to appropriate type
|
||||
if isinstance(default, int):
|
||||
if isinstance(default, bool):
|
||||
return value.strip().lower() in ("true", "1", "yes", "on")
|
||||
elif isinstance(default, int):
|
||||
return int(value)
|
||||
elif isinstance(default, float):
|
||||
return float(value)
|
||||
elif isinstance(default, bool):
|
||||
return value.lower() in ("true", "1", "yes", "on")
|
||||
return value
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
|
||||
@@ -306,10 +306,10 @@ class JSONExporter:
|
||||
|
||||
self.logger.debug(f"Exporting {len(entities)} entity(ies) to JSON")
|
||||
|
||||
# Build JSON data with JSON-LD context
|
||||
# Build JSON data with JSON-LD context. No @vocab: it would expand
|
||||
# every bare key in the caller's entity dicts into ns# (#1146).
|
||||
json_data = {
|
||||
"@context": {
|
||||
"@vocab": "https://semantica.dev/vocab/",
|
||||
"semantica": SEMANTICA_NS,
|
||||
"entities": {"@id": "semantica:entities", "@container": "@list"},
|
||||
},
|
||||
@@ -339,7 +339,6 @@ class JSONExporter:
|
||||
"""
|
||||
json_data = {
|
||||
"@context": {
|
||||
"@vocab": "https://semantica.dev/vocab/",
|
||||
"semantica": SEMANTICA_NS,
|
||||
"relationships": {
|
||||
"@id": "semantica:relationships",
|
||||
@@ -434,11 +433,14 @@ class JSONExporter:
|
||||
Returns:
|
||||
Dictionary in JSON-LD format with @context, @graph/@value, and metadata
|
||||
"""
|
||||
# Initialize JSON-LD structure with context
|
||||
# Initialize JSON-LD structure with context. No @vocab: for a generic
|
||||
# payload it turned whatever bare keys the caller happened to use into
|
||||
# ns# terms (#1146). Undeclared terms now simply expand to nothing,
|
||||
# which is standard JSON-LD behaviour for a context that does not
|
||||
# know them; the raw payload is still in the document.
|
||||
jsonld = {
|
||||
"@context": {
|
||||
"@vocab": "https://semantica.dev/vocab/",
|
||||
"semantica": "https://semantica.dev/ns#",
|
||||
"semantica": SEMANTICA_NS,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -598,13 +600,21 @@ class JSONExporter:
|
||||
Returns:
|
||||
Dictionary in JSON-LD format with @context, @id, @type, and graph data
|
||||
"""
|
||||
# Initialize JSON-LD structure with RDF context
|
||||
# Initialize JSON-LD structure with RDF context. No @vocab: it applied
|
||||
# to every bare term in caller data, so an extracted type like "ORG"
|
||||
# became ns#ORG and a metadata key like "source" collided with the
|
||||
# real sem:source object property (#1146). Only explicit semantica:
|
||||
# terms resolve now, and the caller's metadata dict is typed @json so
|
||||
# it survives as one rdf:JSON literal instead of expanding its keys.
|
||||
jsonld = {
|
||||
"@context": {
|
||||
"@vocab": "https://semantica.dev/vocab/",
|
||||
"semantica": "https://semantica.dev/ns#",
|
||||
"semantica": SEMANTICA_NS,
|
||||
"rdf": "http://www.w3.org/1999/02/22-rdf-syntax-ns#",
|
||||
"rdfs": "http://www.w3.org/2000/01/rdf-schema#",
|
||||
"semantica:metadata": {
|
||||
"@id": "semantica:metadata",
|
||||
"@type": "@json",
|
||||
},
|
||||
},
|
||||
# Minted from the graph's own content rather than the wall clock
|
||||
# (#1147): re-exporting an unchanged graph must produce the same
|
||||
@@ -664,14 +674,23 @@ class JSONExporter:
|
||||
entity_text = entity.get("text") or entity.get("label", "unknown")
|
||||
entity_id = entity.get("id") or mint_entity_iri(entity_text)
|
||||
|
||||
# The caller's type label is data, not a class we define: minting it
|
||||
# into @type expanded it through @vocab into ns#ORG and friends, terms
|
||||
# that look official but do not exist (#1146). The node is always a
|
||||
# semantica:Entity and the label travels as semantica:type, exactly
|
||||
# how _relationship_to_jsonld has always carried the relationship type.
|
||||
jsonld = {
|
||||
"@id": entity_id,
|
||||
"@type": entity.get("type") or "semantica:Entity",
|
||||
"@type": "semantica:Entity",
|
||||
"semantica:text": entity.get("text") or entity.get("label", ""),
|
||||
"semantica:confidence": entity.get("confidence", 1.0),
|
||||
}
|
||||
entity_type = entity.get("type")
|
||||
if entity_type:
|
||||
jsonld["semantica:type"] = entity_type
|
||||
|
||||
# Add metadata if present
|
||||
# Add metadata if present. The @json term definition on
|
||||
# semantica:metadata keeps the whole dict one rdf:JSON literal.
|
||||
if "metadata" in entity:
|
||||
jsonld["semantica:metadata"] = entity["metadata"]
|
||||
|
||||
|
||||
@@ -146,6 +146,294 @@ def mint_relationship_iri(index: int, source: Any, target: Any) -> str:
|
||||
return f"{SEMANTICA_NS}rel_{index}_{digest}"
|
||||
|
||||
|
||||
#: The metadata keys Semantica itself produces, and the terms they are written
|
||||
#: as. GraphBuilder.build_graph writes the first five, create_snapshot writes
|
||||
#: snapshot_time, and load_from_neo4j writes source / uri / database. These are
|
||||
#: Semantica's own vocabulary, so they are minted in the declared namespace and
|
||||
#: declared in semantica-ns.ttl.
|
||||
#:
|
||||
#: A key the caller supplied is a different matter. Which namespace an
|
||||
#: arbitrary metadata key belongs in is issue #1146, and until that is settled
|
||||
#: the exporter refuses to guess: it warns and skips, and a caller who already
|
||||
#: knows the answer passes ``metadata_terms``.
|
||||
#:
|
||||
#: The map is key -> term rather than key -> namespace because two of the keys
|
||||
#: cannot keep their own name. ``source`` on a graph loaded from Neo4j is the
|
||||
#: system it came from, while sem:source is already the ObjectProperty holding
|
||||
#: the subject of a reified relationship; reusing it would put a string where
|
||||
#: an entity belongs.
|
||||
DEFAULT_METADATA_TERMS: Dict[str, str] = {
|
||||
"num_entities": f"{SEMANTICA_NS}numEntities",
|
||||
"num_relationships": f"{SEMANTICA_NS}numRelationships",
|
||||
"temporal_enabled": f"{SEMANTICA_NS}temporalEnabled",
|
||||
"entity_resolution_applied": f"{SEMANTICA_NS}entityResolutionApplied",
|
||||
"timestamp": f"{SEMANTICA_NS}builtAt",
|
||||
"snapshot_time": f"{SEMANTICA_NS}snapshotAt",
|
||||
"source": f"{SEMANTICA_NS}sourceSystem",
|
||||
"uri": f"{SEMANTICA_NS}sourceUri",
|
||||
"database": f"{SEMANTICA_NS}sourceDatabase",
|
||||
}
|
||||
|
||||
#: Terms whose value is a node rather than a string. Everything else stays a
|
||||
#: literal: a metadata value that merely looks like a URL is not thereby a
|
||||
#: reference to one.
|
||||
IRI_VALUED_METADATA_TERMS: Set[str] = {f"{SEMANTICA_NS}sourceUri"}
|
||||
|
||||
_XSD_NS = "http://www.w3.org/2001/XMLSchema#"
|
||||
|
||||
|
||||
def _escape_literal(value: str) -> str:
|
||||
"""Escape a string for a Turtle or N-Triples quoted literal."""
|
||||
return (
|
||||
value.replace("\\", "\\\\")
|
||||
.replace('"', '\\"')
|
||||
.replace("\n", "\\n")
|
||||
.replace("\r", "\\r")
|
||||
.replace("\t", "\\t")
|
||||
)
|
||||
|
||||
|
||||
def _escape_temporal_literal(value: Any) -> str:
|
||||
"""Escape a temporal bound for a Turtle ``dateTimeStamp`` literal.
|
||||
|
||||
Bounds are normally strings, but callers may hand us a ``datetime`` or
|
||||
``None``. ``_escape_literal`` is str-only, so stringify non-str values
|
||||
first instead of calling ``.replace()`` on them; ``None`` yields an empty
|
||||
bound rather than crashing. Datetimes must use ISO 8601 so the
|
||||
``xsd:dateTimeStamp`` ``T`` separator is preserved — ``str()`` yields a
|
||||
space ("00:00:00+00:00"), which is a lexically invalid timestamp.
|
||||
"""
|
||||
if value is None:
|
||||
return ""
|
||||
if isinstance(value, str):
|
||||
return _escape_literal(value)
|
||||
if hasattr(value, "isoformat"):
|
||||
return value.isoformat()
|
||||
return str(value)
|
||||
|
||||
|
||||
#: Turtle/N-Triples IRIREF grammar excludes these unescaped between `<` and
|
||||
#: `>`: control characters, space, and <>"{}|^`\. An IRI-valued metadata
|
||||
#: value (currently only sem:sourceUri, from the caller-controlled "uri"
|
||||
#: metadata key) is written as `<{value}>` with no other quoting, so a value
|
||||
#: containing one of these characters — a ">" followed by a full triple, for
|
||||
#: instance — closes the IRIREF early and lets the rest of the string be
|
||||
#: parsed as further RDF statements. This is the same shape of defect the
|
||||
#: entity/relationship IRIs were hardened against; that hardening resolves
|
||||
#: prefixes as well, which a metadata value never needs, so this stays a
|
||||
#: narrower, dedicated guard rather than reusing _as_turtle_iri.
|
||||
_IRI_REF_UNSAFE_RE = re.compile(r'[\x00-\x20<>"{}|^`\\]')
|
||||
|
||||
|
||||
def _safe_iri_ref(value: str) -> str:
|
||||
"""Percent-encode the characters an IRIREF may not contain unescaped."""
|
||||
return _IRI_REF_UNSAFE_RE.sub(lambda m: quote(m.group(0), safe=""), value)
|
||||
|
||||
|
||||
def _escape_xml(value: str) -> str:
|
||||
"""Escape a string for either XML element text or an attribute value.
|
||||
|
||||
The quotes matter. This helper feeds `rdf:about`, `rdf:resource` and
|
||||
`xmlns:` attribute values, which are delimited by double quotes, so a value
|
||||
carrying one would close the attribute early and produce a document that
|
||||
does not parse. Escaping them in element text as well is harmless and
|
||||
means one helper cannot be used in the wrong place.
|
||||
"""
|
||||
return (
|
||||
value.replace("&", "&")
|
||||
.replace("<", "<")
|
||||
.replace(">", ">")
|
||||
.replace('"', """)
|
||||
.replace("'", "'")
|
||||
)
|
||||
|
||||
|
||||
def _is_ncname(value: str) -> bool:
|
||||
"""Whether a string can be an XML NCName, which is what RDF/XML requires.
|
||||
|
||||
Checked over the ASCII range rather than the full XML production: the
|
||||
grammar also admits combining characters and extenders, so this is
|
||||
deliberately conservative. It refuses names it could have accepted, and it
|
||||
never accepts one that would produce a document a parser rejects. The
|
||||
earlier check tested only that the first character was not a digit, which
|
||||
let through every other way a local name can fail to be a name.
|
||||
"""
|
||||
if not value:
|
||||
return False
|
||||
if not (value[0].isascii() and (value[0].isalpha() or value[0] == "_")):
|
||||
return False
|
||||
return all(c.isascii() and (c.isalnum() or c in "._-") for c in value[1:])
|
||||
|
||||
|
||||
def _split_iri(iri: str) -> Optional[tuple]:
|
||||
"""Split an IRI into (namespace, local name) for RDF/XML's QName syntax.
|
||||
|
||||
Returns None when no split yields a usable local name. RDF/XML is the only
|
||||
serialization here that cannot write an arbitrary predicate IRI, so this is
|
||||
the one place a term can be unrepresentable, and the caller reports it
|
||||
rather than dropping it quietly.
|
||||
"""
|
||||
for sep in ("#", "/"):
|
||||
index = iri.rfind(sep)
|
||||
if index != -1 and index + 1 < len(iri):
|
||||
local = iri[index + 1 :]
|
||||
if _is_ncname(local):
|
||||
return iri[: index + 1], local
|
||||
return None
|
||||
|
||||
|
||||
def _metadata_statements(
|
||||
metadata: Any,
|
||||
terms: Dict[str, str],
|
||||
logger: Any,
|
||||
) -> List[tuple]:
|
||||
"""Resolve a metadata mapping to a list of (term IRI, value) pairs.
|
||||
|
||||
A key with no term is skipped and reported. Silence is the defect this
|
||||
fixes, so an unmapped key must be louder than a mapped one, not quieter.
|
||||
"""
|
||||
if not isinstance(metadata, dict):
|
||||
return []
|
||||
|
||||
statements: List[tuple] = []
|
||||
for key, value in metadata.items():
|
||||
term = terms.get(key)
|
||||
if term is None:
|
||||
logger.warning(
|
||||
"Metadata key %r has no term and was not exported. Which "
|
||||
"namespace a caller-supplied key belongs in is issue #1146; "
|
||||
"pass metadata_terms={%r: '<iri>'} to export it now.",
|
||||
key,
|
||||
key,
|
||||
)
|
||||
continue
|
||||
if value is None:
|
||||
continue
|
||||
if isinstance(value, (dict, list, tuple, set)):
|
||||
logger.warning(
|
||||
"Metadata key %r holds a %s, which has no modelled RDF shape "
|
||||
"yet, and was not exported.",
|
||||
key,
|
||||
type(value).__name__,
|
||||
)
|
||||
continue
|
||||
statements.append((term, value))
|
||||
return statements
|
||||
|
||||
|
||||
def _resolve_metadata_terms(overrides: Optional[Dict[str, str]]) -> Dict[str, str]:
|
||||
if not overrides:
|
||||
return DEFAULT_METADATA_TERMS
|
||||
return {**DEFAULT_METADATA_TERMS, **overrides}
|
||||
|
||||
|
||||
def _typed_literal_parts(term: str, value: Any) -> tuple:
|
||||
"""Return (kind, lexical, datatype) for one metadata value.
|
||||
|
||||
kind is "iri" or "literal". The lexical form and datatype are chosen once,
|
||||
here, so that the four serializers cannot disagree about them the way they
|
||||
disagreed about confidence in #1100.
|
||||
"""
|
||||
if term in IRI_VALUED_METADATA_TERMS and isinstance(value, str):
|
||||
return "iri", value, None
|
||||
if isinstance(value, bool):
|
||||
return "literal", "true" if value else "false", f"{_XSD_NS}boolean"
|
||||
if isinstance(value, int):
|
||||
return "literal", str(value), f"{_XSD_NS}integer"
|
||||
if isinstance(value, float):
|
||||
# xsd:double, not xsd:decimal. `repr(1e-05)` is "1e-05" and
|
||||
# `repr(float("nan"))` is "nan", and xsd:decimal admits neither the
|
||||
# exponent form nor the special values, so typing a float as decimal
|
||||
# produced lexicals a strict parser rejects. A Python float is an IEEE
|
||||
# 754 double; xsd:double has legal lexicals for all of them, and it is
|
||||
# also the honest claim, since nothing that arrived as a float was ever
|
||||
# exact. `normalize_confidence` keeps xsd:decimal for confidence
|
||||
# deliberately: that is a bounded score where exactness is meaningful
|
||||
# and NaN is not a confidence at all.
|
||||
if value != value:
|
||||
lexical = "NaN"
|
||||
elif value == float("inf"):
|
||||
lexical = "INF"
|
||||
elif value == float("-inf"):
|
||||
lexical = "-INF"
|
||||
else:
|
||||
lexical = repr(value)
|
||||
return "literal", lexical, f"{_XSD_NS}double"
|
||||
return "literal", str(value), None
|
||||
|
||||
|
||||
def _turtle_object(term: str, value: Any) -> str:
|
||||
kind, lexical, datatype = _typed_literal_parts(term, value)
|
||||
if kind == "iri":
|
||||
return f"<{_safe_iri_ref(lexical)}>"
|
||||
if datatype is None:
|
||||
return f'"{_escape_literal(lexical)}"'
|
||||
return f'"{lexical}"^^<{datatype}>'
|
||||
|
||||
|
||||
def _turtle_metadata_clauses(statements: List[tuple]) -> List[str]:
|
||||
return [f"<{term}> {_turtle_object(term, value)}" for term, value in statements]
|
||||
|
||||
|
||||
def _ntriples_metadata_lines(subject: str, statements: List[tuple]) -> List[str]:
|
||||
return [
|
||||
f"<{subject}> <{term}> {_turtle_object(term, value)} ."
|
||||
for term, value in statements
|
||||
]
|
||||
|
||||
|
||||
def _rdfxml_metadata_lines(
|
||||
statements: List[tuple], indent: str, logger: Any = None
|
||||
) -> List[str]:
|
||||
"""RDF/XML needs a QName, so an unprefixed term declares its own prefix.
|
||||
|
||||
A term with no QName form has no RDF/XML representation at all, and this is
|
||||
the only serialization with that restriction. Skipping it quietly would
|
||||
reintroduce, in one format, exactly the silent metadata loss this module
|
||||
was changed to stop, so it is reported and the other three formats still
|
||||
carry the statement in full.
|
||||
"""
|
||||
lines: List[str] = []
|
||||
for position, (term, value) in enumerate(statements):
|
||||
split = _split_iri(term)
|
||||
if split is None:
|
||||
if logger is not None:
|
||||
logger.warning(
|
||||
"Term %r has no QName form, so it cannot be written in "
|
||||
"RDF/XML and was omitted from that serialization only. "
|
||||
"Turtle, N-Triples and JSON-LD carry it in full.",
|
||||
term,
|
||||
)
|
||||
continue
|
||||
namespace, local = split
|
||||
kind, lexical, datatype = _typed_literal_parts(term, value)
|
||||
prefix = f"md{position}"
|
||||
opening = f'{indent}<{prefix}:{local} xmlns:{prefix}="{_escape_xml(namespace)}"'
|
||||
if kind == "iri":
|
||||
lines.append(f'{opening} rdf:resource="{_escape_xml(lexical)}"/>')
|
||||
continue
|
||||
if datatype is not None:
|
||||
opening += f' rdf:datatype="{_escape_xml(datatype)}"'
|
||||
lines.append(f"{opening}>{_escape_xml(lexical)}</{prefix}:{local}>")
|
||||
return lines
|
||||
|
||||
|
||||
def _jsonld_metadata_entries(statements: List[tuple]) -> Dict[str, Any]:
|
||||
"""Absolute IRIs as keys, and explicit @value/@type rather than JSON's own
|
||||
types: JSON's number is xsd:double, which would make the JSON-LD export
|
||||
disagree with the other three about the datatype of an integer."""
|
||||
entries: Dict[str, Any] = {}
|
||||
for term, value in statements:
|
||||
kind, lexical, datatype = _typed_literal_parts(term, value)
|
||||
if kind == "iri":
|
||||
entries[term] = {"@id": lexical}
|
||||
elif datatype is None:
|
||||
entries[term] = lexical
|
||||
else:
|
||||
entries[term] = {"@value": lexical, "@type": datatype}
|
||||
return entries
|
||||
|
||||
|
||||
class NamespaceManager:
|
||||
"""
|
||||
RDF namespace management engine.
|
||||
@@ -354,6 +642,37 @@ class RDFSerializer:
|
||||
|
||||
self.logger.debug("RDF serializer initialized")
|
||||
|
||||
@staticmethod
|
||||
def _local_name_from_id(identifier: str) -> str:
|
||||
"""Derive a human-readable local name from an entity identifier.
|
||||
|
||||
Handles HTTP(S)/IRI identifiers (path segments and fragments, tolerating
|
||||
trailing slashes) as well as compact/CURIE and URN-style identifiers.
|
||||
"""
|
||||
raw = str(identifier).strip()
|
||||
if not raw:
|
||||
return ""
|
||||
|
||||
# Prefer a fragment if present (e.g. http://ex.org/onto#acme -> acme).
|
||||
if "#" in raw:
|
||||
candidate = raw.rsplit("#", 1)[-1]
|
||||
if candidate:
|
||||
return candidate
|
||||
|
||||
# For IRIs/paths, take the last non-empty path segment.
|
||||
if "/" in raw:
|
||||
segment = raw.rstrip("/").rsplit("/", 1)[-1]
|
||||
if segment:
|
||||
return segment
|
||||
|
||||
# Fall back to the tail of a CURIE/URN (e.g. urn:x:acme, semantica:acme).
|
||||
if ":" in raw:
|
||||
candidate = raw.rsplit(":", 1)[-1]
|
||||
if candidate:
|
||||
return candidate
|
||||
|
||||
return raw
|
||||
|
||||
def convert_kg_to_rdf(self, knowledge_graph: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""
|
||||
Convert knowledge graph to RDF data structure.
|
||||
@@ -393,8 +712,11 @@ class RDFSerializer:
|
||||
if "name" in norm_entity:
|
||||
norm_entity["label"] = norm_entity["name"]
|
||||
elif "id" in norm_entity:
|
||||
# Use ID part as label if no name/text
|
||||
norm_entity["label"] = str(norm_entity["id"]).split(":")[-1]
|
||||
# Derive a readable label from the identifier's local name
|
||||
# (fragment/last path segment/CURIE tail). See #1097.
|
||||
local_name = self._local_name_from_id(norm_entity["id"])
|
||||
if local_name:
|
||||
norm_entity["label"] = local_name
|
||||
|
||||
rdf_data["entities"].append(norm_entity)
|
||||
|
||||
@@ -501,6 +823,8 @@ class RDFSerializer:
|
||||
"""
|
||||
include_temporal: bool = options.pop("include_temporal", False)
|
||||
time_axis: str = options.pop("time_axis", "valid")
|
||||
metadata_terms = _resolve_metadata_terms(options.pop("metadata_terms", None))
|
||||
graph_uri: Optional[str] = options.pop("graph_uri", None)
|
||||
|
||||
lines = []
|
||||
|
||||
@@ -534,21 +858,32 @@ class RDFSerializer:
|
||||
text = entity.get("text") or entity.get("label", "")
|
||||
confidence = normalize_confidence(entity.get("confidence", 1.0))
|
||||
|
||||
lines.append(
|
||||
f"<{self._as_turtle_iri(entity_id, merged_namespaces)}> a "
|
||||
f"<{self._as_turtle_iri(entity_type, merged_namespaces)}> ;"
|
||||
)
|
||||
clauses = [
|
||||
f"a <{self._as_turtle_iri(entity_type, merged_namespaces)}>",
|
||||
f'semantica:text "{_escape_literal(text)}"',
|
||||
]
|
||||
if confidence is None:
|
||||
self.logger.warning(
|
||||
f"Entity {entity_id} has a confidence that is not a number "
|
||||
f"({entity.get('confidence')!r}), so no confidence is written"
|
||||
)
|
||||
lines.append(f' semantica:text "{text}" .')
|
||||
else:
|
||||
lines.append(f' semantica:text "{text}" ;')
|
||||
lines.append(
|
||||
f' semantica:confidence "{confidence}"^^<{CONFIDENCE_DATATYPE}> .'
|
||||
clauses.append(
|
||||
f'semantica:confidence "{confidence}"^^<{CONFIDENCE_DATATYPE}>'
|
||||
)
|
||||
clauses.extend(
|
||||
_turtle_metadata_clauses(
|
||||
_metadata_statements(
|
||||
entity.get("metadata"), metadata_terms, self.logger
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
entity_iri = self._as_turtle_iri(entity_id, merged_namespaces)
|
||||
lines.append(f"<{entity_iri}> {clauses[0]} ;")
|
||||
for clause in clauses[1:-1]:
|
||||
lines.append(f" {clause} ;")
|
||||
lines.append(f" {clauses[-1]} .")
|
||||
lines.append("")
|
||||
|
||||
# Convert relationships to RDF triplets
|
||||
@@ -582,6 +917,31 @@ class RDFSerializer:
|
||||
)
|
||||
lines.extend(owl_lines)
|
||||
|
||||
# Graph-level metadata needs a subject, and this serializer has never
|
||||
# minted a document node. Rather than invent one here, it is written
|
||||
# only when the caller names the graph; issue #1147 is where the
|
||||
# default subject comes from once that lands.
|
||||
graph_clauses = (
|
||||
_turtle_metadata_clauses(
|
||||
_metadata_statements(
|
||||
rdf_data.get("metadata"), metadata_terms, self.logger
|
||||
)
|
||||
)
|
||||
if graph_uri
|
||||
else []
|
||||
)
|
||||
if graph_clauses:
|
||||
graph_iri = self._as_turtle_iri(graph_uri, merged_namespaces)
|
||||
lines.append("")
|
||||
lines.append(
|
||||
f"<{graph_iri}> {graph_clauses[0]} "
|
||||
+ (";" if len(graph_clauses) > 1 else ".")
|
||||
)
|
||||
for clause in graph_clauses[1:-1]:
|
||||
lines.append(f" {clause} ;")
|
||||
if len(graph_clauses) > 1:
|
||||
lines.append(f" {graph_clauses[-1]} .")
|
||||
|
||||
return "\n".join(lines)
|
||||
|
||||
def _reified_relationship_triples(
|
||||
@@ -692,7 +1052,7 @@ class RDFSerializer:
|
||||
lines.append(f" time:hasEnd <{end_id}> .")
|
||||
lines.append(f"<{end_id}> a time:Instant ;")
|
||||
lines.append(
|
||||
f' time:inXSDDateTimeStamp "{until_val}"^^xsd:dateTimeStamp .'
|
||||
f' time:inXSDDateTimeStamp "{_escape_temporal_literal(until_val)}"^^xsd:dateTimeStamp .'
|
||||
)
|
||||
else:
|
||||
lines[-1] = (
|
||||
@@ -701,7 +1061,7 @@ class RDFSerializer:
|
||||
|
||||
lines.append(f"<{begin_id}> a time:Instant ;")
|
||||
lines.append(
|
||||
f' time:inXSDDateTimeStamp "{from_val}"^^xsd:dateTimeStamp .'
|
||||
f' time:inXSDDateTimeStamp "{_escape_temporal_literal(from_val)}"^^xsd:dateTimeStamp .'
|
||||
)
|
||||
lines.append("")
|
||||
|
||||
@@ -730,6 +1090,9 @@ class RDFSerializer:
|
||||
... }
|
||||
>>> rdfxml = serializer.serialize_to_rdfxml(rdf_data)
|
||||
"""
|
||||
metadata_terms = _resolve_metadata_terms(options.pop("metadata_terms", None))
|
||||
graph_uri: Optional[str] = options.pop("graph_uri", None)
|
||||
|
||||
lines = ['<?xml version="1.0" encoding="UTF-8"?>']
|
||||
lines.append('<rdf:RDF xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"')
|
||||
lines.append(' xmlns:rdfs="http://www.w3.org/2000/01/rdf-schema#"')
|
||||
@@ -752,6 +1115,10 @@ class RDFSerializer:
|
||||
confidence = normalize_confidence(entity.get("confidence", 1.0))
|
||||
|
||||
# RDF/XML syntax: rdf:Description with rdf:about
|
||||
# Attribute values are delimited by quotes, and both of these
|
||||
# are caller input. Element text (semantica:text) is caller input
|
||||
# too, so it needs the same escaping to avoid injecting markup
|
||||
# or breaking out of the element (#1097 / #1113).
|
||||
entity_iri = xml_escape(
|
||||
self._as_turtle_iri(entity_id, namespaces), quote=True
|
||||
)
|
||||
@@ -760,7 +1127,9 @@ class RDFSerializer:
|
||||
)
|
||||
lines.append(f' <rdf:Description rdf:about="{entity_iri}">')
|
||||
lines.append(f' <rdf:type rdf:resource="{entity_type_iri}"/>')
|
||||
lines.append(f" <semantica:text>{text}</semantica:text>")
|
||||
lines.append(
|
||||
f" <semantica:text>{xml_escape(text)}</semantica:text>"
|
||||
)
|
||||
if confidence is None:
|
||||
self.logger.warning(
|
||||
f"Entity {entity_id} has a confidence that is not a number "
|
||||
@@ -771,6 +1140,15 @@ class RDFSerializer:
|
||||
f' <semantica:confidence rdf:datatype="{CONFIDENCE_DATATYPE}">'
|
||||
f"{confidence}</semantica:confidence>"
|
||||
)
|
||||
lines.extend(
|
||||
_rdfxml_metadata_lines(
|
||||
_metadata_statements(
|
||||
entity.get("metadata"), metadata_terms, self.logger
|
||||
),
|
||||
" ",
|
||||
self.logger,
|
||||
)
|
||||
)
|
||||
lines.append(" </rdf:Description>")
|
||||
lines.append("")
|
||||
|
||||
@@ -795,6 +1173,26 @@ class RDFSerializer:
|
||||
lines.append(" </rdf:Description>")
|
||||
lines.append("")
|
||||
|
||||
graph_lines = (
|
||||
_rdfxml_metadata_lines(
|
||||
_metadata_statements(
|
||||
rdf_data.get("metadata"), metadata_terms, self.logger
|
||||
),
|
||||
" ",
|
||||
self.logger,
|
||||
)
|
||||
if graph_uri
|
||||
else []
|
||||
)
|
||||
if graph_lines:
|
||||
graph_iri = xml_escape(
|
||||
self._as_turtle_iri(graph_uri, namespaces), quote=True
|
||||
)
|
||||
lines.append(f' <rdf:Description rdf:about="{graph_iri}">')
|
||||
lines.extend(graph_lines)
|
||||
lines.append(" </rdf:Description>")
|
||||
lines.append("")
|
||||
|
||||
lines.append("</rdf:RDF>")
|
||||
return "\n".join(lines)
|
||||
|
||||
@@ -825,11 +1223,17 @@ class RDFSerializer:
|
||||
"""
|
||||
import json
|
||||
|
||||
# Initialize JSON-LD structure with context
|
||||
metadata_terms = _resolve_metadata_terms(options.pop("metadata_terms", None))
|
||||
graph_uri: Optional[str] = options.pop("graph_uri", None)
|
||||
|
||||
# Initialize JSON-LD structure with context. No @vocab: it applied to
|
||||
# every bare term in caller data, so an extracted type like "ORG"
|
||||
# became ns#ORG and a metadata key like "source" collided with the
|
||||
# real sem:source object property (#1146). Only explicit semantica:
|
||||
# terms resolve now.
|
||||
jsonld = {
|
||||
"@context": {
|
||||
"@vocab": "https://semantica.dev/vocab/",
|
||||
"semantica": "https://semantica.dev/ns#",
|
||||
"semantica": SEMANTICA_NS,
|
||||
"rdf": "http://www.w3.org/1999/02/22-rdf-syntax-ns#",
|
||||
"rdfs": "http://www.w3.org/2000/01/rdf-schema#",
|
||||
},
|
||||
@@ -851,11 +1255,20 @@ class RDFSerializer:
|
||||
# and was dropped in full by a JSON-LD parser, silently.
|
||||
entity_id = entity.get("id") or mint_entity_iri(entity.get("text", ""))
|
||||
|
||||
# The caller's type label is data, not a class we define: minting
|
||||
# it into @type expanded it through @vocab into ns#ORG and
|
||||
# friends, terms that look official but do not exist (#1146).
|
||||
# The node is always a semantica:Entity and the label travels as
|
||||
# semantica:type, matching the relationship node below and
|
||||
# JSONExporter._entity_to_jsonld.
|
||||
node = {
|
||||
"@id": entity_id,
|
||||
"@type": entity.get("type", "semantica:Entity"),
|
||||
"@type": "semantica:Entity",
|
||||
"semantica:text": entity.get("text") or entity.get("label", ""),
|
||||
}
|
||||
entity_type = entity.get("type")
|
||||
if entity_type:
|
||||
node["semantica:type"] = entity_type
|
||||
confidence = normalize_confidence(entity.get("confidence", 1.0))
|
||||
if confidence is None:
|
||||
self.logger.warning(
|
||||
@@ -869,6 +1282,13 @@ class RDFSerializer:
|
||||
"@value": confidence,
|
||||
"@type": CONFIDENCE_DATATYPE,
|
||||
}
|
||||
node.update(
|
||||
_jsonld_metadata_entries(
|
||||
_metadata_statements(
|
||||
entity.get("metadata"), metadata_terms, self.logger
|
||||
)
|
||||
)
|
||||
)
|
||||
jsonld["@graph"].append(node)
|
||||
|
||||
# Convert relationships to JSON-LD
|
||||
@@ -893,6 +1313,18 @@ class RDFSerializer:
|
||||
}
|
||||
)
|
||||
|
||||
graph_entries = (
|
||||
_jsonld_metadata_entries(
|
||||
_metadata_statements(
|
||||
rdf_data.get("metadata"), metadata_terms, self.logger
|
||||
)
|
||||
)
|
||||
if graph_uri
|
||||
else {}
|
||||
)
|
||||
if graph_entries:
|
||||
jsonld["@graph"].append({"@id": graph_uri, **graph_entries})
|
||||
|
||||
return json.dumps(jsonld, indent=2, ensure_ascii=False)
|
||||
|
||||
def serialize_to_ntriples(self, rdf_data: Dict[str, Any], **options) -> str:
|
||||
@@ -909,6 +1341,9 @@ class RDFSerializer:
|
||||
Returns:
|
||||
String containing N-Triples serialization
|
||||
"""
|
||||
metadata_terms = _resolve_metadata_terms(options.pop("metadata_terms", None))
|
||||
graph_uri: Optional[str] = options.pop("graph_uri", None)
|
||||
|
||||
lines = []
|
||||
|
||||
namespaces = self.namespace_manager.extract_namespaces(rdf_data)
|
||||
@@ -938,7 +1373,7 @@ class RDFSerializer:
|
||||
# Text property
|
||||
text = entity.get("text") or entity.get("label", "")
|
||||
if text:
|
||||
safe_text = text.replace('"', '\\"').replace("\n", "\\n")
|
||||
safe_text = _escape_literal(text)
|
||||
lines.append(
|
||||
f'{subject} {expand_uri("semantica:text")} "{safe_text}" .'
|
||||
)
|
||||
@@ -959,6 +1394,15 @@ class RDFSerializer:
|
||||
f'"{confidence}"^^<{CONFIDENCE_DATATYPE}> .'
|
||||
)
|
||||
|
||||
lines.extend(
|
||||
_ntriples_metadata_lines(
|
||||
subject.strip("<>"),
|
||||
_metadata_statements(
|
||||
entity.get("metadata"), metadata_terms, self.logger
|
||||
),
|
||||
)
|
||||
)
|
||||
|
||||
# Convert relationships
|
||||
relationships = rdf_data.get("relationships", [])
|
||||
for rel in relationships:
|
||||
@@ -971,6 +1415,16 @@ class RDFSerializer:
|
||||
f"{expand_uri(source_id)} {expand_uri(rel_type)} {expand_uri(target_id)} ."
|
||||
)
|
||||
|
||||
if graph_uri:
|
||||
lines.extend(
|
||||
_ntriples_metadata_lines(
|
||||
graph_uri,
|
||||
_metadata_statements(
|
||||
rdf_data.get("metadata"), metadata_terms, self.logger
|
||||
),
|
||||
)
|
||||
)
|
||||
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
@@ -1309,6 +1763,12 @@ class RDFExporter:
|
||||
|
||||
self.logger.debug(f"Exporting to RDF format: {format}")
|
||||
|
||||
# Normalize the graph before serialization so every format benefits
|
||||
# from field normalization (e.g. mapping 'name' -> 'label'/'text').
|
||||
# Without this, graphs produced by GraphBuilder (which emit 'name')
|
||||
# export with an empty semantica:text on all RDF paths. See #1097.
|
||||
data = self.serializer.convert_kg_to_rdf(data)
|
||||
|
||||
self.progress_tracker.update_tracking(
|
||||
tracking_id, message="Validating RDF data..."
|
||||
)
|
||||
|
||||
@@ -556,6 +556,13 @@ class GraphStore:
|
||||
)
|
||||
self.config = config
|
||||
|
||||
# Application-id -> backend-internal-id map for nodes added through
|
||||
# the compatibility layer (#1136). add_nodes()/create_node() record
|
||||
# the internal ids the backend returns; create_relationship()
|
||||
# resolves known application ids through it so string ids stop
|
||||
# mismatching backends that match on internal ids (Neo4j id(n)).
|
||||
self._app_node_id_map: Dict[Any, Any] = {}
|
||||
|
||||
# Initialize store backend
|
||||
self._store_backend = None
|
||||
self._manager = None
|
||||
@@ -630,7 +637,9 @@ class GraphStore:
|
||||
**options,
|
||||
) -> Dict[str, Any]:
|
||||
"""Create a node."""
|
||||
return self._manager.nodes.create(labels, properties, **options)
|
||||
created = self._manager.nodes.create(labels, properties, **options)
|
||||
self._record_app_node_id(created)
|
||||
return created
|
||||
|
||||
def create_nodes(
|
||||
self,
|
||||
@@ -688,9 +697,25 @@ class GraphStore:
|
||||
properties: Optional[Dict[str, Any]] = None,
|
||||
**options,
|
||||
) -> Dict[str, Any]:
|
||||
"""Create a relationship."""
|
||||
"""Create a relationship.
|
||||
|
||||
Node ids added through the compatibility layer are application-level
|
||||
strings, while backends such as Neo4j match on internal integer ids
|
||||
(#1136). Known application ids are resolved to the internal ids the
|
||||
backend returned at creation time; unknown ids pass through
|
||||
unchanged, so direct internal-id callers keep working. Only string
|
||||
application ids participate: internal ids are commonly integers, so
|
||||
recording or resolving an integer key could remap a caller-supplied
|
||||
internal id to a different node.
|
||||
"""
|
||||
return self._manager.relationships.create(
|
||||
start_node_id, end_node_id, rel_type, properties, **options
|
||||
self._app_node_id_map.get(start_node_id, start_node_id)
|
||||
if isinstance(start_node_id, str) else start_node_id,
|
||||
self._app_node_id_map.get(end_node_id, end_node_id)
|
||||
if isinstance(end_node_id, str) else end_node_id,
|
||||
rel_type,
|
||||
properties,
|
||||
**options,
|
||||
)
|
||||
|
||||
def get_relationships(
|
||||
@@ -794,6 +819,24 @@ class GraphStore:
|
||||
"""Create an index."""
|
||||
return self._manager.create_index(label, property_name, index_type, **options)
|
||||
|
||||
def _record_app_node_id(self, created: Optional[Dict[str, Any]]) -> None:
|
||||
"""Record the application-id -> internal-id pair of a created node.
|
||||
|
||||
Backends return their own internal id alongside the stored properties;
|
||||
when the caller supplied an application id it is preserved in
|
||||
``properties["id"]`` by the compatibility layer, which makes the pair
|
||||
recoverable (#1136). Only STRING application ids are recorded:
|
||||
internal ids are commonly integers, and an integer application id
|
||||
would collide with (and silently remap) a caller-supplied internal id
|
||||
of the same value in ``create_relationship``.
|
||||
"""
|
||||
if not isinstance(created, dict):
|
||||
return
|
||||
app_id = (created.get("properties") or {}).get("id")
|
||||
internal_id = created.get("id")
|
||||
if isinstance(app_id, str) and internal_id is not None:
|
||||
self._app_node_id_map[app_id] = internal_id
|
||||
|
||||
# Compatibility with AgentMemory / ContextGraph interface
|
||||
def add_nodes(self, nodes: List[Dict[str, Any]], **options) -> int:
|
||||
"""
|
||||
@@ -853,12 +896,21 @@ class GraphStore:
|
||||
# and properties.
|
||||
|
||||
result = self.create_nodes(graph_nodes, **options)
|
||||
# Keep the application-id -> internal-id pairs instead of discarding
|
||||
# them, so add_edges()/create_relationship() can resolve the string
|
||||
# ids callers actually use (#1136).
|
||||
for created in result:
|
||||
self._record_app_node_id(created)
|
||||
return len(result)
|
||||
|
||||
def add_edges(self, edges: List[Dict[str, Any]], **options) -> int:
|
||||
"""
|
||||
Add edges (Compatibility method).
|
||||
|
||||
``source_id``/``target_id`` are application-level string ids; they are
|
||||
resolved to the backend's internal ids via the map ``add_nodes``
|
||||
populated when the nodes were created (#1136).
|
||||
|
||||
Args:
|
||||
edges: List of edge dictionaries
|
||||
**options: Additional options
|
||||
|
||||
@@ -130,7 +130,10 @@ Example Usage:
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib
|
||||
from typing import Any, Dict, Tuple
|
||||
from typing import TYPE_CHECKING, Any, Dict, Tuple
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .salesforce_ingestor import SalesforceConnector, SalesforceData, SalesforceIngestor
|
||||
|
||||
from .config import IngestConfig, ingest_config
|
||||
from .file_ingestor import (
|
||||
@@ -152,6 +155,7 @@ from .methods import (
|
||||
ingest_parquet,
|
||||
ingest_public_api,
|
||||
ingest_repository,
|
||||
ingest_salesforce,
|
||||
ingest_stream,
|
||||
ingest_web,
|
||||
ingest_xml,
|
||||
@@ -218,6 +222,10 @@ _LAZY_EXPORTS: Dict[str, Tuple[str, str]] = {
|
||||
"SnowflakeIngestor": (".snowflake_ingestor", "SnowflakeIngestor"),
|
||||
"SnowflakeData": (".snowflake_ingestor", "SnowflakeData"),
|
||||
"SnowflakeConnector": (".snowflake_ingestor", "SnowflakeConnector"),
|
||||
# SAP OData ingestion
|
||||
"SAPIngestor": (".sap_ingestor", "SAPIngestor"),
|
||||
"SAPODataEntity": (".sap_ingestor", "SAPODataEntity"),
|
||||
"SAPODataConnector": (".sap_ingestor", "SAPODataConnector"),
|
||||
# Databricks ingestion
|
||||
"DatabricksIngestor": (".databricks_ingestor", "DatabricksIngestor"),
|
||||
"DatabricksData": (".databricks_ingestor", "DatabricksData"),
|
||||
@@ -231,6 +239,10 @@ _LAZY_EXPORTS: Dict[str, Tuple[str, str]] = {
|
||||
# XML ingestion
|
||||
"XMLIngestor": (".xml_ingestor", "XMLIngestor"),
|
||||
"XMLIngestionData": (".xml_ingestor", "XMLIngestionData"),
|
||||
# Salesforce ingestion
|
||||
"SalesforceIngestor": (".salesforce_ingestor", "SalesforceIngestor"),
|
||||
"SalesforceData": (".salesforce_ingestor", "SalesforceData"),
|
||||
"SalesforceConnector": (".salesforce_ingestor", "SalesforceConnector"),
|
||||
}
|
||||
|
||||
_OPTIONAL_DEPENDENCY_MESSAGES = {
|
||||
@@ -258,6 +270,11 @@ _OPTIONAL_DEPENDENCY_MESSAGES = {
|
||||
"Arrow ingestion requires optional dependency 'pyarrow'. "
|
||||
"Install it before importing ArrowIngestor or using ingest_arrow()."
|
||||
),
|
||||
".salesforce_ingestor": (
|
||||
"Salesforce ingestion requires optional dependency 'simple-salesforce'. "
|
||||
"Install it with: pip install \"semantica[db-salesforce]\" "
|
||||
"or: pip install simple-salesforce>=1.12.0"
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
@@ -272,7 +289,7 @@ def __getattr__(name: str) -> Any:
|
||||
except ModuleNotFoundError as exc:
|
||||
message = _OPTIONAL_DEPENDENCY_MESSAGES.get(module_name)
|
||||
missing_name = getattr(exc, "name", None)
|
||||
if message and missing_name in {"git", "bs4", "pyarrow"}:
|
||||
if message and missing_name in {"git", "bs4", "pyarrow", "simple_salesforce"}:
|
||||
raise ImportError(message) from exc
|
||||
raise
|
||||
|
||||
@@ -345,6 +362,10 @@ __all__ = [
|
||||
"SnowflakeIngestor",
|
||||
"SnowflakeData",
|
||||
"SnowflakeConnector",
|
||||
# SAP OData ingestion
|
||||
"SAPIngestor",
|
||||
"SAPODataEntity",
|
||||
"SAPODataConnector",
|
||||
# Databricks ingestion
|
||||
"DatabricksIngestor",
|
||||
"DatabricksData",
|
||||
@@ -358,6 +379,10 @@ __all__ = [
|
||||
# XML ingestion
|
||||
"XMLIngestor",
|
||||
"XMLIngestionData",
|
||||
# Salesforce ingestion
|
||||
"SalesforceIngestor",
|
||||
"SalesforceData",
|
||||
"SalesforceConnector",
|
||||
# Registry and Methods
|
||||
"MethodRegistry",
|
||||
"method_registry",
|
||||
@@ -369,6 +394,7 @@ __all__ = [
|
||||
"ingest_repository",
|
||||
"ingest_email",
|
||||
"ingest_database",
|
||||
"ingest_salesforce",
|
||||
"ingest_ontology",
|
||||
"ingest_arrow",
|
||||
"ingest_parquet",
|
||||
|
||||
+15
-10
@@ -110,7 +110,7 @@ class IngestConfig:
|
||||
if value:
|
||||
try:
|
||||
if type_func == bool:
|
||||
self._configs[config_key] = value.lower() in (
|
||||
self._configs[config_key] = value.strip().lower() in (
|
||||
"true",
|
||||
"1",
|
||||
"yes",
|
||||
@@ -127,8 +127,8 @@ class IngestConfig:
|
||||
if key.startswith(env_prefix) and key not in env_mappings:
|
||||
config_key = key[len(env_prefix) :].lower()
|
||||
# Try to convert to appropriate type
|
||||
if value.lower() in ("true", "false"):
|
||||
self._configs[config_key] = value.lower() == "true"
|
||||
if value.strip().lower() in ("true", "false"):
|
||||
self._configs[config_key] = value.strip().lower() == "true"
|
||||
elif value.isdigit():
|
||||
self._configs[config_key] = int(value)
|
||||
else:
|
||||
@@ -143,8 +143,8 @@ class IngestConfig:
|
||||
if key.startswith(mcp_prefix) and key not in env_mappings:
|
||||
config_key = key[len(mcp_prefix) :].lower()
|
||||
# Try to convert to appropriate type
|
||||
if value.lower() in ("true", "false"):
|
||||
self._configs[f"mcp_{config_key}"] = value.lower() == "true"
|
||||
if value.strip().lower() in ("true", "false"):
|
||||
self._configs[f"mcp_{config_key}"] = value.strip().lower() == "true"
|
||||
elif value.isdigit():
|
||||
self._configs[f"mcp_{config_key}"] = int(value)
|
||||
else:
|
||||
@@ -169,12 +169,12 @@ class IngestConfig:
|
||||
if value:
|
||||
try:
|
||||
# Try to convert to appropriate type
|
||||
if isinstance(default, int):
|
||||
if isinstance(default, bool):
|
||||
return value.strip().lower() in ("true", "1", "yes", "on")
|
||||
elif isinstance(default, int):
|
||||
return int(value)
|
||||
elif isinstance(default, float):
|
||||
return float(value)
|
||||
elif isinstance(default, bool):
|
||||
return value.lower() in ("true", "1", "yes", "on")
|
||||
return value
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
@@ -186,8 +186,13 @@ class IngestConfig:
|
||||
self._method_configs[method] = config
|
||||
|
||||
def get_method_config(self, method: str) -> Dict:
|
||||
"""Get method-specific configuration."""
|
||||
return self._method_configs.get(method, {})
|
||||
"""Get method-specific configuration.
|
||||
|
||||
Returns a **copy** of the stored method configuration so callers can
|
||||
safely mutate it (e.g. to merge per-call options) without poisoning the
|
||||
global configuration for subsequent calls.
|
||||
"""
|
||||
return dict(self._method_configs.get(method, {}))
|
||||
|
||||
def get_all(self) -> Dict[str, Any]:
|
||||
"""Get all configuration."""
|
||||
|
||||
@@ -815,6 +815,8 @@ class DBIngestor:
|
||||
engine = connector.connect(connection_string)
|
||||
|
||||
try:
|
||||
from sqlalchemy import text
|
||||
|
||||
with engine.connect() as conn:
|
||||
# Execute query with parameters (parameterized queries for safety)
|
||||
result = conn.execute(text(query), params)
|
||||
|
||||
@@ -875,6 +875,96 @@ schema = connector.get_schema(engine)
|
||||
print(f" {table_name}: {[col['name'] for col in columns]}")
|
||||
```
|
||||
|
||||
## Salesforce CRM Ingestion
|
||||
|
||||
Salesforce ingestion requires `simple-salesforce`:
|
||||
|
||||
```bash
|
||||
pip install "semantica[db-salesforce]"
|
||||
```
|
||||
|
||||
### Basic Usage
|
||||
|
||||
```python
|
||||
from semantica.ingest import SalesforceIngestor
|
||||
import os
|
||||
|
||||
ingestor = SalesforceIngestor(
|
||||
username=os.getenv("SALESFORCE_USERNAME"),
|
||||
password=os.getenv("SALESFORCE_PASSWORD"),
|
||||
security_token=os.getenv("SALESFORCE_SECURITY_TOKEN"),
|
||||
domain="login", # "test" for sandbox
|
||||
)
|
||||
|
||||
# Ingest Account records
|
||||
data = ingestor.ingest_sobject(
|
||||
"Account",
|
||||
fields=["Id", "Name", "Industry", "BillingCity"],
|
||||
where="Type = 'Customer'",
|
||||
limit=5000,
|
||||
)
|
||||
print(f"Retrieved {data.row_count} of {data.total_size} matching records")
|
||||
```
|
||||
|
||||
`SalesforceIngestor()` with no arguments reads from `SALESFORCE_USERNAME`, `SALESFORCE_PASSWORD`, `SALESFORCE_SECURITY_TOKEN`, and `SALESFORCE_DOMAIN` environment variables automatically.
|
||||
|
||||
### Custom Objects and Raw SOQL
|
||||
|
||||
```python
|
||||
# Custom object (API name ends in __c)
|
||||
data = ingestor.ingest_sobject("My_Custom_Object__c", fields=["Id", "Name", "Custom_Field__c"])
|
||||
|
||||
# Raw SOQL GÇö pagination is handled automatically
|
||||
data = ingestor.ingest_query("""
|
||||
SELECT Id, Name, StageName, Amount
|
||||
FROM Opportunity
|
||||
WHERE IsClosed = false
|
||||
ORDER BY CloseDate ASC
|
||||
""")
|
||||
print(f"Open opportunities: {data.row_count}")
|
||||
```
|
||||
|
||||
### Document Export
|
||||
|
||||
```python
|
||||
documents = ingestor.export_as_documents(
|
||||
data,
|
||||
id_field="Id", # Salesforce 18-char record Id
|
||||
text_fields=["Name", "Description"],
|
||||
)
|
||||
# Each document: {"id": "001...", "text": "...", "metadata": {"source": "salesforce", ...}}
|
||||
```
|
||||
|
||||
### Convenience Function
|
||||
|
||||
```python
|
||||
from semantica.ingest import ingest_salesforce
|
||||
|
||||
# Fetch records
|
||||
data = ingest_salesforce(
|
||||
method="sobject",
|
||||
sobject_name="Account",
|
||||
fields=["Id", "Name"],
|
||||
limit=500,
|
||||
)
|
||||
|
||||
# Ingest and export as documents in one call
|
||||
docs = ingest_salesforce(
|
||||
method="documents",
|
||||
sobject_name="Account",
|
||||
text_fields=["Name", "Description"],
|
||||
)
|
||||
|
||||
# Using the unified dispatcher
|
||||
from semantica.ingest import ingest
|
||||
result = ingest(None, source_type="salesforce", method="sobject",
|
||||
sobject_name="Account", fields=["Id", "Name"])
|
||||
data = result["data"]
|
||||
```
|
||||
|
||||
See [Salesforce Integration](https://docs.getsemantica.ai/integrations/salesforce) for full documentation including sandbox, schema discovery, pagination details, and troubleshooting.
|
||||
|
||||
|
||||
## MCP Server Ingestion
|
||||
|
||||
**IMPORTANT**: This implementation supports **ONLY Python-based MCP servers and FastMCP servers**. Users can bring their own Python or FastMCP MCP servers via URL connections. JavaScript, TypeScript, C#, Java, and other language implementations are **NOT supported**.
|
||||
@@ -1613,3 +1703,54 @@ for source_type, source_list in sources.items():
|
||||
for batch in process_in_batches(large_dataset, batch_size=1000):
|
||||
result = ingest(batch)
|
||||
```
|
||||
|
||||
## SAP OData Ingestion
|
||||
|
||||
`SAPIngestor` reads an Entity Set from a SAP OData service — S/4HANA Cloud,
|
||||
SuccessFactors, or an on-prem NetWeaver Gateway over its REST surface. It
|
||||
follows OData v2/v4 server-driven pagination and flattens each record into a
|
||||
document dict via `export_as_documents()`.
|
||||
|
||||
Install with `pip install 'semantica[ingest-sap]'`.
|
||||
|
||||
### Connector Construction & Authentication
|
||||
|
||||
```python
|
||||
from semantica.ingest import SAPIngestor
|
||||
|
||||
# OAuth2 client-credentials (BTP / S/4HANA Cloud)
|
||||
ing = SAPIngestor(
|
||||
base_url="https://my-sap.example.com/sap/opu/odata/sap/API_BUSINESS_PARTNER",
|
||||
client_id="...", client_secret="...",
|
||||
token_url="https://my-sap.example.com/oauth/token",
|
||||
)
|
||||
# On-prem NetWeaver often uses Basic auth instead — swap the block above for:
|
||||
# ing = SAPIngestor(base_url="...", username="erp_user", password="...")
|
||||
```
|
||||
|
||||
### Entity-Set Ingestion & Document Export
|
||||
|
||||
```python
|
||||
# 1. Discover entity sets + field types from $metadata
|
||||
sets = ing.discover_service()
|
||||
|
||||
# 2. Page-walk an Entity Set (v2/v4 next links handled automatically)
|
||||
partners = ing.ingest_entity_set(
|
||||
entity_set="A_BusinessPartnerSet",
|
||||
select="BusinessPartner,BusinessPartnerFullName",
|
||||
top=1000,
|
||||
)
|
||||
|
||||
# 3. Flatten to document dicts that GraphBuilder can consume directly
|
||||
docs = ing.export_as_documents(partners)
|
||||
```
|
||||
|
||||
- Use `expand="to_Item"` on a sales-order header set to pull nested line items
|
||||
in one request — handy for modeling order → line-item → material relations.
|
||||
- Every outbound request, including the OAuth2 token exchange, is routed through
|
||||
the SSRF guard, and pagination never follows a next link that points to a
|
||||
different host than the service root.
|
||||
|
||||
> **Security Note:** Never hardcode credentials (`client_secret`, `password`);
|
||||
> pass them via environment variables (`SAP_CLIENT_SECRET`, `SAP_PASSWORD`) or a
|
||||
> secrets manager.
|
||||
|
||||
@@ -203,6 +203,7 @@ if TYPE_CHECKING:
|
||||
from .ontology_ingestor import OntologyData
|
||||
from .parquet_ingestor import ParquetData
|
||||
from .public_api_ingestor import PublicAPIDetection
|
||||
from .salesforce_ingestor import SalesforceData
|
||||
from .stream_ingestor import StreamProcessor
|
||||
from .web_ingestor import WebContent
|
||||
from .xml_ingestor import XMLIngestionData
|
||||
@@ -1126,6 +1127,213 @@ def ingest_database(
|
||||
raise
|
||||
|
||||
|
||||
def ingest_salesforce(
|
||||
source: Optional[Dict[str, Any]] = None,
|
||||
method: str = "sobject",
|
||||
**kwargs,
|
||||
) -> Union["SalesforceData", List[Dict[str, Any]], Dict[str, Any]]:
|
||||
"""Ingest data from Salesforce CRM (convenience function).
|
||||
|
||||
A user-friendly wrapper around :class:`~semantica.ingest.SalesforceIngestor`
|
||||
that connects, ingests, and returns data in a single call.
|
||||
|
||||
Args:
|
||||
source: Optional credential/configuration dictionary. Keys mirror the
|
||||
:class:`~semantica.ingest.SalesforceConnector` constructor:
|
||||
``username``, ``password``, ``security_token``, ``domain``
|
||||
(``"login"`` for production, ``"test"`` for sandbox),
|
||||
``instance_url``, ``session_id``, ``api_version``.
|
||||
When ``None``, credentials are read from environment variables
|
||||
(``SALESFORCE_USERNAME`` / ``SALESFORCE_PASSWORD`` /
|
||||
``SALESFORCE_SECURITY_TOKEN`` etc.).
|
||||
method: Ingestion method:
|
||||
|
||||
* ``"sobject"`` *(default)* — fetch records from a named sObject
|
||||
(requires ``sobject_name`` kwarg).
|
||||
* ``"query"`` — execute a raw SOQL query string (requires
|
||||
``soql`` kwarg).
|
||||
* ``"list_sobjects"`` — return a sorted list of accessible sObject
|
||||
API names.
|
||||
* ``"schema"`` — return field metadata for a named sObject
|
||||
(requires ``sobject_name`` kwarg).
|
||||
* ``"documents"`` — ingest an sObject and convert to the Semantica
|
||||
document format in one step (requires ``sobject_name`` kwarg;
|
||||
optional ``text_fields`` and ``id_field`` kwargs).
|
||||
|
||||
**kwargs: Additional options forwarded to the ingestor method.
|
||||
Common kwargs for ``"sobject"`` / ``"documents"``:
|
||||
|
||||
* ``sobject_name`` — Salesforce sObject API name (e.g.
|
||||
``"Account"``, ``"My_Custom__c"``).
|
||||
* ``fields`` — list of field API names to select. When omitted
|
||||
all selectable fields are fetched via ``describe()``.
|
||||
* ``where`` — SOQL ``WHERE`` clause fragment (trusted input only).
|
||||
* ``order_by`` — SOQL ``ORDER BY`` clause fragment.
|
||||
* ``limit`` — maximum number of records.
|
||||
|
||||
For ``"query"``:
|
||||
|
||||
* ``soql`` — full SOQL query string.
|
||||
|
||||
For ``"schema"``:
|
||||
|
||||
* ``sobject_name`` — sObject to describe.
|
||||
|
||||
Returns:
|
||||
* ``"sobject"`` / ``"query"`` → :class:`~semantica.ingest.SalesforceData`
|
||||
* ``"documents"`` → ``List[Dict[str, Any]]`` (Semantica document format)
|
||||
* ``"list_sobjects"`` → ``List[str]``
|
||||
* ``"schema"`` → ``Dict[str, Any]``
|
||||
|
||||
Raises:
|
||||
:class:`~semantica.utils.exceptions.ConfigurationError`: If
|
||||
``simple-salesforce`` is not installed.
|
||||
:class:`~semantica.utils.exceptions.ValidationError`: If credentials
|
||||
are incomplete or an sObject / field name is invalid.
|
||||
:class:`~semantica.utils.exceptions.ProcessingError`: If the
|
||||
Salesforce API call fails.
|
||||
|
||||
Examples::
|
||||
|
||||
>>> from semantica.ingest import ingest_salesforce
|
||||
|
||||
>>> # Fetch Account records (credentials from env vars)
|
||||
>>> data = ingest_salesforce(
|
||||
... method="sobject",
|
||||
... sobject_name="Account",
|
||||
... fields=["Id", "Name", "Industry"],
|
||||
... limit=500,
|
||||
... )
|
||||
|
||||
>>> # Execute a raw SOQL query (credentials from environment variables)
|
||||
>>> data = ingest_salesforce(
|
||||
... method="query",
|
||||
... soql="SELECT Id, Name FROM Contact WHERE IsActive = true",
|
||||
... )
|
||||
|
||||
>>> # Ingest and export as documents for GraphBuilder in one step
|
||||
>>> docs = ingest_salesforce(
|
||||
... method="documents",
|
||||
... sobject_name="Account",
|
||||
... text_fields=["Name", "Description"],
|
||||
... limit=1000,
|
||||
... )
|
||||
|
||||
>>> # List all accessible sObjects in the connected org
|
||||
>>> sobject_names = ingest_salesforce(method="list_sobjects")
|
||||
|
||||
>>> # Use sandbox org
|
||||
>>> data = ingest_salesforce(
|
||||
... method="sobject",
|
||||
... sobject_name="Account",
|
||||
... ) # set SALESFORCE_DOMAIN=test in environment for sandbox
|
||||
"""
|
||||
# Registry hook — allows callers to register a custom "salesforce" method
|
||||
custom_method = method_registry.get("salesforce", method)
|
||||
if custom_method and custom_method != ingest_salesforce:
|
||||
fallback = kwargs.pop("fallback_on_custom_error", False)
|
||||
result = call_custom_method(
|
||||
logger, method, custom_method, source,
|
||||
fallback_on_custom_error=fallback, **kwargs,
|
||||
)
|
||||
if result is not CUSTOM_METHOD_FELL_BACK:
|
||||
return result
|
||||
|
||||
try:
|
||||
from .salesforce_ingestor import SalesforceIngestor
|
||||
except ModuleNotFoundError as exc:
|
||||
if _is_missing_dependency(exc, "simple_salesforce"):
|
||||
raise _missing_optional_dependency(
|
||||
"Salesforce ingestion", "simple-salesforce"
|
||||
) from exc
|
||||
raise
|
||||
|
||||
# Unpack credential dict (if given); everything else stays in kwargs.
|
||||
creds: Dict[str, Any] = {}
|
||||
if source is not None:
|
||||
if not isinstance(source, dict):
|
||||
raise ProcessingError(
|
||||
"ingest_salesforce() source must be a credential dict or None. "
|
||||
"Pass sobject_name / soql as keyword arguments."
|
||||
)
|
||||
creds = dict(source)
|
||||
|
||||
# Merge any ingest_config method config under "salesforce".
|
||||
# get_method_config() now returns a copy, so this dict is safe to mutate.
|
||||
# We build the final connector config in order of increasing priority:
|
||||
# 1. base method config (lowest — global defaults set by operator)
|
||||
# 2. per-call credential dict supplied via `source`
|
||||
# 3. per-call connector params supplied as kwargs
|
||||
# Credentials are extracted from kwargs and removed so they don't also
|
||||
# flow into the ingest method call (which doesn't understand them).
|
||||
_CONNECTOR_PARAMS = frozenset({
|
||||
"username", "password", "security_token", "domain",
|
||||
"instance_url", "session_id", "api_version",
|
||||
})
|
||||
connector_kwargs = {k: v for k, v in kwargs.items() if k in _CONNECTOR_PARAMS}
|
||||
for k in _CONNECTOR_PARAMS:
|
||||
kwargs.pop(k, None)
|
||||
|
||||
# Build a fresh per-call config dict — never mutate the global store.
|
||||
config: Dict[str, Any] = {
|
||||
**ingest_config.get_method_config("salesforce"), # base (already a copy)
|
||||
**creds, # source dict credentials
|
||||
**connector_kwargs, # kwarg credentials
|
||||
}
|
||||
|
||||
ingestor = SalesforceIngestor(**config)
|
||||
|
||||
if method == "sobject":
|
||||
sobject_name = kwargs.pop("sobject_name", None)
|
||||
if not sobject_name:
|
||||
raise ProcessingError(
|
||||
"ingest_salesforce() with method='sobject' requires "
|
||||
"sobject_name keyword argument."
|
||||
)
|
||||
return ingestor.ingest_sobject(sobject_name, **kwargs)
|
||||
|
||||
elif method == "query":
|
||||
soql = kwargs.pop("soql", None)
|
||||
if not soql:
|
||||
raise ProcessingError(
|
||||
"ingest_salesforce() with method='query' requires "
|
||||
"soql keyword argument."
|
||||
)
|
||||
return ingestor.ingest_query(soql, **kwargs)
|
||||
|
||||
elif method == "list_sobjects":
|
||||
return ingestor.list_sobjects()
|
||||
|
||||
elif method == "schema":
|
||||
sobject_name = kwargs.pop("sobject_name", None)
|
||||
if not sobject_name:
|
||||
raise ProcessingError(
|
||||
"ingest_salesforce() with method='schema' requires "
|
||||
"sobject_name keyword argument."
|
||||
)
|
||||
return ingestor.get_sobject_schema(sobject_name)
|
||||
|
||||
elif method == "documents":
|
||||
sobject_name = kwargs.pop("sobject_name", None)
|
||||
if not sobject_name:
|
||||
raise ProcessingError(
|
||||
"ingest_salesforce() with method='documents' requires "
|
||||
"sobject_name keyword argument."
|
||||
)
|
||||
id_field = kwargs.pop("id_field", "Id")
|
||||
text_fields = kwargs.pop("text_fields", None)
|
||||
data = ingestor.ingest_sobject(sobject_name, **kwargs)
|
||||
return ingestor.export_as_documents(data, id_field=id_field,
|
||||
text_fields=text_fields)
|
||||
|
||||
else:
|
||||
raise ProcessingError(
|
||||
f"Unknown ingest_salesforce method: {method!r}. "
|
||||
"Valid methods: 'sobject', 'query', 'list_sobjects', 'schema', "
|
||||
"'documents'."
|
||||
)
|
||||
|
||||
|
||||
def ingest_mcp(
|
||||
source: Union[str, Dict[str, Any]],
|
||||
method: str = "resources",
|
||||
@@ -1306,6 +1514,7 @@ def ingest(
|
||||
- "ontology": Ontology ingestion
|
||||
- "parquet": Apache Parquet file or directory ingestion
|
||||
- "xml": XML file or directory ingestion
|
||||
- "salesforce": Salesforce CRM ingestion (pass credentials via kwargs)
|
||||
method: Optional specific ingestion method
|
||||
**kwargs: Additional options passed to ingestor
|
||||
|
||||
@@ -1428,6 +1637,9 @@ def ingest(
|
||||
return {"ontology": ingest_ontology(sources, method=method or "file", **kwargs)}
|
||||
elif source_type == "mcp":
|
||||
return {"data": ingest_mcp(sources, method=method or "resources", **kwargs)}
|
||||
elif source_type == "salesforce":
|
||||
return {"data": ingest_salesforce(sources,
|
||||
method=method or "sobject", **kwargs)}
|
||||
else:
|
||||
raise ProcessingError(f"Unknown source type: {source_type}")
|
||||
|
||||
@@ -1540,3 +1752,9 @@ method_registry.register("ontology", "file", ingest_ontology)
|
||||
method_registry.register("ontology", "directory", ingest_ontology)
|
||||
method_registry.register("ingest", "default", ingest)
|
||||
method_registry.register("ingest", "unified", ingest)
|
||||
method_registry.register("salesforce", "default", ingest_salesforce)
|
||||
method_registry.register("salesforce", "sobject", ingest_salesforce)
|
||||
method_registry.register("salesforce", "query", ingest_salesforce)
|
||||
method_registry.register("salesforce", "list_sobjects", ingest_salesforce)
|
||||
method_registry.register("salesforce", "schema", ingest_salesforce)
|
||||
method_registry.register("salesforce", "documents", ingest_salesforce)
|
||||
|
||||
@@ -40,7 +40,7 @@ from pathlib import Path
|
||||
from typing import Any, Dict, List, Optional, Union
|
||||
|
||||
import rdflib
|
||||
from rdflib import RDF, RDFS, OWL, Graph
|
||||
from rdflib import RDF, RDFS, OWL, Dataset, Graph
|
||||
|
||||
from ..utils.exceptions import ProcessingError, ValidationError
|
||||
from ..utils.logging import get_logger
|
||||
@@ -106,15 +106,24 @@ class OntologyIngestor:
|
||||
raise ValidationError(f"File not found: {file_path}")
|
||||
|
||||
self.progress.update_tracking(tracking_id, message="Parsing RDF graph...")
|
||||
g = Graph()
|
||||
|
||||
# `Dataset`, not `Graph`: a JSON-LD document with a top-level `@id` *and*
|
||||
# `@graph` places its terms in a NAMED graph. `Graph.parse()` loads only the
|
||||
# default graph and discards the rest without an error, so every class and
|
||||
# property in such a document was dropped while the load reported success.
|
||||
# Same migration #757 made for JenaStore; the ingest path was not covered by it.
|
||||
# `default_union=True` makes the Dataset itself present triples from every
|
||||
# graph as one merged view (it is an rdflib.Graph subclass, so it satisfies
|
||||
# _convert_to_dict()'s Graph-typed contract directly) instead of copying every
|
||||
# quad into a second in-memory Graph.
|
||||
ds = Dataset(default_union=True)
|
||||
|
||||
# Use provided format or let rdflib guess based on extension
|
||||
parse_kwargs = kwargs.copy()
|
||||
if format:
|
||||
parse_kwargs['format'] = format
|
||||
|
||||
|
||||
try:
|
||||
g.parse(file_path, **parse_kwargs)
|
||||
ds.parse(file_path, **parse_kwargs)
|
||||
except Exception as e:
|
||||
# Fallback: try to guess format from extension if not provided and initial parse failed
|
||||
if not format:
|
||||
@@ -130,12 +139,14 @@ class OntologyIngestor:
|
||||
guessed_fmt = fmt_map.get(ext)
|
||||
if guessed_fmt:
|
||||
self.logger.info(f"Retrying with guessed format: {guessed_fmt}")
|
||||
g.parse(file_path, format=guessed_fmt, **kwargs)
|
||||
ds.parse(file_path, format=guessed_fmt, **kwargs)
|
||||
else:
|
||||
raise e
|
||||
else:
|
||||
raise e
|
||||
|
||||
g = ds
|
||||
|
||||
self.progress.update_tracking(tracking_id, message="Converting to internal format...")
|
||||
|
||||
# Determine format for metadata
|
||||
|
||||
@@ -66,6 +66,7 @@ class MethodRegistry:
|
||||
"parquet": {},
|
||||
"arrow": {},
|
||||
"xml": {},
|
||||
"salesforce": {},
|
||||
"ingest": {},
|
||||
}
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,617 @@
|
||||
"""SAP OData ingestion module.
|
||||
|
||||
Pulls an Entity Set from a SAP OData service (S/4HANA and on-prem NetWeaver
|
||||
REST surfaces) and flattens it into document dicts that the pipeline can feed
|
||||
to ``GraphBuilder``.
|
||||
|
||||
Why this exists
|
||||
---------------
|
||||
Semantica ingests from many sources; SAP is the ERP backbone of finance and
|
||||
regulated industries, and its master/transactional data (customers, vendors,
|
||||
sales orders) is exactly the "context" a Context Graph wants. SAP exposes that
|
||||
data over OData (v2 on many on-prem NetWeaver systems, v4 on BTP / S/4HANA
|
||||
Cloud). This connector speaks the REST surface of OData only.
|
||||
|
||||
Design notes
|
||||
------------
|
||||
Three classes, matching the Snowflake/Databricks ingestors:
|
||||
- ``SAPODataEntity``: a collection fetch from one Entity Set (``records``,
|
||||
``count``, ``service``, ``metadata``), flattened to document dicts for
|
||||
``GraphBuilder`` via ``export_as_documents``.
|
||||
- ``SAPODataConnector``: auth (OAuth2 client-credentials or Basic) + the
|
||||
shared, SSRF-guarded :mod:`requests` session. *Every* outbound request,
|
||||
including the OAuth2 token exchange, goes through
|
||||
``request_with_ssrf_guard`` so user-supplied endpoints can not reach
|
||||
private/loopback/link-local address space.
|
||||
- ``SAPIngestor``: the three methods the issue requested —
|
||||
``discover_service``, ``ingest_entity_set`` and ``export_as_documents``
|
||||
(plus ``close`` for symmetry with the SQL connectors).
|
||||
|
||||
EDMX
|
||||
----
|
||||
``$metadata`` is plain CSDL XML in *both* OData v2 and v4, so we hand-roll a
|
||||
minimal parser with :mod:`xml.etree` instead of pulling in ``pyodata``. That
|
||||
keeps phase 1 ``requests``-only, exactly as scoped in the issue.
|
||||
|
||||
Pagination
|
||||
----------
|
||||
OData uses a server-driven "next link": OData v2 surfaces it as the atom
|
||||
``__next`` element, OData v4 as the ``@odata.nextLink`` field on the JSON
|
||||
payload. ``ingest_entity_set`` follows whichever it sees until the set is
|
||||
exhausted.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import os
|
||||
import xml.etree.ElementTree as ET
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime
|
||||
from typing import Any, Dict, List, Optional
|
||||
from urllib.parse import urljoin, urlparse
|
||||
|
||||
import requests
|
||||
from requests.adapters import HTTPAdapter
|
||||
|
||||
try:
|
||||
from urllib3.util.retry import Retry
|
||||
except (ImportError, OSError): # pragma: no cover - old urllib3 layout
|
||||
from requests.packages.urllib3.util.retry import Retry # type: ignore
|
||||
|
||||
from ..utils.exceptions import ProcessingError, ValidationError
|
||||
from ..utils.logging import get_logger
|
||||
from .ssrf import parse_bool, request_with_ssrf_guard
|
||||
|
||||
__all__ = [
|
||||
"SAPODataEntity",
|
||||
"SAPODataConnector",
|
||||
"SAPIngestor",
|
||||
]
|
||||
|
||||
_logger = get_logger("sap_ingestor")
|
||||
|
||||
|
||||
def _prop_is_nullable(prop: Any) -> bool:
|
||||
"""CSDL structural properties default to nullable=True when omitted."""
|
||||
val = prop.get("Nullable", prop.get("nullable"))
|
||||
return True if val is None else val.strip().lower() == "true"
|
||||
|
||||
|
||||
def _match(elem: Any, localname: str) -> bool:
|
||||
"""True if *elem* has the given local name in any namespace."""
|
||||
return elem.tag.rsplit("}", 1)[-1] == localname
|
||||
|
||||
|
||||
@dataclass
|
||||
class SAPODataEntity:
|
||||
"""A collection fetch from a SAP OData Entity Set.
|
||||
|
||||
Holds the rows pulled from one Entity Set (all paging fan-in'd), with the
|
||||
shape the issue specifies: ``records`` (the row data), ``count``,
|
||||
``service`` (the resolved service root), optional ``metadata`` (entity-set
|
||||
schema from ``$metadata``) and ``ingested_at``.
|
||||
"""
|
||||
|
||||
records: List[Dict[str, Any]]
|
||||
entity_set: str
|
||||
count: int
|
||||
service: str
|
||||
metadata: Optional[Dict[str, Any]] = None
|
||||
ingested_at: datetime = field(default_factory=datetime.now)
|
||||
|
||||
def to_documents(self) -> List[Dict[str, Any]]:
|
||||
"""Flatten each record to a document dict ``GraphBuilder`` can consume.
|
||||
|
||||
GraphBuilder only treats a dict as an entity when it carries
|
||||
``id``/``entity_id``/``name`` (or ``text``+``type``); SAP records have
|
||||
none of those, so they would be silently dropped. We inject an
|
||||
identifier resolved from each record's primary-key-like field, falling
|
||||
back to ``entity_set:index``, and expose it under both ``id`` and
|
||||
``name``.
|
||||
"""
|
||||
docs: List[Dict[str, Any]] = []
|
||||
for index, record in enumerate(self.records):
|
||||
doc = dict(record)
|
||||
key_value = self._id_value(record)
|
||||
doc.setdefault("id", key_value or f"{self.entity_set}:{index}")
|
||||
doc.setdefault("name", key_value or self.entity_set)
|
||||
doc.setdefault("source", self.service)
|
||||
docs.append(doc)
|
||||
return docs
|
||||
|
||||
@staticmethod
|
||||
def _id_value(record: Dict[str, Any]) -> str:
|
||||
for key, value in record.items():
|
||||
if "id" in key.lower() and value not in (None, ""):
|
||||
return str(value)
|
||||
return ""
|
||||
|
||||
|
||||
class SAPODataConnector:
|
||||
"""Connection + authentication management for a SAP OData REST service.
|
||||
|
||||
Supports the two auth landscapes called out in the issue:
|
||||
|
||||
- **OAuth2 client-credentials** (BTP / S/4HANA Cloud). The token URL is
|
||||
user supplied; both the token exchange *and* every subsequent data
|
||||
request are validated through the SSRF guard.
|
||||
- **Basic** (on-prem NetWeaver). Username/password passed through as an
|
||||
``Authorization: Basic`` header, also through the guard.
|
||||
|
||||
Example usage::
|
||||
|
||||
>>> connector = SAPODataConnector(
|
||||
... base_url="https://myhost/sap/opu/odata/sap/",
|
||||
... token_url="https://myhost/oauth/token",
|
||||
... client_id="cid", client_secret="secret",
|
||||
... )
|
||||
>>> session = connector.get_session()
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
base_url: Optional[str] = None,
|
||||
*,
|
||||
auth: Optional[str] = None,
|
||||
token_url: Optional[str] = None,
|
||||
client_id: Optional[str] = None,
|
||||
client_secret: Optional[str] = None,
|
||||
username: Optional[str] = None,
|
||||
password: Optional[str] = None,
|
||||
allow_private_ips: bool = False,
|
||||
**config: Any,
|
||||
) -> None:
|
||||
"""Initialize the SAP OData connector.
|
||||
|
||||
Args:
|
||||
base_url: Base OData service URL, e.g. ``https://host/sap/opu
|
||||
/odata/sap/``. The issue's ``service`` value.
|
||||
auth: Explicit auth flow, ``"oauth2"`` or ``"basic"``. When
|
||||
omitted, the flow is inferred from which credentials are set.
|
||||
token_url: OAuth2 token endpoint. Required only for OAuth2 flow.
|
||||
client_id: OAuth2 client id (OAuth2 flow).
|
||||
client_secret: OAuth2 client secret (OAuth2 flow).
|
||||
username: Basic-auth username (on-prem flow).
|
||||
password: Basic-auth password (on-prem flow).
|
||||
allow_private_ips: Opt into private/loopback/link-local endpoints.
|
||||
Defaults to False (SSRF-safe).
|
||||
**config: Extra options, notably ``timeout``, ``max_retries``,
|
||||
``backoff_factor``, ``headers``.
|
||||
"""
|
||||
self.logger = _logger
|
||||
self.base_url = base_url or os.getenv("SAP_BASE_URL")
|
||||
self.auth = (auth or os.getenv("SAP_AUTH") or "").lower()
|
||||
self.token_url = token_url or os.getenv("SAP_TOKEN_URL")
|
||||
self.client_id = client_id or os.getenv("SAP_CLIENT_ID")
|
||||
self.client_secret = client_secret or os.getenv("SAP_CLIENT_SECRET")
|
||||
self.username = username or os.getenv("SAP_USERNAME")
|
||||
self.password = password or os.getenv("SAP_PASSWORD")
|
||||
self.allow_private_ips = parse_bool(
|
||||
config.pop("allow_private_ips", allow_private_ips), default=False
|
||||
)
|
||||
self.config = config
|
||||
|
||||
if not self.base_url:
|
||||
raise ValidationError(
|
||||
"SAP base_url is required. Provide via 'base_url' or "
|
||||
"SAP_BASE_URL environment variable."
|
||||
)
|
||||
oauth_configured = bool(self.client_id or self.client_secret or self.token_url)
|
||||
if self.auth in ("oauth2", "oauth"):
|
||||
if not (self.client_id and self.client_secret and self.token_url):
|
||||
raise ValidationError(
|
||||
"SAP OAuth2 flow requires client_id, client_secret and "
|
||||
"token_url all set."
|
||||
)
|
||||
elif self.auth == "basic":
|
||||
if not (self.username and self.password):
|
||||
raise ValidationError("SAP Basic flow requires username and password.")
|
||||
elif oauth_configured:
|
||||
if not (self.client_id and self.client_secret and self.token_url):
|
||||
raise ValidationError(
|
||||
"SAP OAuth2 flow requires client_id, client_secret and "
|
||||
"token_url all set."
|
||||
)
|
||||
elif not self.username:
|
||||
raise ValidationError(
|
||||
"SAP authentication requires either (username/password) or "
|
||||
"(client_id/client_secret + token_url)."
|
||||
)
|
||||
|
||||
self.session = requests.Session()
|
||||
retry_strategy = Retry(
|
||||
total=self.config.get("max_retries", 3),
|
||||
backoff_factor=self.config.get("backoff_factor", 1),
|
||||
status_forcelist=[429, 500, 502, 503, 504],
|
||||
)
|
||||
adapter = HTTPAdapter(max_retries=retry_strategy)
|
||||
self.session.mount("http://", adapter)
|
||||
self.session.mount("https://", adapter)
|
||||
default_headers = self.config.get("headers", {})
|
||||
if default_headers:
|
||||
self.session.headers.update(default_headers)
|
||||
|
||||
self._token: Optional[str] = None
|
||||
self.logger.debug(
|
||||
"SAP OData connector initialized (base_url=%s, allow_private_ips=%s)",
|
||||
self.base_url,
|
||||
self.allow_private_ips,
|
||||
)
|
||||
|
||||
def get_session(self) -> requests.Session:
|
||||
"""Return an authenticated session for data requests.
|
||||
|
||||
For the Basic flow the credentials are attached eagerly; for the
|
||||
OAuth2 flow a token is fetched (and cached) on first use. The token
|
||||
is never refreshed, so a job that runs past the token TTL (typically
|
||||
3600s on SAP) will fail with 401 -- re-create the connector instead.
|
||||
"""
|
||||
if self.username:
|
||||
self.session.headers["Authorization"] = "Basic " + self._basic_header()
|
||||
return self.session
|
||||
if self._token is None:
|
||||
self._token = self._fetch_token()
|
||||
self.session.headers["Authorization"] = "Bearer " + self._token
|
||||
return self.session
|
||||
|
||||
def _basic_header(self) -> str:
|
||||
pair = f"{self.username}:{self.password or ''}".encode("utf-8")
|
||||
return base64.b64encode(pair).decode("ascii")
|
||||
|
||||
def _fetch_token(self) -> str:
|
||||
"""Perform the OAuth2 client-credentials token exchange (SSRF-guarded)."""
|
||||
if not self.token_url or not self.client_id:
|
||||
raise ProcessingError("OAuth2 flow requires token_url and client_id.")
|
||||
body = {
|
||||
"grant_type": "client_credentials",
|
||||
"client_id": self.client_id,
|
||||
"client_secret": self.client_secret or "",
|
||||
}
|
||||
resp = request_with_ssrf_guard(
|
||||
"POST",
|
||||
self.token_url,
|
||||
session=self.session,
|
||||
allow_private_ips=self.allow_private_ips,
|
||||
data=body,
|
||||
timeout=self.config.get("timeout", 30),
|
||||
)
|
||||
try:
|
||||
resp.raise_for_status()
|
||||
except requests.exceptions.RequestException as exc:
|
||||
raise ProcessingError(f"SAP OAuth2 token exchange failed: {exc}") from exc
|
||||
try:
|
||||
payload = resp.json()
|
||||
except ValueError as exc:
|
||||
raise ProcessingError(
|
||||
"SAP OAuth2 token endpoint did not return JSON."
|
||||
) from exc
|
||||
token = payload.get("access_token")
|
||||
if not token:
|
||||
raise ProcessingError("SAP OAuth2 token response missing 'access_token'.")
|
||||
return str(token)
|
||||
|
||||
def close(self) -> None:
|
||||
"""Close the underlying :mod:`requests` session."""
|
||||
self.session.close()
|
||||
|
||||
|
||||
class SAPIngestor:
|
||||
"""Ingest an Entity Set from a SAP OData service.
|
||||
|
||||
Example usage::
|
||||
|
||||
>>> from semantica.ingest import SAPIngestor
|
||||
>>> ing = SAPIngestor(
|
||||
... base_url="https://host/sap/opu/odata/sap/",
|
||||
... username="u", password="p", # or client_id/client_secret/token_url
|
||||
... )
|
||||
>>> sets = ing.discover_service()
|
||||
... # -> [{"name": "SalesOrderSet", "fields": [...]}, ...]
|
||||
>>> docs = ing.export_as_documents(
|
||||
... ing.ingest_entity_set(entity_set="SalesOrderSet", expand="to_Item"))
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
base_url: Optional[str] = None,
|
||||
connector: Optional[SAPODataConnector] = None,
|
||||
**config: Any,
|
||||
) -> None:
|
||||
"""Initialize the SAP ingestor.
|
||||
|
||||
Args:
|
||||
base_url: Base OData service URL. Mutually exclusive with
|
||||
``connector``; ignored if a connector is given.
|
||||
connector: An existing :class:`SAPODataConnector`. When provided,
|
||||
its session and base URL are reused.
|
||||
**config: Passed to :class:`SAPODataConnector` when one is created.
|
||||
"""
|
||||
self.logger = _logger
|
||||
self.connector = connector or SAPODataConnector(base_url=base_url, **config)
|
||||
# urljoin() replaces the last path segment unless the base ends in '/',
|
||||
# so normalize once here: .../API_BUSINESS_PARTNER -> .../$metadata would
|
||||
# silently drop the service segment.
|
||||
self._base_url = self.connector.base_url
|
||||
if not self._base_url.endswith("/"):
|
||||
self._base_url += "/"
|
||||
|
||||
def discover_service(self, service: Optional[str] = None) -> List[Dict[str, Any]]:
|
||||
"""Fetch and parse ``$metadata`` into the service's entity sets.
|
||||
|
||||
Args:
|
||||
service: Service root URL (absolute) or path suffix resolved
|
||||
against the base URL. Defaults to the base URL. ``$metadata``
|
||||
is appended automatically — same meaning as in
|
||||
:meth:`ingest_entity_set`.
|
||||
|
||||
Returns:
|
||||
List of dicts, one per EntitySet, each with ``name`` and ``fields``
|
||||
(a list of ``{name, type, nullable}`` parsed from the CSDL).
|
||||
"""
|
||||
metadata_url = self._metadata_url(service)
|
||||
session = self.connector.get_session()
|
||||
resp = request_with_ssrf_guard(
|
||||
"GET",
|
||||
metadata_url,
|
||||
session=session,
|
||||
allow_private_ips=self.connector.allow_private_ips,
|
||||
headers={"Accept": "application/xml"},
|
||||
timeout=self.connector.config.get("timeout", 30),
|
||||
)
|
||||
try:
|
||||
resp.raise_for_status()
|
||||
except requests.exceptions.RequestException as exc:
|
||||
self.logger.error("Failed to fetch SAP metadata %s: %s", metadata_url, exc)
|
||||
raise ProcessingError(f"Failed to fetch SAP $metadata: {exc}") from exc
|
||||
|
||||
return self._parse_metadata(resp.text)
|
||||
|
||||
def _metadata_url(self, service: Optional[str]) -> str:
|
||||
"""Build the ``$metadata`` URL for a service root.
|
||||
|
||||
``service`` has the same meaning as in :meth:`ingest_entity_set` —
|
||||
a service root (absolute URL or path suffix resolved against the
|
||||
base URL). ``$metadata`` is appended here, so callers pass the root
|
||||
the same way for both discovery and ingestion. A value already
|
||||
ending in ``$metadata`` is used as-is.
|
||||
"""
|
||||
if not service:
|
||||
return urljoin(self._base_url, "$metadata")
|
||||
if "://" not in service:
|
||||
service = urljoin(self._base_url, service)
|
||||
if service.endswith("$metadata"):
|
||||
return service
|
||||
if not service.endswith("/"):
|
||||
service += "/"
|
||||
return urljoin(service, "$metadata")
|
||||
|
||||
def _parse_metadata(self, metadata_xml: str) -> List[Dict[str, Any]]:
|
||||
"""Minimal CSDL/EDMX parser -> entity set name + property fields.
|
||||
|
||||
Element local names (``Schema``/``EntitySet``/``EntityType``/
|
||||
``Property``) are stable across OData v2 (Microsoft ns) and v4 (OASIS
|
||||
ns), so we match them by local name instead of hard-coding one
|
||||
namespace. Entity-Type references are resolved per-schema, so
|
||||
same-named types in different schemas cannot bleed fields into each
|
||||
other.
|
||||
"""
|
||||
try:
|
||||
root = ET.fromstring(metadata_xml)
|
||||
except ET.ParseError as exc:
|
||||
raise ProcessingError(f"SAP $metadata is not valid XML: {exc}") from exc
|
||||
|
||||
# Index fully-qualified type name -> property fields, per schema.
|
||||
schema_types: Dict[str, List[Dict[str, Any]]] = {}
|
||||
for schema in (e for e in root.iter() if _match(e, "Schema")):
|
||||
ns = (schema.get("Namespace") or schema.get("namespace") or "").rstrip(".")
|
||||
for entity_type in (e for e in schema.iter() if _match(e, "EntityType")):
|
||||
tname = entity_type.get("Name") or entity_type.get("name")
|
||||
if not tname:
|
||||
continue
|
||||
fq = f"{ns}.{tname}" if ns else tname
|
||||
schema_types[fq] = [
|
||||
{
|
||||
"name": prop.get("Name") or prop.get("name"),
|
||||
"type": prop.get("Type") or prop.get("type"),
|
||||
"nullable": _prop_is_nullable(prop),
|
||||
}
|
||||
for prop in (e for e in entity_type.iter() if _match(e, "Property"))
|
||||
]
|
||||
|
||||
entity_sets: List[Dict[str, Any]] = []
|
||||
for schema in (e for e in root.iter() if _match(e, "Schema")):
|
||||
ns = (schema.get("Namespace") or schema.get("namespace") or "").rstrip(".")
|
||||
for entity_set in (e for e in schema.iter() if _match(e, "EntitySet")):
|
||||
name = entity_set.get("Name") or entity_set.get("name")
|
||||
ref = entity_set.get("EntityType") or entity_set.get("entityType") or ""
|
||||
qualified = ref if "." in ref else (f"{ns}.{ref}" if ns else ref)
|
||||
fields = schema_types.get(qualified) or schema_types.get(ref) or []
|
||||
entity_sets.append({"name": name, "fields": fields})
|
||||
return entity_sets
|
||||
|
||||
def ingest_entity_set(
|
||||
self,
|
||||
service: Optional[str] = None,
|
||||
entity_set: Optional[str] = None,
|
||||
*,
|
||||
select: Optional[str] = None,
|
||||
filter: Optional[str] = None,
|
||||
expand: Optional[str] = None,
|
||||
top: Optional[int] = None,
|
||||
skip: Optional[int] = None,
|
||||
batch_size: int = 100,
|
||||
) -> SAPODataEntity:
|
||||
"""Fetch pages of *entity_set* from the OData service.
|
||||
|
||||
Args:
|
||||
service: Service root URL (absolute) or path suffix resolved
|
||||
against the base URL. Defaults to the base URL. Same meaning
|
||||
as in :meth:`discover_service`.
|
||||
entity_set: Entity set name, e.g. ``"SalesOrderSet"``.
|
||||
select: Optional ``$select`` comma string.
|
||||
filter: Optional ``$filter`` expression.
|
||||
expand: Optional ``$expand`` expression (e.g. ``"to_Item"`` for
|
||||
use case 2's sales-order headers -> line items).
|
||||
top: Maximum number of rows to return.
|
||||
skip: Number of leading rows to skip.
|
||||
batch_size: ``$top`` pagination size per request.
|
||||
|
||||
Returns:
|
||||
A single :class:`SAPODataEntity` holding every fetched record
|
||||
(server-driven pagination is followed to completion).
|
||||
"""
|
||||
if service is None:
|
||||
base = self._base_url
|
||||
elif "://" in service:
|
||||
base = service
|
||||
else:
|
||||
base = urljoin(self._base_url, service)
|
||||
if not base.endswith("/"):
|
||||
base += "/"
|
||||
if not entity_set:
|
||||
raise ValidationError("SAP 'entity_set' is required.")
|
||||
|
||||
session = self.connector.get_session()
|
||||
records: List[Dict[str, Any]] = []
|
||||
next_link: Optional[str] = urljoin(base, entity_set)
|
||||
params = self._query_params(select, filter, expand, top, skip, batch_size)
|
||||
|
||||
if top is not None and top < 0:
|
||||
raise ValidationError("SAP 'top' must be >= 0 (got %r)" % top)
|
||||
if top == 0:
|
||||
return SAPODataEntity(
|
||||
records=[], entity_set=entity_set, count=0, service=base
|
||||
)
|
||||
original_host = (urlparse(base).hostname or "").lower()
|
||||
|
||||
while next_link:
|
||||
# Server-provided next links may point anywhere; never send the
|
||||
# session credentials (Basic/Bearer) to a different origin than
|
||||
# the service root. Legit SAP pagination stays on the same host.
|
||||
next_host = (urlparse(next_link).hostname or "").lower()
|
||||
if next_host != original_host:
|
||||
raise ProcessingError(
|
||||
f"SAP next link '{next_link}' points to a different host "
|
||||
f"than service root '{base}'"
|
||||
)
|
||||
resp = request_with_ssrf_guard(
|
||||
"GET",
|
||||
next_link,
|
||||
session=session,
|
||||
allow_private_ips=self.connector.allow_private_ips,
|
||||
headers={"Accept": "application/json"},
|
||||
params=params,
|
||||
timeout=self.connector.config.get("timeout", 30),
|
||||
)
|
||||
try:
|
||||
resp.raise_for_status()
|
||||
except requests.exceptions.RequestException as exc:
|
||||
self.logger.error(
|
||||
"Failed to fetch SAP entity set %s: %s", entity_set, exc
|
||||
)
|
||||
raise ProcessingError(
|
||||
f"Failed to fetch SAP entity set {entity_set}: {exc}"
|
||||
) from exc
|
||||
|
||||
payload = self._parse_page(resp)
|
||||
rows, next_link = payload["rows"], payload["next_link"]
|
||||
|
||||
for raw_row in rows:
|
||||
records.append(self._flatten_row(raw_row))
|
||||
|
||||
self.logger.debug(
|
||||
"Fetched %d rows from %s (next=%s)",
|
||||
len(rows),
|
||||
entity_set,
|
||||
bool(next_link),
|
||||
)
|
||||
if top is not None and len(records) >= top:
|
||||
break
|
||||
|
||||
params = None # query params already baked into the server next link
|
||||
# Refresh next_link against base in case it's a relative pointer.
|
||||
if next_link and not next_link.startswith("http"):
|
||||
next_link = urljoin(resp.url, next_link)
|
||||
|
||||
return SAPODataEntity(
|
||||
records=records,
|
||||
entity_set=entity_set,
|
||||
count=len(records),
|
||||
service=base,
|
||||
)
|
||||
|
||||
def _query_params(
|
||||
self,
|
||||
select: Optional[str],
|
||||
filter: Optional[str],
|
||||
expand: Optional[str],
|
||||
top_value: Optional[int],
|
||||
skip: Optional[int],
|
||||
batch_size: int,
|
||||
) -> Dict[str, str]:
|
||||
params: Dict[str, str] = {}
|
||||
if batch_size > 0:
|
||||
if top_value is not None:
|
||||
params["$top"] = str(min(batch_size, top_value))
|
||||
else:
|
||||
params["$top"] = str(batch_size)
|
||||
if select:
|
||||
params["$select"] = select
|
||||
if filter:
|
||||
params["$filter"] = filter
|
||||
if expand:
|
||||
params["$expand"] = expand
|
||||
if skip is not None:
|
||||
params["$skip"] = str(skip)
|
||||
return params
|
||||
|
||||
def _parse_page(self, resp: requests.Response) -> Dict[str, Any]:
|
||||
try:
|
||||
payload = resp.json()
|
||||
except ValueError as exc:
|
||||
raise ProcessingError(f"SAP OData response is not JSON: {exc}") from exc
|
||||
|
||||
rows: Any
|
||||
next_link: Optional[str] = None
|
||||
if isinstance(payload, list):
|
||||
rows = payload
|
||||
elif isinstance(payload, dict):
|
||||
d = payload.get("d")
|
||||
if isinstance(d, dict):
|
||||
# OData v2 atom: {"d": {"results": [...], "__next": ...}}
|
||||
rows = d.get("results")
|
||||
nxt = d.get("__next") or payload.get("@odata.nextLink")
|
||||
else:
|
||||
# OData v4 JSON: {"value": [...], "@odata.nextLink": ...}
|
||||
rows = payload.get("value", d)
|
||||
nxt = payload.get("@odata.nextLink")
|
||||
if isinstance(nxt, dict):
|
||||
nxt = nxt.get("__deferred", {}).get("uri")
|
||||
next_link = nxt
|
||||
else:
|
||||
rows = None
|
||||
|
||||
if not isinstance(rows, list):
|
||||
raise ProcessingError(
|
||||
"SAP OData payload has no list of rows (got %s)" % type(rows).__name__
|
||||
)
|
||||
return {"rows": rows, "next_link": next_link}
|
||||
|
||||
def _flatten_row(self, row: Any) -> Dict[str, Any]:
|
||||
if isinstance(row, dict):
|
||||
# v2 wraps items in "__metadata"; keep it but expose plain keys.
|
||||
return {k: v for k, v in row.items() if k != "__metadata"}
|
||||
return {"value": row}
|
||||
|
||||
def export_as_documents(self, data: SAPODataEntity) -> List[Dict[str, Any]]:
|
||||
"""Convert an ingested entity set to flat document dicts.
|
||||
|
||||
Normalizes every records held by ``data`` into a list of dicts with an
|
||||
injected ``id``/``name``/``source``, ready to hand to ``GraphBuilder``.
|
||||
"""
|
||||
return data.to_documents()
|
||||
|
||||
def close(self) -> None:
|
||||
"""Close the underlying connector's session."""
|
||||
self.connector.close()
|
||||
@@ -106,7 +106,7 @@ class KGConfig:
|
||||
if value:
|
||||
try:
|
||||
if type_func == bool:
|
||||
self._configs[config_key] = value.lower() in (
|
||||
self._configs[config_key] = value.strip().lower() in (
|
||||
"true",
|
||||
"1",
|
||||
"yes",
|
||||
@@ -123,8 +123,8 @@ class KGConfig:
|
||||
if key.startswith(env_prefix) and key not in env_mappings:
|
||||
config_key = key[len(env_prefix) :].lower()
|
||||
# Try to convert to appropriate type
|
||||
if value.lower() in ("true", "false"):
|
||||
self._configs[config_key] = value.lower() == "true"
|
||||
if value.strip().lower() in ("true", "false"):
|
||||
self._configs[config_key] = value.strip().lower() == "true"
|
||||
elif value.isdigit():
|
||||
self._configs[config_key] = int(value)
|
||||
else:
|
||||
@@ -149,12 +149,12 @@ class KGConfig:
|
||||
if value:
|
||||
try:
|
||||
# Try to convert to appropriate type
|
||||
if isinstance(default, int):
|
||||
if isinstance(default, bool):
|
||||
return value.strip().lower() in ("true", "1", "yes", "on")
|
||||
elif isinstance(default, int):
|
||||
return int(value)
|
||||
elif isinstance(default, float):
|
||||
return float(value)
|
||||
elif isinstance(default, bool):
|
||||
return value.lower() in ("true", "1", "yes", "on")
|
||||
return value
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
|
||||
@@ -54,10 +54,11 @@ Version: 1.0.0
|
||||
"""
|
||||
|
||||
from typing import Any, Dict, List, Optional
|
||||
from datetime import datetime
|
||||
import uuid
|
||||
import time
|
||||
|
||||
from ..utils.helpers import utc_now_iso
|
||||
|
||||
|
||||
class GraphBuilderWithProvenance:
|
||||
"""
|
||||
@@ -103,7 +104,7 @@ class GraphBuilderWithProvenance:
|
||||
|
||||
def build(self, sources, **kwargs):
|
||||
"""Build graph with provenance tracking."""
|
||||
activity_started_at_time = datetime.utcnow().isoformat()
|
||||
activity_started_at_time = utc_now_iso()
|
||||
# Track the build operation (recorded before the build runs, so it
|
||||
# has no end time yet — this is the "in progress" marker).
|
||||
if self.provenance and self._prov_manager:
|
||||
@@ -124,7 +125,7 @@ class GraphBuilderWithProvenance:
|
||||
)
|
||||
|
||||
result = self._builder.build(sources, **kwargs)
|
||||
activity_ended_at_time = datetime.utcnow().isoformat()
|
||||
activity_ended_at_time = utc_now_iso()
|
||||
|
||||
# Track individual entities and relationships if available
|
||||
if self.provenance and self._prov_manager and hasattr(result, 'get'):
|
||||
@@ -180,7 +181,7 @@ class GraphBuilderWithProvenance:
|
||||
|
||||
def build_single_source(self, kg_data, **kwargs):
|
||||
"""Build graph from single source with provenance tracking."""
|
||||
activity_started_at_time = datetime.utcnow().isoformat()
|
||||
activity_started_at_time = utc_now_iso()
|
||||
# Track the build operation (recorded before the build runs, so it
|
||||
# has no end time yet — this is the "in progress" marker).
|
||||
if self.provenance and self._prov_manager:
|
||||
@@ -200,7 +201,7 @@ class GraphBuilderWithProvenance:
|
||||
)
|
||||
|
||||
result = self._builder.build_single_source(kg_data, **kwargs)
|
||||
activity_ended_at_time = datetime.utcnow().isoformat()
|
||||
activity_ended_at_time = utc_now_iso()
|
||||
|
||||
# Track entities and relationships if available
|
||||
if self.provenance and self._prov_manager and isinstance(result, dict):
|
||||
|
||||
@@ -62,6 +62,13 @@ logging.basicConfig(stream=sys.stderr, level=_log_level,
|
||||
format="%(asctime)s [semantica-mcp] %(levelname)s %(message)s")
|
||||
log = logging.getLogger("semantica.mcp_server")
|
||||
|
||||
# MCP stdio framing IS stdout: a progress bar or other console renderer writing
|
||||
# to stdout would interleave with the JSON-RPC stream and hang every client
|
||||
# (observed 2026-08-20: export_graph over MCP timed out at 300s while the same
|
||||
# call returned in <1s directly). Force the progress trackers off for this
|
||||
# process — stdout is not a console here.
|
||||
os.environ["SEMANTICA_DISABLE_PROGRESS"] = "1"
|
||||
|
||||
# ── lazy graph session ──────────────────────────────────────────────────────
|
||||
_graph: Any = None
|
||||
|
||||
@@ -74,7 +81,7 @@ def _get_graph():
|
||||
kg_path = os.environ.get("SEMANTICA_KG_PATH")
|
||||
if kg_path and os.path.exists(kg_path):
|
||||
try:
|
||||
_graph.load(kg_path)
|
||||
_graph.load_from_file(kg_path)
|
||||
log.info("Loaded graph from %s", kg_path)
|
||||
except Exception as exc:
|
||||
log.warning("Could not load graph from %s: %s", kg_path, exc)
|
||||
@@ -86,30 +93,57 @@ def _get_graph():
|
||||
# ══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
def _tool_extract_entities(args: dict) -> dict:
|
||||
"""Extract named entities from text."""
|
||||
"""Extract named entities from text.
|
||||
|
||||
Optional ``model`` (spaCy pipeline, e.g. ``zh_core_web_sm`` for Chinese)
|
||||
and ``language`` allow non-English NER; defaults to the Semantica English
|
||||
pipeline when omitted. ``method`` defaults to ``ml`` (spaCy); other
|
||||
options are ``huggingface``, ``llm``, ``pattern``.
|
||||
"""
|
||||
text = args.get("text", "")
|
||||
if not text:
|
||||
return {"error": "text is required"}
|
||||
from semantica.semantic_extract import NamedEntityRecognizer
|
||||
entities = NamedEntityRecognizer().extract_entities(text)
|
||||
init_kwargs = {}
|
||||
for k in ("model", "language", "confidence_threshold"):
|
||||
if args.get(k) is not None:
|
||||
init_kwargs[k] = args[k]
|
||||
method = args.get("method", "ml")
|
||||
ner = NamedEntityRecognizer(methods=[method], **init_kwargs)
|
||||
entities = ner.extract_entities(text)
|
||||
return {
|
||||
"entities": [
|
||||
{"label": getattr(e, "label", str(e)),
|
||||
"type": getattr(e, "type", None),
|
||||
"start": getattr(e, "start", None),
|
||||
"end": getattr(e, "end", None)}
|
||||
{"text": getattr(e, "text", ""),
|
||||
"label": getattr(e, "label", ""),
|
||||
"type": getattr(e, "label", None),
|
||||
"start": getattr(e, "start_char", getattr(e, "start", None)),
|
||||
"end": getattr(e, "end_char", getattr(e, "end", None)),
|
||||
"confidence": getattr(e, "confidence", 1.0)}
|
||||
for e in (entities or [])
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
def _tool_extract_relations(args: dict) -> dict:
|
||||
"""Extract relations and triplets from text."""
|
||||
"""Extract relations and triplets from text.
|
||||
|
||||
Optional ``model``/``language`` enable non-English extraction.
|
||||
``method`` defaults to ``pattern``; ``dependency`` uses spaCy syntactic
|
||||
parsing (requires a spaCy model, e.g. ``zh_core_web_sm``).
|
||||
"""
|
||||
text = args.get("text", "")
|
||||
if not text:
|
||||
return {"error": "text is required"}
|
||||
from semantica.semantic_extract import RelationExtractor, TripletExtractor
|
||||
relations = RelationExtractor().extract_relations(text)
|
||||
from semantica.semantic_extract import NamedEntityRecognizer, RelationExtractor, TripletExtractor
|
||||
rel_kwargs = {}
|
||||
ner_kwargs = {}
|
||||
for k in ("model", "language"):
|
||||
if args.get(k) is not None:
|
||||
rel_kwargs[k] = args[k]
|
||||
ner_kwargs[k] = args[k]
|
||||
method = args.get("method", "pattern")
|
||||
entities = NamedEntityRecognizer(methods=["ml"], **ner_kwargs).extract_entities(text) or []
|
||||
relations = RelationExtractor(method=method, **rel_kwargs).extract_relations(text, entities)
|
||||
triplets = TripletExtractor().extract_triplets(text)
|
||||
return {
|
||||
"relations": [
|
||||
@@ -156,10 +190,12 @@ def _tool_query_decisions(args: dict) -> dict:
|
||||
graph = _get_graph()
|
||||
try:
|
||||
if query:
|
||||
results = graph.find_similar_decisions(query, max_results=limit)
|
||||
results = graph.find_similar_decisions(query, max_results=limit, min_similarity=0.05)
|
||||
elif category:
|
||||
nodes = graph.find_nodes(node_type="decision")
|
||||
results = [n for n in nodes if n.get("category") == category][:limit]
|
||||
results = [n for n in nodes
|
||||
if n.get("category") == category
|
||||
or n.get("metadata", {}).get("category") == category][:limit]
|
||||
else:
|
||||
results = graph.find_nodes(node_type="decision")[:limit]
|
||||
return {"decisions": results if isinstance(results, list) else list(results)}
|
||||
@@ -175,7 +211,9 @@ def _tool_find_precedents(args: dict) -> dict:
|
||||
max_results = int(args.get("max_results", 5))
|
||||
graph = _get_graph()
|
||||
try:
|
||||
precedents = graph.find_similar_decisions(scenario, max_results=max_results)
|
||||
min_similarity = float(args.get("min_similarity", 0.05))
|
||||
precedents = graph.find_similar_decisions(
|
||||
scenario, max_results=max_results, min_similarity=min_similarity)
|
||||
return {"precedents": precedents if isinstance(precedents, list) else list(precedents)}
|
||||
except Exception as exc:
|
||||
return {"error": str(exc), "precedents": []}
|
||||
@@ -260,16 +298,28 @@ def _tool_get_graph_analytics(args: dict) -> dict:
|
||||
return {"error": str(exc)}
|
||||
|
||||
|
||||
_EXPORT_GRAPH_FORMATS = ("turtle", "ttl", "nt", "xml", "json-ld", "json")
|
||||
|
||||
|
||||
def _tool_export_graph(args: dict) -> dict:
|
||||
"""Export the current knowledge graph to a serialised format."""
|
||||
fmt = args.get("format", "json-ld")
|
||||
if fmt not in _EXPORT_GRAPH_FORMATS:
|
||||
return {
|
||||
"error": f"Unsupported format '{fmt}'. Supported: {', '.join(_EXPORT_GRAPH_FORMATS)}"
|
||||
}
|
||||
graph = _get_graph()
|
||||
try:
|
||||
from semantica.export import RDFExporter, JSONExporter
|
||||
from semantica.export import RDFExporter
|
||||
# The exporters consume the canonical kg dict, not the ContextGraph
|
||||
# object (regression: the old code passed the object straight through,
|
||||
# so every branch failed — JSONExporter.export() with no file_path on
|
||||
# the json branch, AttributeError on the RDF branches).
|
||||
kg = graph.to_kg_dict()
|
||||
if fmt in ("turtle", "ttl", "nt", "xml", "json-ld"):
|
||||
result = RDFExporter().export_to_rdf(graph, format=fmt)
|
||||
result = RDFExporter().export_to_rdf(kg, format=fmt)
|
||||
else:
|
||||
result = JSONExporter().export(graph)
|
||||
result = json.dumps(kg, indent=2, ensure_ascii=False)
|
||||
return {"format": fmt, "data": result}
|
||||
except Exception as exc:
|
||||
return {"error": str(exc)}
|
||||
@@ -290,6 +340,160 @@ def _tool_get_graph_summary(args: dict) -> dict:
|
||||
return {"error": str(exc), "graph_ready": False}
|
||||
|
||||
|
||||
def _tool_update_node(args: dict) -> dict:
|
||||
"""Update properties of an existing node and persist to SEMANTICA_KG_PATH.
|
||||
|
||||
Common use: mark an action node's status (todo/doing/done) with an
|
||||
optional note. The graph is mutated in-memory then saved back to the
|
||||
file it was loaded from, so changes survive server restarts.
|
||||
"""
|
||||
node_id = args.get("node_id", "")
|
||||
if not node_id:
|
||||
return {"error": "node_id is required"}
|
||||
properties = args.get("properties", {})
|
||||
if not isinstance(properties, dict) or not properties:
|
||||
return {"error": "properties (non-empty object) is required"}
|
||||
graph = _get_graph()
|
||||
try:
|
||||
if not graph.find_node(node_id):
|
||||
return {"error": f"node '{node_id}' not found"}
|
||||
graph.add_node_attribute(node_id, properties)
|
||||
# Persist back to disk so the change survives restarts
|
||||
kg_path = os.environ.get("SEMANTICA_KG_PATH")
|
||||
if kg_path:
|
||||
graph.save_to_file(kg_path)
|
||||
persisted = True
|
||||
else:
|
||||
persisted = False
|
||||
updated = graph.find_node(node_id)
|
||||
return {
|
||||
"status": "updated",
|
||||
"node_id": node_id,
|
||||
"properties": {k: (updated.get("metadata") or {}).get(k) for k in properties},
|
||||
"persisted": persisted,
|
||||
}
|
||||
except Exception as exc:
|
||||
return {"error": str(exc)}
|
||||
|
||||
|
||||
def _tool_delete_node(args: dict) -> dict:
|
||||
"""Archive a node (soft delete) and persist to SEMANTICA_KG_PATH.
|
||||
|
||||
The node is kept in the graph for history but marked status='archived'.
|
||||
Use to retire an action you no longer actively track.
|
||||
"""
|
||||
node_id = args.get("node_id", "")
|
||||
if not node_id:
|
||||
return {"error": "node_id is required"}
|
||||
graph = _get_graph()
|
||||
try:
|
||||
if not graph.find_node(node_id):
|
||||
return {"error": f"node '{node_id}' not found"}
|
||||
graph.add_node_attribute(node_id, {"status": "archived"})
|
||||
kg_path = os.environ.get("SEMANTICA_KG_PATH")
|
||||
if kg_path:
|
||||
graph.save_to_file(kg_path)
|
||||
return {"status": "archived", "node_id": node_id, "persisted": bool(kg_path)}
|
||||
except Exception as exc:
|
||||
return {"error": str(exc)}
|
||||
|
||||
|
||||
def _tool_query_graph(args: dict) -> dict:
|
||||
"""Query the live knowledge graph: node detail, neighbours, or keyword search.
|
||||
|
||||
mode:
|
||||
- "node" : get one node by id (needs node_id)
|
||||
- "neighbors": traverse up to `depth` hops from node_id (default depth=1)
|
||||
- "search" : keyword search over node id+content (needs query)
|
||||
"""
|
||||
graph = _get_graph()
|
||||
mode = args.get("mode", "neighbors")
|
||||
try:
|
||||
if mode == "node":
|
||||
node_id = args.get("node_id", "")
|
||||
if not node_id:
|
||||
return {"error": "node_id is required"}
|
||||
node = graph.find_node(node_id)
|
||||
return {"node": node}
|
||||
|
||||
if mode == "neighbors":
|
||||
node_id = args.get("node_id", "")
|
||||
if not node_id:
|
||||
return {"error": "node_id is required"}
|
||||
depth = int(args.get("depth", 1))
|
||||
rel_types = args.get("relationship_types")
|
||||
if isinstance(rel_types, str):
|
||||
rel_types = [rel_types]
|
||||
rel_set = set(rel_types) if rel_types else None
|
||||
limit = args.get("limit")
|
||||
limit = int(limit) if limit is not None else None
|
||||
depth = min(max(depth, 1), 5)
|
||||
# Out-edges (multi-hop) via get_neighbors
|
||||
nb = graph.get_neighbors(
|
||||
node_id, hops=depth, relationship_types=rel_types, limit=limit,
|
||||
)
|
||||
out = [
|
||||
{"id": n.get("id"), "type": n.get("type"),
|
||||
"content": n.get("content"),
|
||||
"relationship": n.get("relationship"),
|
||||
"direction": "out", "hop": n.get("hop", 1)}
|
||||
for n in (nb or [])
|
||||
]
|
||||
# In-edges (1-hop): scan edges whose target == node_id.
|
||||
# Deduplicate by source node so that multiple edges between the
|
||||
# same pair of nodes (different edge types) produce one entry.
|
||||
# Stop early once we have already collected `limit` inbound results
|
||||
# (if a limit is set) to avoid scanning the full edge list.
|
||||
inb = []
|
||||
seen_inbound = set()
|
||||
for e in graph.find_edges():
|
||||
if e.get("target") != node_id:
|
||||
continue
|
||||
if rel_set is not None and e.get("type") not in rel_set:
|
||||
continue
|
||||
src_id = e.get("source")
|
||||
if src_id in seen_inbound:
|
||||
continue
|
||||
seen_inbound.add(src_id)
|
||||
src = graph.find_node(src_id) or {}
|
||||
inb.append({"id": src_id, "type": src.get("type"),
|
||||
"content": src.get("content"),
|
||||
"relationship": e.get("type"),
|
||||
"direction": "in", "hop": 1})
|
||||
# Early-exit: we already have `limit` inbound results; the
|
||||
# combined list will be truncated to `limit` anyway.
|
||||
if limit is not None and len(inb) >= limit:
|
||||
break
|
||||
neighbors = out + inb
|
||||
# Apply final limit. Use ``is not None`` so limit=0 (zero results)
|
||||
# is honoured correctly; ``if limit:`` would treat 0 as falsy.
|
||||
if limit is not None:
|
||||
neighbors = neighbors[:limit]
|
||||
return {"node_id": node_id, "depth": depth, "neighbors": neighbors}
|
||||
|
||||
if mode == "search":
|
||||
q = (args.get("query") or "").lower()
|
||||
if not q:
|
||||
return {"error": "query is required"}
|
||||
node_type = args.get("node_type")
|
||||
limit = int(args.get("limit", 50))
|
||||
nodes = graph.find_nodes(node_type=node_type) if node_type else graph.find_nodes()
|
||||
hits = []
|
||||
for n in nodes:
|
||||
# Check limit BEFORE appending so limit=0 returns empty.
|
||||
if len(hits) >= limit:
|
||||
break
|
||||
blob = f"{n.get('id','')} {n.get('content','')}".lower()
|
||||
if q in blob:
|
||||
hits.append({"id": n.get("id"), "type": n.get("type"),
|
||||
"content": n.get("content")})
|
||||
return {"query": q, "results": hits, "total": len(hits)}
|
||||
|
||||
return {"error": f"unknown mode '{mode}': use node|neighbors|search"}
|
||||
except Exception as exc:
|
||||
return {"error": str(exc)}
|
||||
|
||||
|
||||
# ══════════════════════════════════════════════════════════════════════════════
|
||||
# MCP protocol tables
|
||||
# ══════════════════════════════════════════════════════════════════════════════
|
||||
@@ -301,7 +505,11 @@ TOOLS = [
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"text": {"type": "string", "description": "Input text to extract entities from"}
|
||||
"text": {"type": "string", "description": "Input text to extract entities from"},
|
||||
"model": {"type": "string", "description": "spaCy model name, e.g. 'zh_core_web_sm' for Chinese, 'en_core_web_sm' for English. Defaults to English pipeline."},
|
||||
"language": {"type": "string", "description": "Language code, e.g. 'zh', 'en'."},
|
||||
"method": {"type": "string", "description": "Extraction method: 'ml' (spaCy, default), 'huggingface', 'llm', 'pattern'."},
|
||||
"confidence_threshold": {"type": "number", "description": "Minimum confidence 0-1 (default 0.5)."}
|
||||
},
|
||||
"required": ["text"],
|
||||
},
|
||||
@@ -313,7 +521,10 @@ TOOLS = [
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"text": {"type": "string", "description": "Input text to extract relations from"}
|
||||
"text": {"type": "string", "description": "Input text to extract relations from"},
|
||||
"model": {"type": "string", "description": "spaCy model name for dependency parsing, e.g. 'zh_core_web_sm'."},
|
||||
"language": {"type": "string", "description": "Language code, e.g. 'zh'."},
|
||||
"method": {"type": "string", "description": "Extraction method: 'pattern' (default), 'dependency', 'cooccurrence', 'huggingface', 'llm'."}
|
||||
},
|
||||
"required": ["text"],
|
||||
},
|
||||
@@ -441,7 +652,7 @@ TOOLS = [
|
||||
"properties": {
|
||||
"format": {
|
||||
"type": "string",
|
||||
"enum": ["turtle", "ttl", "nt", "xml", "json-ld", "json"],
|
||||
"enum": list(_EXPORT_GRAPH_FORMATS),
|
||||
"description": "Export format (default: json-ld)",
|
||||
}
|
||||
},
|
||||
@@ -454,6 +665,48 @@ TOOLS = [
|
||||
"inputSchema": {"type": "object", "properties": {}},
|
||||
"_handler": _tool_get_graph_summary,
|
||||
},
|
||||
{
|
||||
"name": "query_graph",
|
||||
"description": "Query the live knowledge graph: get a node, traverse its neighbours (up to 5 hops), or keyword-search nodes by id+content.",
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"mode": {"type": "string", "description": "node | neighbors | search (default: neighbors)"},
|
||||
"node_id": {"type": "string", "description": "Node id (required for node/neighbors mode)"},
|
||||
"depth": {"type": "integer", "description": "Hop depth for neighbors (1-5, default 1)"},
|
||||
"relationship_types": {"type": "array", "items": {"type": "string"}, "description": "Optional filter by edge type(s)"},
|
||||
"query": {"type": "string", "description": "Keyword for search mode (matched against node id+content)"},
|
||||
"node_type": {"type": "string", "description": "Optional node_type filter for search mode"},
|
||||
"limit": {"type": "integer", "description": "Max results for neighbors/search"}
|
||||
},
|
||||
},
|
||||
"_handler": _tool_query_graph,
|
||||
},
|
||||
{
|
||||
"name": "update_node",
|
||||
"description": "Update properties of an existing node (e.g. mark an action todo/doing/done with a note) and persist to SEMANTICA_KG_PATH.",
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"node_id": {"type": "string", "description": "Node id to update"},
|
||||
"properties": {"type": "object", "description": "Property key-values to merge onto the node, e.g. {\"status\":\"done\",\"updated_at\":\"2026-08-13\",\"note\":\"...\"}"}
|
||||
},
|
||||
"required": ["node_id", "properties"],
|
||||
},
|
||||
"_handler": _tool_update_node,
|
||||
},
|
||||
{
|
||||
"name": "delete_node",
|
||||
"description": "Archive a node (soft delete: marks status='archived', keeps it for history) and persist to SEMANTICA_KG_PATH. Use to retire an action you no longer track.",
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"node_id": {"type": "string", "description": "Node id to delete"}
|
||||
},
|
||||
"required": ["node_id"],
|
||||
},
|
||||
"_handler": _tool_delete_node,
|
||||
},
|
||||
]
|
||||
|
||||
RESOURCES = [
|
||||
|
||||
@@ -98,7 +98,7 @@ class NormalizeConfig:
|
||||
if value:
|
||||
try:
|
||||
if type_func == bool:
|
||||
self._configs[config_key] = value.lower() in (
|
||||
self._configs[config_key] = value.strip().lower() in (
|
||||
"true",
|
||||
"1",
|
||||
"yes",
|
||||
@@ -113,8 +113,8 @@ class NormalizeConfig:
|
||||
for key, value in os.environ.items():
|
||||
if key.startswith(env_prefix) and key not in env_mappings:
|
||||
config_key = key[len(env_prefix) :].lower()
|
||||
if value.lower() in ("true", "false"):
|
||||
self._configs[config_key] = value.lower() == "true"
|
||||
if value.strip().lower() in ("true", "false"):
|
||||
self._configs[config_key] = value.strip().lower() == "true"
|
||||
elif value.isdigit():
|
||||
self._configs[config_key] = int(value)
|
||||
else:
|
||||
@@ -136,12 +136,12 @@ class NormalizeConfig:
|
||||
value = os.getenv(env_key)
|
||||
if value:
|
||||
try:
|
||||
if isinstance(default, int):
|
||||
if isinstance(default, bool):
|
||||
return value.strip().lower() in ("true", "1", "yes", "on")
|
||||
elif isinstance(default, int):
|
||||
return int(value)
|
||||
elif isinstance(default, float):
|
||||
return float(value)
|
||||
elif isinstance(default, bool):
|
||||
return value.lower() in ("true", "1", "yes", "on")
|
||||
return value
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
|
||||
@@ -99,7 +99,7 @@ class OntologyConfig:
|
||||
if value:
|
||||
try:
|
||||
if type_func == bool:
|
||||
self._configs[config_key] = value.lower() in (
|
||||
self._configs[config_key] = value.strip().lower() in (
|
||||
"true",
|
||||
"1",
|
||||
"yes",
|
||||
@@ -114,8 +114,8 @@ class OntologyConfig:
|
||||
for key, value in os.environ.items():
|
||||
if key.startswith(env_prefix) and key not in env_mappings:
|
||||
config_key = key[len(env_prefix) :].lower()
|
||||
if value.lower() in ("true", "false"):
|
||||
self._configs[config_key] = value.lower() == "true"
|
||||
if value.strip().lower() in ("true", "false"):
|
||||
self._configs[config_key] = value.strip().lower() == "true"
|
||||
elif value.isdigit():
|
||||
self._configs[config_key] = int(value)
|
||||
else:
|
||||
@@ -137,12 +137,12 @@ class OntologyConfig:
|
||||
value = os.getenv(env_key)
|
||||
if value:
|
||||
try:
|
||||
if isinstance(default, int):
|
||||
if isinstance(default, bool):
|
||||
return value.strip().lower() in ("true", "1", "yes", "on")
|
||||
elif isinstance(default, int):
|
||||
return int(value)
|
||||
elif isinstance(default, float):
|
||||
return float(value)
|
||||
elif isinstance(default, bool):
|
||||
return value.lower() in ("true", "1", "yes", "on")
|
||||
return value
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
|
||||
@@ -309,13 +309,15 @@ class OntologyGenerator:
|
||||
concepts[entity_type] = {"instances": [], "relationships": []}
|
||||
concepts[entity_type]["instances"].append(entity)
|
||||
|
||||
entity_aliases = self._build_entity_aliases(entities)
|
||||
|
||||
# Extract relationships
|
||||
normalized_relationships = []
|
||||
for rel_item in relationships:
|
||||
# Normalize relationship to dictionary
|
||||
rel = None
|
||||
if isinstance(rel_item, dict):
|
||||
rel = rel_item
|
||||
rel = dict(rel_item)
|
||||
elif hasattr(rel_item, "subject") and hasattr(rel_item, "predicate") and hasattr(rel_item, "object"):
|
||||
# Handle Relation object (subject, predicate, object)
|
||||
rel = {
|
||||
@@ -356,26 +358,12 @@ class OntologyGenerator:
|
||||
if not rel:
|
||||
continue
|
||||
|
||||
rel_type = rel.get("type") or rel.get("relationship_type", "relatedTo")
|
||||
source_type = rel.get("source_type")
|
||||
target_type = rel.get("target_type")
|
||||
|
||||
# Try to resolve source/target types if not provided
|
||||
if not source_type or source_type == "Entity":
|
||||
# Look up source in entities list to find its type
|
||||
source_name = rel.get("source")
|
||||
for ent in entities:
|
||||
if ent.get("name") == source_name or ent.get("text") == source_name:
|
||||
source_type = ent.get("type") or ent.get("entity_type")
|
||||
break
|
||||
|
||||
if not target_type or target_type == "Entity":
|
||||
# Look up target in entities list to find its type
|
||||
target_name = rel.get("target")
|
||||
for ent in entities:
|
||||
if ent.get("name") == target_name or ent.get("text") == target_name:
|
||||
target_type = ent.get("type") or ent.get("entity_type")
|
||||
break
|
||||
source_type = self._resolve_relationship_endpoint_type(
|
||||
rel, "source", entity_aliases
|
||||
)
|
||||
target_type = self._resolve_relationship_endpoint_type(
|
||||
rel, "target", entity_aliases
|
||||
)
|
||||
|
||||
# Update rel with resolved types
|
||||
rel["source_type"] = source_type
|
||||
@@ -392,6 +380,60 @@ class OntologyGenerator:
|
||||
"relationships": normalized_relationships,
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def _build_entity_aliases(entities: List[Dict[str, Any]]) -> Dict[str, set]:
|
||||
"""Build an unambiguous alias-to-type index for relationship endpoints."""
|
||||
aliases: Dict[str, set] = {}
|
||||
for entity in entities:
|
||||
entity_type = entity.get("type") or entity.get("entity_type")
|
||||
if not entity_type:
|
||||
continue
|
||||
|
||||
for key in ("id", "entity_id", "name", "text", "label"):
|
||||
if key not in entity or entity[key] is None or entity[key] == "":
|
||||
continue
|
||||
aliases.setdefault(str(entity[key]), set()).add(entity_type)
|
||||
|
||||
return aliases
|
||||
|
||||
@staticmethod
|
||||
def _get_relationship_endpoint(rel: Dict[str, Any], endpoint: str) -> Any:
|
||||
"""Return an endpoint value from either ID or legacy relationship fields."""
|
||||
for key in (f"{endpoint}_id", endpoint):
|
||||
if key not in rel:
|
||||
continue
|
||||
|
||||
value = rel[key]
|
||||
if value is None or value == "":
|
||||
continue
|
||||
if isinstance(value, dict):
|
||||
for alias_key in ("id", "entity_id", "name", "text", "label"):
|
||||
if alias_key not in value:
|
||||
continue
|
||||
alias_value = value[alias_key]
|
||||
if alias_value is not None and alias_value != "":
|
||||
return alias_value
|
||||
continue
|
||||
return value
|
||||
|
||||
return None
|
||||
|
||||
def _resolve_relationship_endpoint_type(
|
||||
self, rel: Dict[str, Any], endpoint: str, aliases: Dict[str, set]
|
||||
) -> Optional[str]:
|
||||
"""Resolve an endpoint type without treating missing fields as aliases."""
|
||||
explicit_type = rel.get(f"{endpoint}_type")
|
||||
if explicit_type and explicit_type != "Entity":
|
||||
return explicit_type
|
||||
|
||||
endpoint_value = self._get_relationship_endpoint(rel, endpoint)
|
||||
if endpoint_value is not None:
|
||||
candidates = aliases.get(str(endpoint_value), set())
|
||||
if len(candidates) == 1:
|
||||
return next(iter(candidates))
|
||||
|
||||
return explicit_type
|
||||
|
||||
def _stage2_yaml_to_definition(
|
||||
self, semantic_network: Dict[str, Any], **options
|
||||
) -> Dict[str, Any]:
|
||||
@@ -840,7 +882,16 @@ class SHACLGenerator:
|
||||
"""
|
||||
self.logger = get_logger("ontology_shacl")
|
||||
self.progress_tracker = get_progress_tracker()
|
||||
self.base_uri = base_uri.rstrip("/") + "/"
|
||||
# Preserve an RDF namespace that already ends in `#` (the common
|
||||
# convention for vocabularies): `...manufacturing#` must not become
|
||||
# `...manufacturing#/`, or every generated URI lands in the wrong
|
||||
# namespace and SHACL validation silently targets nothing. Matches
|
||||
# the `#`-aware normalization in `generate()`. Slash-terminated
|
||||
# bases are collapsed to a single trailing `/` so redundant runs
|
||||
# (`.../ns////`) cannot leak a different namespace into emitted IRIs.
|
||||
self.base_uri = (
|
||||
base_uri if base_uri.endswith("#") else base_uri.rstrip("/") + "/"
|
||||
)
|
||||
self.shapes_uri = shapes_uri or (self.base_uri + "shapes")
|
||||
self.include_inherited = include_inherited
|
||||
self.severity = severity
|
||||
|
||||
@@ -117,6 +117,8 @@ class PropertyGenerator:
|
||||
data_properties = self._infer_data_properties(entities, classes, **options)
|
||||
properties.extend(data_properties)
|
||||
|
||||
properties = self._coalesce_normalized_properties(properties)
|
||||
|
||||
self.progress_tracker.stop_tracking(
|
||||
tracking_id,
|
||||
status="completed",
|
||||
@@ -193,27 +195,87 @@ class PropertyGenerator:
|
||||
|
||||
return properties
|
||||
|
||||
def _coalesce_normalized_properties(
|
||||
self, properties: List[Dict[str, Any]]
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""Merge same-kind properties that normalize to the same name."""
|
||||
property_kinds = defaultdict(set)
|
||||
for prop in properties:
|
||||
property_kinds[prop["name"]].add(prop.get("type"))
|
||||
|
||||
collisions = {
|
||||
name: sorted(kind for kind in kinds if kind is not None)
|
||||
for name, kinds in property_kinds.items()
|
||||
if len({kind for kind in kinds if kind is not None}) > 1
|
||||
}
|
||||
if collisions:
|
||||
raise ValidationError(
|
||||
"Normalized property names cannot be shared by object and "
|
||||
"data properties.",
|
||||
validation_context={"property_kind_collisions": collisions},
|
||||
)
|
||||
|
||||
merged: Dict[tuple, Dict[str, Any]] = {}
|
||||
result = []
|
||||
for prop in properties:
|
||||
key = (prop.get("type"), prop["name"])
|
||||
existing = merged.get(key)
|
||||
if existing is None:
|
||||
merged[key] = prop
|
||||
result.append(prop)
|
||||
continue
|
||||
|
||||
existing["domain"] = self._merge_property_values(
|
||||
existing.get("domain", []), prop.get("domain", [])
|
||||
)
|
||||
if prop.get("type") == "object":
|
||||
existing["range"] = self._merge_property_values(
|
||||
existing.get("range", []), prop.get("range", [])
|
||||
)
|
||||
existing_metadata = existing.setdefault("metadata", {})
|
||||
existing_metadata["occurrence_count"] = (
|
||||
existing_metadata.get("occurrence_count", 0)
|
||||
+ prop.get("metadata", {}).get("occurrence_count", 0)
|
||||
)
|
||||
elif existing.get("range") != prop.get("range"):
|
||||
existing["range"] = self._get_more_general_type(
|
||||
existing["range"], prop["range"]
|
||||
)
|
||||
|
||||
return result
|
||||
|
||||
@staticmethod
|
||||
def _merge_property_values(current: Any, incoming: Any) -> List[Any]:
|
||||
"""Merge scalar-or-list property values while preserving input order."""
|
||||
values = list(current) if isinstance(current, list) else [current]
|
||||
incoming_values = (
|
||||
incoming if isinstance(incoming, list) else [incoming]
|
||||
)
|
||||
for value in incoming_values:
|
||||
if value not in values:
|
||||
values.append(value)
|
||||
return [value for value in values if value is not None]
|
||||
|
||||
def _infer_data_properties(
|
||||
self, entities: List[Dict[str, Any]], classes: List[Dict[str, Any]], **options
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""Infer data properties from entity attributes."""
|
||||
# Group entities by type
|
||||
entity_types = defaultdict(list)
|
||||
# Group entities by their inferred class so normalized class names remain
|
||||
# aligned with the class definitions emitted by ClassInferrer.
|
||||
class_entities = defaultdict(list)
|
||||
class_lookup = self._build_class_type_lookup(classes)
|
||||
for entity in entities:
|
||||
entity_type = entity.get("type") or entity.get("entity_type", "Entity")
|
||||
entity_types[entity_type].append(entity)
|
||||
class_def = self._find_class_for_entity_type(entity_type, class_lookup)
|
||||
if not class_def:
|
||||
continue
|
||||
class_name = class_def["name"]
|
||||
class_entities[class_name].append(entity)
|
||||
|
||||
# Extract data properties for each class
|
||||
properties = []
|
||||
|
||||
for entity_type, type_entities in entity_types.items():
|
||||
# Find corresponding class
|
||||
class_def = next(
|
||||
(cls for cls in classes if cls["name"] == entity_type), None
|
||||
)
|
||||
if not class_def:
|
||||
continue
|
||||
|
||||
for class_name, type_entities in class_entities.items():
|
||||
# Extract data properties
|
||||
data_props = self._extract_data_properties(type_entities)
|
||||
|
||||
@@ -233,7 +295,7 @@ class PropertyGenerator:
|
||||
else None,
|
||||
"label": normalized_name,
|
||||
"comment": f"Data property for {prop_name}",
|
||||
"domain": [entity_type],
|
||||
"domain": [class_name],
|
||||
"range": prop_type,
|
||||
"metadata": {"inferred_from": prop_name},
|
||||
}
|
||||
@@ -242,6 +304,38 @@ class PropertyGenerator:
|
||||
|
||||
return properties
|
||||
|
||||
def _build_class_type_lookup(
|
||||
self, classes: List[Dict[str, Any]]
|
||||
) -> Dict[str, Dict[str, Any]]:
|
||||
"""Build a lookup for raw, normalized, and recorded source type names."""
|
||||
lookup: Dict[str, Dict[str, Any]] = {}
|
||||
for class_def in classes:
|
||||
class_name = class_def.get("name")
|
||||
if class_name:
|
||||
lookup.setdefault(str(class_name), class_def)
|
||||
lookup.setdefault(
|
||||
self.naming_conventions.normalize_class_name(str(class_name)),
|
||||
class_def,
|
||||
)
|
||||
|
||||
inferred_from = class_def.get("metadata", {}).get("inferred_from")
|
||||
if inferred_from is not None:
|
||||
lookup.setdefault(str(inferred_from), class_def)
|
||||
lookup.setdefault(
|
||||
self.naming_conventions.normalize_class_name(str(inferred_from)),
|
||||
class_def,
|
||||
)
|
||||
|
||||
return lookup
|
||||
|
||||
def _find_class_for_entity_type(
|
||||
self, entity_type: Any, class_lookup: Dict[str, Dict[str, Any]]
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
"""Find a class using the precomputed type lookup."""
|
||||
raw_type = str(entity_type)
|
||||
normalized_type = self.naming_conventions.normalize_class_name(raw_type)
|
||||
return class_lookup.get(raw_type) or class_lookup.get(normalized_type)
|
||||
|
||||
def _extract_data_properties(
|
||||
self, entities: List[Dict[str, Any]]
|
||||
) -> Dict[str, str]:
|
||||
|
||||
@@ -70,7 +70,8 @@ sem:metadata a owl:AnnotationProperty ;
|
||||
rdfs:label "metadata" ;
|
||||
rdfs:comment """Free-form metadata carried through from extraction. An
|
||||
annotation property because its value is an arbitrary structure rather than a
|
||||
modelled one.""" ;
|
||||
modelled one; in the JSON-LD export the whole mapping is written as one
|
||||
rdf:JSON literal so caller keys never expand into this namespace (#1146).""" ;
|
||||
rdfs:isDefinedBy <https://semantica.dev/ns> .
|
||||
|
||||
# ── Relationship terms (JSON-LD export) ──────────────────────────────────────
|
||||
@@ -96,10 +97,10 @@ sem:target a owl:ObjectProperty ;
|
||||
|
||||
sem:type a owl:DatatypeProperty ;
|
||||
rdfs:label "type" ;
|
||||
rdfs:comment """The relationship type as a label, as emitted in the JSON-LD
|
||||
export. Distinct from rdf:type, which relates a node to a class rather than to
|
||||
a string.""" ;
|
||||
rdfs:domain sem:Relationship ;
|
||||
rdfs:comment """The entity or relationship type as a label, as emitted in
|
||||
the JSON-LD export. Distinct from rdf:type, which relates a node to a class
|
||||
rather than to a string. Emitted for both entities and relationships, so the
|
||||
domain is left open rather than tied to sem:Relationship.""" ;
|
||||
rdfs:range xsd:string ;
|
||||
rdfs:isDefinedBy <https://semantica.dev/ns> .
|
||||
|
||||
@@ -135,6 +136,90 @@ JSONExporter.export_to_jsonld in export/json_exporter.py.""" ;
|
||||
rdfs:range xsd:string ;
|
||||
rdfs:isDefinedBy <https://semantica.dev/ns> .
|
||||
|
||||
# ── Metadata carried through from the graph builder ──────────────────────────
|
||||
#
|
||||
# The keys GraphBuilder and the Neo4j loader write into "metadata". Declared
|
||||
# here because the RDF serializers emit them (#1154); a caller-supplied key is
|
||||
# not declared here and is not emitted, because which namespace it belongs in
|
||||
# is #1146.
|
||||
|
||||
sem:numEntities a owl:DatatypeProperty ;
|
||||
rdfs:label "number of entities" ;
|
||||
rdfs:comment """Count of entities in the graph as built, from
|
||||
GraphBuilder.build_graph. A count of what was built, not a constraint on what
|
||||
the graph contains: an export filtered after the fact will disagree with it.""" ;
|
||||
rdfs:range xsd:integer ;
|
||||
rdfs:isDefinedBy <https://semantica.dev/ns> .
|
||||
|
||||
sem:numRelationships a owl:DatatypeProperty ;
|
||||
rdfs:label "number of relationships" ;
|
||||
rdfs:comment "Count of relationships in the graph as built." ;
|
||||
rdfs:range xsd:integer ;
|
||||
rdfs:isDefinedBy <https://semantica.dev/ns> .
|
||||
|
||||
sem:temporalEnabled a owl:DatatypeProperty ;
|
||||
rdfs:label "temporal enabled" ;
|
||||
rdfs:comment """True when the builder was configured to track valid time.
|
||||
False does not mean the graph is untimed; it means no temporal bounds were
|
||||
recorded for it.""" ;
|
||||
rdfs:range xsd:boolean ;
|
||||
rdfs:isDefinedBy <https://semantica.dev/ns> .
|
||||
|
||||
sem:entityResolutionApplied a owl:DatatypeProperty ;
|
||||
rdfs:label "entity resolution applied" ;
|
||||
rdfs:comment """True when a resolver ran over the extracted entities, so a
|
||||
consumer knows whether two nodes with the same surface text were ever
|
||||
considered for merging.""" ;
|
||||
rdfs:range xsd:boolean ;
|
||||
rdfs:isDefinedBy <https://semantica.dev/ns> .
|
||||
|
||||
sem:builtAt a owl:DatatypeProperty ;
|
||||
rdfs:label "built at" ;
|
||||
rdfs:comment """When the graph was built, as GraphBuilder recorded it.
|
||||
|
||||
The range is xsd:string, deliberately, and not xsd:dateTime. GraphBuilder
|
||||
stamps with a timezone-naive datetime.now(), and #1114 is the demonstration of
|
||||
what typing such a value as xsd:dateTime costs: a timezone-qualified SPARQL
|
||||
filter over it raises an indeterminate comparison and silently drops the row.
|
||||
#1121 swept the export and provenance modules to an explicit UTC offset and
|
||||
deliberately left kg/ alone, because the context and vector-store modules
|
||||
compare against naive values already on disk. Until that sweep reaches
|
||||
GraphBuilder this value is a string that looks like a timestamp, and saying so
|
||||
is more useful than a type that invites arithmetic it cannot support.""" ;
|
||||
rdfs:range xsd:string ;
|
||||
rdfs:isDefinedBy <https://semantica.dev/ns> .
|
||||
|
||||
sem:snapshotAt a owl:DatatypeProperty ;
|
||||
rdfs:label "snapshot at" ;
|
||||
rdfs:comment """The point in time a snapshot represents, from
|
||||
GraphBuilder.create_snapshot. A string for the same reason as sem:builtAt.""" ;
|
||||
rdfs:range xsd:string ;
|
||||
rdfs:isDefinedBy <https://semantica.dev/ns> .
|
||||
|
||||
sem:sourceSystem a owl:DatatypeProperty ;
|
||||
rdfs:label "source system" ;
|
||||
rdfs:comment """The system a graph was loaded from, currently the literal
|
||||
"neo4j" written by GraphBuilder.load_from_neo4j.
|
||||
|
||||
Named sourceSystem rather than source because sem:source is already the
|
||||
ObjectProperty carrying the subject of a reified relationship. The metadata key
|
||||
is still "source"; the exporter maps the key to this term.""" ;
|
||||
rdfs:range xsd:string ;
|
||||
rdfs:isDefinedBy <https://semantica.dev/ns> .
|
||||
|
||||
sem:sourceUri a owl:ObjectProperty ;
|
||||
rdfs:label "source URI" ;
|
||||
rdfs:comment """The address of the system a graph was loaded from. The one
|
||||
metadata term whose value is a node rather than a literal, because it names a
|
||||
thing rather than describing one.""" ;
|
||||
rdfs:isDefinedBy <https://semantica.dev/ns> .
|
||||
|
||||
sem:sourceDatabase a owl:DatatypeProperty ;
|
||||
rdfs:label "source database" ;
|
||||
rdfs:comment "The database within the source system a graph was loaded from." ;
|
||||
rdfs:range xsd:string ;
|
||||
rdfs:isDefinedBy <https://semantica.dev/ns> .
|
||||
|
||||
# ── Temporal term (OWL-Time export) ──────────────────────────────────────────
|
||||
|
||||
sem:openEndedInterval a owl:DatatypeProperty ;
|
||||
|
||||
@@ -98,7 +98,7 @@ class ParseConfig:
|
||||
if value:
|
||||
try:
|
||||
if type_func == bool:
|
||||
self._configs[config_key] = value.lower() in (
|
||||
self._configs[config_key] = value.strip().lower() in (
|
||||
"true",
|
||||
"1",
|
||||
"yes",
|
||||
@@ -113,8 +113,8 @@ class ParseConfig:
|
||||
for key, value in os.environ.items():
|
||||
if key.startswith(env_prefix) and key not in env_mappings:
|
||||
config_key = key[len(env_prefix) :].lower()
|
||||
if value.lower() in ("true", "false"):
|
||||
self._configs[config_key] = value.lower() == "true"
|
||||
if value.strip().lower() in ("true", "false"):
|
||||
self._configs[config_key] = value.strip().lower() == "true"
|
||||
elif value.isdigit():
|
||||
self._configs[config_key] = int(value)
|
||||
else:
|
||||
@@ -136,12 +136,12 @@ class ParseConfig:
|
||||
value = os.getenv(env_key)
|
||||
if value:
|
||||
try:
|
||||
if isinstance(default, int):
|
||||
if isinstance(default, bool):
|
||||
return value.strip().lower() in ("true", "1", "yes", "on")
|
||||
elif isinstance(default, int):
|
||||
return int(value)
|
||||
elif isinstance(default, float):
|
||||
return float(value)
|
||||
elif isinstance(default, bool):
|
||||
return value.lower() in ("true", "1", "yes", "on")
|
||||
return value
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
|
||||
@@ -33,6 +33,7 @@ License: MIT
|
||||
"""
|
||||
|
||||
import email
|
||||
import email.message
|
||||
from dataclasses import dataclass, field
|
||||
from email import message_from_bytes, message_from_string
|
||||
from email.header import decode_header
|
||||
|
||||
@@ -143,15 +143,39 @@ class PDFParser:
|
||||
)
|
||||
pages.append(page_data)
|
||||
|
||||
full_text = "\n\n".join(page.text for page in pages)
|
||||
|
||||
# Scanned/image-only PDFs have no text layer; warn so the
|
||||
# failure surfaces at parse time instead of downstream.
|
||||
# Check any() over page texts to avoid a temporary stripped
|
||||
# copy of the full concatenation for large documents.
|
||||
no_text = (
|
||||
options.get("extract_text", True)
|
||||
and pages
|
||||
and not any(page.text.strip() for page in pages)
|
||||
)
|
||||
if no_text:
|
||||
self.logger.warning(
|
||||
f"PDF {file_path.name}: parsed {len(pages)} page(s) but "
|
||||
f"extracted no text. This is likely a scanned "
|
||||
f"(image-only) PDF. Retry with "
|
||||
f"parse_pdf(..., method='docling', enable_ocr=True) "
|
||||
f"for OCR-based extraction."
|
||||
)
|
||||
|
||||
self.progress_tracker.stop_tracking(
|
||||
tracking_id,
|
||||
status="completed",
|
||||
message=f"Parsed {len(pages)} pages",
|
||||
message=(
|
||||
f"Parsed {len(pages)} page(s) (no text layer detected)"
|
||||
if no_text
|
||||
else f"Parsed {len(pages)} page(s)"
|
||||
),
|
||||
)
|
||||
return {
|
||||
"metadata": metadata.__dict__,
|
||||
"pages": [page.__dict__ for page in pages],
|
||||
"full_text": "\n\n".join(page.text for page in pages),
|
||||
"full_text": full_text,
|
||||
"total_pages": len(pdf.pages),
|
||||
}
|
||||
|
||||
|
||||
@@ -32,8 +32,10 @@ Author: Semantica Contributors
|
||||
License: MIT
|
||||
"""
|
||||
|
||||
import copy
|
||||
import threading
|
||||
import time
|
||||
from concurrent.futures import ThreadPoolExecutor, as_completed
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime
|
||||
from enum import Enum
|
||||
@@ -59,6 +61,14 @@ class PipelineStatus(Enum):
|
||||
STOPPED = "stopped"
|
||||
|
||||
|
||||
class _ParallelResultContractError(ProcessingError):
|
||||
"""Raised when a parallel step violates the dict-result contract.
|
||||
|
||||
Contract violations are deterministic: re-running the handler cannot
|
||||
change its return type, so the retry loop must be skipped entirely.
|
||||
"""
|
||||
|
||||
|
||||
@dataclass
|
||||
class ExecutionResult:
|
||||
"""Pipeline execution result."""
|
||||
@@ -242,11 +252,67 @@ class ExecutionEngine:
|
||||
return ExecutionResult(success=False, output=None, errors=[str(e)])
|
||||
|
||||
def _execute_steps(self, pipeline: Pipeline, data: Any, **options) -> Any:
|
||||
"""Execute pipeline steps."""
|
||||
# Sort steps by dependencies (topological sort)
|
||||
sorted_steps = self._topological_sort(pipeline.steps)
|
||||
"""
|
||||
Execute pipeline steps.
|
||||
|
||||
Steps are grouped into dependency layers. When a pipeline is
|
||||
configured with parallelism > 1, a layer whose steps are all marked
|
||||
``parallel_safe`` and whose input is a dict is executed concurrently
|
||||
(bounded by the effective parallelism); every other layer runs
|
||||
sequentially, preserving the default serial behaviour.
|
||||
"""
|
||||
effective_parallelism = self._get_effective_parallelism(pipeline)
|
||||
|
||||
if effective_parallelism <= 1:
|
||||
return self._execute_steps_sequential(pipeline, data, **options)
|
||||
|
||||
layers = self._group_steps_by_dependency_level(pipeline.steps)
|
||||
|
||||
current_data = data
|
||||
for layer in layers:
|
||||
if self.pipeline_status.get(pipeline.name) == PipelineStatus.STOPPED:
|
||||
break
|
||||
|
||||
# Wait if paused
|
||||
while self.pipeline_status.get(pipeline.name) == PipelineStatus.PAUSED:
|
||||
time.sleep(0.1)
|
||||
|
||||
if self._can_run_layer_in_parallel(layer, current_data):
|
||||
merged = self._execute_parallel_group(
|
||||
layer,
|
||||
current_data,
|
||||
effective_parallelism,
|
||||
pipeline_name=pipeline.name,
|
||||
**options,
|
||||
)
|
||||
if merged is None:
|
||||
# Input isolation failed before any handler started;
|
||||
# run this layer sequentially instead.
|
||||
current_data = self._execute_steps_sequential(
|
||||
pipeline, current_data, steps=layer, **options
|
||||
)
|
||||
else:
|
||||
current_data = merged
|
||||
else:
|
||||
current_data = self._execute_steps_sequential(
|
||||
pipeline, current_data, steps=layer, **options
|
||||
)
|
||||
|
||||
return current_data
|
||||
|
||||
def _execute_steps_sequential(
|
||||
self,
|
||||
pipeline: Pipeline,
|
||||
data: Any,
|
||||
steps: Optional[List[PipelineStep]] = None,
|
||||
**options,
|
||||
) -> Any:
|
||||
"""Execute steps sequentially following dependency order."""
|
||||
if steps is None:
|
||||
sorted_steps = self._topological_sort(pipeline.steps)
|
||||
else:
|
||||
sorted_steps = list(steps)
|
||||
|
||||
# Execute steps
|
||||
current_data = data
|
||||
total_steps = len(sorted_steps)
|
||||
|
||||
@@ -258,76 +324,320 @@ class ExecutionEngine:
|
||||
while self.pipeline_status.get(pipeline.name) == PipelineStatus.PAUSED:
|
||||
time.sleep(0.1)
|
||||
|
||||
# Track step execution
|
||||
step_tracking_id = self.progress_tracker.start_tracking(
|
||||
module="pipeline",
|
||||
submodule=step.step_type or step.name,
|
||||
message=f"Step {step_idx + 1}/{total_steps}: {step.name}",
|
||||
current_data = self._execute_step_with_retries(
|
||||
step,
|
||||
current_data,
|
||||
step_label=f"Step {step_idx + 1}/{total_steps}: {step.name}",
|
||||
pipeline_name=pipeline.name,
|
||||
**options,
|
||||
)
|
||||
|
||||
try:
|
||||
# Execute step
|
||||
step.status = StepStatus.RUNNING
|
||||
step_result = self._execute_step(step, current_data, **options)
|
||||
step.status = StepStatus.COMPLETED
|
||||
step.result = step_result
|
||||
current_data = step_result
|
||||
return current_data
|
||||
|
||||
def _execute_step_with_retries(
|
||||
self,
|
||||
step: PipelineStep,
|
||||
data: Any,
|
||||
step_label: Optional[str] = None,
|
||||
pipeline_name: Optional[str] = None,
|
||||
require_dict_result: bool = False,
|
||||
**options,
|
||||
) -> Any:
|
||||
"""
|
||||
Execute a single step with retry handling.
|
||||
|
||||
Shared by the sequential and parallel execution paths so that retry
|
||||
policies, step status tracking and progress reporting behave
|
||||
identically. Returns the step result, or raises the final error
|
||||
after retries are exhausted.
|
||||
|
||||
When ``require_dict_result`` is set (parallel layers), a handler
|
||||
returning a non-dict raises ProcessingError before the step is
|
||||
marked completed, so status and progress reporting stay consistent.
|
||||
Such contract violations are never retried: the handler's return
|
||||
type cannot change between attempts.
|
||||
"""
|
||||
step_tracking_id = self.progress_tracker.start_tracking(
|
||||
module="pipeline",
|
||||
# Pipeline identity + step name keep tracking IDs unique so
|
||||
# concurrent steps of the same step_type cannot overwrite each
|
||||
# other's progress records.
|
||||
submodule=(
|
||||
f"{pipeline_name or 'pipeline'}:"
|
||||
f"{step.step_type or 'step'}:{step.name}"
|
||||
),
|
||||
message=step_label or f"Executing step: {step.name}",
|
||||
)
|
||||
|
||||
try:
|
||||
step.status = StepStatus.RUNNING
|
||||
step_result = self._execute_step(step, data, **options)
|
||||
if require_dict_result and not isinstance(step_result, dict):
|
||||
raise _ParallelResultContractError(
|
||||
f"Step '{step.name}' is marked parallel_safe and must "
|
||||
f"return a dict so parallel results can be merged, got "
|
||||
f"{type(step_result).__name__}"
|
||||
)
|
||||
step.status = StepStatus.COMPLETED
|
||||
step.result = step_result
|
||||
|
||||
self.progress_tracker.stop_tracking(
|
||||
step_tracking_id,
|
||||
status="completed",
|
||||
message=f"Completed step: {step.name}",
|
||||
)
|
||||
return step_result
|
||||
|
||||
except Exception as e:
|
||||
step.status = StepStatus.FAILED
|
||||
step.error = e
|
||||
|
||||
# Contract violations are deterministic failures: re-running
|
||||
# the handler cannot change its return type, so never consult
|
||||
# the retry policy for them.
|
||||
if isinstance(e, _ParallelResultContractError):
|
||||
self.progress_tracker.stop_tracking(
|
||||
step_tracking_id, status="failed", message=str(e)
|
||||
)
|
||||
raise
|
||||
|
||||
# Retry loop respecting max_retries from the policy
|
||||
retry_policy = self.failure_handler.get_retry_policy(step.step_type)
|
||||
max_retries = retry_policy.max_retries if retry_policy else 0
|
||||
retry_count = 0
|
||||
success = False
|
||||
|
||||
while retry_count < max_retries:
|
||||
recovery_result = self.failure_handler.handle_step_failure(step, e)
|
||||
if not recovery_result.get("retry", False):
|
||||
break
|
||||
retry_delay = recovery_result.get("retry_delay", 0.0)
|
||||
if retry_delay > 0:
|
||||
time.sleep(retry_delay)
|
||||
self.progress_tracker.update_tracking(
|
||||
step_tracking_id,
|
||||
status="running",
|
||||
message=f"Retrying step: {step.name} (attempt {retry_count + 1})",
|
||||
)
|
||||
step.status = StepStatus.RUNNING
|
||||
try:
|
||||
step_result = self._execute_step(step, data, **options)
|
||||
if require_dict_result and not isinstance(
|
||||
step_result, dict
|
||||
):
|
||||
raise _ParallelResultContractError(
|
||||
f"Step '{step.name}' is marked parallel_safe "
|
||||
f"and must return a dict so parallel results "
|
||||
f"can be merged, got "
|
||||
f"{type(step_result).__name__}"
|
||||
)
|
||||
step.status = StepStatus.COMPLETED
|
||||
step.result = step_result
|
||||
success = True
|
||||
break
|
||||
except Exception as retry_e:
|
||||
step.status = StepStatus.FAILED
|
||||
step.error = retry_e
|
||||
e = retry_e
|
||||
retry_count += 1
|
||||
|
||||
if success:
|
||||
self.progress_tracker.stop_tracking(
|
||||
step_tracking_id,
|
||||
status="completed",
|
||||
message=f"Completed step: {step.name}",
|
||||
message=f"Retry successful: {step.name}",
|
||||
)
|
||||
return step_result
|
||||
else:
|
||||
self.progress_tracker.stop_tracking(
|
||||
step_tracking_id, status="failed", message=str(e)
|
||||
)
|
||||
raise e
|
||||
|
||||
except Exception as e:
|
||||
step.status = StepStatus.FAILED
|
||||
step.error = e
|
||||
def _get_effective_parallelism(self, pipeline: Pipeline) -> int:
|
||||
"""Return the parallelism actually used for this pipeline."""
|
||||
configured = pipeline.config.get("parallelism", 1)
|
||||
if not isinstance(configured, int) or configured <= 0:
|
||||
return 1
|
||||
return min(configured, self.parallelism_manager.max_workers)
|
||||
|
||||
# Retry loop respecting max_retries from the policy
|
||||
retry_policy = self.failure_handler.get_retry_policy(step.step_type)
|
||||
max_retries = retry_policy.max_retries if retry_policy else 0
|
||||
retry_count = 0
|
||||
success = False
|
||||
def _group_steps_by_dependency_level(
|
||||
self, steps: List[PipelineStep]
|
||||
) -> List[List[PipelineStep]]:
|
||||
"""
|
||||
Group steps into dependency layers, preserving declaration order.
|
||||
|
||||
while retry_count < max_retries:
|
||||
recovery_result = self.failure_handler.handle_step_failure(step, e)
|
||||
if not recovery_result.get("retry", False):
|
||||
break
|
||||
retry_delay = recovery_result.get("retry_delay", 0.0)
|
||||
if retry_delay > 0:
|
||||
time.sleep(retry_delay)
|
||||
self.progress_tracker.update_tracking(
|
||||
step_tracking_id,
|
||||
status="running",
|
||||
message=f"Retrying step: {step.name} (attempt {retry_count + 1})",
|
||||
Circular or unknown dependencies raise ValidationError so the
|
||||
parallel path fails deterministically, matching the validation
|
||||
behaviour of the serial topological sort.
|
||||
"""
|
||||
step_map = {step.name: step for step in steps}
|
||||
levels: Dict[str, int] = {}
|
||||
visiting: set = set()
|
||||
|
||||
for step in steps:
|
||||
for dep in step.dependencies:
|
||||
if dep not in step_map:
|
||||
raise ValidationError(
|
||||
f"Step '{step.name}' depends on unknown step '{dep}'"
|
||||
)
|
||||
step.status = StepStatus.RUNNING
|
||||
try:
|
||||
step_result = self._execute_step(step, current_data, **options)
|
||||
step.status = StepStatus.COMPLETED
|
||||
step.result = step_result
|
||||
current_data = step_result
|
||||
success = True
|
||||
break
|
||||
except Exception as retry_e:
|
||||
step.status = StepStatus.FAILED
|
||||
step.error = retry_e
|
||||
e = retry_e
|
||||
retry_count += 1
|
||||
|
||||
if success:
|
||||
self.progress_tracker.stop_tracking(
|
||||
step_tracking_id,
|
||||
status="completed",
|
||||
message=f"Retry successful: {step.name}",
|
||||
)
|
||||
def get_level(step_name: str) -> int:
|
||||
if step_name in levels:
|
||||
return levels[step_name]
|
||||
if step_name in visiting:
|
||||
raise ValidationError(
|
||||
"Circular dependency detected in pipeline "
|
||||
f"(cycle passes through step '{step_name}')"
|
||||
)
|
||||
visiting.add(step_name)
|
||||
step = step_map[step_name]
|
||||
if not step.dependencies:
|
||||
level = 0
|
||||
else:
|
||||
level = max(get_level(dep) for dep in step.dependencies) + 1
|
||||
visiting.discard(step_name)
|
||||
levels[step_name] = level
|
||||
return level
|
||||
|
||||
for step in steps:
|
||||
get_level(step.name)
|
||||
|
||||
grouped: Dict[int, List[PipelineStep]] = {}
|
||||
for step in steps:
|
||||
grouped.setdefault(levels[step.name], []).append(step)
|
||||
return [grouped[level] for level in sorted(grouped)]
|
||||
|
||||
def _can_run_layer_in_parallel(self, layer: List[PipelineStep], data: Any) -> bool:
|
||||
"""Check whether a dependency layer can safely run in parallel."""
|
||||
if len(layer) <= 1:
|
||||
return False
|
||||
if not isinstance(data, dict):
|
||||
return False
|
||||
for step in layer:
|
||||
# Strict boolean check: truthy non-bool values (e.g. the
|
||||
# string "false") must never opt a step into concurrency.
|
||||
if getattr(step, "parallel_safe", False) is not True:
|
||||
return False
|
||||
if getattr(step, "delta_mode", False):
|
||||
return False
|
||||
return True
|
||||
|
||||
def _execute_parallel_group(
|
||||
self,
|
||||
layer: List[PipelineStep],
|
||||
data: Any,
|
||||
effective_parallelism: int,
|
||||
pipeline_name: Optional[str] = None,
|
||||
**options,
|
||||
) -> Optional[Any]:
|
||||
"""
|
||||
Execute a dependency layer concurrently.
|
||||
|
||||
Per-step inputs are deep-copied before any handler starts so that
|
||||
parallel steps do not share mutable state. Returns the merged dict
|
||||
result, or None when input isolation failed (before any handler
|
||||
ran) and the layer should fall back to sequential execution.
|
||||
"""
|
||||
# Isolate per-step inputs before starting any handler
|
||||
try:
|
||||
step_inputs = {step.name: copy.deepcopy(data) for step in layer}
|
||||
except Exception as e:
|
||||
self.logger.warning(
|
||||
f"Falling back to sequential execution: input for parallel "
|
||||
f"layer could not be isolated ({e})"
|
||||
)
|
||||
return None
|
||||
|
||||
step_results: Dict[str, Any] = {}
|
||||
failure: Optional[BaseException] = None
|
||||
max_workers = min(effective_parallelism, len(layer))
|
||||
|
||||
with ThreadPoolExecutor(max_workers=max_workers) as executor:
|
||||
futures = {
|
||||
executor.submit(
|
||||
self._execute_step_with_retries,
|
||||
step,
|
||||
step_inputs[step.name],
|
||||
# Validate the dict-result contract inside the shared
|
||||
# lifecycle path, before completion is reported.
|
||||
pipeline_name=pipeline_name,
|
||||
require_dict_result=True,
|
||||
**options,
|
||||
): step
|
||||
for step in layer
|
||||
}
|
||||
|
||||
for future in as_completed(futures):
|
||||
step = futures[future]
|
||||
try:
|
||||
step_result = future.result()
|
||||
except Exception as e:
|
||||
if failure is None:
|
||||
failure = e
|
||||
# Cancel steps that have not started yet
|
||||
for pending in futures:
|
||||
pending.cancel()
|
||||
else:
|
||||
self.progress_tracker.stop_tracking(
|
||||
step_tracking_id, status="failed", message=str(e)
|
||||
step_results[step.name] = step_result
|
||||
|
||||
if failure is not None:
|
||||
raise failure
|
||||
|
||||
return self._merge_parallel_results(data, layer, step_results)
|
||||
|
||||
def _merge_parallel_results(
|
||||
self,
|
||||
base: Dict[str, Any],
|
||||
layer: List[PipelineStep],
|
||||
step_results: Dict[str, Any],
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Merge the results of a parallel layer into a single dict.
|
||||
|
||||
Steps are processed in declaration order (never by completion
|
||||
order). Keys whose values are unchanged from the shared base input
|
||||
are skipped (handlers commonly return complete dicts such as
|
||||
``{**data, ...}``); only added or changed keys count as branch
|
||||
writes. Keys written with equal values by multiple steps are
|
||||
allowed; conflicting values for the same key raise a
|
||||
ProcessingError naming the key and both steps. Ambiguous equality
|
||||
comparisons count as changed, never as unchanged.
|
||||
"""
|
||||
merged = dict(base)
|
||||
key_sources: Dict[str, str] = {}
|
||||
|
||||
for step in layer:
|
||||
step_result = step_results.get(step.name)
|
||||
if step_result is None:
|
||||
continue
|
||||
for key, value in step_result.items():
|
||||
if key in base and self._values_equal(base[key], value):
|
||||
# Echo of the shared input: not a branch write, so it
|
||||
# cannot conflict with a sibling that changes the key.
|
||||
continue
|
||||
if key in key_sources and not self._values_equal(
|
||||
merged.get(key), value
|
||||
):
|
||||
raise ProcessingError(
|
||||
f"Conflicting values for key '{key}' in parallel step "
|
||||
f"results: step '{step.name}' produced {value!r}, but "
|
||||
f"step '{key_sources[key]}' previously produced "
|
||||
f"{merged.get(key)!r}"
|
||||
)
|
||||
raise e
|
||||
key_sources[key] = step.name
|
||||
merged[key] = value
|
||||
|
||||
return merged
|
||||
|
||||
@staticmethod
|
||||
def _values_equal(left: Any, right: Any) -> bool:
|
||||
"""Safely compare two values; ambiguous comparisons count as conflicts."""
|
||||
try:
|
||||
return bool(left == right)
|
||||
except (TypeError, ValueError):
|
||||
return False
|
||||
|
||||
return current_data
|
||||
|
||||
def _execute_step(self, step: PipelineStep, data: Any, **options) -> Any:
|
||||
"""
|
||||
@@ -379,7 +689,7 @@ class ExecutionEngine:
|
||||
|
||||
data = delta_result
|
||||
|
||||
if step.handler:
|
||||
if step.handler is not None:
|
||||
return step.handler(data, **step.config, **options)
|
||||
else:
|
||||
return data
|
||||
|
||||
@@ -67,6 +67,7 @@ class PipelineStep:
|
||||
delta_mode: bool = False
|
||||
base_version_id: Optional[str] = None
|
||||
target_version_id: Optional[str] = None
|
||||
parallel_safe: bool = False
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -131,16 +132,27 @@ class PipelineBuilder:
|
||||
delta_mode = config.pop("delta_mode", False)
|
||||
base_version_id = config.pop("base_version_id", None)
|
||||
target_version_id = config.pop("target_version_id", None)
|
||||
dependencies = config.pop("dependencies", [])
|
||||
handler = config.pop("handler", None)
|
||||
parallel_safe = config.pop("parallel_safe", False)
|
||||
if not isinstance(parallel_safe, bool):
|
||||
raise ValidationError(
|
||||
f"parallel_safe must be a boolean, got "
|
||||
f"{type(parallel_safe).__name__} for step '{step_name}'"
|
||||
)
|
||||
if handler is None:
|
||||
handler = self.step_registry.get(step_type)
|
||||
|
||||
step = PipelineStep(
|
||||
name=step_name,
|
||||
step_type=step_type,
|
||||
config=config,
|
||||
dependencies=config.get("dependencies", []),
|
||||
handler=config.get("handler"),
|
||||
dependencies=dependencies,
|
||||
handler=handler,
|
||||
delta_mode = delta_mode,
|
||||
base_version_id=base_version_id,
|
||||
target_version_id=target_version_id,
|
||||
parallel_safe=parallel_safe,
|
||||
)
|
||||
|
||||
self.steps.append(step)
|
||||
@@ -182,6 +194,14 @@ class PipelineBuilder:
|
||||
Returns:
|
||||
Self for method chaining
|
||||
"""
|
||||
if (
|
||||
isinstance(level, bool)
|
||||
or not isinstance(level, int)
|
||||
or level <= 0
|
||||
):
|
||||
raise ValidationError(
|
||||
f"Parallelism level must be a positive integer, got {level!r}"
|
||||
)
|
||||
self.pipeline_config["parallelism"] = level
|
||||
return self
|
||||
|
||||
@@ -272,7 +292,29 @@ class PipelineBuilder:
|
||||
step_name = step_config.get("name")
|
||||
step_type = step_config.get("type")
|
||||
if step_name and step_type:
|
||||
self.add_step(step_name, step_type, **step_config.get("config", {}))
|
||||
step = self.add_step(
|
||||
step_name, step_type, **step_config.get("config", {})
|
||||
)
|
||||
step.dependencies = list(
|
||||
step_config.get("dependencies", step.dependencies)
|
||||
)
|
||||
step.delta_mode = step_config.get("delta_mode", step.delta_mode)
|
||||
step.base_version_id = step_config.get(
|
||||
"base_version_id", step.base_version_id
|
||||
)
|
||||
step.target_version_id = step_config.get(
|
||||
"target_version_id", step.target_version_id
|
||||
)
|
||||
raw_parallel_safe = step_config.get(
|
||||
"parallel_safe", step.parallel_safe
|
||||
)
|
||||
if not isinstance(raw_parallel_safe, bool):
|
||||
raise ValidationError(
|
||||
"parallel_safe must be a boolean for step "
|
||||
f"'{step_name}', got "
|
||||
f"{type(raw_parallel_safe).__name__}"
|
||||
)
|
||||
step.parallel_safe = raw_parallel_safe
|
||||
|
||||
# Set parallelism if specified
|
||||
if "parallelism" in pipeline_config:
|
||||
@@ -328,6 +370,7 @@ class PipelineBuilder:
|
||||
"type": step.step_type,
|
||||
"config": step.config,
|
||||
"dependencies": step.dependencies,
|
||||
"parallel_safe": step.parallel_safe,
|
||||
}
|
||||
for step in self.steps
|
||||
],
|
||||
@@ -398,18 +441,35 @@ class PipelineSerializer:
|
||||
|
||||
Returns:
|
||||
Serialized pipeline
|
||||
|
||||
Notes:
|
||||
Step handlers are runtime callables and are intentionally omitted from
|
||||
the serialized representation. They must be rebound after deserialization.
|
||||
"""
|
||||
reserved_config_keys = {
|
||||
"handler",
|
||||
"dependencies",
|
||||
"delta_mode",
|
||||
"base_version_id",
|
||||
"target_version_id",
|
||||
"parallel_safe",
|
||||
}
|
||||
pipeline_data = {
|
||||
"name": pipeline.name,
|
||||
"steps": [
|
||||
{
|
||||
"name": step.name,
|
||||
"type": step.step_type,
|
||||
"config": step.config,
|
||||
"config": {
|
||||
key: value
|
||||
for key, value in step.config.items()
|
||||
if key not in reserved_config_keys
|
||||
},
|
||||
"dependencies": step.dependencies,
|
||||
"delta_mode": getattr(step, "delta_mode", False),
|
||||
"base_version_id": getattr(step, "base_version_id", None),
|
||||
"target_version_id": getattr(step, "target_version_id", None),
|
||||
"parallel_safe": getattr(step, "parallel_safe", False),
|
||||
}
|
||||
for step in pipeline.steps
|
||||
],
|
||||
@@ -445,6 +505,24 @@ class PipelineSerializer:
|
||||
else:
|
||||
pipeline_data = serialized_pipeline
|
||||
|
||||
# Runtime handlers are process-local and cannot be reconstructed safely
|
||||
# from serialized data. Copy before sanitizing so dict inputs are not mutated.
|
||||
pipeline_data = dict(pipeline_data)
|
||||
sanitized_steps = []
|
||||
for step_data in pipeline_data.get("steps", []):
|
||||
sanitized_step = dict(step_data)
|
||||
step_config = dict(sanitized_step.get("config", {}))
|
||||
step_config.pop("handler", None)
|
||||
sanitized_step["config"] = step_config
|
||||
sanitized_steps.append(sanitized_step)
|
||||
pipeline_data["steps"] = sanitized_steps
|
||||
|
||||
# Reapply pipeline-level config (e.g. parallelism) at the top level
|
||||
# so build_pipeline picks it up
|
||||
serialized_config = pipeline_data.pop("config", None) or {}
|
||||
for key, value in serialized_config.items():
|
||||
pipeline_data.setdefault(key, value)
|
||||
|
||||
# Reconstruct pipeline
|
||||
builder = PipelineBuilder(**self.config)
|
||||
pipeline = builder.build_pipeline(pipeline_data, **options)
|
||||
|
||||
@@ -8,6 +8,13 @@ and native Datalog evaluation.
|
||||
"""
|
||||
|
||||
from .reasoner import Reasoner, InferenceResult, Rule, Fact, RuleType
|
||||
from .reasoner import (
|
||||
Action,
|
||||
AssertAction,
|
||||
RetractAction,
|
||||
CallAction,
|
||||
EmitEventAction,
|
||||
)
|
||||
from .graph_reasoner import GraphReasoner
|
||||
from .explanation_generator import (
|
||||
Explanation,
|
||||
@@ -37,6 +44,12 @@ __all__ = [
|
||||
"Rule",
|
||||
"Fact",
|
||||
"RuleType",
|
||||
# Rule-driven actions
|
||||
"Action",
|
||||
"AssertAction",
|
||||
"RetractAction",
|
||||
"CallAction",
|
||||
"EmitEventAction",
|
||||
# Rete engine
|
||||
"ReteEngine",
|
||||
"ReteNode",
|
||||
|
||||
+424
-19
@@ -7,13 +7,16 @@ supported by the Semantica framework. It serves as a facade for different reason
|
||||
|
||||
import re
|
||||
import uuid
|
||||
from collections.abc import Mapping, Sequence, Set as AbstractSet
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime, timezone
|
||||
from enum import Enum
|
||||
from typing import Any, Dict, List, Optional, Set, Tuple, Union, Callable
|
||||
from typing import Any, Callable, Dict, List, Optional, Set, Tuple, Union
|
||||
|
||||
from ..utils.logging import get_logger
|
||||
from ..utils.progress_tracker import get_progress_tracker
|
||||
|
||||
|
||||
class RuleType(Enum):
|
||||
"""Rule types."""
|
||||
IMPLICATION = "implication"
|
||||
@@ -21,6 +24,306 @@ class RuleType(Enum):
|
||||
CONSTRAINT = "constraint"
|
||||
TRANSFORMATION = "transformation"
|
||||
|
||||
|
||||
def _substitute_variables(template: str, bindings: Dict[str, str]) -> str:
|
||||
"""Substitute ``?var`` placeholders with their bound values, token-aware.
|
||||
|
||||
A naive ``str.replace(f"?{var}", value)`` corrupts placeholders that share
|
||||
a prefix -- e.g. binding ``?x`` would also rewrite the ``?x`` inside ``?xy``.
|
||||
We replace every ``?word`` token in a single regex pass so that only whole
|
||||
variable names are matched (``\\w+`` never partially matches a longer name),
|
||||
leaving unbound placeholders untouched.
|
||||
"""
|
||||
if not bindings:
|
||||
return template
|
||||
|
||||
def _replace(match: "re.Match") -> str:
|
||||
var_name = match.group(1)
|
||||
# Preserve unbound placeholders verbatim.
|
||||
return str(bindings[var_name]) if var_name in bindings else match.group(0)
|
||||
|
||||
return re.sub(r"\?(\w+)", _replace, template)
|
||||
|
||||
|
||||
def _canonicalize_activation_value(
|
||||
value: Any, active_containers: Optional[Dict[int, int]] = None
|
||||
) -> Tuple[Any, ...]:
|
||||
"""Convert nested activation data into a deterministic, hashable value."""
|
||||
if active_containers is None:
|
||||
active_containers = {}
|
||||
|
||||
value_type = (type(value).__module__, type(value).__qualname__)
|
||||
is_mapping = isinstance(value, Mapping)
|
||||
is_sequence = isinstance(value, Sequence) and not isinstance(
|
||||
value, (str, bytes, bytearray)
|
||||
)
|
||||
is_set = isinstance(value, AbstractSet) and not isinstance(
|
||||
value, (str, bytes, bytearray)
|
||||
)
|
||||
if not (is_mapping or is_sequence or is_set):
|
||||
return ("scalar", value_type, repr(value))
|
||||
|
||||
object_id = id(value)
|
||||
if object_id in active_containers:
|
||||
return ("reference", active_containers[object_id])
|
||||
active_containers[object_id] = len(active_containers)
|
||||
|
||||
try:
|
||||
if is_mapping:
|
||||
keyed_items = [
|
||||
(
|
||||
_canonicalize_activation_value(key, active_containers),
|
||||
item,
|
||||
)
|
||||
for key, item in value.items()
|
||||
]
|
||||
keyed_items.sort(key=lambda entry: repr(entry[0]))
|
||||
entries = tuple(
|
||||
(
|
||||
key,
|
||||
_canonicalize_activation_value(item, active_containers),
|
||||
)
|
||||
for key, item in keyed_items
|
||||
)
|
||||
return ("mapping", value_type, entries)
|
||||
if is_sequence:
|
||||
return (
|
||||
"sequence",
|
||||
value_type,
|
||||
tuple(
|
||||
_canonicalize_activation_value(item, active_containers)
|
||||
for item in value
|
||||
),
|
||||
)
|
||||
items = tuple(
|
||||
sorted(
|
||||
(
|
||||
_canonicalize_activation_value(item, active_containers)
|
||||
for item in value
|
||||
),
|
||||
key=repr,
|
||||
)
|
||||
)
|
||||
return ("set", value_type, items)
|
||||
finally:
|
||||
del active_containers[object_id]
|
||||
|
||||
|
||||
def _make_activation_key(
|
||||
rule_id: str, bindings: Dict[str, Any], fact_tokens: List[Any]
|
||||
) -> Tuple[Any, ...]:
|
||||
"""Return a stable identity for one concrete rule activation."""
|
||||
canonical_bindings = tuple(
|
||||
sorted(
|
||||
(str(name), _canonicalize_activation_value(value))
|
||||
for name, value in bindings.items()
|
||||
)
|
||||
)
|
||||
return (
|
||||
rule_id,
|
||||
canonical_bindings,
|
||||
tuple(
|
||||
sorted(
|
||||
(_canonicalize_activation_value(token) for token in fact_tokens),
|
||||
key=repr,
|
||||
)
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def _parse_fact(fact: str) -> Optional[Tuple[str, List[str]]]:
|
||||
"""Parse a ``Predicate(arg1, arg2, ...)`` fact string.
|
||||
|
||||
Returns ``(predicate, [args])`` or ``None`` when the fact is not in the
|
||||
canonical predicate form (e.g. a bare atom). Whitespace around args is
|
||||
stripped and empty arg lists are supported (``Foo()`` -> ``("Foo", [])``).
|
||||
"""
|
||||
match = re.match(r"^\s*([^()\s]+)\s*\((.*)\)\s*$", fact)
|
||||
if not match:
|
||||
return None
|
||||
predicate = match.group(1)
|
||||
inner = match.group(2).strip()
|
||||
if not inner:
|
||||
return predicate, []
|
||||
args = [arg.strip() for arg in inner.split(",")]
|
||||
return predicate, args
|
||||
|
||||
|
||||
def _write_fact_to_graph(graph: Any, fact: str, *, retract: bool = False) -> None:
|
||||
"""Persist (or remove) a fact against a knowledge-graph-like target.
|
||||
|
||||
Write-back follows an explicit, ordered protocol so that
|
||||
``AssertAction(write_back=True)`` never silently no-ops:
|
||||
|
||||
1. If the target exposes an explicit fact API (``add_fact`` / ``assert_fact``
|
||||
for asserts, ``remove_fact`` / ``retract_fact`` / ``discard_fact`` for
|
||||
retracts), that is used verbatim.
|
||||
2. Otherwise, if the target looks like the canonical
|
||||
:class:`~semantica.kg.knowledge_graph.KnowledgeGraph` (has ``entities``
|
||||
and ``relationships`` lists), the fact is translated into a node
|
||||
(single-arg predicate) or relationship (two-arg predicate) and
|
||||
added/removed accordingly.
|
||||
3. Any other target, or a fact that cannot be translated, raises
|
||||
:class:`ValueError` so the failure surfaces instead of being swallowed.
|
||||
"""
|
||||
if retract:
|
||||
for method_name in ("retract_fact", "remove_fact", "discard_fact"):
|
||||
method = getattr(graph, method_name, None)
|
||||
if callable(method):
|
||||
method(fact)
|
||||
return
|
||||
else:
|
||||
for method_name in ("add_fact", "assert_fact"):
|
||||
method = getattr(graph, method_name, None)
|
||||
if callable(method):
|
||||
method(fact)
|
||||
return
|
||||
|
||||
entities = getattr(graph, "entities", None)
|
||||
relationships = getattr(graph, "relationships", None)
|
||||
if isinstance(entities, list) and isinstance(relationships, list):
|
||||
parsed = _parse_fact(fact)
|
||||
if parsed is None:
|
||||
raise ValueError(
|
||||
f"Cannot translate fact {fact!r} into graph node/relationship: "
|
||||
"expected canonical Predicate(args) form."
|
||||
)
|
||||
predicate, args = parsed
|
||||
if len(args) == 1:
|
||||
node = {"id": args[0], "type": predicate}
|
||||
if retract:
|
||||
_remove_matching(
|
||||
entities,
|
||||
lambda e: e.get("id") == args[0] and e.get("type") == predicate,
|
||||
)
|
||||
elif node not in entities:
|
||||
entities.append(node)
|
||||
return
|
||||
if len(args) == 2:
|
||||
rel = {"source": args[0], "target": args[1], "type": predicate}
|
||||
if retract:
|
||||
_remove_matching(
|
||||
relationships,
|
||||
lambda r: r.get("source") == args[0]
|
||||
and r.get("target") == args[1]
|
||||
and r.get("type") == predicate,
|
||||
)
|
||||
elif rel not in relationships:
|
||||
relationships.append(rel)
|
||||
return
|
||||
raise ValueError(
|
||||
f"Cannot write fact {fact!r} to graph: only unary (node) and binary "
|
||||
"(relationship) predicates are supported by the default adapter."
|
||||
)
|
||||
|
||||
raise ValueError(
|
||||
f"knowledge_graph target {type(graph).__name__!r} does not expose a "
|
||||
"supported write-back API (add_fact/assert_fact or entities/relationships)."
|
||||
)
|
||||
|
||||
|
||||
def _remove_matching(items: List[Dict[str, Any]], predicate: Callable[[Dict[str, Any]], bool]) -> None:
|
||||
"""Remove in place every dict in ``items`` for which ``predicate`` is True."""
|
||||
items[:] = [item for item in items if not predicate(item)]
|
||||
|
||||
class Action:
|
||||
"""Base class for an action fired when a rule matches.
|
||||
|
||||
Actions turn the reasoner from a pure inference engine into a
|
||||
production-rule system: when a rule's conditions match, its actions run
|
||||
with the match's variable bindings, allowing side effects (asserting or
|
||||
retracting facts, calling external tools, emitting events) rather than
|
||||
only deriving a new fact.
|
||||
|
||||
Subclasses implement :meth:`execute`, which receives the substituted
|
||||
``bindings`` and the owning ``reasoner`` and returns an optional
|
||||
description of what happened (used for provenance / explanation).
|
||||
"""
|
||||
|
||||
def execute(self, bindings: Dict[str, str], reasoner: "Reasoner") -> Optional[str]:
|
||||
raise NotImplementedError
|
||||
|
||||
@staticmethod
|
||||
def _substitute(template: str, bindings: Dict[str, str]) -> str:
|
||||
return _substitute_variables(template, bindings)
|
||||
|
||||
|
||||
@dataclass
|
||||
class AssertAction(Action):
|
||||
"""Assert a new fact when the rule fires.
|
||||
|
||||
``fact`` may contain ``?var`` placeholders that are substituted with the
|
||||
match bindings. If ``write_back`` is set and the reasoner exposes a
|
||||
knowledge graph, the fact is also written there.
|
||||
"""
|
||||
|
||||
fact: str
|
||||
write_back: bool = False
|
||||
|
||||
def execute(self, bindings: Dict[str, str], reasoner: "Reasoner") -> Optional[str]:
|
||||
concrete = self._substitute(self.fact, bindings)
|
||||
reasoner.facts.add(concrete)
|
||||
if self.write_back and getattr(reasoner, "knowledge_graph", None) is not None:
|
||||
_write_fact_to_graph(reasoner.knowledge_graph, concrete)
|
||||
return f"assert {concrete}"
|
||||
|
||||
|
||||
@dataclass
|
||||
class RetractAction(Action):
|
||||
"""Retract a fact when the rule fires (basic truth maintenance).
|
||||
|
||||
If ``write_back`` is set and the reasoner exposes a knowledge graph, the
|
||||
fact is also removed there using the graph's delete semantics (mirroring
|
||||
:class:`AssertAction`'s write-back).
|
||||
"""
|
||||
|
||||
fact: str
|
||||
write_back: bool = False
|
||||
|
||||
def execute(self, bindings: Dict[str, str], reasoner: "Reasoner") -> Optional[str]:
|
||||
concrete = self._substitute(self.fact, bindings)
|
||||
reasoner.facts.discard(concrete)
|
||||
if self.write_back and getattr(reasoner, "knowledge_graph", None) is not None:
|
||||
_write_fact_to_graph(reasoner.knowledge_graph, concrete, retract=True)
|
||||
return f"retract {concrete}"
|
||||
|
||||
|
||||
@dataclass
|
||||
class CallAction(Action):
|
||||
"""Call an external function/tool when the rule fires.
|
||||
|
||||
Wraps an arbitrary callable, which is invoked as ``func(bindings,
|
||||
reasoner)``. This is the structured replacement for the previously
|
||||
unused ``Rule.handler`` callback.
|
||||
"""
|
||||
|
||||
func: Callable
|
||||
name: str = "call"
|
||||
|
||||
def execute(self, bindings: Dict[str, str], reasoner: "Reasoner") -> Optional[str]:
|
||||
self.func(bindings, reasoner)
|
||||
return f"call {self.name}"
|
||||
|
||||
|
||||
@dataclass
|
||||
class EmitEventAction(Action):
|
||||
"""Emit an event to the reasoner's registered event sink when fired.
|
||||
|
||||
The event name may contain ``?var`` placeholders. Events are delivered to
|
||||
any callable registered via :meth:`Reasoner.on_event`.
|
||||
"""
|
||||
|
||||
event: str
|
||||
payload: Dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
def execute(self, bindings: Dict[str, str], reasoner: "Reasoner") -> Optional[str]:
|
||||
concrete = self._substitute(self.event, bindings)
|
||||
sink = getattr(reasoner, "_event_sink", None)
|
||||
if callable(sink):
|
||||
sink(concrete, {**self.payload, "bindings": dict(bindings)})
|
||||
return f"emit {concrete}"
|
||||
|
||||
|
||||
@dataclass
|
||||
class Rule:
|
||||
"""Simplified rule definition."""
|
||||
@@ -32,6 +335,7 @@ class Rule:
|
||||
confidence: float = 1.0
|
||||
priority: int = 0
|
||||
handler: Optional[Callable] = None
|
||||
actions: List[Action] = field(default_factory=list)
|
||||
metadata: Dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
@dataclass
|
||||
@@ -79,7 +383,70 @@ class Reasoner:
|
||||
self.rules: List[Rule] = []
|
||||
self.facts: Set[str] = set()
|
||||
self.rule_counter = 0
|
||||
|
||||
self._fired_activations: Set[Tuple[Any, ...]] = set()
|
||||
|
||||
# Optional knowledge graph for AssertAction(write_back=True) targets.
|
||||
self.knowledge_graph = kwargs.get("knowledge_graph")
|
||||
# Optional event sink for EmitEventAction; register via on_event().
|
||||
self._event_sink: Optional[Callable] = None
|
||||
# When True, action-induced fact changes are recorded for provenance
|
||||
# via _record_action() -> self.action_log.
|
||||
self.provenance: bool = bool(kwargs.get("provenance", False))
|
||||
self.action_log: List[Dict[str, Any]] = []
|
||||
|
||||
def on_event(self, sink: Callable) -> None:
|
||||
"""Register a callable ``sink(event_name, payload)`` for EmitEventAction."""
|
||||
self._event_sink = sink
|
||||
|
||||
def _record_action(
|
||||
self, rule: "Rule", action: "Action", description: Optional[str], bindings: Dict[str, str]
|
||||
) -> None:
|
||||
"""Record a fired action for provenance / explanation when enabled.
|
||||
|
||||
Each entry is a structured dict carrying an ISO-8601 ``timestamp`` and a
|
||||
parsed ``operation``/``fact`` split (when the description follows the
|
||||
``"<op> <fact>"`` convention used by the built-in actions) so that
|
||||
downstream consumers such as :class:`ExplanationGenerator` and the
|
||||
provenance layer can reason about *what changed* without re-parsing the
|
||||
free-text description.
|
||||
"""
|
||||
if not self.provenance or description is None:
|
||||
return
|
||||
operation, _, subject = description.partition(" ")
|
||||
self.action_log.append(
|
||||
{
|
||||
"action_id": uuid.uuid4().hex[:8],
|
||||
"rule_id": rule.rule_id,
|
||||
"action": type(action).__name__,
|
||||
"operation": operation or None,
|
||||
"fact": subject or None,
|
||||
"description": description,
|
||||
"bindings": dict(bindings),
|
||||
"confidence": rule.confidence,
|
||||
"timestamp": datetime.now(timezone.utc).isoformat(),
|
||||
}
|
||||
)
|
||||
|
||||
def _fire_actions(self, rule: "Rule", bindings: Dict[str, str]) -> None:
|
||||
"""Run a fired rule's actions (and legacy handler) with match bindings.
|
||||
|
||||
Backward compatible: a rule with an old-style ``handler`` but no
|
||||
``actions`` still has its handler invoked, so pre-existing rules keep
|
||||
working while new rules use the structured Action layer.
|
||||
"""
|
||||
actions = list(rule.actions)
|
||||
if rule.handler is not None:
|
||||
actions.append(CallAction(rule.handler, name=f"handler:{rule.rule_id}"))
|
||||
for action in actions:
|
||||
try:
|
||||
description = action.execute(bindings, self)
|
||||
self._record_action(rule, action, description, bindings)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
self.logger.error(
|
||||
f"Error executing action {type(action).__name__} "
|
||||
f"for rule '{rule.rule_id}': {exc}"
|
||||
)
|
||||
|
||||
def add_rule(self, rule_def: Union[str, Rule]) -> Rule:
|
||||
"""Add a rule to the reasoner.
|
||||
|
||||
@@ -161,6 +528,20 @@ class Reasoner:
|
||||
Returns:
|
||||
List of inferred facts (conclusions)
|
||||
"""
|
||||
return [result.conclusion for result in self.infer_with_results(facts, rules)]
|
||||
|
||||
def infer_with_results(
|
||||
self,
|
||||
facts: Union[List[Any], Dict[str, Any]],
|
||||
rules: Optional[List[Union[str, Rule]]] = None,
|
||||
) -> List[InferenceResult]:
|
||||
"""Infer new facts and return the full :class:`InferenceResult` objects.
|
||||
|
||||
Unlike :meth:`infer_facts` (which returns only conclusion strings for
|
||||
backward compatibility), this preserves each result's ``rule_used``,
|
||||
``premises`` and ``confidence`` so callers such as the provenance
|
||||
wrapper can record real confidence values instead of ``None``.
|
||||
"""
|
||||
tracking_id = self.progress_tracker.start_tracking(
|
||||
module="reasoning",
|
||||
submodule="Reasoner",
|
||||
@@ -180,17 +561,14 @@ class Reasoner:
|
||||
|
||||
# Perform inference
|
||||
results = self.forward_chain()
|
||||
|
||||
# Extract conclusions from results
|
||||
inferred_facts = [result.conclusion for result in results]
|
||||
|
||||
self.progress_tracker.stop_tracking(
|
||||
tracking_id,
|
||||
status="completed",
|
||||
message=f"Inferred {len(inferred_facts)} new facts"
|
||||
message=f"Inferred {len(results)} new facts"
|
||||
)
|
||||
|
||||
return inferred_facts
|
||||
return results
|
||||
|
||||
except Exception as e:
|
||||
self.progress_tracker.stop_tracking(
|
||||
@@ -213,7 +591,15 @@ class Reasoner:
|
||||
new_facts_added = True
|
||||
max_iterations = self.config.get("max_iterations", 50)
|
||||
iteration = 0
|
||||
|
||||
# Activations (rule + concrete bindings + matched facts) whose actions
|
||||
# have already fired. Actions are side-effecting and must
|
||||
# fire exactly once per distinct match, decoupled from whether the
|
||||
# rule's *conclusion* is new. This fixes two failure modes:
|
||||
# * A valid binding whose conclusion is already known (or duplicated
|
||||
# within a pass) previously never fired its actions.
|
||||
# * A RetractAction that removes a premise of its own rule previously
|
||||
# re-fired every pass, iterating to max_iterations. Recording the
|
||||
# activation means it fires once and stops driving iterations.
|
||||
while new_facts_added and iteration < max_iterations:
|
||||
new_facts_added = False
|
||||
iteration += 1
|
||||
@@ -236,7 +622,21 @@ class Reasoner:
|
||||
pass_results: Dict[str, InferenceResult] = {}
|
||||
|
||||
for rule in self.rules:
|
||||
for conclusion, matched_facts in self._match_rule(rule):
|
||||
for conclusion, matched_facts, bindings in self._match_rule(rule):
|
||||
# Fire this activation's actions exactly once, independent
|
||||
# of the conclusion-dedup below. Keyed by rule id + the
|
||||
# concrete bindings so distinct matches each fire, but a
|
||||
# repeated match (same bindings across passes) does not.
|
||||
if rule.actions or rule.handler is not None:
|
||||
activation_key = _make_activation_key(
|
||||
rule.rule_id,
|
||||
bindings,
|
||||
matched_facts,
|
||||
)
|
||||
if activation_key not in self._fired_activations:
|
||||
self._fired_activations.add(activation_key)
|
||||
self._fire_actions(rule, bindings)
|
||||
|
||||
if conclusion in pass_results:
|
||||
# Another derivation of a conclusion already produced
|
||||
# earlier in this same pass: merge premises, dedup.
|
||||
@@ -372,14 +772,17 @@ class Reasoner:
|
||||
conclusion=conclusion_str.strip()
|
||||
)
|
||||
|
||||
def _match_rule(self, rule: Rule) -> List[Tuple[str, List[str]]]:
|
||||
def _match_rule(self, rule: Rule) -> List[Tuple[str, List[str], Dict[str, str]]]:
|
||||
"""
|
||||
Match rule conditions against facts and return instantiated conclusions
|
||||
paired with the facts that satisfied each condition.
|
||||
paired with the facts that satisfied each condition and the variable
|
||||
bindings that produced them.
|
||||
|
||||
Returns:
|
||||
List of (conclusion, matched_facts) tuples, where matched_facts is
|
||||
the ordered list of facts bound to this rule's conditions.
|
||||
List of (conclusion, matched_facts, bindings) tuples, where
|
||||
matched_facts is the ordered list of facts bound to this rule's
|
||||
conditions and bindings maps variable name -> matched value (used
|
||||
to fire the rule's actions).
|
||||
"""
|
||||
if not rule.conditions:
|
||||
return []
|
||||
@@ -410,7 +813,7 @@ class Reasoner:
|
||||
results = []
|
||||
for bindings, matched_facts in bindings_list:
|
||||
instantiated_conclusion = self._substitute(rule.conclusion, bindings)
|
||||
results.append((instantiated_conclusion, matched_facts))
|
||||
results.append((instantiated_conclusion, matched_facts, bindings))
|
||||
|
||||
return results
|
||||
|
||||
@@ -456,16 +859,18 @@ class Reasoner:
|
||||
|
||||
def _substitute(self, pattern: str, bindings: Dict[str, str]) -> str:
|
||||
"""Substitute variables in a pattern with bound values."""
|
||||
result = pattern
|
||||
for var, value in bindings.items():
|
||||
result = result.replace(f"?{var}", value)
|
||||
return result
|
||||
return _substitute_variables(pattern, bindings)
|
||||
|
||||
def reset_action_history(self) -> None:
|
||||
"""Allow previously fired rule activations to execute their actions again."""
|
||||
self._fired_activations.clear()
|
||||
|
||||
def clear(self) -> None:
|
||||
"""Clear facts and rules."""
|
||||
"""Clear facts, rules, and action activation history."""
|
||||
self.facts.clear()
|
||||
self.rules.clear()
|
||||
self.rule_counter = 0
|
||||
self.reset_action_history()
|
||||
|
||||
def reset(self) -> None:
|
||||
"""Alias for clear()."""
|
||||
|
||||
@@ -13,9 +13,9 @@ Author: Semantica Contributors
|
||||
License: MIT
|
||||
"""
|
||||
|
||||
from typing import Any, Optional
|
||||
from datetime import datetime
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
from typing import Any, Optional
|
||||
|
||||
|
||||
class ReasoningEngineWithProvenance:
|
||||
@@ -28,10 +28,10 @@ class ReasoningEngineWithProvenance:
|
||||
is_automated: bool = True,
|
||||
**config,
|
||||
):
|
||||
from .reasoning_engine import ReasoningEngine
|
||||
from .reasoner import Reasoner
|
||||
|
||||
self.provenance = provenance
|
||||
self._engine = ReasoningEngine(**config)
|
||||
self._engine = Reasoner(provenance=provenance, **config)
|
||||
self._prov_manager = None
|
||||
self._agent_id = agent_id or self.__class__.__name__
|
||||
self._is_automated = is_automated
|
||||
@@ -43,12 +43,27 @@ class ReasoningEngineWithProvenance:
|
||||
except ImportError:
|
||||
self.provenance = False
|
||||
|
||||
def infer(self, premises: Any, source: str = None, **kwargs):
|
||||
"""Perform inference with provenance tracking."""
|
||||
def infer(self, premises: Any, source: str = None, rules: Any = None):
|
||||
"""Perform inference with provenance tracking.
|
||||
|
||||
Only the reasoner's real parameters (``premises`` and ``rules``) are
|
||||
forwarded to the underlying engine; arbitrary keyword arguments are no
|
||||
longer passed through (they previously reached
|
||||
``Reasoner.infer_facts`` -- which accepts only ``facts``/``rules`` --
|
||||
and raised ``TypeError``).
|
||||
"""
|
||||
activity_started_at_time = datetime.utcnow().isoformat()
|
||||
result = self._engine.infer(premises, **kwargs)
|
||||
results = self._engine.infer_with_results(premises, rules)
|
||||
activity_ended_at_time = datetime.utcnow().isoformat()
|
||||
|
||||
# Aggregate confidence across the derived results (min = weakest link);
|
||||
# None only when nothing was inferred.
|
||||
confidence = (
|
||||
min(r.confidence for r in results) if results else None
|
||||
)
|
||||
# Preserve the historical return shape: a list of conclusion strings.
|
||||
inferred = [r.conclusion for r in results]
|
||||
|
||||
if self.provenance and self._prov_manager:
|
||||
self._prov_manager.track_entity(
|
||||
entity_id=f"inference_{uuid.uuid4().hex[:8]}",
|
||||
@@ -61,11 +76,11 @@ class ReasoningEngineWithProvenance:
|
||||
activity_ended_at_time=activity_ended_at_time,
|
||||
metadata={
|
||||
"premises_count": len(premises) if hasattr(premises, '__len__') else 1,
|
||||
"confidence": getattr(result, 'confidence', None)
|
||||
"confidence": confidence,
|
||||
}
|
||||
)
|
||||
|
||||
return result
|
||||
return inferred
|
||||
|
||||
def __getattr__(self, name):
|
||||
return getattr(self._engine, name)
|
||||
|
||||
@@ -33,14 +33,75 @@ Author: Semantica Contributors
|
||||
License: MIT
|
||||
"""
|
||||
|
||||
from collections import defaultdict
|
||||
import re
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any, Dict, List, Optional, Set, Tuple
|
||||
|
||||
from ..utils.exceptions import ProcessingError, ValidationError
|
||||
from ..utils.logging import get_logger
|
||||
from ..utils.progress_tracker import get_progress_tracker
|
||||
from .reasoner import Fact, Rule
|
||||
from .reasoner import Fact, Rule, _make_activation_key
|
||||
|
||||
|
||||
def _extract_bindings(condition: Any, fact: Fact) -> Dict[str, Any]:
|
||||
"""Extract ``?var`` bindings by matching a condition pattern against a fact.
|
||||
|
||||
``condition`` is the pattern stored on the alpha node (typically a string
|
||||
like ``"Person(?x)"``); ``fact`` is the working-memory :class:`Fact`. The
|
||||
fact's canonical string form (``Predicate(arg1, arg2, ...)``) is matched
|
||||
against the pattern using the same ``?\\w+`` placeholder convention as the
|
||||
Reasoner, so downstream actions receive real bindings (e.g. ``{"x": "John"}``)
|
||||
instead of the empty dict that previously left ``?x`` placeholders
|
||||
unsubstituted.
|
||||
|
||||
Returns an empty dict when the condition is not a string pattern or does
|
||||
not match -- callers treat that as "no bindings extracted".
|
||||
"""
|
||||
if not isinstance(condition, str):
|
||||
return {}
|
||||
|
||||
segments = re.split(r"(\?\w+)", condition)
|
||||
seen_vars: Set[str] = set()
|
||||
p_regex = ""
|
||||
for seg in segments:
|
||||
if seg.startswith("?"):
|
||||
var_name = seg[1:]
|
||||
if var_name in seen_vars:
|
||||
p_regex += f"(?P={var_name})"
|
||||
else:
|
||||
p_regex += f"(?P<{var_name}>.+?)"
|
||||
seen_vars.add(var_name)
|
||||
else:
|
||||
p_regex += re.escape(seg)
|
||||
p_regex = f"^{p_regex}$"
|
||||
|
||||
try:
|
||||
match = re.match(p_regex, str(fact))
|
||||
except re.error:
|
||||
return {}
|
||||
if not match:
|
||||
return {}
|
||||
return {k: v for k, v in match.groupdict().items() if v is not None}
|
||||
|
||||
|
||||
def _bindings_for_rule(rule: Rule, facts: List[Fact]) -> Dict[str, Any]:
|
||||
"""Merge ``?var`` bindings from matching a rule's conditions against facts.
|
||||
|
||||
Each fact is matched against every condition of the rule; the first
|
||||
condition that yields bindings for a fact contributes them. Bindings from
|
||||
all facts are merged so multi-condition (joined) rules receive the full
|
||||
variable environment. Later conflicting values do not overwrite earlier
|
||||
ones, preserving the binding that a join already validated.
|
||||
"""
|
||||
bindings: Dict[str, Any] = {}
|
||||
for fact in facts:
|
||||
for condition in rule.conditions:
|
||||
extracted = _extract_bindings(condition, fact)
|
||||
if not extracted:
|
||||
continue
|
||||
for key, value in extracted.items():
|
||||
bindings.setdefault(key, value)
|
||||
break
|
||||
return bindings
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -58,7 +119,7 @@ class ReteNode:
|
||||
|
||||
def __init__(self, node_id: str):
|
||||
self.node_id = node_id
|
||||
self.children: List["ReteNode"] = []
|
||||
self.children: List[ReteNode] = []
|
||||
|
||||
|
||||
class AlphaNode(ReteNode):
|
||||
@@ -151,6 +212,17 @@ class ReteEngine:
|
||||
self.facts: List[Fact] = []
|
||||
self.fact_counter = 0
|
||||
self.node_counter = 0
|
||||
self._executed_activations: Set[Tuple[Any, ...]] = set()
|
||||
# Optional Reasoner used to fire rule-driven actions on match. When
|
||||
# set, execute_matches() runs each matched rule's ``actions`` (and any
|
||||
# legacy ``handler``) through the Reasoner's action machinery so that
|
||||
# Rete-based matching benefits from the same production-rule behaviour
|
||||
# as forward_chain(). Left None keeps the pure-matching mode.
|
||||
self.reasoner: Optional[Any] = self.config.get("reasoner")
|
||||
|
||||
def bind_reasoner(self, reasoner: Any) -> None:
|
||||
"""Attach a Reasoner so matched rules can fire their actions."""
|
||||
self.reasoner = reasoner
|
||||
|
||||
def build_network(self, rules: List[Rule]) -> None:
|
||||
"""
|
||||
@@ -166,6 +238,7 @@ class ReteEngine:
|
||||
)
|
||||
|
||||
try:
|
||||
self.reset_action_history()
|
||||
self.network.clear()
|
||||
|
||||
self.progress_tracker.update_tracking(
|
||||
@@ -252,15 +325,24 @@ class ReteEngine:
|
||||
# Propagate to children
|
||||
for grandchild in child.children:
|
||||
if isinstance(grandchild, TerminalNode):
|
||||
facts = [left_fact, fact]
|
||||
match = Match(
|
||||
rule=grandchild.rule,
|
||||
facts=[left_fact, fact],
|
||||
facts=facts,
|
||||
bindings=_bindings_for_rule(
|
||||
grandchild.rule, facts
|
||||
),
|
||||
confidence=1.0,
|
||||
)
|
||||
grandchild.activate(match)
|
||||
elif isinstance(child, TerminalNode):
|
||||
# Direct activation
|
||||
match = Match(rule=child.rule, facts=[fact], confidence=1.0)
|
||||
match = Match(
|
||||
rule=child.rule,
|
||||
facts=[fact],
|
||||
bindings=_bindings_for_rule(child.rule, [fact]),
|
||||
confidence=1.0,
|
||||
)
|
||||
child.activate(match)
|
||||
|
||||
def match_patterns(self, facts: Optional[List[Fact]] = None) -> List[Match]:
|
||||
@@ -276,7 +358,7 @@ class ReteEngine:
|
||||
tracking_id = self.progress_tracker.start_tracking(
|
||||
module="reasoning",
|
||||
submodule="ReteEngine",
|
||||
message=f"Matching patterns using Rete algorithm",
|
||||
message="Matching patterns using Rete algorithm",
|
||||
)
|
||||
|
||||
try:
|
||||
@@ -339,10 +421,28 @@ class ReteEngine:
|
||||
)
|
||||
results = []
|
||||
for match in matches:
|
||||
# Conclusions are the pure inference result and remain
|
||||
# independent from optional side-effect execution below.
|
||||
results.append(match.rule.conclusion)
|
||||
try:
|
||||
# Execute rule
|
||||
result = match.rule.conclusion
|
||||
results.append(result)
|
||||
# Fire the rule's actions (and any legacy handler) through
|
||||
# the bound Reasoner so Rete matching produces the same
|
||||
# side effects / provenance as forward_chain(). Falls back
|
||||
# to just recording the conclusion when no Reasoner is bound.
|
||||
if self.reasoner is not None and (
|
||||
match.rule.actions or match.rule.handler is not None
|
||||
):
|
||||
activation_key = _make_activation_key(
|
||||
match.rule.rule_id,
|
||||
match.bindings,
|
||||
[
|
||||
(fact.fact_id, fact.predicate, fact.arguments)
|
||||
for fact in match.facts
|
||||
],
|
||||
)
|
||||
if activation_key not in self._executed_activations:
|
||||
self._executed_activations.add(activation_key)
|
||||
self.reasoner._fire_actions(match.rule, match.bindings)
|
||||
except Exception as e:
|
||||
self.logger.error(f"Error executing match: {e}")
|
||||
|
||||
@@ -359,13 +459,16 @@ class ReteEngine:
|
||||
)
|
||||
raise
|
||||
|
||||
def reset_action_history(self) -> None:
|
||||
"""Allow previously executed activations to fire their actions again."""
|
||||
self._executed_activations.clear()
|
||||
|
||||
def reset(self) -> None:
|
||||
"""Reset Rete engine."""
|
||||
"""Reset Rete working memory and action activation history."""
|
||||
self.facts.clear()
|
||||
self.reset_action_history()
|
||||
for node in self.network.values():
|
||||
if isinstance(node, AlphaNode):
|
||||
node.matches.clear()
|
||||
elif isinstance(node, BetaNode):
|
||||
if isinstance(node, AlphaNode) or isinstance(node, BetaNode):
|
||||
node.matches.clear()
|
||||
elif isinstance(node, TerminalNode):
|
||||
node.activations.clear()
|
||||
|
||||
@@ -482,8 +482,8 @@ class SeedDataManager:
|
||||
List of loaded data records as dictionaries
|
||||
|
||||
Raises:
|
||||
ProcessingError: If API request fails, response parsing fails, or
|
||||
requests library is not available
|
||||
ProcessingError: If the API request fails (connection error,
|
||||
timeout, non-2xx status) or the response cannot be parsed
|
||||
|
||||
Example:
|
||||
>>> records = manager.load_from_api(
|
||||
@@ -559,10 +559,6 @@ class SeedDataManager:
|
||||
self.logger.info(f"Loaded {len(records)} records from API: {full_url}")
|
||||
return records
|
||||
|
||||
except (ImportError, OSError):
|
||||
raise ProcessingError(
|
||||
"requests library not available. Install with: pip install requests"
|
||||
)
|
||||
except Exception as e:
|
||||
raise ProcessingError(f"Failed to load from API: {e}") from e
|
||||
|
||||
|
||||
@@ -140,6 +140,47 @@ _result_cache = ExtractionCache(
|
||||
if not config.get("cache_enabled", True):
|
||||
_result_cache.enabled = False
|
||||
|
||||
# Generation kwargs that affect provider output and must therefore be part of
|
||||
# the cache key. This is the union of every generation-affecting parameter
|
||||
# read across providers.py, including params picked up outside _add_if_set
|
||||
# (e.g. AnthropicProvider's manual pass-through loop). Sensitive values
|
||||
# (api_key, token, etc.) are already filtered out by
|
||||
# ExtractionCache._generate_key, so they need not be excluded here.
|
||||
_GENERATION_CACHE_KEYS = frozenset({
|
||||
"max_tokens",
|
||||
"max_completion_tokens",
|
||||
"temperature",
|
||||
"top_p",
|
||||
"top_k",
|
||||
"seed",
|
||||
"frequency_penalty",
|
||||
"presence_penalty",
|
||||
"stop",
|
||||
"stop_sequences", # Anthropic/Gemini spelling of "stop"
|
||||
"logit_bias",
|
||||
"user",
|
||||
"system", # Anthropic system prompt
|
||||
"metadata", # Anthropic request metadata
|
||||
"candidate_count", # Gemini
|
||||
"repeat_penalty", # Ollama
|
||||
"num_ctx", # Ollama
|
||||
"context_window", # Ollama alias for num_ctx
|
||||
})
|
||||
|
||||
|
||||
def _generation_cache_params(kwargs: dict) -> dict:
|
||||
"""Return the subset of *kwargs* that affects generation output.
|
||||
|
||||
Only keys listed in ``_GENERATION_CACHE_KEYS`` are included so that
|
||||
irrelevant or sensitive caller kwargs do not pollute the cache key.
|
||||
Values that are ``None`` are omitted; a caller passing
|
||||
``temperature=None`` is equivalent to not passing it at all.
|
||||
"""
|
||||
return {
|
||||
k: v for k, v in kwargs.items()
|
||||
if k in _GENERATION_CACHE_KEYS and v is not None
|
||||
}
|
||||
|
||||
# Try to import spaCy
|
||||
from ..utils.helpers import safe_import
|
||||
|
||||
@@ -957,6 +998,7 @@ def extract_entities_llm(
|
||||
"max_text_length": max_text_length,
|
||||
"structured_output_mode": structured_output_mode,
|
||||
"entity_types": kwargs.get("entity_types"),
|
||||
**_generation_cache_params(kwargs),
|
||||
}
|
||||
cached_result = _result_cache.get("entities", text, **cache_params)
|
||||
if cached_result is not None:
|
||||
@@ -1124,47 +1166,6 @@ Text to extract from:
|
||||
return []
|
||||
|
||||
|
||||
def _parse_entity_result(result: Any, provider: str, model: Optional[str]) -> List[Entity]:
|
||||
"""Helper to parse raw LLM result into Entity objects."""
|
||||
entities = []
|
||||
items = []
|
||||
|
||||
if isinstance(result, list):
|
||||
items = result
|
||||
elif isinstance(result, dict):
|
||||
# Handle cases where LLM wraps the list in a key
|
||||
for key in ["entities", "data", "results"]:
|
||||
if key in result and isinstance(result[key], list):
|
||||
items = result[key]
|
||||
break
|
||||
if not items and "text" in result: # Single object instead of list
|
||||
items = [result]
|
||||
|
||||
for item in items:
|
||||
if not isinstance(item, dict):
|
||||
continue
|
||||
|
||||
text = item.get("text", "")
|
||||
if not text:
|
||||
continue
|
||||
|
||||
entities.append(
|
||||
Entity(
|
||||
text=text,
|
||||
label=item.get("label", "UNKNOWN"),
|
||||
start_char=item.get("start", 0),
|
||||
end_char=item.get("end", 0),
|
||||
confidence=item.get("confidence", 0.9),
|
||||
metadata={
|
||||
"provider": provider,
|
||||
"model": model,
|
||||
"extraction_method": "llm",
|
||||
},
|
||||
)
|
||||
)
|
||||
return entities
|
||||
|
||||
|
||||
def _extract_entities_chunked(
|
||||
text: str,
|
||||
provider: str,
|
||||
@@ -1747,7 +1748,8 @@ def extract_relations_llm(
|
||||
"relation_types": kwargs.get("relation_types"),
|
||||
"extract_temporal_bounds": extract_temporal_bounds,
|
||||
# Include entities hash/str in cache key implicitly via **cache_params
|
||||
"entities_hash": hash(tuple(sorted([e.text for e in entities]))) if entities else 0
|
||||
"entities_hash": hash(tuple(sorted([e.text for e in entities]))) if entities else 0,
|
||||
**_generation_cache_params(kwargs),
|
||||
}
|
||||
cached_result = _result_cache.get("relations", text, **cache_params)
|
||||
if cached_result is not None:
|
||||
@@ -1947,13 +1949,10 @@ Entities found in text: {entities_str}"""
|
||||
"[methods.extract_relations_llm] Calling llm.generate_typed (%s/%s)...",
|
||||
provider, model,
|
||||
)
|
||||
# Only forward minimal, safe parameters to provider calls
|
||||
call_kwargs = {}
|
||||
if "temperature" in kwargs:
|
||||
call_kwargs["temperature"] = kwargs["temperature"]
|
||||
if "verbose" in kwargs:
|
||||
call_kwargs["verbose"] = kwargs["verbose"]
|
||||
|
||||
# Forward all caller-supplied generation kwargs so they reach
|
||||
# generate_typed and the underlying provider API. max_retries is
|
||||
# always set from the explicit parameter.
|
||||
call_kwargs = kwargs.copy()
|
||||
call_kwargs["max_retries"] = max_retries
|
||||
|
||||
# Select schema based on whether temporal extraction is requested
|
||||
@@ -2405,7 +2404,8 @@ def extract_triplets_llm(
|
||||
"triplet_types": kwargs.get("triplet_types"),
|
||||
# Include entities/relations hash in cache key implicitly via **cache_params
|
||||
"entities_hash": hash(tuple(sorted([e.text for e in entities]))) if entities else 0,
|
||||
"relations_hash": hash(tuple(sorted([str(r) for r in relations]))) if relations else 0
|
||||
"relations_hash": hash(tuple(sorted([str(r) for r in relations]))) if relations else 0,
|
||||
**_generation_cache_params(kwargs),
|
||||
}
|
||||
cached_result = _result_cache.get("triplets", text, **cache_params)
|
||||
if cached_result is not None:
|
||||
@@ -2559,48 +2559,6 @@ Text to extract from:
|
||||
return []
|
||||
|
||||
|
||||
def _parse_triplet_result(result: Any, provider: str, model: Optional[str]) -> List[Triplet]:
|
||||
"""Helper to parse raw LLM result into Triplet objects."""
|
||||
triplets = []
|
||||
items = []
|
||||
|
||||
if isinstance(result, list):
|
||||
items = result
|
||||
elif isinstance(result, dict):
|
||||
for key in ["triplets", "data", "results"]:
|
||||
if key in result and isinstance(result[key], list):
|
||||
items = result[key]
|
||||
break
|
||||
if not items and "subject" in result:
|
||||
items = [result]
|
||||
|
||||
for item in items:
|
||||
if not isinstance(item, dict):
|
||||
continue
|
||||
|
||||
subject = item.get("subject", "")
|
||||
predicate = item.get("predicate", "")
|
||||
obj = item.get("object", "")
|
||||
|
||||
if not subject or not predicate or not obj:
|
||||
continue
|
||||
|
||||
triplets.append(
|
||||
Triplet(
|
||||
subject=str(subject),
|
||||
predicate=str(predicate),
|
||||
object=str(obj),
|
||||
confidence=item.get("confidence", 0.9),
|
||||
metadata={
|
||||
"provider": provider,
|
||||
"model": model,
|
||||
"extraction_method": "llm",
|
||||
},
|
||||
)
|
||||
)
|
||||
return triplets
|
||||
|
||||
|
||||
def _extract_triplets_chunked(
|
||||
text: str,
|
||||
provider: str,
|
||||
|
||||
@@ -139,17 +139,19 @@ class NERExtractor:
|
||||
if not self.progress_tracker.enabled:
|
||||
self.progress_tracker.enabled = True
|
||||
|
||||
# Initialize spaCy model if ML method is used
|
||||
self.nlp = None
|
||||
# Validate the spaCy runtime up front if ML method is used. The model
|
||||
# itself is loaded lazily by extract_entities_ml() through the
|
||||
# process-level cache in methods.py; this instance only tracks whether
|
||||
# ML dispatch should be attempted at all.
|
||||
self._ml_runtime_usable = True
|
||||
if "ml" in self.method and SPACY_AVAILABLE:
|
||||
try:
|
||||
# Deferred import: keeps semantic_extract.methods out of the
|
||||
# module-level import graph and routes loading through the
|
||||
# module-level import graph and routes validation through the
|
||||
# process-level cache so repeated NERExtractor constructions
|
||||
# never pay the ~120 ms spacy.load() cost more than once.
|
||||
from .methods import load_spacy_model
|
||||
self.nlp = load_spacy_model(self.model_name)
|
||||
load_spacy_model(self.model_name)
|
||||
except OSError:
|
||||
self.logger.warning(
|
||||
f"spaCy model {self.model_name} not found. ML method will fallback."
|
||||
@@ -535,42 +537,6 @@ class NERExtractor:
|
||||
|
||||
return processed
|
||||
|
||||
def _extract_with_spacy(
|
||||
self, text: str, min_confidence: float, entity_types: Optional[List[str]]
|
||||
) -> List[Entity]:
|
||||
"""Extract entities using spaCy."""
|
||||
entities = []
|
||||
|
||||
doc = self.nlp(text)
|
||||
|
||||
for ent in doc.ents:
|
||||
# Filter by entity types if specified
|
||||
if entity_types and ent.label_ not in entity_types:
|
||||
continue
|
||||
|
||||
# Get confidence if available
|
||||
confidence = 1.0
|
||||
if hasattr(ent, "confidence"):
|
||||
confidence = ent.confidence
|
||||
elif hasattr(ent, "score"):
|
||||
confidence = ent.score
|
||||
|
||||
if confidence >= min_confidence:
|
||||
entities.append(
|
||||
Entity(
|
||||
text=ent.text,
|
||||
label=ent.label_,
|
||||
start_char=ent.start_char,
|
||||
end_char=ent.end_char,
|
||||
confidence=confidence,
|
||||
metadata={
|
||||
"lemma": ent.lemma_ if hasattr(ent, "lemma_") else ent.text
|
||||
},
|
||||
)
|
||||
)
|
||||
|
||||
return entities
|
||||
|
||||
def _extract_fallback(self, text: str) -> List[Entity]:
|
||||
"""Fallback entity extraction using simple patterns."""
|
||||
entities = []
|
||||
|
||||
@@ -123,12 +123,12 @@ class SplitConfig:
|
||||
if value:
|
||||
try:
|
||||
# Try to convert to appropriate type
|
||||
if isinstance(default, int):
|
||||
if isinstance(default, bool):
|
||||
return value.strip().lower() in ("true", "1", "yes", "on")
|
||||
elif isinstance(default, int):
|
||||
return int(value)
|
||||
elif isinstance(default, float):
|
||||
return float(value)
|
||||
elif isinstance(default, bool):
|
||||
return value.lower() in ("true", "1", "yes", "on")
|
||||
return value
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
|
||||
@@ -101,7 +101,6 @@ from .triplet_store import TripletStore
|
||||
# Global store registry
|
||||
_global_stores: Dict[str, TripletStore] = {}
|
||||
_default_store_id: Optional[str] = None
|
||||
_global_query_engine: Optional[QueryEngine] = None
|
||||
_global_bulk_loader: Optional[BulkLoader] = None
|
||||
|
||||
|
||||
@@ -131,25 +130,6 @@ def _get_store(store_id: Optional[str] = None) -> TripletStore:
|
||||
return _global_stores[target_id]
|
||||
|
||||
|
||||
def _get_query_engine() -> QueryEngine:
|
||||
"""Get or create global QueryEngine instance."""
|
||||
global _global_query_engine
|
||||
if _global_query_engine is None:
|
||||
# We need a store backend for the engine, but QueryEngine in this module
|
||||
# seems to be initialized with config in the old code.
|
||||
# In the new code, TripletStore has its own query_engine.
|
||||
# If we use this standalone function, we might need to rely on the store's engine.
|
||||
# But let's keep a standalone one if needed, or better, delegate to store.
|
||||
config = triplet_store_config.get_all()
|
||||
# QueryEngine now expects a backend, but we can initialize it without one
|
||||
# if we pass the backend at execution time?
|
||||
# Checking QueryEngine implementation... it takes `store_backend` in __init__.
|
||||
# So we can't easily have a global one without a store.
|
||||
# We'll rely on the store's engine.
|
||||
pass
|
||||
return None # Deprecated use of global engine
|
||||
|
||||
|
||||
def _get_bulk_loader() -> BulkLoader:
|
||||
"""Get or create global BulkLoader instance."""
|
||||
global _global_bulk_loader
|
||||
|
||||
@@ -42,6 +42,10 @@ class OxigraphStore:
|
||||
ProcessingError: If the store cannot be opened.
|
||||
"""
|
||||
self.logger = get_logger("oxigraph_store")
|
||||
# Accept storage_path as an alias for path (matches the convention used
|
||||
# by other Semantica stores). Pop it so it isn't left in self.config.
|
||||
if path is None and "storage_path" in config:
|
||||
path = config.pop("storage_path")
|
||||
self.config = config
|
||||
self.path = path if path is not None else config.get("path")
|
||||
|
||||
@@ -74,24 +78,65 @@ class OxigraphStore:
|
||||
) from exc
|
||||
|
||||
def add_triplet(self, triplet: Triplet, **options) -> Dict[str, Any]:
|
||||
"""Add one triplet to the default graph or ``options['graph']``."""
|
||||
return self.add_triplets([triplet], **options)
|
||||
"""Add one triplet to the default graph or ``options['graph']``.
|
||||
|
||||
def add_triplets(self, triplets: List[Triplet], **options) -> Dict[str, Any]:
|
||||
"""Add triplets in one native Oxigraph batch."""
|
||||
The write is committed to the store's in-memory state immediately.
|
||||
pyoxigraph's background threads will persist it to disk shortly
|
||||
afterward; call :meth:`flush` explicitly if you need a synchronous
|
||||
durability guarantee before reopening or crashing.
|
||||
"""
|
||||
try:
|
||||
graph_name = self._graph_name(options.get("graph"))
|
||||
quads = [self._to_quad(triplet, graph_name) for triplet in triplets]
|
||||
self.store.extend(quads)
|
||||
self.store.extend([self._to_quad(triplet, graph_name)])
|
||||
return {
|
||||
"success": True,
|
||||
"triplets_loaded": len(triplets),
|
||||
"triplets_loaded": 1,
|
||||
"graph": options.get("graph"),
|
||||
}
|
||||
except Exception as exc:
|
||||
self.logger.error(f"Oxigraph load failed: {exc}")
|
||||
raise ProcessingError(f"Oxigraph load failed: {exc}") from exc
|
||||
|
||||
def add_triplets(self, triplets: List[Triplet], **options) -> Dict[str, Any]:
|
||||
"""Add triplets in one native Oxigraph batch.
|
||||
|
||||
The batch is written transactionally and then explicitly flushed to
|
||||
disk before returning. This makes the full batch durable without
|
||||
requiring a separate :meth:`flush` call. In-memory stores skip the
|
||||
flush (there is nothing to sync).
|
||||
|
||||
For high-volume imports the :class:`~.bulk_loader.BulkLoader` splits
|
||||
work into chunks and calls this method once per chunk, so each chunk
|
||||
lands as one atomic, durable unit.
|
||||
"""
|
||||
try:
|
||||
graph_name = self._graph_name(options.get("graph"))
|
||||
quads = [self._to_quad(triplet, graph_name) for triplet in triplets]
|
||||
self.store.extend(quads)
|
||||
except Exception as exc:
|
||||
self.logger.error(f"Oxigraph load failed: {exc}")
|
||||
raise ProcessingError(f"Oxigraph load failed: {exc}") from exc
|
||||
|
||||
# Flush is kept outside the write try/except so that a flush I/O error
|
||||
# does not produce a misleading "load failed" message when extend()
|
||||
# already committed the batch successfully.
|
||||
if self.path is not None:
|
||||
try:
|
||||
self.flush()
|
||||
except OSError as exc:
|
||||
self.logger.warning(
|
||||
f"Oxigraph flush failed after successful write: {exc}"
|
||||
)
|
||||
raise ProcessingError(
|
||||
f"Oxigraph flush failed after successful write: {exc}"
|
||||
) from exc
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"triplets_loaded": len(triplets),
|
||||
"graph": options.get("graph"),
|
||||
}
|
||||
|
||||
def bulk_load(self, triplets: List[Triplet], **options) -> Dict[str, Any]:
|
||||
"""Load a batch of triplets using Oxigraph's native bulk operation."""
|
||||
return self.add_triplets(triplets, **options)
|
||||
|
||||
@@ -28,7 +28,7 @@ License: MIT
|
||||
|
||||
import re
|
||||
from typing import Any, Dict, List, Optional
|
||||
from urllib.parse import urlparse
|
||||
from urllib.parse import quote, urlparse
|
||||
|
||||
import requests
|
||||
from rdflib import Graph, Literal
|
||||
@@ -67,7 +67,8 @@ class RDF4JStore:
|
||||
self.progress_tracker.enabled = True
|
||||
|
||||
self.endpoint = endpoint.rstrip("/")
|
||||
self.repository_id = config.get("repository_id", "default")
|
||||
self.repository_id = repository_id or config.get("repository_id", "default")
|
||||
self._encoded_repository_id = quote(self.repository_id, safe="")
|
||||
self.username = config.get("username")
|
||||
self.password = config.get("password")
|
||||
self.timeout = config.get("timeout", 30)
|
||||
@@ -79,7 +80,7 @@ class RDF4JStore:
|
||||
"""Connect to RDF4J server."""
|
||||
try:
|
||||
# Test connection
|
||||
test_url = f"{self.endpoint}/repositories/{self.repository_id}"
|
||||
test_url = f"{self.endpoint}/repositories/{self._encoded_repository_id}"
|
||||
response = requests.get(
|
||||
test_url,
|
||||
timeout=self.timeout,
|
||||
@@ -100,11 +101,11 @@ class RDF4JStore:
|
||||
|
||||
def _get_sparql_endpoint(self) -> str:
|
||||
"""Get SPARQL query endpoint."""
|
||||
return f"{self.endpoint}/repositories/{self.repository_id}"
|
||||
return f"{self.endpoint}/repositories/{self._encoded_repository_id}"
|
||||
|
||||
def _get_update_endpoint(self) -> str:
|
||||
"""Get SPARQL Update endpoint."""
|
||||
return f"{self.endpoint}/repositories/{self.repository_id}/statements"
|
||||
return f"{self.endpoint}/repositories/{self._encoded_repository_id}/statements"
|
||||
|
||||
def _is_construct_query(self, query: str) -> bool:
|
||||
"""
|
||||
@@ -163,7 +164,7 @@ class RDF4JStore:
|
||||
"""
|
||||
# RDF4J transaction support
|
||||
transaction_url = (
|
||||
f"{self.endpoint}/repositories/{self.repository_id}/transactions"
|
||||
f"{self.endpoint}/repositories/{self._encoded_repository_id}/transactions"
|
||||
)
|
||||
|
||||
try:
|
||||
|
||||
@@ -64,6 +64,29 @@ def _progress_disabled_from_env() -> bool:
|
||||
"on",
|
||||
)
|
||||
|
||||
|
||||
def _progress_forced_from_env() -> bool:
|
||||
"""Return whether console progress is forced on despite a non-interactive stdout."""
|
||||
return os.getenv("SEMANTICA_FORCE_PROGRESS", "").strip().lower() in (
|
||||
"1",
|
||||
"true",
|
||||
"yes",
|
||||
"on",
|
||||
)
|
||||
|
||||
|
||||
def _stdout_is_tty() -> bool:
|
||||
"""Return whether stdout is an interactive terminal.
|
||||
|
||||
Replacement streams do not always implement ``isatty`` and closed streams can
|
||||
raise, so both cases are treated as non-interactive.
|
||||
"""
|
||||
try:
|
||||
return bool(sys.stdout is not None and sys.stdout.isatty())
|
||||
except (AttributeError, ValueError):
|
||||
return False
|
||||
|
||||
|
||||
# Try to import IPython for Jupyter support
|
||||
try:
|
||||
from IPython import get_ipython
|
||||
@@ -1040,18 +1063,24 @@ class ProgressTracker:
|
||||
# Create displays
|
||||
self.displays: List[ProgressDisplay] = []
|
||||
|
||||
# Console output only suits an interactive stdout. When output is piped or
|
||||
# redirected (scripts, CI logs) the progress bars and their escape
|
||||
# sequences would otherwise drown the program's own output.
|
||||
console_ok = _stdout_is_tty() or self.is_jupyter or _progress_forced_from_env()
|
||||
|
||||
# Always try Jupyter first if available, fallback to console
|
||||
if IPYTHON_AVAILABLE:
|
||||
# Try to detect Jupyter - if available, use it
|
||||
if self.is_jupyter and not self.disable_jupyter_progress:
|
||||
self.displays.append(JupyterProgressDisplay(use_emoji=use_emoji))
|
||||
# Also add console as fallback for immediate feedback
|
||||
self.displays.append(
|
||||
ConsoleProgressDisplay(
|
||||
use_emoji=use_emoji, update_interval=update_interval
|
||||
if console_ok:
|
||||
self.displays.append(
|
||||
ConsoleProgressDisplay(
|
||||
use_emoji=use_emoji, update_interval=update_interval
|
||||
)
|
||||
)
|
||||
)
|
||||
else:
|
||||
elif console_ok:
|
||||
self.displays.append(
|
||||
ConsoleProgressDisplay(
|
||||
use_emoji=use_emoji, update_interval=update_interval
|
||||
|
||||
+3
-3
@@ -5,11 +5,11 @@ This module provides the worker process for the Semantica framework,
|
||||
enabling distributed and background task processing.
|
||||
"""
|
||||
|
||||
import time
|
||||
import signal
|
||||
import sys
|
||||
from .utils.logging import get_logger, setup_logging
|
||||
import time
|
||||
|
||||
from .core.orchestrator import Semantica
|
||||
from .utils.logging import get_logger, setup_logging
|
||||
|
||||
# Initialize logging
|
||||
setup_logging()
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user