mirror of
https://github.com/semantica-agi/semantica.git
synced 2026-09-06 04:00:19 +00:00
Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fb4c0c4488 | ||
|
|
bca70335b5 | ||
|
|
f1ebe95d46 |
@@ -543,7 +543,7 @@ cuda-pathfinder==1.6.0 \
|
||||
# via
|
||||
# -c requirements-ci.txt
|
||||
# cuda-bindings
|
||||
cuda-toolkit==13.0.3 \
|
||||
cuda-toolkit==13.0.3.0 \
|
||||
--hash=sha256:d693caaa261214ddd7dbb60d68e71cbed884e68c2be7509778f3051da0b91c3f
|
||||
# via
|
||||
# -c requirements-ci.txt
|
||||
|
||||
@@ -403,7 +403,7 @@ cuda-pathfinder==1.6.0 \
|
||||
# via
|
||||
# -c requirements-ci.txt
|
||||
# cuda-bindings
|
||||
cuda-toolkit==13.0.3 \
|
||||
cuda-toolkit==13.0.3.0 \
|
||||
--hash=sha256:d693caaa261214ddd7dbb60d68e71cbed884e68c2be7509778f3051da0b91c3f
|
||||
# via
|
||||
# -c requirements-ci.txt
|
||||
|
||||
@@ -547,7 +547,7 @@ cuda-pathfinder==1.6.0 \
|
||||
# via
|
||||
# -c requirements-ci.txt
|
||||
# cuda-bindings
|
||||
cuda-toolkit==13.0.3 \
|
||||
cuda-toolkit==13.0.3.0 \
|
||||
--hash=sha256:d693caaa261214ddd7dbb60d68e71cbed884e68c2be7509778f3051da0b91c3f
|
||||
# via
|
||||
# -c requirements-ci.txt
|
||||
|
||||
@@ -600,7 +600,7 @@ cuda-pathfinder==1.6.0 \
|
||||
# via
|
||||
# -c requirements-ci.txt
|
||||
# cuda-bindings
|
||||
cuda-toolkit==13.0.3 \
|
||||
cuda-toolkit==13.0.3.0 \
|
||||
--hash=sha256:d693caaa261214ddd7dbb60d68e71cbed884e68c2be7509778f3051da0b91c3f
|
||||
# via
|
||||
# -c requirements-ci.txt
|
||||
|
||||
@@ -168,5 +168,3 @@ jobs:
|
||||
|
||||
print("Explorer frontend is packaged")
|
||||
PY
|
||||
- name: Run Google ADK Integration Tests
|
||||
run: pytest tests/integrations/google_adk/
|
||||
|
||||
+1
-113
@@ -9,8 +9,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [0.6.8] - 2026-09-05
|
||||
|
||||
### Added
|
||||
|
||||
- **Salesforce ingestor** (#1240) by @Sameer6305
|
||||
@@ -20,6 +18,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
- New `pip install semantica[db-salesforce]` extra (`simple-salesforce>=1.12.0`)
|
||||
- New `tests/test_salesforce_ingestor.py`
|
||||
- Docs: `docs/integrations/salesforce.md`
|
||||
|
||||
- **`ErasureCoordinator` completes the erasure workflow `purge_node()` only starts — the graph node was removed while the same content survived verbatim in `AgentMemory` and as an embedding** (closes #1018) by @pravit-amp
|
||||
- New `semantica/context/erasure.py`, exporting `ErasureCoordinator` and `ErasureReceipt` from `semantica.context`. `purge_node()`/`purge_edge()` (#957) are graph-scope by design and their changelog entry documents this gap explicitly; the changelog also names GDPR Article 17 as the motivation, and an Article 17 erasure that removes the node while the content stays retrievable by similarity search is not an erasure — it is worse than not offering one, because `purge_node()` returns `True` and writes a tombstone attesting the content is gone
|
||||
- The coordinator **composes** the existing public APIs — nothing in `context_graph.py` or `agent_memory.py` changes behaviorally, and `ContextGraph` keeps its documented graph-scope contract rather than acquiring references to `AgentMemory`/`vector_store` that would invert the dependency
|
||||
@@ -40,117 +39,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
- **Fixed during review** (Qodo): the constructor's "at least one store" guard used `not vector_store`, rejecting a valid store whose `__bool__`/`__len__` makes an empty instance falsey, and reporting `vector_store=None` in the error when an object had been passed; it now distinguishes `None` (absent) from `False` (deliberately disabled) from any other value (provided), and echoes what it actually received
|
||||
- **Fixed during review** (Qodo): `at` annotations accepted only `str`/`datetime` while the shared `ContextGraph` normalizer they delegate to also takes epoch seconds; widened to `int`/`float` with the docstrings updated, so the coordinator no longer advertises less than the graph API it wraps
|
||||
- **Known limitation, unchanged by this PR**: erasure still cannot be *completed* on FAISS/Milvus/Weaviate — `delete_vectors()` is declared on the `VectorStore` facade (`vector_store.py:786`) but not implemented across the backend set, under at least three different names. That is worth its own issue; the coordinator ships reporting `unsupported` and starts reporting `erased` for those backends once it is fixed, with no API change here
|
||||
- **Ontology package gains a deterministic, CI-friendly quality gate for ontologies and knowledge graphs** (#1397, closes #1393) by @T1mn — machine-readable quality findings with severities, metrics, statistics, and configurable thresholds; deterministic checks cover ontology structure, class/property coverage, domain/range references, and KG relationship endpoints. Reuses the existing `OntologyValidator`, `OntologyEvaluator`, and `GraphValidator` with no new runtime dependencies. Exposed through `semantica.ontology` and `OntologyEngine`. New `semantica/ontology/quality_gate.py`; new `tests/ontology/test_ontology_quality_gate.py`, and the full targeted ontology/graph-validator suite the author ran alongside it: 63 passed, 4 skipped. This first version reports findings only — no auto-fix, dashboard, or benchmark integration yet.
|
||||
- **`VectorStore` gains `scan_vectors()`/`iter_vectors()` enumeration, and `store migrate` becomes functional** (#1264, part of #1265) by @ZohaibHassan16 — previously vector stores exposed only `get_vector(id)`/`count()`, so there was no way to loop over all vectors, and `semantica store migrate` always told users to export/reindex manually. Adds `scan_vectors(offset, limit)` to `FAISSStore`, `SQLiteVecStore`, and `PgVectorStore` — backends that can support normal positional pagination; in-memory is handled directly by the facade, other backends delegate when they support it, and unsupported backends raise `NotImplementedError` rather than silently returning nothing. `store migrate` now actually migrates between faiss/sqlite/pgvector, copying vectors and metadata in batches and stamping `--namespace` onto metadata that doesn't already have one. Pinecone/Qdrant/Milvus/Weaviate are deferred to follow-up PRs since each backend paginates differently. 22 new tests covering backend scanning, facade behavior, and the migrate CLI.
|
||||
- **`VectorStore.iter_vectors()` dispatches to a new `iter_all()` cursor primitive, with Qdrant as the first cursor-based backend** (#1316, part of #1265) by @ZohaibHassan16 — none of Qdrant/Pinecone/Milvus/Weaviate's native pagination APIs can properly implement positional `scan_vectors(offset, limit)` (Qdrant's cursor is a point ID, Pinecone's is an opaque continuation token, Milvus's `offset` is capped at a 16,384-result window, Weaviate's cursor is the previous object's UUID), so rather than faking positional offsets, backends can now implement `iter_all(batch_size)` as a generator over their native paging API; the facade uses it via `callable()` when present (consistent with existing `count()` dispatch) and falls back to the `scan_vectors()` loop otherwise. `QdrantStore.iter_all()` threads `scroll()`'s `next_page_offset` between requests — correctly yielding a final non-empty page even when the cursor is already exhausted — and raises on an uninitialized store rather than returning empty, so `store migrate` can't report success after copying zero vectors (the failure mode from #1083). Also fixes `store migrate` inferring vector dimension from a nonexistent `._backend_store.dimension` attribute on Qdrant/Milvus/Weaviate (silently falling back to a wrong default of 768) by reading dimension off the first scanned record and chaining it back into the iterator. Qdrant is added to `store migrate`'s supported backends. 6 facade dispatch tests, new `tests/vector_store/test_qdrant_store.py` (10 tests), and 5 CLI dimension-inference tests.
|
||||
- **`WeaviateStore.iter_all()` adds cursor-based full-collection iteration for Weaviate** (#1317, part of #1265, stacked on #1316) by @ZohaibHassan16 — Weaviate's `fetch_objects(after=<uuid>)` pagination has no way to map a numeric offset to a cursor, so this reuses/extracts the cursor-loop and version-fallback logic already in `filter_by_metadata`. Unlike that method's `seen_ids` set (unbounded memory over a full scan), `iter_all()` detects a stalled scan by checking whether the next cursor advanced, keeping memory use O(1). An empty page under cursor pagination is not treated as end-of-scan on its own — `after` has no server-issued continuation value of its own, so a batch could in principle land entirely on a gap (tombstoned objects) with live data past it, the same risk previously confirmed for Qdrant's scroll cursor — so the iterator falls back to an offset-based check once before ending the scan. Also fixes `_extract_vector()` silently producing a corrupted 0-d array against a real (non-mocked) Weaviate collection by unwrapping weaviate-client v4's `{'default': [...]}` vector shape. New `tests/vector_store/test_weaviate_store.py` covering cursor threading, short-page termination, the empty-page/gap fallback, stalled-cursor termination, and the offset fallback when a client rejects `after`. Wiring Weaviate into `store migrate` itself is deferred to #1335 — the facade's write dispatch (`store_vectors()` only recognizes `add`/`add_vectors`, not Weaviate's `add_objects`) and initialization (the facade never calls `connect()`/collection-selection) aren't ready for a backend shaped like this one.
|
||||
- **`MilvusStore.iter_all()` adds Milvus to the `iter_vectors()` cursor family via Milvus's query iterator** (#1326, part of #1265, stacked on #1316) by @ZohaibHassan16 — Milvus's `query(offset=...)` caps `offset + limit` at a documented 16,384-result window, so an offset-based scan would silently truncate any collection larger than that; `query_iterator()` is the primitive actually meant for scans beyond it. The iterator is closed in a `finally` block since it holds server-side state, covered by tests for both normal exhaustion and early/exception-path abandonment. Matches the missing-iterator-raises-rather-than-returns-empty behavior established for Qdrant (#1316) and Weaviate (#1317), so an unsupported `pymilvus` version can't make `store migrate` look like it copied an empty collection successfully; `store migrate` wiring for Milvus is left for a separate PR. New `tests/vector_store/test_milvus_store.py`: 13 tests (Milvus had no dedicated test file before).
|
||||
- **`WeaviateStore` gains `delete_vectors()`, completing Weaviate support for `ErasureCoordinator`** (#1392) by @pkupt — Weaviate half of #1374 (Milvus landed in #1391; FAISS stays unsupported since flat indices can't delete in place). IDs are the object UUIDs `store_vectors()` returns, deleted one at a time via `collection.data.delete_by_id`, which returns `False` rather than raising for a missing UUID, so the erasure receipt's `backend_result` count stays honest. 10 tests cover single/multi-id deletes, not-found-uuid counting, empty ids, and the missing-collection path, plus two integration tests binding `WeaviateStore` as a backend; author notes this is logic-level coverage since Weaviate wasn't available locally to verify live wire behavior.
|
||||
- **`semantica.llms` gains a first-class `Anthropic` provider wrapper** (#1255, closes #1253) by @ZohaibHassan16 — matches the existing `Groq`/`OpenAI` wrapper pattern (`generate`, `generate_structured`, `generate_typed`, `is_available`) over the `AnthropicProvider` already used internally by semantic extraction; previously reachable only through the generic LiteLLM passthrough. New docs section in `docs/guides/llm-integrations.md`; 6 new tests in `tests/test_llm_anthropic.py`.
|
||||
- **`semantica.llms` gains `Gemini`, `Ollama`, `DeepSeek`, and `Novita` provider wrappers** (#1262, closes #1261) by @ZohaibHassan16 — these four providers already existed in `semantic_extract/providers.py` but weren't exposed from the public `semantica.llms` API. Each follows the same `generate`/`generate_structured`/`generate_typed`/`is_available` pattern as `Groq`/`OpenAI`/`Anthropic`. Adds the missing `llm-novita` extra to `pyproject.toml` (uses the `openai` dependency, like DeepSeek), included in `llm-all`; docs added for Gemini/Ollama/DeepSeek, and the existing Novita docs updated to use the new wrapper instead of calling `create_provider()` directly. 32 new tests (8 per provider), following the `test_llm_anthropic.py` pattern.
|
||||
- **Explorer's read-only Markdown viewer becomes a full editor for live `ContextGraph` nodes and host-supplied `AgentMemory` items** (#1349, closes #1327) by @genni613
|
||||
- New canonical single-resource Markdown export/apply methods on `ContextGraph` and `AgentMemory`; resource IDs are validated against frontmatter before mutation, stale writes are rejected via `expected_revision` with HTTP 409, and writes validate fully before commit so failures can't leave a partial mutation. Edits apply to the live in-memory runtime object only — this PR does not introduce disk or restart persistence.
|
||||
- New Explorer endpoints: `GET`/`PUT /api/markdown/{kind}/{resource_id:path}` and paginated `GET /api/memories`, returning structured 404/409/422/500 responses behind existing Explorer auth; `/api/info` now exposes `capabilities.agent_memory` so the UI can detect whether a host app supplied a memory store.
|
||||
- Explorer UI gains Edit/Apply/Cancel alongside the existing Preview/Source/Copy; edits validate against the full canonical document (including supported frontmatter), no-op Applies are disabled, drafts persist across validation/conflict/network/server errors, navigation is guarded when a draft has unapplied changes, and Apply refreshes canonical source, revision, graph content, and labels. A new Memories workspace appears only when the host app supplies `create_app(agent_memory=...)`.
|
||||
- Test coverage: domain round-trip/identity/validation/rollback tests, API success/conflict/authorization/failure-path tests, editor interaction tests (Apply/Cancel/dirty-navigation/retry), and capability/Memories-workspace tests. Author-reported: targeted Python acceptance suite 133 passed, 2 skipped; `npm run test:graph-workspace` 106 passed; `test:graph-store`, `test:deterministic-e2e`, and `test:plugin-registry` (7 passed) all green; `npm run build` passed. Full Python test collection was blocked locally by unrelated NumPy/h5py/spaCy binary incompatibilities.
|
||||
- **New deterministic Explorer rendering example and end-to-end test covering build -> persist -> API -> frontend hydration -> canvas rendering** (#1041, closes #1037) by @alexsmolya — new `examples/explorer_deterministic_rendering_example.py` builds a canonical 4-node/3-edge graph (`Alice --WORKS_AT--> Acme`, `Bob --KNOWS--> Alice`, `Acme --LOCATED_IN--> New York`), persists it with `ContextGraph.save_to_file()`, and reloads with `GraphSession.from_file()`, printing setup/auth/launch guidance. New backend test `tests/explorer/test_explorer_deterministic_rendering_e2e.py` covers graph construction/serialization, `GraphSession`, and exact `/api/graph/*` node/edge/label responses across auth modes. New frontend tests (`deterministicExplorerRendering.test.ts`, `.e2e.ts`) mount the real Explorer app in Chromium, hydrate the real graph store through `useLoadGraph`, render the real Sigma canvas, and assert `WORKS_AT`/`KNOWS`/`LOCATED_IN` are actually drawn and stay labeled after zoom; redundant extra `label` plumbing is removed now that edge labels render from the already-hydrated `edgeType`. Author-reported: backend e2e 5 passed; frontend deterministic suites 49 graph-workspace + 1 graph-store + 7 plugin + 1 Chromium canvas E2E test passed; broader `tests/explorer` run 261 passed, 2 skipped, 2 pre-existing unrelated SHACL failures.
|
||||
- **`integrations/google_adk`: first-class Google ADK support** (#1312, resubmit) by @Hitesh-XS — new `integrations/google_adk/` package (`kg_tools.py`, `decision_tools.py`, `session_service.py`) exposing Semantica's context-graph and decision-intelligence APIs as Google ADK tools and a session service, with its own README. Bundles `google-adk` into the Agentic Framework Integrations section of `pyproject.toml` and into the `all` extra. Also restores packaging state that had regressed on `main` (pinned `anthropic`/`pyarrow` bounds, `ingest-sap`, `langchain`, and package-data fixes) and replaces the deprecated `pinecone-client` dependency with the official `pinecone` package, which had been crashing context-graph initialization — and with it every integration test touching Pinecone. `mcp/` is renamed to `semantica_mcp/mcp/`, with import paths updated across MCP tests and tools. Author reports all 36 tests in `tests/integrations/google_adk/` passing against the corrected Pinecone dependency.
|
||||
|
||||
### Changed
|
||||
|
||||
- **`docs/guides/decision-intelligence.md`: fixed a broken `add_decision` pattern and a wrong hybrid-search description** (#1466) by @ZohaibHassan16 — the alternative "build a `Decision` object, pass to `add_decision`" pattern silently produced nodes invisible to `find_precedents`/`get_causal_chain`/`get_decision_insights` and raised `ValueError` on trace; replaced with the working keyword-argument form. Corrected the hybrid search description (was described as semantic similarity + Node2Vec embeddings at 0.7/0.3; actually word-level Jaccard overlap + connection-count structural similarity) and fixed a wrong decision id in the banking loan example that silently attached to a phantom node
|
||||
- **Tightened prose for clarity and conciseness across the setup, architecture, cookbook, resources, glossary, modules, contributing, and community-facing docs** (#1459, #1458, #1457, #1456, #1454, #1453, #1452, #1442) by @Deep070203 — `cli-setup.md`, `explorer-setup.md`, `installation.md`, `quickstart.md`, `architecture.md`, `cookbook.md`, `citation.md`, `faq.md`, `learning-more.md`, `project-license.md`, `glossary.md`, `choose-your-module.md`, `modules.md`, `contributing-guide.md`, `community-projects.md`, `community.md`, and `governance.md`; no technical content changed
|
||||
- **`docs/reference/ontology.md`: documented Quality Gate threshold semantics** (#1450) by @KaifAhmad1 — added a `### Thresholds` table covering `min_coverage`, `max_errors`, `max_warnings`, and `fail_on_warnings` (noting the latter is a separate constructor/call parameter, not a `thresholds` key), verified against `OntologyQualityGate.DEFAULT_THRESHOLDS`
|
||||
- **Replaced the retired `claude-sonnet-4-20250514` model id in docs and LLM wrappers** (#1449) by @ZohaibHassan16 — updated roughly 15 examples across `graphrag.md`, `llm-integrations.md`, `multi-agent.md`, `ontology.md`, and `reference/llms.md` (plus the LiteLLM/Anthropic wrapper defaults) to `claude-sonnet-5`, `claude-opus-4-7`, and a current Bedrock model id
|
||||
- **`docs/guides/semantic-extraction.md`: fixed a wrong triplet count and a retired model id** (#1448) by @ZohaibHassan16 — the pipeline example printed `{}/{} triplets valid` using the Turtle output's string length instead of the triplet count (producing output like `7/4231`); now uses a real `triplets_total` value. Also replaced `claude-sonnet-4-6` with the dated model id used elsewhere, and clarified the sample NER output is illustrative
|
||||
- **`docs/reference/reasoning.md`: clarified Datalog query result ordering** (#1447) by @ZohaibHassan16 — the `datalog.query(...)` example implied a fixed result order; results are set-backed and unordered, so the comment no longer implies otherwise
|
||||
- **`docs/index.md`: rewrote the landing page as a lean developer welcome** (#1446) by @KaifAhmad1 — replaced the long feature-dump page with a shorter one built around Semantica's deterministic semantic/context-infrastructure positioning, trimming the module table, use-case grid, and duplicate link lists (kept as a collapsed accordion so the module-coverage check still passes)
|
||||
- **`docs/guides/pipeline.md`: fixed the retry-policy example** (#1444) by @ZohaibHassan16 — the example configured a `FailureHandler` with custom retry policies but never assigned it to the `ExecutionEngine`, which builds its own handler, so the configured policies were silently ignored; added `engine.failure_handler = handler`. Also replaced a hardcoded node/edge-count output comment with a shape-only example
|
||||
- **`docs/modules.md`: fixed code examples across the module catalogue to match the current API** (#1443) by @ZohaibHassan16 — corrected snippets using nonexistent or outdated APIs (e.g. `NERExtractor`'s `method="llm"`, `SimilarityCalculator.calculate_similarity()`, `Reasoner.apply_transitivity()`/`infer()`, `EntityResolver`, `ConflictDetector.resolve()`, treating `Pipeline` as a builder/runtime API) across extraction, graph building, reasoning, deduplication, conflicts, embeddings, vector store, export, pipeline, seed data, and evals sections; all 31 code blocks now parse and were run against current source
|
||||
- **`docs/integrations/langchain.md`: tightened integration prose** (#1432) by @taljeon — replaced a remaining em dash with direct sentences and reformatted the component list as name/type pairs; no technical content changed
|
||||
- **`docs/guides/graphrag.md`: fixed broken example strings and clarified `max_hops`** (#1431) by @ZohaibHassan16 — the banking example's multi-line string literals raised `IndentationError`; wrapped in parentheses to match the working Clinical example. Clarified that `AgentContext.retrieve(max_hops=)` only bounds anchored proximity scoring rather than graph-expansion depth (`max_expansion_hops` controls that); also fixed a made-up node/edge count comment
|
||||
- **Tightened prose and fixed two broken relative links in `concepts.md`, `guides/graphrag.md`, and `reference/context.md`** (#1422) by @KaifAhmad1 — removed em dashes from explanatory prose (left intact in simulated document/alert examples); fixed `reference/context.md` links to `reasoning`/`provenance` that were missing a leading slash and would 404; updated `concepts.md`'s intro tagline to match #1421
|
||||
- **`docs/index.md`: rewrote landing-page prose to be crisp and direct** (#1421) by @KaifAhmad1 — cut the marketing/storytelling framing and all em dashes; updated the tagline to "The Context and Semantic Layer for AI in High-Stakes Domains" across `docs.json` and `index.md`, keeping audit trail/accountability as a property rather than the headline
|
||||
- **Restructured the docs nav** (#1419) by @KaifAhmad1 — dropped the standalone FAQ and Changelog tabs (their pages moved under Overview) and added a dedicated API Reference tab holding the `reference/*` pages split out of Modules
|
||||
- **`docs/assets/custom.css`: replaced decorative hover/fade animations with static styling** (#1418) by @KaifAhmad1 — removed the page-load fade-in and hover lift/glow effects on code blocks, cards, buttons, and nav links site-wide, keeping the existing color palette and accessibility focus rings
|
||||
- **`docs/concepts.md`: fixed 9 of 13 code examples that no longer matched the current API** (#1417) by @ZohaibHassan16 — corrected the `GraphBuilder`, GraphRAG, forward-chaining/Rete/Datalog reasoning, `GraphReasoner`, `SimilarityCalculator`, provenance, and `MethodRegistry` snippets, plus the distance-band terminology and engine comparison table
|
||||
- **`docs/quickstart.md`: fixed the parsed-document example to read `full_text`** (#1415) by @ZohaibHassan16
|
||||
- **`docs/getting-started.md`: fixed broken Knowledge Graph and GraphRAG "Choose Your Path" examples** (#1414) by @ZohaibHassan16 — the extractor calls now pass parsed text instead of a `FileObject`, and the GraphRAG example uses `context.store()` + `retrieve(use_graph=True, ...)` instead of the nonexistent `load_graph()`/`query(mode=...)` APIs
|
||||
- **Fixed ~300 relative body links across 74 docs pages that 404'd on the live site** (#1407, closes #1405) by @Duansg — GitHub Pages' trailing-slash redirect resolved hand-written relative Markdown links against the wrong base path; links are now rewritten as root paths
|
||||
- **Fixed two broken cookbook notebook links** (#1403) by @ZohaibHassan16 — `docs/learning-more.md` pointed to a nonexistent `09_Embeddings.ipynb` (now the correct `12_Embedding_Generation.ipynb`), and `docs/reference/distance.md`'s dead link to a nonexistent Distance Intelligence notebook was removed
|
||||
- **`docs/quickstart.md`/`docs/faq.md`: addressed Qodo review findings** (#1402, follow-up to #1401) by @ZohaibHassan16
|
||||
- **`docs/quickstart.md`: fixed the Full Pipeline walkthrough against current APIs** (#1401) by @ZohaibHassan16 — corrected the parse, extract, ingest (`WebIngestor`/`XMLIngestor`), export (`ArangoAQLExporter`, Parquet), OCR, and `PipelineBuilder` examples, and fixed a stale `Pipeline(workers=N)` example also present in `faq.md`
|
||||
- **Updated stale latest-version references to v0.6.7** across `docs/faq.md`, `docs/index.md`, and `docs/quickstart.md` (#1400) by @ZohaibHassan16
|
||||
- **`docs/reference/mcp_server.md` and related pages: documented all 15 MCP tools** (#1399) by @ZohaibHassan16 — added the three previously-undocumented tools (`query_graph`, `update_node`, `delete_node`) and corrected the tool count everywhere it appeared
|
||||
- **`docs/reference/evals.md`: rewritten to match the shipped `semantica.evals` API** (#1398) by @ZohaibHassan16 — replaced the stale "not yet implemented" placeholder with `evaluate()`, `list_evaluators()`, `EvalMetric`/`CaseResult`/`EvalSummary`, all 10 built-in evaluators, and the `decision_scores` sub-checks
|
||||
- **README: propagated SAP OData connector mentions consistently and trimmed the audience list** (#1396) by @KaifAhmad1 — added SAP mentions to the Enterprise Data Platforms bullet, ingest summary, module reference table, and supported-sources line (previously only in "What's New"); tightened the "Who it's for" bullets; removed sample `semantica doctor` output from the quickstart snippet
|
||||
- **Rewrote the Semantic Layer Basics cookbook lesson as a runnable introductory workflow** (#1361, closes #1325) by @taoche — replaced the removed `advanced/09_Semantic_Layer_Construction.ipynb`, which never used `TripletStore`, left mappings empty, and never executed a query, with `introduction/26_Semantic_Layer_Basics.ipynb`, whose ontology, mappings, RDF, and SPARQL query now agree end to end
|
||||
- **Rewrote cookbook notebook 08 into a real, rerunnable knowledge-graph workflow** (#1359, closes #1289) by @taoche — it previously read the wrong parser key, substituted hard-coded extraction fixtures, bypassed `GraphBuilder`, and never called `KGVisualizer`; it now runs parse → NER/relation extraction → `GraphBuilder` → `KGVisualizer` end to end
|
||||
- **Fixed cookbook notebook 07's graph mapping and deduplication output** (#1357, closes #1287) by @taoche — edges were built from loop indices instead of extracted relation endpoints, and the dedup output showed only merge operations, making 5 mentions falsely appear to collapse to 1 entity instead of the correct 4
|
||||
- **README: repositioned Semantica's opening pitch around the semantic/context/knowledge layer** (#1348) by @KaifAhmad1 — leads with Context Graph, KG, and ontology governance (OWL/SHACL/SKOS) rather than framing audit trails as the flagship pattern; reordered the hero pillar list to lead with Context Management/Knowledge Modeling ahead of Decision Intelligence
|
||||
- **Hash-pin every pip install across the Dockerfile and CI workflows for Scorecard Pinned-Dependencies** (#1338) by @KaifAhmad1 — CI/build hardening, no runtime behavior change. Closes 21 OpenSSF Scorecard alerts: existing `pkg==X.Y.Z` version pins (even installs already reading a hashed `requirements-ci.txt`) still scored low because no hash is visible on the install command itself. Adds hash-locked `.github/requirements/*.txt` files (via `uv pip compile --generate-hashes`) for every pip target not already covered, adds `--require-hashes` to all `-r requirements-ci.txt` installs, and splits local-source installs into `pip install --no-deps -e .` plus a separately hash-pinned dependency install (a local source tree has nothing to hash directly). The Dockerfile now installs from a pre-generated `explorer-extra.txt` rather than extracting constraints at build time
|
||||
- **Test-only contributions**: fixed `sys.modules` mock leakage in `test_extractors_dispatch.py` that made 132 tests pass in isolation but fail in a full-suite run, by installing the mocks per-test via `patch.dict`/`addCleanup` instead of at module scope (#1337, closes #1336, by @dex0shubham); added missing `__init__.py` package markers to `tests/integrations/crewai/` and `tests/integrations/langchain/`, fixing a pytest collection abort from two same-named `test_degradation.py` files colliding under prepend import mode (#1252, closes #1251, by @dex0shubham); guarded fastapi-dependent Explorer test modules so `tests/explorer/` and `tests/test_security_regression.py` collect successfully without the `explorer` extra installed (#1232, closes #1167, by @dex0shubham)
|
||||
- **`ContextGraph`'s temporal-input normalizer is now a public API** (#1455, closes #1377) by @Saket7002 — `normalize_temporal_input` is exposed publicly so `context/erasure.py`'s `ErasureCoordinator` can call it directly instead of reaching across modules for a private helper. No behavior change. Regression coverage added for the public normalizer; full targeted run (`test_context_graph_retraction.py` + `test_erasure_coordinator.py`): 101 passed.
|
||||
- **New acceptance tests pin known contract gaps between `VectorStore`'s facade and the Qdrant/Pinecone/Milvus/Weaviate backends, as strict `xfail`** (#1332) by @ZohaibHassan16 — existing vector-store tests all bypass `_init_backend_store` (the code path that actually constructs cloud backend adapters), either mocking backend internals directly or injecting a fake backend, which is how #1316 could be fully green while broken end to end: a Qdrant-backed `VectorStore` can't read (no connection/collection ever established) and can't write (`store_vectors()` doesn't dispatch to `QdrantStore.insert_vectors`). New `tests/vector_store/test_backend_facade_contract.py` constructs each backend through the real facade path and marks the two capability gaps `xfail(strict=True)` for Qdrant/Pinecone/Weaviate (Milvus already passes, pinned separately as a control) — a fix will flip these to unexpected passes and fail the suite until the marker is removed, making them acceptance criteria rather than assertions of the broken behavior itself. 13 new tests (6 pass, 7 xfail); no application code changed.
|
||||
- **CI now reports required status checks correctly on docs-only PRs** (#1410) by @Sameer6305 — `ci.yml`/`security-scan.yml` still trigger on every PR including docs-only changes, but skip their expensive jobs for docs-only diffs while still reporting a check status, so required checks don't block on jobs that never ran; full build/security scans are preserved for source or mixed changes, and non-PR triggers are unaffected.
|
||||
- **CI gains npm Dependabot coverage for `explorer/` and container image scanning** (#1286) by @KaifAhmad1 — `dependabot.yml` previously had no `npm` ecosystem entry for `explorer/`, which is why the `brace-expansion`/`nanoid` CVEs fixed in #1280 went undetected until a manual check; added, mirroring the existing `pip` entry's schedule/labels/reviewers. New `container-scan.yml` builds the Dockerfile image, scans it with Trivy (CRITICAL/HIGH to the Security tab as SARIF, `ignore-unfixed: true`), and generates an SPDX SBOM with Syft, running on push to main, weekly, and on manual dispatch. Trivy runs report-only for now (no `exit-code` gate) until the first CRITICAL/HIGH baseline is triaged.
|
||||
- **Distribution and trust-signal infrastructure: reusable install action, a PyPI install matrix, and release-pipeline hardening** (#1266) by @KaifAhmad1
|
||||
- New `.github/actions/setup-semantica` composite action other repos can call to install and verify `semantica` in one step
|
||||
- New `install-matrix.yml` verifies the *published* PyPI package installs and imports cleanly across Ubuntu/macOS/Windows and Python 3.9-3.12, on a weekly schedule and on every release, backing a new "pip install" README badge
|
||||
- New `scorecard.yml` runs OpenSSF Scorecard analysis weekly and on push to main, backing a new README trust-signal badge
|
||||
- `release.yml` gains a `twine check` gate before publish, catching a broken PyPI long-description render before it ships; the existing Trusted Publishing/OIDC + SLSA attestation signing flow is otherwise unchanged
|
||||
- New `CITATION.cff` (enables GitHub's native "Cite this repository" button alongside the existing `docs/citation.md`) and `examples/ci/` copy-paste GitHub Actions/GitLab CI/CircleCI templates for downstream adopters
|
||||
- New `GROWTH.md` tracks distribution-channel status with explicit guardrails against artificially inflating download/install metrics
|
||||
- No application code changed; new workflow YAML validated with `yaml.safe_load` and new action pins verified against the GitHub API
|
||||
- **Resynced `github/codeql-action` pin to current v4 SHA** (#1249) by @ZohaibHassan16 — the v4 tag's underlying SHA had changed, failing "Verify Action Pins" on every PR; all 8 refs across `codeql.yml` and `defender-for-devops.yml` updated and reverified (40/40 clean).
|
||||
|
||||
### Fixed
|
||||
|
||||
- **README's production deploy instructions pointed at an environment variable that exists nowhere in the codebase** (#1473, fixes #1429) by @v01dst — `README.md:1546` told deployers to set `SEMANTICA_SECRET_KEY`, but the Explorer auth code (`semantica/explorer/dependencies.py:30`) reads `SEMANTICA_API_KEY` (with `SEMANTICA_ALLOW_ANONYMOUS=true` as the opt-out), so a deploy following the README set a silently-ignored variable and then hit 503s or unintended anonymous mode. One-line docs fix; `grep SEMANTICA_SECRET_KEY README.md` shows 0 hits afterward
|
||||
- **The Python 3.9 install matrix was still broken after the spaCy/thinc fix in #1329** (#1445, closes #1347) by @ZohaibHassan16 — `scikit-learn`, `requests`, `chardet`, `grpcio`, `pillow`, `click`, and `onnxruntime` all now ship minimum versions requiring Python 3.10+, so a plain no-extras install on 3.9 failed to resolve. Adds Python-version markers for each, following the existing spaCy/thinc pattern: 3.9 is capped at the latest compatible release per package, 3.10+ stays unconstrained. Verified with `uv pip compile --python-version 3.9` for Linux/Windows/macOS, plus 3.10 and 3.12
|
||||
- **Ontology property generation inferred framework bookkeeping fields as business datatype properties** (#1420, closes #1416) by @pkupt — `_extract_data_properties` only skipped `id`/`type`/`entity_type`/`text`/`label`/`confidence`, so structural fields `GraphBuilder` and `EntityMerger` attach to entity dicts (`properties`, `relationships`, `metadata`, `provenance`, `merged_from`, `merge_strategy`) were emitted as bogus datatype properties alongside real attributes. The skip set is now a single `_CONTROL_FIELDS` constant covering all of them; flat top-level business attributes are unaffected. New `tests/ontology/test_ontology_framework_fields.py`
|
||||
- **`ErasureCoordinator(vector_store=False)` didn't actually stop all vector deletion — it only stopped the coordinator's own leg** (#1395, closes #1378) by @Harsh4r0ra — disabling the vector leg made the coordinator itself report `status="not_configured"`, but `AgentMemory.batch_delete()` → `delete_memory()` still ran its own best-effort vector-delete cascade internally, catching any failure and returning `True` regardless, so `receipt.complete` could read `True` while an embedding was still live. A `skip_vector` flag is now threaded from `ErasureCoordinator` into a new keyword-only `AgentMemory.batch_delete(skip_vector=...)` parameter whenever the vector leg is explicitly disabled. The existing test that had asserted the buggy behavior is rewritten, plus a new regression test pinning `delete_calls == 0`
|
||||
- **MCP graph persistence and setup were broken across multiple surfaces** (#1394, closes #1134) by @Sameer6305 — the root MCP server loaded graphs with a non-existent method instead of `load_from_file()`, and mutations made through MCP tools weren't persisted back to `SEMANTICA_KG_PATH` on either server implementation. Fixed graph loading, wired persistence through for both MCP server implementations, corrected the MCP installation and Claude Code setup docs (including the `claude mcp add` invocation and documenting the required `PYTHONPATH`), and added end-to-end MCP stdio JSON-RPC regression coverage
|
||||
- **CI's Safety-based security scan crashed intermittently instead of reporting real findings** (#1390, closes #1389) by @ZohaibHassan16 — the same crash pattern previously seen with `cuda-toolkit` recurred with `torchvision`, and identical runs against `requirements-ci.txt` could either succeed or crash, so `IGNORED_VULN_IDS` couldn't help — Safety crashed before it ever wrote a report. Replaces the Safety step in `security-scan.yml` with `pip-audit` (already used successfully in `security.yml` against the same dependencies) and removes `security.yml` entirely now that `security-scan.yml` covers everything it did, plus Bandit, Semgrep, and PR reporting on a broader trigger set. `IGNORED_VULN_IDS` is now empty since `pip-audit`'s OSV source doesn't carry either CVE Safety was flagging. Verified via YAML/embedded-JS syntax checks, report-handling tests against six report shapes, and `verify-action-pins.sh` passing with 47 action references (down from 49 after removing `security.yml`)
|
||||
- **`verify-action-pins.sh` failed after `actions/deploy-pages`'s v5 tag moved** (#1387) by @ZohaibHassan16 — the tag advanced from v5.0.0 to v5.0.1 (backoff/jitter added to deployment polling, confirmed via the GitHub API); the pinned SHA in `docs.yml` is updated to match. Verified all 49 action references pass
|
||||
- **CI's security scan failed on an unreachable, transitive `torchvision` CVE** (#1385, closes #1384) by @ZohaibHassan16 — `SFTY-20260723-60537` (CVE-2026-65918) is a GIF-decoder finding in `torchvision`, pulled in transitively via `safetensors`/`sentence-transformers` and never used directly (confirmed by grep across `semantica/`, `mcp/`, `integrations/`); fixed upstream in commit `4e05dc2` but not yet in any released `torchvision`. Added to `IGNORED_VULN_IDS`, matching the existing `cuda-toolkit` precedent
|
||||
- **`ErasureReceipt.to_dict()` returned nested dicts shared by reference with the live receipt** (#1381, fixes #1376) by @BinarySpecter — `backend_result`'s nested dicts weren't copied, so a caller mutating the returned dict could corrupt the receipt's own internal state; the audit record it's meant to be is no longer safe to hand out. Fixed with a proper deep copy in `semantica/context/erasure.py`. `tests/context/test_erasure_coordinator.py`: 49 passed, 3 subtests
|
||||
- **The `--ignore`-based Safety CVE suppression added in #1370 crashed CI on the very next run** (#1371) by @KaifAhmad1 — a correction to #1370: `--ignore` only crashes once Safety has to apply itself against a real match, and the push-triggered run on `main` immediately after #1370 merged hit the exact `'cuda-toolkit'` crash #1131/#1157 had already fixed, even though a plain scan (no `--ignore`) had run clean moments earlier on the same dependencies. The author notes their own pre-merge local testing was misleading — their local Safety database didn't surface the CVE at all, so `--ignore` never had a real match to crash against locally. Fix: drop `--ignore` entirely, run the plain scan proven not to crash, and filter the accepted vulnerability ID out of the JSON report in `jq` before both the count check and detail-printing. Also fixes a latent bug where `.vulnerabilities | length` silently returned `0` for a null/missing `vulnerabilities` key instead of erroring, which the existing Guard 2 comment had assumed already happened. Validated the jq filter against six synthetic report shapes rather than relying on a local Safety run
|
||||
- **CI's security scan failed on a real, unfixable-upstream `cuda-toolkit` CVE with no released fix available** (#1370) by @KaifAhmad1 — `SFTY-20260120-40557` (CVE-2025-33228) is a hard `==13.0.3` pin from `torch==2.13.0`'s own wheel metadata (the latest available torch release), so no version bump can resolve it; the CVE itself is OS command injection in NVIDIA Nsight Systems' `gfx_hotspot` recipe, which Semantica never invokes and which isn't among the CUDA extras torch actually requests here. Added `--ignore SFTY-20260120-40557` to the `safety check` invocation, scoped to this one vulnerability ID with an inline comment explaining why and when to revisit. Verified locally against Safety 3.8.1 that the ignore only suppresses this ID and no others. (Superseded the following day by #1371, which found this `--ignore` itself reintroduced a Safety crash in live CI)
|
||||
- **A malformed Safety report could be silently read as a clean scan** (#1366) by @T1mn — the Security Scan workflow had no check that `safety-report.json` actually contained a well-formed, array-valued `vulnerabilities` field before counting findings, so a present-but-malformed report risked passing as zero findings. Adds an independent fail-closed check that validates the field's shape and renders an explicit invalid-report warning instead of treating malformed data as clean; the existing `--file requirements-ci.txt` Safety scan and the separate `security.yml` pip-audit workflow are unchanged
|
||||
- **The bundled Claude Code plugin failed to install entirely** (#1363, fixes #1350) by @7487 — `plugins/.claude-plugin/plugin.json` declared `"agents": "./agents"`, but unlike `skills`, Claude Code's plugin schema rejects a bare directory string for `agents` (`Validation errors: agents: Invalid input`) and requires an explicit array of `.md` file paths. Replaced with `["./agents/decision-advisor.md", "./agents/explainability.md", "./agents/kg-assistant.md"]`. New `tests/test_plugin_manifest.py` guards that `agents` stays a non-empty array of existing `.md` paths in sync with `plugins/agents/`. Verified with the official validator (Claude Code 2.1.231): validation now passes
|
||||
- **Checkov's own suppressed findings kept reopening as brand-new GitHub code-scanning alerts on every rescan** (#1346) by @KaifAhmad1 — the same 4 Checkov k8s findings on `deploy/helm/knowledge-explorer` (namespace/seccomp) were already suppressed via working `checkov.io/skipN` annotations and correctly marked `SKIPPED` in Checkov's JSON output, but Checkov's SARIF exporter emits every evaluated check as an ordinary `level: warning` result regardless of skip status and never populates SARIF's own `suppressions` field — so GitHub had no way to know these were suppressed and opened new alert numbers across three separate scans. New `.github/scripts/filter_checkov_skipped.py` cross-references Checkov's JSON `skipped_checks` against the SARIF `results` (matched on check ID plus the last two path segments, since JSON and SARIF use different path roots) and drops already-suppressed results before the SARIF reaches GitHub. Verified locally against a real checkov 3.3.1 + helm 3.16.4 run: removed exactly the 4 known-suppressed results, left 2 genuinely real findings elsewhere in the repo untouched
|
||||
- **A Scorecard Pinned-Dependencies alert flagged an install step for a directory that doesn't exist in the repo** (#1345) by @KaifAhmad1 — `benchmark.yml:51` ran `pip install -r benchmarks/requirements.txt`, but `benchmarks/` doesn't exist anywhere in the repository, so the step couldn't be hash-pinned and the job already failed on the very next real step (`benchmarks/benchmarks_runner.py`, also missing) — the line did nothing useful. Dropped it rather than leave it unpinned. Also closed directly via the API without a PR: #6099 (Dockerfile Pinned-Dependencies, dismissed won't-fix — installing our own git-tracked source with `--no-deps --no-build-isolation` has no third-party fetch to pin, and pip rejects `--hash`/`--require-hashes` on local directory targets) and #6112–#6115 (same suppressed-Checkov-alert root cause as #1346, dismissed as false positive)
|
||||
- **`MilvusStore.get_collection()` attached to a mismatched collection and only failed later, far from the root cause** (#1344, closes #1331) by @pkupt — the method wrapped `Collection(name)` right after the `has_collection` guard with no schema check, so an INT64-pk or metadata-less collection attached successfully and only surfaced an error deep inside `get_vector`/`get_metadata`. A schema check now runs immediately after attach, before the store assigns `self.collection`, so a mismatch is caught early with an error naming the actual problem. 9 new focused tests in `tests/vector_store/test_milvus_get_collection.py` cover the matching case and each rejection case
|
||||
- **The Docker build broke outright after #1338, failing Container Security Scan on the build step itself rather than just SBOM/Trivy** (#1341) by @KaifAhmad1 — `explorer-extra.txt` was compiled with `--python-version 3.11` but installed on the Dockerfile's actual `python:3.13-slim` interpreter; `librosa`'s `audioread` dependency needs `standard-aifc`/`standard-sunau` only under `python_version >= "3.13"` (Python 3.13 dropped `aifc`/`sunau` from stdlib), and a lockfile resolved for 3.11 carries no hashes for those packages at all, so `--require-hashes` failed outright once pip resolved against the real 3.13 environment. Split into `explorer-extra-py311.txt` (used by `ci.yml`, unchanged resolution) and a newly-compiled `explorer-extra-py313.txt` (used by the Dockerfile, including the `standard-aifc`/`standard-sunau`/`standard-chunk` hashes), with `.github/requirements/README.md` documenting why the two can't be recombined
|
||||
- **The Neo4j persistence example in `docs/quickstart.md` raised `AttributeError` when followed as written** (#1340, fixes #1135) by @Sameer6305 — the example passed a raw `Neo4jStore` backend directly to `GraphBuilder(graph_store=store)`, but `GraphBuilder` expects the `GraphStore` facade and calls `add_nodes()`/`add_edges()`, which the raw backend doesn't expose (`'Neo4jStore' object has no attribute 'add_nodes'`). Updated the example to construct `GraphStore(backend="neo4j", ...)` instead. New regression test in `tests/kg/test_graph_builder_with_graph_store.py` covering `GraphBuilder` against the `GraphStore` facade
|
||||
- **`pip install semantica` failed on Python 3.9 across all three OSes** (#1329) by @KaifAhmad1 — `spacy` had no upper bound, so pip resolved spacy 3.8.16 whose `thinc>=8.3.12` requirement has no cp39 wheels and no working sdist build path either. Caps `spacy<3.8.8` and adds `thinc<8.3.5` for `python_version < '3.10'` (py3.10+ stays unconstrained); verified with a dry-run resolve against manylinux/win_amd64/macosx_arm64, all landing on prebuilt wheels (spacy 3.8.7 + thinc 8.3.4). Also pins Docker base images by digest and remaining unpinned CI tool installs, and adds Sigstore signing so `dist/*.sigstore.json` ships alongside release artifacts (OpenSSF Scorecard Pinned-Dependencies/Signed-Releases hardening)
|
||||
- **FAISS vector store silently lost `vector_ids`/`metadata` across save/load, so a reloaded index reported zero vectors and `semantica store migrate --from faiss` silently copied zero records** (#1314, closes #1272) by @AhmadBilalDSA — loading a saved index reinitialized `vector_ids = []` and `metadata = {}`, so `scan_vectors()` returned `[]` and `count()` returned `0` despite a valid binary index on disk. Metadata now persists to an atomic companion `.meta.json` file written alongside the index, restored exactly on reload, with a `RuntimeWarning` plus a logged warning when the binary index exists but its sidecar is missing. New end-to-end regression test verifying `scan_vectors()` matches the original records across fresh store instances
|
||||
- **Registered ontologies opened the Ontology Editor to an empty canvas, and ontology deep links didn't land on the Editor at all** (#1278, closes #1274) by @taoche — the app shell ignored `ontologyTab`/`ontologyEntity` URL state, and even when the Editor did open, it loaded registry metadata but never fetched the selected ontology's schema nodes and structural edges. Adds `GET /api/ontology/graph?uri=...` returning the bounded schema subgraph, wires deep-link state into startup tab selection, and maps the response into React Flow nodes/edges with loading/error/selection handling. 40 backend tests plus 77 explorer graph-workspace tests pass
|
||||
- **Explorer's Full Graph view rendered small, multi-component graphs as unlabeled dots with relationships suppressed** (#1277, closes #1275) by @taoche — coordinate-free graphs of any size got the same large-graph seed layout, ForceAtlas2 stabilization, and overview edge LOD, which crushes node spacing and hides ordinary edges on a small graph. Adds a deterministic, component-aware layout path for coordinate-free graphs of up to 48 nodes — skips force stabilization, keeps labels visible, preserves relationship edges — while larger graphs and graphs with existing coordinates are unaffected. 81 explorer tests pass
|
||||
- **Explorer graph-loading failures showed only a generic `Fetch failed: <status>` message, discarding the server's actionable error detail** (#1260, closes #1256) by @wanglin1111111 — e.g. an unconfigured `SEMANTICA_API_KEY` returns a specific remediation string in the response body's `detail` field, but the UI overlay showed a generic "check that the backend is running" hint instead, sending users down the wrong troubleshooting path. `useLoadGraph.ts` now reads the JSON body on a non-OK response and appends `detail` to the thrown error, degrading gracefully when the body isn't JSON
|
||||
- **`ConsoleProgressDisplay` wrote progress bars to `sys.stdout`, corrupting the JSON-RPC protocol on stdio MCP servers** (#1254, closes #1134) by @dex0shubham — stdio MCP servers frame newline-delimited JSON-RPC on stdout, so an interleaved progress bar could make a response body unparseable. Progress now defaults to `sys.stderr` (resolved per-write via a property so a later rebinding, e.g. pytest capture, is honored), with an optional `stream` override; the cp1252 emoji-capability probe now inspects the actual target stream instead of always stdout. 9 new tests in `tests/utils/test_progress_stream.py`
|
||||
- **`SlidingWindowChunker` accepted a zero or negative `stride`, and a failed `chunk_with_overlap()` call could leave chunker state un-restored** (#1245, closes #1244) by @HsienW — the fixed-size chunking path depends on `stride` to advance the cursor, but an explicit non-positive value passed validation; a temporary overlap override used internally by `chunk_with_overlap()` could also derive a non-positive stride, and the original overlap/custom stride weren't guaranteed to be restored if chunking raised. Non-positive stride/overlap values are now rejected before chunking, and the temporary override is restored via `try`/`finally` on both success and failure. 13 new/updated tests
|
||||
- **Explorer's temporal scrubber sent duplicate snapshot requests and could apply a stale response over a newer one** (#1241, closes #1128) by @ALDRIN121 — repeated `onTimeChange` calls at the same timestamp (timeline recreation, play ticks, drag events) each fired a fresh `/api/temporal/snapshot` request with no dedup — 13+ identical-`at` requests observed at ~500ms cadence — and under variable network latency an older position's response could land after a newer one's, leaving the active-node chip visibly lagging the scrubber. New `temporalSnapshotGuards.ts` dedupes in-flight requests per scrubber position, caches and re-applies snapshots on revisit, and applies a response only while the scrubber is still on that position; state resets when the graph summary changes. 16 new unit tests
|
||||
- **Distinct property spellings normalizing to the same ontology name produced duplicate property definitions, and object/data properties could collide under one IRI** (#1231) by @T1mn — follow-up to #1170/#1171. Same-kind properties normalizing to the same name are now merged, preserving their domains and ranges; a normalized name shared across an object and a data property now raises a structured `ValidationError` instead of silently colliding
|
||||
- **Class inference could emit duplicate ontology classes for source types that normalize to the same name (e.g. `Person`/`person`), silently misassigning properties to the first class** (#1230) by @T1mn — follow-up to #1171. The collision is now detected and rejected with a structured `ValidationError` before duplicate classes or misassigned properties are emitted. New regression test for the `Person`/`person` case
|
||||
- **`OntologyGenerator.infer_properties`'s public entry point still fell back to `owl:Thing` when relationship endpoints were given by entity ID or alias**, even though the main generation pipeline had already been fixed (#1229) by @T1mn — follow-up to #1170. The endpoint-resolution logic is now extracted into a shared `relationship_utils.py` helper used by both `PropertyGenerator` and the public inference path, so the two can't drift again
|
||||
- **`auto_generate_id=False` on the six decision-model dataclasses was unreachable dead code** (#1153, fixes #1152) by @cxzg007 — `Decision`, `DecisionContext`, `Policy`, `PolicyException`, `Precedent`, and `ApprovalChain` declared `auto_generate_id` only as a plain `__post_init__` parameter rather than a dataclass field or `InitVar`, so the generated `__init__` never forwarded it — it was always `True`, and the "require a caller-supplied id" validation branch could never run. Declared as `InitVar[bool] = True` on each dataclass, restoring the intended contract with no serialization change (`InitVar` isn't a real field, so `to_dict()`/`from_dict()` are unaffected). 38 tests pass in `tests/context/test_decision_models.py`; 108 downstream tests unaffected
|
||||
- **Three functions used mutable list-literal default arguments**, a classic Python pitfall where the same list object persists and can accumulate mutations across calls (#1068) by @yzxcj797 — `GraphAnalyzer.analyze_temporal_evolution(metrics=[...])`, `HierarchicalChunker.__init__(levels=[...])`, and `split_hierarchical(levels=[...])` now default to `None` with a fresh list built in-body. New regression tests in `tests/kg/test_kg.py` and `tests/split/test_chunkers.py`
|
||||
- **`AgentMemory.find_by_entity()` defaulted to `limit=10`, silently truncating results** (#1024) by @yzxcj797 — the erasure workflow added in #1018 (`ErasureCoordinator`) computing what references an entity from a truncated page could leave the untruncated remainder live after a supposedly-complete erasure. Default changed to `limit=None` (all matches), with explicit limits still supported for pagination. New regression tests in `tests/context/test_agent_memory_find_by_entity.py`
|
||||
- **Explorer SHACL validation error messages didn't name the environment variable that controls the limit being hit** (#1437, closes #1430) by @pkupt — the Turtle-size, triple-count, and timeout limit-exceeded messages in `validate_shacl` now name the specific env var to change, and `docs/guides/shacl-validation.md` documents all four resource-limit variables with their defaults. Existing message-assertion tests extended to also check the env var name appears.
|
||||
- **Explorer's `POST /api/export` only supported `json`/`csv`, while the MCP `export_graph` tool already resolved Turtle, N-Triples, RDF/XML, JSON-LD, and GraphML through the same exporters** (#1157, closes #1131) by @13g4d0 — the Explorer route now reaches the same `semantica.export` exporters the MCP tool uses (`RDFExporter.export_to_rdf`, `GraphMLExporter.export`) rather than reimplementing anything, with an alias table shared with (and tested against) `mcp/tools/export.py`'s `_FORMAT_ALIASES`, correct media types/extensions per format, a 422 message that now names the supported formats instead of just saying the requested one isn't, and a missing optional dependency now returning 503 instead of a misleading 422. Parquet export is explicitly left out — it writes a file/path rather than a response body, and deserves its own review. Tests parse each of the seven RDF spellings with `rdflib` rather than asserting on strings, plus a canary that the Explorer and MCP alias tables agree; `tests/explorer/test_explorer_api.py`: 110 passed.
|
||||
- **`semantica ingest` reported "✓ Ingested" while writing nothing to a configured Neo4j backend** (#1465, closes #1351) by @evgenyponomarev — `ingest()`/`ingest_file()` never referenced a graph store at all, so `--store`/`GRAPH_STORE_DEFAULT_BACKEND` were accepted and silently discarded; the command now raises a clear error when a non-memory graph backend is configured, naming both this and the related `kg build` no-op (#1352) rather than recommending a workaround that fails the same way. `--output <file>.json` writes the ingested result instead (via the existing `_write_result_output` helper), and `_json_default` now expands dataclasses (`FileObject`) and decodes `bytes` so the written file holds real content, not a Python repr. 3 new regression tests; full `tests/test_cli_commands.py`: 270 passed
|
||||
|
||||
### Security
|
||||
|
||||
- **Five HIGH-severity Trivy findings in the built container image** (#1334) by @KaifAhmad1 — `setuptools` 70.3.0 (CVE-2025-47273, path traversal; base-image-bundled and never touched by our own build) upgraded explicitly to 78.1.1. `msgpack` 1.1.2 (GHSA-6v7p-g79w-8964, OOB read/crash on Unpacker reuse) shipped because the Dockerfile's bare `pip install ".[explorer]"` re-resolved dependencies from scratch instead of reusing the audited, hash-pinned `requirements-ci.txt` (which already pins `msgpack==1.2.1`) — the image now installs against a constraints file derived from `requirements-ci.txt` so it matches what's actually been audited. `openssl`/`libssl3t64` (CVE-2026-14456, QUIC server DoS) has no packaged fix yet in Debian's `trixie-security`; an upgrade step is added so the next rebuild picks it up automatically, documented as non-exploitable here since the image only serves plain HTTP via uvicorn and never opens a QUIC listener
|
||||
- **Two npm advisories in `explorer/package-lock.json` flagged by OpenSSF Scorecard, plus over-broad workflow token permissions** (#1280) by @KaifAhmad1 — `brace-expansion` (transitive via `minimatch`) 5.0.8→5.0.9 and `nanoid` (transitive via `postcss`) 3.3.16→3.3.18 close GHSA-rgw5-rvv9-x895 and GHSA-2v37-7h3g-55p8 (both unbounded/looping-input DoS); lockfile-only, both versions already satisfy their parents' declared ranges. Also narrows `security-events: write`/`actions: read` from workflow-level to job-level scope in `codeql.yml` and `defender-for-devops.yml`, matching least-privilege token-permission guidance
|
||||
- **12 Dependabot alerts against `aiohttp`** (request smuggling, websocket/parser bugs, cookie/redirect and deserialization issues, one rated High), pinned transitively via `checkov` in `.github/requirements/checkov.txt` (#1342) by @KaifAhmad1 — root cause: `checkov==3.3.1` itself constrained `aiohttp<3.14.0`, excluding every patched release. Bumping to `checkov==3.3.16` relaxes that to `aiohttp<3.15.0`, letting `aiohttp` resolve to the patched `3.14.3` and clearing all 12 alerts at once. Two related alerts are documented as left open rather than fixed here: `asteval` (checkov 3.3.16 still hard-pins `asteval==1.0.6` with no compatible range yet) and `ecdsa` (`0.19.2` is already latest; no fix exists yet for the Minerva timing-attack advisory GHSA-wj6h-64fc-37mp, which upstream has declared out of scope) — both assessed as non-exploitable here since these are checkov's own transitive dependencies used only for local static IaC analysis, with no network-signing or cloud-auth code path exercised
|
||||
|
||||
### Dependencies
|
||||
|
||||
- Routine version bump fixing 2 disclosed advisories with no application-facing behavior change: `browserslist` (transitive dev dependency in `explorer/`) 4.28.2→4.28.8, closing GHSA-73wf-gq98-2v4g and GHSA-c83g-rgw3-j3cx (#1382)
|
||||
|
||||
## [0.6.7] - 2026-08-28
|
||||
|
||||
|
||||
+2
-2
@@ -7,8 +7,8 @@ authors:
|
||||
repository-code: "https://github.com/semantica-agi/semantica"
|
||||
url: "https://getsemantica.ai"
|
||||
license: MIT
|
||||
version: 0.6.8
|
||||
date-released: 2026-09-05
|
||||
version: 0.6.7
|
||||
date-released: 2026-08-28
|
||||
keywords:
|
||||
- knowledge-graph
|
||||
- context-graph
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
|
||||
### Graph-Native Infrastructure for Context and Accountable AI Systems
|
||||
|
||||
#### *Developer-first, knowledge infrastructure for AI, alternative to expensive enterprise platforms.*
|
||||
#### *The Open Source Palantir for AI Agents*
|
||||
|
||||
> Ingest your enterprise data, extract what matters, build a Context Graph and knowledge graph (KG), and run graph analytics and causal reasoning over all of it, with full decision provenance baked in. Explainable, traceable, and trustworthy by design.
|
||||
|
||||
@@ -1461,36 +1461,20 @@ semantica-explorer --graph my_graph.json
|
||||
|
||||
For contributor / dev-server setup: **[explorer/README.md: Local Setup Guide](explorer/README.md)**
|
||||
|
||||
The CLI exposes the loaded `ContextGraph`. To also browse and edit an existing
|
||||
`AgentMemory`, create the ASGI app programmatically with both live objects:
|
||||
|
||||
```python
|
||||
from semantica.context import AgentMemory, ContextGraph
|
||||
from semantica.explorer.app import create_app
|
||||
from semantica.explorer.session import GraphSession
|
||||
|
||||
graph = ContextGraph()
|
||||
memory = AgentMemory()
|
||||
app = create_app(session=GraphSession(graph), agent_memory=memory)
|
||||
```
|
||||
|
||||
The Memories workspace is shown only when `agent_memory` is provided. Apply
|
||||
updates the supplied runtime object; it does not add disk persistence.
|
||||
|
||||
---
|
||||
|
||||
## What's New in v0.6.8
|
||||
## What's New in v0.6.7
|
||||
|
||||
**Every release from here on is cryptographically signed** — the build now runs SLSA build-provenance attestation plus Sigstore signing, and `.sigstore.json` bundles ship alongside the wheel/sdist on every GitHub Release, closing the OpenSSF Scorecard Signed-Releases gap. Beyond that, this is a large fix-and-hardening release plus a batch of vector-store and LLM-provider additions:
|
||||
**Feature release**, plus one SSRF hardening fix and a large batch of correctness fixes across the RDF/ontology export pipeline:
|
||||
|
||||
- **Vector store gains real enumeration**: `scan_vectors()`/`iter_vectors()` land across FAISS, SQLiteVec, PgVector, Qdrant, Weaviate, and Milvus (each via the pagination primitive its API actually supports), making `semantica store migrate` functional between backends for the first time; Weaviate also gains `delete_vectors()` for `ErasureCoordinator` support
|
||||
- **`semantica.llms` gains first-class `Anthropic`, `Gemini`, `Ollama`, `DeepSeek`, and `Novita` provider wrappers**, matching the existing `Groq`/`OpenAI` pattern
|
||||
- **Ontology package gains a deterministic, CI-friendly quality gate** for ontologies and knowledge graphs, plus first-class Google ADK integration and a Salesforce ingestor
|
||||
- **Explorer's read-only Markdown viewer becomes a full editor** for live `ContextGraph` nodes and host-supplied `AgentMemory` items
|
||||
- **`ErasureCoordinator`** completes the erasure workflow `purge_node()` only started, so a purged entity no longer survives verbatim in `AgentMemory` or as an embedding
|
||||
- **Security**: 12 Dependabot `aiohttp` alerts, 5 HIGH-severity Trivy container findings, and 2 npm advisories all resolved
|
||||
- **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 fixes 35 correctness bugs (Python 3.9 install breakage, FAISS save/load metadata loss, `semantica ingest`'s silent no-op against a configured graph store, MCP persistence, Explorer graph rendering, ontology property-collision handling, and more) and a large batch of documentation corrections across the site.
|
||||
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)
|
||||
|
||||
@@ -1543,7 +1527,7 @@ pip install semantica[watch] # Directory file watcher
|
||||
pip install semantica[explorer] # Knowledge Explorer dashboard
|
||||
```
|
||||
|
||||
For production deployments, use Docker or Kubernetes rather than a local `pip install`. Set `SEMANTICA_API_KEY`, configure a persistent LPG graph store (Neo4j / FalkorDB / Apache AGE / AWS Neptune) and/or RDF triple store (Blazegraph / Apache Jena / Eclipse RDF4J), and point the vector store at a hosted backend (Qdrant / Pinecone). See [ARCHITECTURE.md](ARCHITECTURE.md) for the full deployment topology.
|
||||
For production deployments, use Docker or Kubernetes rather than a local `pip install`. Set `SEMANTICA_SECRET_KEY`, configure a persistent LPG graph store (Neo4j / FalkorDB / Apache AGE / AWS Neptune) and/or RDF triple store (Blazegraph / Apache Jena / Eclipse RDF4J), and point the vector store at a hosted backend (Qdrant / Pinecone). See [ARCHITECTURE.md](ARCHITECTURE.md) for the full deployment topology.
|
||||
|
||||
```bash
|
||||
# From source
|
||||
|
||||
@@ -149,25 +149,25 @@ registry.register_plugin("my_plugin", MyPlugin, version="1.0.0")
|
||||
|
||||
<Accordion title="Modularity: use only what you need" icon="puzzle-piece">
|
||||
|
||||
Every component works standalone. `NERExtractor` runs without a graph store. `VectorStore` runs without decision tracking. The framework never forces a full stack instantiation; you pay only for what you import.
|
||||
Every component works standalone. `NERExtractor` runs without a graph store. `VectorStore` runs without decision tracking. The framework never forces a full stack instantiation: you pay only for what you import.
|
||||
|
||||
</Accordion>
|
||||
|
||||
<Accordion title="Pluggability: extend without modifying core" icon="plug">
|
||||
|
||||
Custom ingestors, extractors, validators, and exporters follow the same base class pattern. Register them via `PluginRegistry` and they participate in the full pipeline (provenance tracking, retry policies, and parallel execution included) with no changes to core code.
|
||||
Custom ingestors, extractors, validators, and exporters follow the same base class pattern. Register them via `PluginRegistry` and they participate in the full pipeline: provenance tracking, retry policies, and parallel execution included: with no changes to core code.
|
||||
|
||||
</Accordion>
|
||||
|
||||
<Accordion title="Provenance by default" icon="link">
|
||||
|
||||
Lineage tracking is built into graph construction at the lowest level. Every node and edge carries a `source_id` pointing back to the originating document, extraction method, and timestamp. There is no opt-in required; provenance is always on.
|
||||
Lineage tracking is built into graph construction at the lowest level. Every node and edge carries a `source_id` pointing back to the originating document, extraction method, and timestamp. There's no opt-in required: provenance is always on.
|
||||
|
||||
</Accordion>
|
||||
|
||||
<Accordion title="Configuration over convention" icon="sliders">
|
||||
|
||||
Centralized `ConfigManager` with environment variable overrides. No magic defaults; all behavior is explicit and overridable. Suitable for multi-environment deployments where dev, staging, and production need different backends.
|
||||
Centralized `ConfigManager` with environment variable overrides. No magic defaults: all behavior is explicit and overridable. Suitable for multi-environment deployments where dev, staging, and production need different backends.
|
||||
|
||||
</Accordion>
|
||||
|
||||
@@ -179,13 +179,13 @@ Centralized `ConfigManager` with environment variable overrides. No magic defaul
|
||||
| Characteristic | Mechanism |
|
||||
| :-------------- | :--------- |
|
||||
| **Parallel execution** | `Pipeline(workers=N)` with configurable workers per stage |
|
||||
| **Delta processing** | Incremental graph updates (no full recompute on new data) |
|
||||
| **Delta processing** | Incremental graph updates: no full recompute on new data |
|
||||
| **Streaming ingestion** | Process large corpora without loading everything into memory |
|
||||
| **Backend flexibility** | Swap in-memory NetworkX for Neo4j / FalkorDB with no API changes |
|
||||
| **Deduplication v2** | `blocking_v2`, `hybrid_v2`, `semantic_v2`: up to 7x faster than v1 |
|
||||
| **Indexed search** | Explorer search at 0.004ms on 118k nodes (v0.5.0) |
|
||||
|
||||
- [Modules](/modules): full module documentation with code examples.
|
||||
- [Learning More](/learning-more): configuration reference, performance guide, and troubleshooting.
|
||||
- [Pipeline Reference](/reference/pipeline): pipeline orchestration, workers, and retry policies.
|
||||
- [Core Reference](/reference/core): framework lifecycle, plugin registry, and configuration.
|
||||
- [Modules](/modules) — Full module documentation with code examples.
|
||||
- [Learning More](/learning-more) — Configuration reference, performance guide, and troubleshooting.
|
||||
- [Pipeline Reference](/reference/pipeline) — Pipeline orchestration, workers, and retry policies.
|
||||
- [Core Reference](/reference/core) — Framework lifecycle, plugin registry, and configuration.
|
||||
|
||||
+19
-19
@@ -5,7 +5,7 @@ icon: "compass"
|
||||
---
|
||||
|
||||
<Info>
|
||||
Every module works independently: import only what you need. This page maps developer goals to starting points. The [Module Reference](/modules) covers every module in depth.
|
||||
Every module works independently — import only what you need. This page maps developer goals to starting points. The [Module Reference](/modules) covers every module in depth.
|
||||
</Info>
|
||||
|
||||
## Quick Reference
|
||||
@@ -75,7 +75,7 @@ Pick your goal to see the minimum imports and a working skeleton.
|
||||
sources = FileIngestor().ingest("report.pdf")
|
||||
parsed = DocumentParser().parse_document("report.pdf")
|
||||
|
||||
# No API key required: pattern-based extraction
|
||||
# No API key required — pattern-based extraction
|
||||
entities = NERExtractor(method="pattern").extract(parsed)
|
||||
relationships = RelationExtractor(method="rule").extract(parsed, entities=entities)
|
||||
|
||||
@@ -89,7 +89,7 @@ Pick your goal to see the minimum imports and a working skeleton.
|
||||
Pass `method="pattern"` to `NERExtractor` for zero-cost, zero-API-key extraction. Switch to `method="llm"` with any of the supported providers for higher recall.
|
||||
</Tip>
|
||||
|
||||
See the [Quickstart →](/quickstart) for a full pipeline with visualization and export.
|
||||
**Next:** [Quickstart →](/quickstart) — full pipeline with visualization and export.
|
||||
</Tab>
|
||||
|
||||
<Tab title="Build GraphRAG">
|
||||
@@ -109,7 +109,7 @@ Pick your goal to see the minimum imports and a working skeleton.
|
||||
knowledge_graph=ContextGraph(advanced_analytics=True),
|
||||
)
|
||||
|
||||
# Store facts: retrieval uses both vectors and graph structure
|
||||
# Store facts — retrieval uses both vectors and graph structure
|
||||
context.store("Apple Inc. was co-founded by Steve Jobs in 1976 in Cupertino.")
|
||||
|
||||
# GraphRAG query with multi-hop reasoning trace
|
||||
@@ -206,11 +206,11 @@ Pick your goal to see the minimum imports and a working skeleton.
|
||||
```python
|
||||
from semantica.export import RDFExporter, ParquetExporter, LPGExporter, ArangoAQLExporter
|
||||
|
||||
# RDF: multiple serialization formats
|
||||
# RDF — multiple serialization formats
|
||||
RDFExporter().export(graph, "graph.ttl", format="turtle")
|
||||
RDFExporter().export(graph, "graph.jsonld", format="jsonld")
|
||||
|
||||
# Parquet: for Spark, BigQuery, Databricks, Snowflake
|
||||
# Parquet — for Spark, BigQuery, Databricks, Snowflake
|
||||
ParquetExporter().export(graph, "output/graph.parquet")
|
||||
|
||||
# Neo4j / Memgraph via Cypher
|
||||
@@ -225,15 +225,15 @@ Pick your goal to see the minimum imports and a working skeleton.
|
||||
**Next:** [Export module reference →](/reference/export)
|
||||
</Tab>
|
||||
|
||||
<Tab title="MCP: Claude / Cursor">
|
||||
Use Semantica from Claude Desktop, Cursor, VS Code, or any MCP-aware tool; no Python code required after setup. 15 tools are available.
|
||||
<Tab title="MCP — Claude / Cursor">
|
||||
Use Semantica from Claude Desktop, Cursor, VS Code, or any MCP-aware tool — no Python code required after setup. 15 tools available instantly.
|
||||
|
||||
**Step 1: Install**
|
||||
**Step 1 — Install:**
|
||||
```bash
|
||||
pip install semantica
|
||||
```
|
||||
|
||||
**Step 2: Add to your MCP client config**
|
||||
**Step 2 — Add to your MCP client config:**
|
||||
|
||||
<CodeGroup>
|
||||
|
||||
@@ -273,10 +273,10 @@ Pick your goal to see the minimum imports and a working skeleton.
|
||||
</Tabs>
|
||||
|
||||
|
||||
## Architecture Selection Guidance
|
||||
## Still Unsure?
|
||||
|
||||
<AccordionGroup>
|
||||
<Accordion title="Knowledge graph vs. vector store selection" icon="scale-balanced">
|
||||
<Accordion title="Knowledge graph vs. vector store — which do I need?" icon="scale-balanced">
|
||||
Use a **knowledge graph** (`kg`) when you need structured reasoning, multi-hop traversal, provenance, or compliance audit trails.
|
||||
|
||||
Use a **vector store** (`vector_store`) when you need fast fuzzy similarity search over large text corpora and relationships between items don't matter.
|
||||
@@ -286,12 +286,12 @@ Pick your goal to see the minimum imports and a working skeleton.
|
||||
See also: [Core Concepts](/concepts)
|
||||
</Accordion>
|
||||
|
||||
<Accordion title="Fast local pipeline setup" icon="rocket">
|
||||
<Accordion title="I just want to run something quickly." icon="rocket">
|
||||
Start with the [Quickstart](/quickstart). It builds a complete pipeline (ingest → parse → extract → graph → visualize → export) with no API key required.
|
||||
</Accordion>
|
||||
|
||||
<Accordion title="Minimum configuration for existing agents" icon="plug">
|
||||
Add `AgentContext` to equip an existing agent with memory, decision tracking, and precedent search, with no changes to your LLM provider or agent framework required.
|
||||
<Accordion title="I'm adding Semantica to an existing agent — what's the minimum?" icon="plug">
|
||||
Add `AgentContext`. It wraps your existing agent with memory, decision tracking, and precedent search — no changes to your LLM provider or agent framework needed.
|
||||
|
||||
```python
|
||||
from semantica.context import AgentContext, ContextGraph
|
||||
@@ -307,7 +307,7 @@ Pick your goal to see the minimum imports and a working skeleton.
|
||||
[Context module reference →](/reference/context)
|
||||
</Accordion>
|
||||
|
||||
<Accordion title="Minimum stack for compliance-ready pipelines" icon="shield-check">
|
||||
<Accordion title="I need a compliance-ready pipeline — what's the minimum stack?" icon="shield-check">
|
||||
| Layer | Module | Key class |
|
||||
| :---- | :------ | :--------- |
|
||||
| Ingestion | `ingest` | `FileIngestor` |
|
||||
@@ -322,6 +322,6 @@ Pick your goal to see the minimum imports and a working skeleton.
|
||||
|
||||
---
|
||||
|
||||
- [Quickstart](/quickstart): full pipeline in 5 minutes.
|
||||
- [Module Reference](/modules): every module with examples and common chains.
|
||||
- [API Reference](/reference/context): complete class and method documentation.
|
||||
- [Quickstart](/quickstart) — Full pipeline in 5 minutes.
|
||||
- [Module Reference](/modules) — Every module with examples and common chains.
|
||||
- [API Reference](/reference/context) — Complete class and method documentation.
|
||||
|
||||
+3
-3
@@ -43,10 +43,10 @@ icon: "quote-left"
|
||||
|
||||
## Share Your Research
|
||||
|
||||
If you publish research using Semantica, [let us know](https://github.com/semantica-agi/semantica/issues) so we can feature your work.
|
||||
Published research using Semantica? [Let us know](https://github.com/semantica-agi/semantica/issues): we may feature your work.
|
||||
|
||||
|
||||
## See Also
|
||||
|
||||
- [License](/project-license): MIT License details.
|
||||
- [Community](/community): connect with the Semantica community.
|
||||
- [License](/project-license) — MIT License details.
|
||||
- [Community](/community) — Connect with the Semantica community.
|
||||
|
||||
+11
-11
@@ -18,7 +18,7 @@ After installation the following commands are available:
|
||||
| Command | Entry point | What it does |
|
||||
| :------- | :----------- | :------------ |
|
||||
| `semantica` | `semantica.cli:main` | General-purpose CLI for pipeline runs, extraction, and graph operations |
|
||||
| `semantica-server` | `semantica.server:main` | FastAPI/uvicorn REST API server bound to `127.0.0.1:8000` by default (set `SEMANTICA_HOST` to override) |
|
||||
| `semantica-server` | `semantica.server:main` | FastAPI/uvicorn REST API server bound to `0.0.0.0:8000` |
|
||||
| `semantica-worker` | `semantica.worker:main` | Background worker process entry point for Semantica deployments |
|
||||
| `semantica-explorer` | `semantica.explorer:main` | Interactive browser dashboard for knowledge graph exploration |
|
||||
| `semantica-mcp` | `semantica.mcp_server:main` | MCP server (stdio) for Claude Desktop, Cursor, Windsurf, and other MCP clients |
|
||||
@@ -49,11 +49,11 @@ python -c "import semantica; print(semantica.__version__)"
|
||||
|
||||
## When to Use Each Command
|
||||
|
||||
- **semantica**: general-purpose CLI. Use it for one-off pipeline runs, entity extraction, and graph operations from a shell script or CI job.
|
||||
- **semantica-server**: starts the REST API server. Binds to `127.0.0.1:8000` by default; set `SEMANTICA_HOST` to expose beyond localhost. Use this when another service or application needs programmatic access to Semantica over HTTP.
|
||||
- **semantica-worker**: background task processor. Run alongside `semantica-server` when you need async pipeline execution outside the request cycle. Start the server first, then start one or more workers pointing at the same backend.
|
||||
- **semantica-explorer**: launches the browser dashboard. Requires `pip install semantica[explorer]`. Use this to explore a saved knowledge graph interactively. See [Explorer Setup](/explorer-setup).
|
||||
- **semantica-mcp**: runs the MCP server over stdio. Configure it in your MCP client's settings file to expose all 15 tools and 3 resources to Claude Desktop, Cursor, Windsurf, or any MCP-aware client. See [MCP Server](/reference/mcp_server).
|
||||
- **semantica** — The general-purpose CLI. Use it for one-off pipeline runs, entity extraction, and graph operations from a shell script or CI job.
|
||||
- **semantica-server** — Starts the REST API server. Binds to `0.0.0.0:8000`. Use this when another service or application needs programmatic access to Semantica over HTTP.
|
||||
- **semantica-worker** — Background task processor. Run alongside `semantica-server` when you need async pipeline execution outside the request cycle. Start the server first, then start one or more workers pointing at the same backend.
|
||||
- **semantica-explorer** — Launches the browser dashboard. Requires `pip install semantica[explorer]`. Use this to explore a saved knowledge graph interactively. See [Explorer Setup](/explorer-setup).
|
||||
- **semantica-mcp** — Runs the MCP server over stdio. Configure it in your MCP client's settings file to expose all 15 tools and 3 resources to Claude Desktop, Cursor, Windsurf, or any MCP-aware client. See [MCP Server](/reference/mcp_server).
|
||||
|
||||
|
||||
## Usage Examples
|
||||
@@ -61,7 +61,7 @@ python -c "import semantica; print(semantica.__version__)"
|
||||
<Tabs>
|
||||
<Tab title="REST server">
|
||||
```bash
|
||||
# Starts FastAPI + uvicorn on 127.0.0.1:8000 (set SEMANTICA_HOST to change)
|
||||
# Starts FastAPI + uvicorn on 0.0.0.0:8000
|
||||
semantica-server
|
||||
```
|
||||
|
||||
@@ -228,7 +228,7 @@ Install the [Microsoft Visual C++ Redistributable](https://aka.ms/vs/17/release/
|
||||
|
||||
## Next Steps
|
||||
|
||||
- [Explorer Setup](/explorer-setup): build a graph, save it, and launch the browser dashboard.
|
||||
- [MCP Server](/reference/mcp_server): all 15 tools and 3 resources exposed over the MCP protocol.
|
||||
- [Installation](/installation): virtual environments, optional extras, and platform-specific notes.
|
||||
- [Quickstart](/quickstart): end-to-end pipeline walkthrough with working code.
|
||||
- [Explorer Setup](/explorer-setup) — Build a graph, save it, and launch the browser dashboard.
|
||||
- [MCP Server](/reference/mcp_server) — All 15 tools and 3 resources exposed over the MCP protocol.
|
||||
- [Installation](/installation) — Virtual environments, optional extras, and platform-specific notes.
|
||||
- [Quickstart](/quickstart) — End-to-end pipeline walkthrough with working code.
|
||||
|
||||
@@ -66,12 +66,12 @@ Production deployments span regulated and high-stakes industries where AI accoun
|
||||
| :-------- | :---- |
|
||||
| **OpenAI** | GPT-4o, GPT-4, GPT-3.5 |
|
||||
| **Anthropic** | Claude Opus, Sonnet, Haiku |
|
||||
| **Google Gemini** | Gemini Pro and other Gemini models |
|
||||
| **Groq** | LLaMA, Mixtral (fast inference) |
|
||||
| **Google Gemini** |: |
|
||||
| **Groq** | LLaMA, Mixtral: fast inference |
|
||||
| **Ollama** | Fully local, air-gapped |
|
||||
| **HuggingFace** | Transformers-based local LLM models |
|
||||
| **DeepSeek** | deepseek-chat and reasoning models |
|
||||
| **Novita AI** | OpenAI-compatible gateway, DeepSeek-V3.2 default |
|
||||
| **HuggingFace** |: |
|
||||
| **DeepSeek** |: |
|
||||
| **Novita AI** |: |
|
||||
| **LiteLLM** | 100+ model gateway |
|
||||
</Tab>
|
||||
<Tab title="NLP Libraries">
|
||||
@@ -114,7 +114,7 @@ See [Architecture](/architecture#extension-points) for the full extension guide.
|
||||
|
||||
## How to Contribute
|
||||
|
||||
- [Contributing Guide](/contributing-guide): submit code, documentation, tests, or cookbook notebooks.
|
||||
- [GitHub Issues](https://github.com/semantica-agi/semantica/issues): report bugs, request features, or propose integrations.
|
||||
- [Discord](https://discord.gg/sV34vps5hH): share what you're building with the community.
|
||||
- [GitHub Discussions](https://github.com/semantica-agi/semantica/discussions): long-form questions, design discussions, and ideas.
|
||||
- [Contributing Guide](/contributing-guide) — Submit code, documentation, tests, or cookbook notebooks.
|
||||
- [GitHub Issues](https://github.com/semantica-agi/semantica/issues) — Report bugs, request features, or propose integrations.
|
||||
- [Discord](https://discord.gg/sV34vps5hH) — Share what you're building with the community.
|
||||
- [GitHub Discussions](https://github.com/semantica-agi/semantica/discussions) — Long-form questions, design discussions, and ideas.
|
||||
|
||||
+8
-8
@@ -9,10 +9,10 @@ Semantica is built in the open, with contributions from researchers, engineers,
|
||||
|
||||
## Get Help
|
||||
|
||||
- [GitHub Issues](https://github.com/semantica-agi/semantica/issues): file bug reports and feature requests with full context.
|
||||
- [GitHub Discussions](https://github.com/semantica-agi/semantica/discussions): ask questions, share ideas, and discuss design decisions.
|
||||
- [Pull Requests](https://github.com/semantica-agi/semantica/pulls): browse open contributions and submit your own.
|
||||
- [Security Issues](https://github.com/semantica-agi/semantica/security/advisories/new): report vulnerabilities privately (never in public issues).
|
||||
- [GitHub Issues](https://github.com/semantica-agi/semantica/issues) — File bug reports and feature requests with full context.
|
||||
- [GitHub Discussions](https://github.com/semantica-agi/semantica/discussions) — Ask questions, share ideas, and discuss design decisions.
|
||||
- [Pull Requests](https://github.com/semantica-agi/semantica/pulls) — Browse open contributions and submit your own.
|
||||
- [Security Issues](https://github.com/semantica-agi/semantica/security/advisories/new) — Report vulnerabilities privately: never in public issues.
|
||||
|
||||
|
||||
## Community Guidelines
|
||||
@@ -68,7 +68,7 @@ See the [Contributing Guide](/contributing-guide) for the full development workf
|
||||
|
||||
## See Also
|
||||
|
||||
- [Contributing Guide](/contributing-guide): step-by-step guide for submitting PRs and setting up your dev environment.
|
||||
- [Community Projects](/community-projects): projects and integrations built by the community.
|
||||
- [FAQ](/faq): common questions answered.
|
||||
- [Governance](/governance): how the project is run and decisions are made.
|
||||
- [Contributing Guide](/contributing-guide) — Step-by-step guide for submitting PRs and setting up your dev environment.
|
||||
- [Community Projects](/community-projects) — Projects and integrations built by the community.
|
||||
- [FAQ](/faq) — Common questions answered.
|
||||
- [Governance](/governance) — How the project is run and decisions are made.
|
||||
|
||||
@@ -4,7 +4,7 @@ description: "How to contribute code, documentation, tests, and community suppor
|
||||
icon: "code-pull-request"
|
||||
---
|
||||
|
||||
Contributions of all kinds are welcome (code, documentation, tests, and community support). Every contribution is recognized in release notes and the GitHub contributors list.
|
||||
Contributions of all kinds are welcome: code, documentation, tests, and community support. Every contribution is recognized in release notes and the GitHub contributors list.
|
||||
|
||||
|
||||
## Quick Start
|
||||
@@ -17,15 +17,15 @@ pip install -e ".[dev]"
|
||||
pytest
|
||||
```
|
||||
|
||||
First-time contributors can start with [`good-first-issue`](https://github.com/semantica-agi/semantica/labels/good-first-issue) labeled tickets, which are scoped to be completable in a few hours without deep codebase knowledge.
|
||||
New to the project? Start with [`good-first-issue`](https://github.com/semantica-agi/semantica/labels/good-first-issue) labeled tickets: they're scoped to be completable in a few hours without deep codebase knowledge.
|
||||
|
||||
|
||||
## Ways to Contribute
|
||||
|
||||
- **Code**: fix bugs, implement features, optimize performance, or add new ingestors, parsers, and exporters using the plugin registry.
|
||||
- **Documentation**: fix typos, improve clarity, add missing examples, write tutorials, or keep the API reference accurate as modules evolve.
|
||||
- **Testing**: add test coverage for untested modules or edge cases, reproduce reported bugs with minimal repros, or improve cross-platform reliability.
|
||||
- **Community**: answer questions in GitHub Issues and Discussions, review pull requests with constructive feedback, or share Semantica in blog posts and talks.
|
||||
- **Code** — Fix bugs, implement features, optimize performance, or add new ingestors, parsers, and exporters using the plugin registry.
|
||||
- **Documentation** — Fix typos, improve clarity, add missing examples, write tutorials, or keep the API reference accurate as modules evolve.
|
||||
- **Testing** — Add test coverage for untested modules or edge cases, reproduce reported bugs with minimal repros, or improve cross-platform reliability.
|
||||
- **Community** — Answer questions in GitHub Issues and Discussions, review pull requests with constructive feedback, or share Semantica in blog posts and talks.
|
||||
|
||||
|
||||
## Development Setup
|
||||
@@ -76,7 +76,7 @@ Before submitting a PR, confirm:
|
||||
|
||||
## Code of Conduct
|
||||
|
||||
All contributors are expected to follow the [Contributor Covenant Code of Conduct](https://github.com/semantica-agi/semantica/blob/main/CODE_OF_CONDUCT.md). Be respectful, patient, and constructive, especially toward newcomers. Report violations by opening an issue with the `[CoC]` prefix.
|
||||
All contributors are expected to follow the [Contributor Covenant Code of Conduct](https://github.com/semantica-agi/semantica/blob/main/CODE_OF_CONDUCT.md). Be respectful, patient, and constructive: especially toward newcomers. Report violations by opening an issue with the `[CoC]` prefix.
|
||||
|
||||
|
||||
## Help
|
||||
@@ -85,5 +85,5 @@ All contributors are expected to follow the [Contributor Covenant Code of Conduc
|
||||
- [GitHub Discussions](https://github.com/semantica-agi/semantica/discussions)
|
||||
- [Discord](https://discord.gg/sV34vps5hH)
|
||||
|
||||
- [Community](/community): community guidelines and values.
|
||||
- [Governance](/governance): how decisions are made and the project is run.
|
||||
- [Community](/community) — Community guidelines and values.
|
||||
- [Governance](/governance) — How decisions are made and the project is run.
|
||||
|
||||
+25
-25
@@ -18,43 +18,43 @@ icon: "flask"
|
||||
|
||||
## Featured Recipe
|
||||
|
||||
- **[Your First Knowledge Graph](https://github.com/semantica-agi/semantica/blob/main/cookbook/introduction/08_Your_First_Knowledge_Graph.ipynb)**: go from raw text to a queryable knowledge graph in 20 minutes. Topics: Extraction, Graph Construction, Visualization · *Beginner*
|
||||
- **[Your First Knowledge Graph](https://github.com/semantica-agi/semantica/blob/main/cookbook/introduction/08_Your_First_Knowledge_Graph.ipynb)** — Go from raw text to a queryable knowledge graph in 20 minutes. Topics: Extraction, Graph Construction, Visualization · *Beginner*
|
||||
|
||||
|
||||
## Core Tutorials
|
||||
|
||||
Essential guides to master the Semantica framework.
|
||||
|
||||
- **[Welcome to Semantica](https://github.com/semantica-agi/semantica/blob/main/cookbook/introduction/01_Welcome_to_Semantica.ipynb)**: interactive introduction to the framework's core philosophy and all modules. Topics: Framework Overview, Architecture · *Beginner*
|
||||
- **[Data Ingestion](https://github.com/semantica-agi/semantica/blob/main/cookbook/introduction/02_Data_Ingestion.ipynb)**: loading data from files, web, databases, streams, feeds, repositories, email, and MCP. Topics: FileIngestor, WebIngestor, DBIngestor · *Beginner*
|
||||
- **[Document Parsing](https://github.com/semantica-agi/semantica/blob/main/cookbook/introduction/03_Document_Parsing.ipynb)**: extracting clean text from complex formats like PDF, DOCX, and HTML. Topics: OCR, PDF Parsing, Text Extraction · *Beginner*
|
||||
- **[Data Normalization](https://github.com/semantica-agi/semantica/blob/main/cookbook/introduction/04_Data_Normalization.ipynb)**: pipelines for cleaning, normalizing, and preparing text. Topics: Text Cleaning, Unicode, Formatting · *Beginner*
|
||||
- **[Entity Extraction](https://github.com/semantica-agi/semantica/blob/main/cookbook/introduction/05_Entity_Extraction.ipynb)**: using NER to identify people, organizations, and custom entities. Topics: NER, spaCy, LLM Extraction · *Beginner*
|
||||
- **[Relation Extraction](https://github.com/semantica-agi/semantica/blob/main/cookbook/introduction/06_Relation_Extraction.ipynb)**: discovering and classifying relationships between entities. Topics: Relation Classification, Dependency Parsing · *Beginner*
|
||||
- **[Embedding Generation](https://github.com/semantica-agi/semantica/blob/main/cookbook/introduction/12_Embedding_Generation.ipynb)**: creating and managing vector embeddings for semantic search. Topics: Embeddings, OpenAI, HuggingFace · *Intermediate*
|
||||
- **[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*
|
||||
- **[Semantic Layer Basics](https://github.com/semantica-agi/semantica/blob/main/cookbook/introduction/26_Semantic_Layer_Basics.ipynb)**: capstone tutorial that combines a knowledge graph, generated ontology, explicit mappings, ontology-aligned RDF, and a SPARQL query. Topics: Semantic Layer, Ontology Mapping, Oxigraph, SPARQL · *Intermediate*
|
||||
- **[Welcome to Semantica](https://github.com/semantica-agi/semantica/blob/main/cookbook/introduction/01_Welcome_to_Semantica.ipynb)** — Interactive introduction to the framework's core philosophy and all modules. Topics: Framework Overview, Architecture · *Beginner*
|
||||
- **[Data Ingestion](https://github.com/semantica-agi/semantica/blob/main/cookbook/introduction/02_Data_Ingestion.ipynb)** — Loading data from files, web, databases, streams, feeds, repositories, email, and MCP. Topics: FileIngestor, WebIngestor, DBIngestor · *Beginner*
|
||||
- **[Document Parsing](https://github.com/semantica-agi/semantica/blob/main/cookbook/introduction/03_Document_Parsing.ipynb)** — Extracting clean text from complex formats like PDF, DOCX, and HTML. Topics: OCR, PDF Parsing, Text Extraction · *Beginner*
|
||||
- **[Data Normalization](https://github.com/semantica-agi/semantica/blob/main/cookbook/introduction/04_Data_Normalization.ipynb)** — Pipelines for cleaning, normalizing, and preparing text. Topics: Text Cleaning, Unicode, Formatting · *Beginner*
|
||||
- **[Entity Extraction](https://github.com/semantica-agi/semantica/blob/main/cookbook/introduction/05_Entity_Extraction.ipynb)** — Using NER to identify people, organizations, and custom entities. Topics: NER, spaCy, LLM Extraction · *Beginner*
|
||||
- **[Relation Extraction](https://github.com/semantica-agi/semantica/blob/main/cookbook/introduction/06_Relation_Extraction.ipynb)** — Discovering and classifying relationships between entities. Topics: Relation Classification, Dependency Parsing · *Beginner*
|
||||
- **[Embedding Generation](https://github.com/semantica-agi/semantica/blob/main/cookbook/introduction/12_Embedding_Generation.ipynb)** — Creating and managing vector embeddings for semantic search. Topics: Embeddings, OpenAI, HuggingFace · *Intermediate*
|
||||
- **[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*
|
||||
- **[Semantic Layer Basics](https://github.com/semantica-agi/semantica/blob/main/cookbook/introduction/26_Semantic_Layer_Basics.ipynb)** — Capstone tutorial that combines a knowledge graph, generated ontology, explicit mappings, ontology-aligned RDF, and a SPARQL query. Topics: Semantic Layer, Ontology Mapping, Oxigraph, SPARQL · *Intermediate*
|
||||
|
||||
|
||||
## Advanced Concepts
|
||||
|
||||
Deep dive into advanced features, customization, and complex workflows.
|
||||
|
||||
- **[Advanced Extraction](https://github.com/semantica-agi/semantica/blob/main/cookbook/advanced/01_Advanced_Extraction.ipynb)**: custom extractors, LLM-based extraction, and complex pattern matching. Topics: Custom Models, Regex, LLMs · *Advanced*
|
||||
- **[Advanced Graph Analytics](https://github.com/semantica-agi/semantica/blob/main/cookbook/advanced/02_Advanced_Graph_Analytics.ipynb)**: centrality, community detection, and pathfinding algorithms. Topics: PageRank, Louvain, Shortest Path · *Advanced*
|
||||
- **[Advanced Context Engineering](https://github.com/semantica-agi/semantica/blob/main/cookbook/advanced/11_Advanced_Context_Engineering.ipynb)**: persistent memory system for AI agents using FAISS and Neo4j. Topics: Agent Memory, GraphRAG, Entity Injection · *Advanced*
|
||||
- **[Complete Visualization Suite](https://github.com/semantica-agi/semantica/blob/main/cookbook/advanced/03_Complete_Visualization_Suite.ipynb)**: interactive network, analytics, and temporal visualizations for graphs. Topics: PyVis, NetworkX, D3.js · *Intermediate*
|
||||
- **[Conflict Resolution](https://github.com/semantica-agi/semantica/blob/main/cookbook/introduction/17_Conflict_Detection_and_Resolution.ipynb)**: strategies for handling contradictory information from multiple sources. Topics: Truth Discovery, Voting, Confidence · *Advanced*
|
||||
- **[Multi-Format Export](https://github.com/semantica-agi/semantica/blob/main/cookbook/advanced/05_Multi_Format_Export.ipynb)**: exporting to RDF, OWL, JSON-LD, and NetworkX formats. Topics: Serialization, Interoperability · *Intermediate*
|
||||
- **[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)**: W3C PROV-O-aligned lineage tracking and checksum verification for entities, relationships, and chunks. 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*
|
||||
- **[Advanced Extraction](https://github.com/semantica-agi/semantica/blob/main/cookbook/advanced/01_Advanced_Extraction.ipynb)** — Custom extractors, LLM-based extraction, and complex pattern matching. Topics: Custom Models, Regex, LLMs · *Advanced*
|
||||
- **[Advanced Graph Analytics](https://github.com/semantica-agi/semantica/blob/main/cookbook/advanced/02_Advanced_Graph_Analytics.ipynb)** — Centrality, community detection, and pathfinding algorithms. Topics: PageRank, Louvain, Shortest Path · *Advanced*
|
||||
- **[Advanced Context Engineering](https://github.com/semantica-agi/semantica/blob/main/cookbook/advanced/11_Advanced_Context_Engineering.ipynb)** — Production-grade memory system for AI agents using FAISS and Neo4j. Topics: Agent Memory, GraphRAG, Entity Injection · *Advanced*
|
||||
- **[Complete Visualization Suite](https://github.com/semantica-agi/semantica/blob/main/cookbook/advanced/03_Complete_Visualization_Suite.ipynb)** — Interactive, publication-ready visualizations of your graphs. Topics: PyVis, NetworkX, D3.js · *Intermediate*
|
||||
- **[Conflict Resolution](https://github.com/semantica-agi/semantica/blob/main/cookbook/introduction/17_Conflict_Detection_and_Resolution.ipynb)** — Strategies for handling contradictory information from multiple sources. Topics: Truth Discovery, Voting, Confidence · *Advanced*
|
||||
- **[Multi-Format Export](https://github.com/semantica-agi/semantica/blob/main/cookbook/advanced/05_Multi_Format_Export.ipynb)** — Exporting to RDF, OWL, JSON-LD, and NetworkX formats. Topics: Serialization, Interoperability · *Intermediate*
|
||||
- **[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
|
||||
|
||||
@@ -109,7 +109,7 @@ Explorer loads a graph from a JSON file on disk. You need to create that file fi
|
||||
</Steps>
|
||||
|
||||
<Tip>
|
||||
Pipelines that already produced a saved graph can skip straight to Step 2, provided the file was saved with `ContextGraph.save_to_file()`.
|
||||
Already have a graph from a pipeline run? Skip straight to Step 2. The only requirement is that the file was saved with `ContextGraph.save_to_file()`.
|
||||
</Tip>
|
||||
|
||||
|
||||
@@ -264,7 +264,7 @@ Once running, Explorer exposes a REST API and dashboard for:
|
||||
|
||||
The full endpoint catalogue is documented in the Swagger UI at `/docs` and in the reference page below.
|
||||
|
||||
- [Explorer Reference](/reference/explorer): every REST endpoint, WebSocket events, analytics, and all supported flags.
|
||||
- [CLI Setup](/cli-setup): all five Semantica executables and when to use each one.
|
||||
- [Context Module](/reference/context): full documentation for ContextGraph (build, query, save, and load).
|
||||
- [Quickstart](/quickstart): end-to-end pipeline (ingest → extract → build graph → export).
|
||||
- [Explorer Reference](/reference/explorer) — Every REST endpoint, WebSocket events, analytics, and all supported flags.
|
||||
- [CLI Setup](/cli-setup) — All five Semantica executables and when to use each one.
|
||||
- [Context Module](/reference/context) — Full documentation for ContextGraph: build, query, save, and load.
|
||||
- [Quickstart](/quickstart) — End-to-end pipeline: ingest → extract → build graph → export.
|
||||
|
||||
+10
-10
@@ -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, security fixes shipped in every release (see [CHANGELOG](https://github.com/semantica-agi/semantica/blob/main/CHANGELOG.md)) |
|
||||
| Latest version? | **v0.6.8** (September 2026) |
|
||||
| Latest version? | **v0.6.7** (August 2026) |
|
||||
| Local LLMs? | Yes: Ollama via LiteLLM, HuggingFaceLLM for air-gapped |
|
||||
|
||||
|
||||
@@ -27,7 +27,7 @@ icon: "circle-question"
|
||||
|
||||
<Accordion title="What is Semantica?" icon="info-circle">
|
||||
|
||||
Semantica is an open-source framework for building context graphs and decision intelligence layers for AI. It transforms unstructured data (documents, APIs, databases) into structured knowledge graphs with full provenance tracking, making AI systems explainable and auditable.
|
||||
Semantica is an open-source framework for building context graphs and decision intelligence layers for AI. It transforms unstructured data: documents, APIs, databases: into structured knowledge graphs with full provenance tracking, making AI systems explainable and auditable.
|
||||
|
||||
It's not a replacement for LangChain or LlamaIndex. It's the **accountability layer** that goes on top: recording decisions, tracing facts to sources, and making reasoning transparent.
|
||||
|
||||
@@ -46,7 +46,7 @@ It's not a replacement for LangChain or LlamaIndex. It's the **accountability la
|
||||
|
||||
<Accordion title="What makes Semantica different from LangChain or LlamaIndex?" icon="scale-balanced">
|
||||
|
||||
Most frameworks stop at retrieval or generation. Semantica adds an **accountability layer**: every decision is recorded, every fact links to a source, and every reasoning step is explainable. It's designed for environments where you need to audit *why* an AI reached a conclusion, not just what it said.
|
||||
Most frameworks stop at retrieval or generation. Semantica adds an **accountability layer**: every decision is recorded, every fact links to a source, and every reasoning step is explainable. It's designed for environments where you need to audit *why* an AI reached a conclusion: not just what it said.
|
||||
|
||||
Semantica works alongside these frameworks, not against them.
|
||||
|
||||
@@ -54,11 +54,11 @@ Semantica works alongside these frameworks, not against them.
|
||||
|
||||
<Accordion title="Does Semantica explain an LLM's internal reasoning or chain-of-thought?" icon="triangle-exclamation">
|
||||
|
||||
No. This is **system-level explainability, not foundation-model explainability**. Semantica does not expose, reconstruct, or explain what happens *inside* the LLM/foundation model. Its internal reasoning or chain-of-thought stays opaque, as it does for any external system.
|
||||
No. This is **system-level explainability, not foundation-model explainability**. Semantica does not expose, reconstruct, or explain what happens *inside* the LLM/foundation model — its internal reasoning or chain-of-thought stays opaque, as it does for any external system.
|
||||
|
||||
What Semantica explains is *outside* the model: what context and data were used, what decision was produced, the provenance behind it, the relevant relationships, the policies applied, and the resulting decision trail.
|
||||
|
||||
In short, Semantica explains and audits *what the AI system did*, not the foundation model's private internal reasoning.
|
||||
In short: Semantica explains and audits *what the AI system did* — not the foundation model's private internal reasoning.
|
||||
|
||||
</Accordion>
|
||||
|
||||
@@ -70,9 +70,9 @@ Yes: MIT licensed, no vendor lock-in, no paywalled features. Some capabilities r
|
||||
|
||||
<Accordion title="What's the latest version?" icon="star">
|
||||
|
||||
**v0.6.8**: released September 2026.
|
||||
**v0.6.7**: released August 2026.
|
||||
|
||||
Highlights: every release is now cryptographically signed (SLSA build provenance + Sigstore, closing the OpenSSF Scorecard Signed-Releases gap), real vector-store enumeration (`scan_vectors()`/`iter_vectors()`) across FAISS/SQLiteVec/PgVector/Qdrant/Weaviate/Milvus making `store migrate` functional, first-class Anthropic/Gemini/Ollama/DeepSeek/Novita LLM provider wrappers, a CI-friendly ontology quality gate, and 35 correctness fixes. The 0.6.x line also added first-class LangChain and CrewAI support and the Semantica RDF vocabulary with deterministic IRIs. See the [CHANGELOG](https://github.com/semantica-agi/semantica/blob/main/CHANGELOG.md) for the full history.
|
||||
Highlights: first-class LangChain integration, SAP OData ingestor, human-editable Markdown round-trip persistence for `ContextGraph`, a structured Action layer for the reasoning engine, and a public `run_shacl_validation` entry point. The 0.6.x line also added first-class CrewAI support and the Semantica RDF vocabulary with deterministic IRIs. See the [CHANGELOG](https://github.com/semantica-agi/semantica/blob/main/CHANGELOG.md) for the full history.
|
||||
|
||||
```bash
|
||||
pip install --upgrade semantica
|
||||
@@ -348,6 +348,6 @@ set PYTHONIOENCODING=utf-8
|
||||
|
||||
## Support
|
||||
|
||||
- [Discord](https://discord.gg/sV34vps5hH): community chat and live support.
|
||||
- [GitHub Issues](https://github.com/semantica-agi/semantica/issues): bug reports and feature requests.
|
||||
- [Contributing](/contributing-guide): help improve Semantica.
|
||||
- [Discord](https://discord.gg/sV34vps5hH) — Community chat and live support.
|
||||
- [GitHub Issues](https://github.com/semantica-agi/semantica/issues) — Bug reports and feature requests.
|
||||
- [Contributing](/contributing-guide) — Help improve Semantica.
|
||||
|
||||
@@ -42,7 +42,7 @@ icon: "rocket"
|
||||
Verify installation:
|
||||
```python
|
||||
import semantica
|
||||
print(semantica.__version__) # 0.6.8
|
||||
print(semantica.__version__) # 0.6.7
|
||||
```
|
||||
</Check>
|
||||
</Step>
|
||||
|
||||
+32
-32
@@ -23,16 +23,16 @@ A persistent, queryable graph of everything an agent knows, decides, and reasons
|
||||
A first-class object in Semantica: a recorded agent choice with category, scenario, reasoning, outcome, confidence score, causal chain, and source provenance. Stored and searchable via `context.record_decision()`.
|
||||
|
||||
**Entity**
|
||||
A distinct object or concept in the real world (person, organization, location, event, or abstract concept). Entities are nodes in a knowledge graph, each with typed properties and a source provenance record.
|
||||
A distinct object or concept in the real world: a person, organization, location, event, or abstract concept. Entities are nodes in a knowledge graph, each with typed properties and a source provenance record.
|
||||
|
||||
**Knowledge Graph (KG)**
|
||||
A structured representation of knowledge using entities (nodes) and relationships (edges). Knowledge graphs enable reasoning, querying, semantic search, and traceable inference, unlike flat vector stores.
|
||||
A structured representation of knowledge using entities (nodes) and relationships (edges). Knowledge graphs enable reasoning, querying, semantic search, and traceable inference: unlike flat vector stores.
|
||||
|
||||
**Relationship**
|
||||
A directed, typed connection between two entities (e.g., `works_for`, `located_in`, `founded_by`). Relationships carry confidence scores and provenance back to the source document.
|
||||
A directed, typed connection between two entities: e.g., `works_for`, `located_in`, `founded_by`. Relationships carry confidence scores and provenance back to the source document.
|
||||
|
||||
**Semantic**
|
||||
Relating to meaning in language or logic. Semantic understanding captures context and intent, going beyond keyword matching to understand what text *means*.
|
||||
Relating to meaning in language or logic. Semantic understanding captures context and intent: going beyond keyword matching to understand what text *means*.
|
||||
|
||||
|
||||
## Data Processing
|
||||
@@ -41,19 +41,19 @@ Relating to meaning in language or logic. Semantic understanding captures contex
|
||||
Splitting large documents into smaller pieces while preserving semantic context. Semantica supports recursive, semantic boundary, entity-aware, relation-aware, sliding window, structural, and table-aware chunking strategies.
|
||||
|
||||
**Ingestion**
|
||||
Loading data from external sources (files, databases, APIs, streams) into the pipeline as a unified `SourceDocument`. The first stage in every Semantica pipeline.
|
||||
Loading data from external sources: files, databases, APIs, streams: into the pipeline as a unified `SourceDocument`. The first stage in every Semantica pipeline.
|
||||
|
||||
**Normalization**
|
||||
Standardizing data into a consistent canonical form by converting dates to ISO format, canonicalizing entity names, fixing encoding issues, and stripping noise. Ensures downstream extraction works on clean, consistent text.
|
||||
Standardizing data into a consistent canonical form: converting dates to ISO format, canonicalizing entity names, fixing encoding issues, stripping noise. Ensures downstream extraction works on clean, consistent text.
|
||||
|
||||
**Parsing**
|
||||
Extracting structured text, layout, and metadata from unstructured or semi-structured documents (PDFs, Word files, HTML, PPTX). `DoclingParser` additionally handles multi-column layouts, merged-cell tables, and OCR.
|
||||
Extracting structured text, layout, and metadata from unstructured or semi-structured documents: PDFs, Word files, HTML, PPTX. `DoclingParser` additionally handles multi-column layouts, merged-cell tables, and OCR.
|
||||
|
||||
|
||||
## Artificial Intelligence
|
||||
|
||||
**Abductive Reasoning**
|
||||
Inference to the most plausible explanation for observed facts. One of six reasoning engines in `semantica.reasoning`, returning the most likely hypothesis given available evidence.
|
||||
Inference to the most plausible explanation for observed facts. One of six reasoning engines in `semantica.reasoning`: returns the most likely hypothesis given available evidence.
|
||||
|
||||
**Datalog**
|
||||
A declarative logic programming language for knowledge base queries. Semantica's `DatalogEngine` supports recursive Horn clause rules with bottom-up semi-naive fixpoint semantics. Added in v0.4.0.
|
||||
@@ -62,7 +62,7 @@ A declarative logic programming language for knowledge base queries. Semantica's
|
||||
An advanced RAG approach that combines vector similarity search with knowledge graph traversal. Every LLM response is grounded in structured graph context, with each claim traceable to a source node. Eliminates hallucination without source attribution.
|
||||
|
||||
**Inference**
|
||||
Deriving new facts or conclusions from existing knowledge using logical rules, without the derived facts being explicitly present in the source data.
|
||||
Deriving new facts or conclusions from existing knowledge using logical rules: without the derived facts being explicitly present in the source data.
|
||||
|
||||
**LLM (Large Language Model)**
|
||||
An AI model trained on large text corpora, capable of understanding and generating natural language. Semantica integrates with 8+ LLM providers for entity extraction, relation extraction, and reasoning.
|
||||
@@ -74,7 +74,7 @@ A technique that enhances LLM outputs by retrieving relevant context from a know
|
||||
## Knowledge Graph Components
|
||||
|
||||
**Allen Interval Algebra**
|
||||
A system of 13 relations for describing how two time intervals relate (before, after, meets, overlaps, during, starts, finishes, equals, and their inverses). Supported in `TemporalKnowledgeGraph` since v0.4.0.
|
||||
A system of 13 relations for describing how two time intervals relate: before, after, meets, overlaps, during, starts, finishes, equals, and their inverses. Supported in `TemporalKnowledgeGraph` since v0.4.0.
|
||||
|
||||
**BiTemporalFact**
|
||||
A fact with two independent time dimensions: *valid time* (when it was true in the world) and *transaction time* (when it was recorded in the system). Enables full audit trails for slowly changing data.
|
||||
@@ -86,43 +86,43 @@ A directed connection between two nodes in a graph, representing a typed relatio
|
||||
A vertex in a knowledge graph representing an entity or concept. Nodes carry typed properties, a confidence score, and provenance linking back to the source document.
|
||||
|
||||
**Property**
|
||||
An attribute or characteristic of an entity or relationship, such as name, date, URI, confidence score, or source URL.
|
||||
An attribute or characteristic of an entity or relationship: name, date, URI, confidence score, source URL.
|
||||
|
||||
**Temporal Graph**
|
||||
A knowledge graph where nodes and edges carry `valid_from` / `valid_until` time windows, enabling point-in-time queries and historical state reconstruction.
|
||||
|
||||
**Triplet**
|
||||
The atomic unit of knowledge: a `(subject, predicate, object)` triple (e.g., `(Apple_Inc, founded_by, Steve_Jobs)`). The building block of RDF and SPARQL-based storage.
|
||||
The atomic unit of knowledge: a `(subject, predicate, object)` triple: e.g., `(Apple_Inc, founded_by, Steve_Jobs)`. The building block of RDF and SPARQL-based storage.
|
||||
|
||||
|
||||
## Entity Recognition & Extraction
|
||||
|
||||
**Coreference Resolution**
|
||||
Determining when multiple expressions in text refer to the same entity (e.g., "Apple" and "the company" both referring to Apple Inc.). Handled by `CoreferenceResolver` in `semantica.semantic_extract`.
|
||||
Determining when multiple expressions in text refer to the same entity: e.g., "Apple" and "the company" both referring to Apple Inc. Handled by `CoreferenceResolver` in `semantica.semantic_extract`.
|
||||
|
||||
**Entity Resolution**
|
||||
Determining when two entity mentions across different documents refer to the same real-world entity. Also called entity linking or deduplication. Uses similarity scoring, blocking, and semantic embeddings.
|
||||
|
||||
**Event Detection**
|
||||
Identifying and classifying events in text (acquisitions, partnerships, product launches, regulatory decisions). Handled by `EventDetector` in `semantica.semantic_extract`.
|
||||
Identifying and classifying events in text: acquisitions, partnerships, product launches, regulatory decisions. Handled by `EventDetector` in `semantica.semantic_extract`.
|
||||
|
||||
**Named Entity Recognition (NER)**
|
||||
Identifying and classifying named entities in text into predefined categories (persons, organizations, locations, dates, products, and custom types). Three modes: pattern-based, ML-based, and LLM-based.
|
||||
Identifying and classifying named entities in text into predefined categories: persons, organizations, locations, dates, products, and custom types. Three modes: pattern-based, ML-based, and LLM-based.
|
||||
|
||||
**Relationship Extraction**
|
||||
Identifying and extracting typed semantic relationships between entities (such as `(Google, acquired, DeepMind)`) from raw text.
|
||||
Identifying and extracting typed semantic relationships between entities: e.g., `(Google, acquired, DeepMind)`: from raw text.
|
||||
|
||||
|
||||
## Ontology & Schema
|
||||
|
||||
**Axiom**
|
||||
A statement accepted as true in an ontology, used to define logical constraints (e.g., "every Person must have a name", "Organization can have at most one CEO at a time").
|
||||
A statement accepted as true in an ontology, used to define logical constraints: e.g., "every Person must have a name", "Organization can have at most one CEO at a time".
|
||||
|
||||
**Class**
|
||||
A category or type of entity in an ontology (`Person`, `Organization`, `Location`). Classes form a hierarchy and carry constraints validated by SHACL.
|
||||
A category or type of entity in an ontology: `Person`, `Organization`, `Location`. Classes form a hierarchy and carry constraints validated by SHACL.
|
||||
|
||||
**Ontology**
|
||||
A formal specification of domain concepts, relationships, and constraints, typically expressed in OWL. Semantica can auto-generate ontologies from knowledge graphs or import existing OWL/RDF/Turtle files.
|
||||
A formal specification of domain concepts, relationships, and constraints: typically expressed in OWL. Semantica can auto-generate ontologies from knowledge graphs or import existing OWL/RDF/Turtle files.
|
||||
|
||||
**Ontology Hub**
|
||||
Semantica's v0.5.0 visual browser UI for the full ontology lifecycle: visual class editor, SHACL Studio, alignment authoring, health dashboard, and version-controlled diffs.
|
||||
@@ -140,13 +140,13 @@ A W3C standard for representing controlled vocabularies, taxonomies, and thesaur
|
||||
## Storage & Retrieval
|
||||
|
||||
**Embedding**
|
||||
A dense numerical vector that represents text, images, or other data in a continuous semantic space. Entities with similar meaning produce vectors that are close together, enabling similarity search and semantic matching.
|
||||
A dense numerical vector that represents text, images, or other data in a continuous semantic space. Entities with similar meaning produce vectors that are close together: enabling similarity search and semantic matching.
|
||||
|
||||
**Graph Database**
|
||||
A database optimized for storing and querying graph-structured data using node and edge primitives. Semantica supports Neo4j, FalkorDB, Apache AGE, and Amazon Neptune.
|
||||
|
||||
**Hybrid Search**
|
||||
A retrieval strategy combining vector similarity search with keyword or metadata filtering, achieving higher accuracy than either approach alone.
|
||||
A retrieval strategy combining vector similarity search with keyword or metadata filtering: higher accuracy than either approach alone.
|
||||
|
||||
**Triplet Store**
|
||||
A database designed specifically for storing and querying RDF `(subject, predicate, object)` triples. Semantica supports embedded Oxigraph as well as Blazegraph, Apache Jena, and RDF4J.
|
||||
@@ -158,19 +158,19 @@ A database optimized for storing and searching high-dimensional embedding vector
|
||||
## Graph Analytics
|
||||
|
||||
**Centrality**
|
||||
A measure of a node's importance in the graph. Common metrics include PageRank (link-based importance), betweenness centrality (bridge nodes), and closeness centrality (average distance to all others).
|
||||
A measure of a node's importance in the graph. Common metrics: PageRank (link-based importance), betweenness centrality (bridge nodes), closeness centrality (average distance to all others).
|
||||
|
||||
**Community Detection**
|
||||
Identifying groups of densely connected nodes (clusters that share more internal links than external ones). Used for finding subject communities, fraud rings, and organizational clusters.
|
||||
Identifying groups of densely connected nodes: clusters that share more internal links than external ones. Used for finding subject communities, fraud rings, and organizational clusters.
|
||||
|
||||
**Distance Band**
|
||||
A classification of a node's semantic proximity to a target (`near`, `mid`, or `far`) based on embedding distance thresholds. Part of Distance Intelligence (v0.5.0).
|
||||
A classification of a node's semantic proximity to a target: `near`, `mid`, or `far`, based on embedding distance thresholds. Part of Distance Intelligence (v0.5.0).
|
||||
|
||||
**Distance Intelligence**
|
||||
Semantica's v0.5.0 feature for semantic neighborhood exploration, including N×N distance matrices, ego-mode visualization centered on a single entity, and distance band classification across the graph.
|
||||
Semantica's v0.5.0 feature for semantic neighborhood exploration: N×N distance matrices, ego-mode visualization centered on a single entity, and distance band classification across the graph.
|
||||
|
||||
**PageRank**
|
||||
An algorithm measuring node importance based on the structure of incoming relationships; originally designed for web pages, but applicable to any directed graph.
|
||||
An algorithm measuring node importance based on the structure of incoming relationships: originally designed for web pages, applicable to any directed graph.
|
||||
|
||||
|
||||
## Query Languages & Standards
|
||||
@@ -194,13 +194,13 @@ The W3C query language for RDF data. Semantica's `SparqlReasoner` uses SPARQL fo
|
||||
Handling contradictory facts from multiple sources in the same knowledge graph. Semantica's `ConflictDetector` surfaces conflicts; resolution strategies include prefer-most-recent, prefer-most-reliable, majority-vote, and flag-for-review.
|
||||
|
||||
**Data Provenance**
|
||||
Complete information about the origin, history, and lineage of every fact (source document, extraction method, timestamp, confidence score). W3C PROV-O compliant in Semantica.
|
||||
Complete information about the origin, history, and lineage of every fact: source document, extraction method, timestamp, confidence score. W3C PROV-O compliant in Semantica.
|
||||
|
||||
**Deduplication**
|
||||
Identifying and merging duplicate entity records. Semantica v2 strategies (`blocking_v2`, `hybrid_v2`, `semantic_v2`) are up to 7x faster than v1.
|
||||
|
||||
**W3C PROV-O**
|
||||
The W3C provenance ontology standard. Semantica tracks lineage across all modules in PROV-O compliant format, suitable for HIPAA, SOX, GDPR, and FDA 21 CFR Part 11 compliance.
|
||||
The W3C provenance ontology standard. Semantica tracks lineage across all modules in PROV-O compliant format: suitable for HIPAA, SOX, GDPR, and FDA 21 CFR Part 11 compliance.
|
||||
|
||||
|
||||
## Security Terms
|
||||
@@ -214,7 +214,7 @@ A vulnerability in XML parsers that allows attackers to read arbitrary files or
|
||||
|
||||
## See Also
|
||||
|
||||
- [Core Concepts](/concepts): deeper explanation of key ideas with code examples.
|
||||
- [Getting Started](/getting-started): first working examples with no prior graph experience required.
|
||||
- [Modules Guide](/modules): all 27 modules explained with code and pipeline chains.
|
||||
- [API Reference](/reference/context): complete technical reference for every class and method.
|
||||
- [Core Concepts](/concepts) — Deeper explanation of key ideas with code examples.
|
||||
- [Getting Started](/getting-started) — First working examples: no prior graph experience required.
|
||||
- [Modules Guide](/modules) — All 27 modules explained with code and pipeline chains.
|
||||
- [API Reference](/reference/context) — Complete technical reference for every class and method.
|
||||
|
||||
+10
-10
@@ -9,9 +9,9 @@ icon: "scale-balanced"
|
||||
|
||||
## Roles
|
||||
|
||||
- **Maintainers**: Semantica team. Review and merge PRs, manage releases and code quality, set project direction and community standards.
|
||||
- **Contributors**: submit code, documentation, and bug reports. Help with issues and reviews. Recognized in [CONTRIBUTORS.md](https://github.com/semantica-agi/semantica/blob/main/CONTRIBUTORS.md).
|
||||
- **Community Members**: use Semantica, provide feedback, share use cases, and participate in GitHub Discussions and Discord.
|
||||
- **Maintainers** — Semantica team: review and merge PRs, manage releases and code quality, set project direction and community standards.
|
||||
- **Contributors** — Submit code, documentation, and bug reports. Help with issues and reviews. Recognized in [CONTRIBUTORS.md](https://github.com/semantica-agi/semantica/blob/main/CONTRIBUTORS.md).
|
||||
- **Community Members** — Use Semantica, provide feedback, share use cases, and participate in GitHub Discussions and Discord.
|
||||
|
||||
|
||||
## Decision Process
|
||||
@@ -65,11 +65,11 @@ Semantica follows **Semantic Versioning** (`MAJOR.MINOR.PATCH`):
|
||||
|
||||
## Project Goals
|
||||
|
||||
- **Usability**: easy to use and understand with sensible defaults, clear documentation, and minimal ceremony.
|
||||
- **Reliability**: production-ready quality tested across Python versions, platforms, and real-world workloads.
|
||||
- **Performance**: efficient and scalable from single-machine notebooks to enterprise graph databases.
|
||||
- **Extensibility**: easy to extend with plugins and custom modules via the `PluginRegistry` pattern.
|
||||
- **Community**: welcoming and inclusive. All backgrounds and experience levels contribute and are recognized.
|
||||
- **Usability** — Easy to use and understand: sensible defaults, clear documentation, minimal ceremony.
|
||||
- **Reliability** — Production-ready quality: tested across Python versions, platforms, and real-world workloads.
|
||||
- **Performance** — Efficient and scalable: from single-machine notebooks to enterprise graph databases.
|
||||
- **Extensibility** — Easy to extend with plugins and custom modules via the `PluginRegistry` pattern.
|
||||
- **Community** — Welcoming and inclusive: all backgrounds and experience levels contribute and are recognized.
|
||||
|
||||
|
||||
## License
|
||||
@@ -79,5 +79,5 @@ MIT License: see [LICENSE](https://github.com/semantica-agi/semantica/blob/main/
|
||||
|
||||
## See Also
|
||||
|
||||
- [Contributing](/contributing-guide): how to submit changes.
|
||||
- [Community](/community): community guidelines and channels.
|
||||
- [Contributing](/contributing-guide) — How to submit changes.
|
||||
- [Community](/community) — Community guidelines and channels.
|
||||
|
||||
@@ -102,8 +102,9 @@ The `Decision` dataclass that backs this node has the following fields — these
|
||||
from semantica.context import Decision
|
||||
from datetime import datetime
|
||||
|
||||
# Constructing a Decision explicitly (alternative to record_decision)
|
||||
d = Decision(
|
||||
decision_id = None, # required arg — None/"" auto-generates a UUID
|
||||
decision_id = "dec_001", # UUID — auto-generated if omitted via record_decision
|
||||
category = "threat_classification",
|
||||
scenario = "Unattributed C2 cluster",
|
||||
reasoning = "Infrastructure overlaps APT29 ASN",
|
||||
@@ -116,29 +117,9 @@ d = Decision(
|
||||
valid_until = "2025-09-30T23:59:59", # ISO datetime
|
||||
metadata = {"source_feed": "isac_partner_b"},
|
||||
)
|
||||
graph.add_decision(d)
|
||||
```
|
||||
|
||||
To actually store a decision built this way, pass its fields to `ContextGraph.add_decision()` as keyword arguments — this is the alternative to `record_decision()` for cases where you want `valid_from`/`valid_until` or extra metadata fields alongside the required ones:
|
||||
|
||||
```python
|
||||
decision_id = graph.add_decision(
|
||||
category = "threat_classification",
|
||||
scenario = "Unattributed C2 cluster",
|
||||
reasoning = "Infrastructure overlaps APT29 ASN",
|
||||
outcome = "classified_as_apt29_cluster",
|
||||
confidence = 0.88, # float 0.0–1.0
|
||||
decision_maker = "cti_pipeline_v2",
|
||||
# optional fields:
|
||||
valid_from = "2025-07-01T00:00:00", # ISO datetime
|
||||
valid_until = "2025-09-30T23:59:59", # ISO datetime
|
||||
source_feed = "isac_partner_b", # extra kwargs are stored as metadata
|
||||
)
|
||||
```
|
||||
|
||||
<Warning>
|
||||
Only pass keyword arguments to `add_decision()`, not a pre-built `Decision` object. `add_decision(Decision(...))` stores the node directly and skips the indexing step that `record_decision()` performs, so the decision becomes invisible to `find_precedents()`, `get_causal_chain()`, and `get_decision_insights()`, and `trace_decision_causality()` raises `ValueError` if you call it on one. The keyword-argument form above does not have this problem — it delegates to `record_decision()` internally. Note that, like `record_decision()`, it always generates its own `decision_id` (returned from the call); there is no way to force a specific ID.
|
||||
</Warning>
|
||||
|
||||
## Searching Precedents Before Deciding
|
||||
|
||||
Before making a significant call, the system should search past decisions for similar scenarios. This is how you prevent the same cluster being classified differently across two agent runs — the second agent finds the first agent's decision and uses it as a prior.
|
||||
@@ -156,7 +137,7 @@ for p in precedents:
|
||||
print(" Similarity: {:.3f}".format(p.metadata.get("similarity_score", 0)))
|
||||
```
|
||||
|
||||
Hybrid search blends two signals: lexical overlap between the query and each decision's `scenario`, `reasoning`, and `entities` text (weight 0.7 — word-level Jaccard similarity, with a character-bigram fallback for CJK-style queries), and structural similarity based on how many other nodes each decision connects to in the graph (weight 0.3, only computed when the graph was built with `advanced_analytics=True`). The result is a ranked list of `Decision` objects, filtered to those scoring at least `similarity_threshold` (default 0.5) — because the match is lexical rather than embedding-based, precedents phrased very differently from the query may not surface even if they describe a similar scenario.
|
||||
Hybrid search blends two signals: semantic similarity over the `scenario` and `reasoning` text (weight 0.7), and structural graph proximity via Node2Vec embeddings (weight 0.3). The result is a ranked list of `Decision` objects — the most similar past decisions float to the top regardless of how differently they were phrased.
|
||||
|
||||
## Building a Causal Chain
|
||||
|
||||
@@ -281,13 +262,8 @@ d = Decision(
|
||||
)
|
||||
|
||||
if engine.check_compliance(d, "cti_confidence_gate"):
|
||||
# Pass fields as kwargs, not the Decision object itself — see the
|
||||
# warning above. add_decision() generates its own decision_id.
|
||||
decision_id = graph.add_decision(
|
||||
category=d.category, scenario=d.scenario, reasoning=d.reasoning,
|
||||
outcome=d.outcome, confidence=d.confidence, decision_maker=d.decision_maker,
|
||||
)
|
||||
engine.record_policy_application(decision_id, "cti_confidence_gate", "1.0")
|
||||
graph.add_decision(d)
|
||||
engine.record_policy_application(d.decision_id, "cti_confidence_gate", "1.0")
|
||||
print("Decision recorded — policy compliant.")
|
||||
else:
|
||||
print("Decision blocked — confidence 0.62 below policy minimum 0.80.")
|
||||
@@ -614,7 +590,7 @@ if engine.check_compliance(d, "lending_policy_v3"):
|
||||
decision_maker=d.decision_maker,
|
||||
)
|
||||
graph.add_causal_relationship(stress_id, loan_id, "INFLUENCED")
|
||||
engine.record_policy_application(loan_id, "lending_policy_v3", "3.0")
|
||||
engine.record_policy_application(d.decision_id, "lending_policy_v3", "3.0")
|
||||
print("Loan decision recorded — policy compliant.")
|
||||
|
||||
# SR 11-7 explainability report
|
||||
|
||||
@@ -195,7 +195,7 @@ apt29_intel = context.retrieve(
|
||||
```python
|
||||
from semantica.llms import LiteLLM
|
||||
|
||||
llm = LiteLLM(model="anthropic/claude-sonnet-5")
|
||||
llm = LiteLLM(model="anthropic/claude-sonnet-4-20250514")
|
||||
|
||||
result = context.query_with_reasoning(
|
||||
"What are APT29's known TTPs against healthcare infrastructure, "
|
||||
@@ -281,7 +281,7 @@ context.store(
|
||||
link_entities=True,
|
||||
)
|
||||
|
||||
llm = LiteLLM(model="anthropic/claude-sonnet-5")
|
||||
llm = LiteLLM(model="anthropic/claude-sonnet-4-20250514")
|
||||
result = context.query_with_reasoning(
|
||||
"Trace the C2 infrastructure chain for APT29 operations targeting "
|
||||
"ITAR-controlled contractors in 2025. Include IP ranges, ASNs, and TTPs.",
|
||||
@@ -351,7 +351,7 @@ Parent: wmiprvse.exe
|
||||
Sigma match: T1053.005 Scheduled Task/Job
|
||||
"""
|
||||
|
||||
llm = LiteLLM(model="anthropic/claude-sonnet-5")
|
||||
llm = LiteLLM(model="anthropic/claude-sonnet-4-20250514")
|
||||
triage = soc_context.query_with_reasoning(
|
||||
"Triage this SIEM alert and identify the correct response runbook:\n{}".format(alert_text),
|
||||
llm_provider=llm,
|
||||
@@ -425,7 +425,7 @@ Patient: 68F, AF, CKD stage 3b (eGFR 32). On warfarin (INR target 2.0–3.0).
|
||||
Presenting for elective hip replacement. Concurrent: amiodarone 200mg, atorvastatin 40mg.
|
||||
"""
|
||||
|
||||
llm = LiteLLM(model="anthropic/claude-sonnet-5")
|
||||
llm = LiteLLM(model="anthropic/claude-sonnet-4-20250514")
|
||||
answer = clinical_context.query_with_reasoning(
|
||||
"What is the evidence-based warfarin bridging protocol for this patient "
|
||||
"given CKD and amiodarone interaction risk?\n\n{}".format(patient_context),
|
||||
@@ -495,7 +495,7 @@ compliance_context.store(
|
||||
extract_relationships=True,
|
||||
)
|
||||
|
||||
llm = LiteLLM(model="anthropic/claude-sonnet-5")
|
||||
llm = LiteLLM(model="anthropic/claude-sonnet-4-20250514")
|
||||
answer = compliance_context.query_with_reasoning(
|
||||
"Under Basel III CRE20, what are the RWA calculation requirements for "
|
||||
"commercial real estate exposures with LTV > 80%? "
|
||||
|
||||
@@ -275,20 +275,20 @@ print(data)
|
||||
|
||||
**LiteLLM** is a universal adapter that provides a single interface to over 100 different LLM providers, including Anthropic Claude, Azure OpenAI, AWS Bedrock, Google Vertex AI, and local Ollama instances. It acts as a translation layer, converting your unified API calls into provider-specific requests, enabling easy switching between providers without code changes.
|
||||
|
||||
`LiteLLM` is the Swiss Army knife. It wraps the `litellm` library, which speaks to every major provider using a unified completion API. The model string encodes both provider and model name: `"anthropic/claude-sonnet-5"`, `"azure/gpt-4o"`, `"bedrock/anthropic.claude-sonnet-4-5-20250929-v1:0"`, `"ollama/llama3.2"`. Change the string, change the provider — no other code changes needed.
|
||||
`LiteLLM` is the Swiss Army knife. It wraps the `litellm` library, which speaks to every major provider using a unified completion API. The model string encodes both provider and model name: `"anthropic/claude-sonnet-4-20250514"`, `"azure/gpt-4o"`, `"bedrock/anthropic.claude-3-5-sonnet-20241022-v2:0"`, `"ollama/llama3.2"`. Change the string, change the provider — no other code changes needed.
|
||||
|
||||
```python
|
||||
from semantica.llms import LiteLLM
|
||||
|
||||
# Anthropic Claude — highest accuracy for complex reasoning
|
||||
llm = LiteLLM(model="anthropic/claude-sonnet-5")
|
||||
llm = LiteLLM(model="anthropic/claude-sonnet-4-20250514")
|
||||
# Reads ANTHROPIC_API_KEY from environment
|
||||
|
||||
# Azure OpenAI — compliance and data-residency requirements
|
||||
llm = LiteLLM(model="azure/gpt-4o", api_key="YOUR_AZURE_KEY")
|
||||
|
||||
# AWS Bedrock — existing cloud agreement, no new vendor
|
||||
llm = LiteLLM(model="bedrock/anthropic.claude-sonnet-4-5-20250929-v1:0")
|
||||
llm = LiteLLM(model="bedrock/anthropic.claude-3-5-sonnet-20241022-v2:0")
|
||||
|
||||
# Google Vertex AI
|
||||
llm = LiteLLM(model="vertex_ai/gemini-1.5-pro")
|
||||
@@ -306,7 +306,7 @@ The environment-variable convention for each provider: `ANTHROPIC_API_KEY`, `AZU
|
||||
import os
|
||||
|
||||
PROVIDER_MAP = {
|
||||
"prod": "anthropic/claude-sonnet-5",
|
||||
"prod": "anthropic/claude-sonnet-4-20250514",
|
||||
"staging": "openai/gpt-4o-mini",
|
||||
"local": "ollama/llama3.2",
|
||||
"azure": "azure/gpt-4o",
|
||||
@@ -378,7 +378,7 @@ print("FAST: {} (conf={:.0%})".format(fast_result["response"], fast_result["con
|
||||
|
||||
# Tier 2: deep answer with Claude if confidence is below threshold
|
||||
if fast_result["confidence"] < 0.85:
|
||||
deep_llm = LiteLLM(model="anthropic/claude-sonnet-5")
|
||||
deep_llm = LiteLLM(model="anthropic/claude-sonnet-4-20250514")
|
||||
deep_result = context.query_with_reasoning(
|
||||
query, llm_provider=deep_llm, max_results=15, max_hops=3
|
||||
)
|
||||
@@ -574,7 +574,7 @@ print("TRIAGE: {} (conf={:.0%})".format(triage["response"], triage["confidence"]
|
||||
|
||||
# Tier 2: escalate to Claude for deep analysis if Tier 1 is uncertain
|
||||
if triage["confidence"] < 0.88:
|
||||
deep_llm = LiteLLM(model="anthropic/claude-sonnet-5")
|
||||
deep_llm = LiteLLM(model="anthropic/claude-sonnet-4-20250514")
|
||||
deep = context.query_with_reasoning(
|
||||
"Full MITRE ATT&CK analysis of this alert: identify the attack chain, "
|
||||
"blast radius, affected systems, and recommended containment steps.",
|
||||
@@ -630,7 +630,7 @@ for d in drugs:
|
||||
# trastuzumab (conf=0.98), pertuzumab (conf=0.97), docetaxel (conf=0.96)
|
||||
|
||||
# Report synthesis with Claude — switch to azure/gpt-4o for HIPAA by changing one string
|
||||
report_llm = LiteLLM(model="anthropic/claude-sonnet-5")
|
||||
report_llm = LiteLLM(model="anthropic/claude-sonnet-4-20250514")
|
||||
# For HIPAA-constrained Azure deployment:
|
||||
# report_llm = LiteLLM(model="azure/gpt-4o", api_key="YOUR_AZURE_KEY")
|
||||
|
||||
@@ -682,7 +682,7 @@ question = (
|
||||
|
||||
# Two-provider consensus — same query, same graph, different LLMs
|
||||
gpt4o = OpenAI(model="gpt-4o", api_key="YOUR_OAI_KEY")
|
||||
claude = LiteLLM(model="anthropic/claude-sonnet-5")
|
||||
claude = LiteLLM(model="anthropic/claude-sonnet-4-20250514")
|
||||
|
||||
answer_a = context.query_with_reasoning(question, llm_provider=gpt4o, max_results=10)
|
||||
answer_b = context.query_with_reasoning(question, llm_provider=claude, max_results=10)
|
||||
|
||||
@@ -197,7 +197,7 @@ reasoning_agent.load("./pipeline/enriched_intel/")
|
||||
# All memories, graph nodes, and vector embeddings from both ingestion agents are now available.
|
||||
|
||||
# Use a high-capability model for the synthesis step
|
||||
llm = LiteLLM(model="anthropic/claude-sonnet-5")
|
||||
llm = LiteLLM(model="anthropic/claude-sonnet-4-20250514")
|
||||
|
||||
synthesis = reasoning_agent.query_with_reasoning(
|
||||
"Summarize the APT29 exploitation of CVE-2024-3400: affected products, "
|
||||
@@ -428,7 +428,7 @@ tier1.store(
|
||||
|
||||
# --- Tier 2: deep investigation when Tier 1 confidence is low ---
|
||||
if triage["confidence"] < 0.90:
|
||||
deep_llm = LiteLLM(model="anthropic/claude-sonnet-5")
|
||||
deep_llm = LiteLLM(model="anthropic/claude-sonnet-4-20250514")
|
||||
|
||||
investigation = tier2.query_with_reasoning(
|
||||
"Full MITRE ATT&CK analysis of incident {}. "
|
||||
@@ -533,7 +533,7 @@ t1.start(); t2.start()
|
||||
t1.join(); t2.join()
|
||||
|
||||
# Chief agent synthesizes across literature and experimental data
|
||||
llm = LiteLLM(model="anthropic/claude-sonnet-5")
|
||||
llm = LiteLLM(model="anthropic/claude-sonnet-4-20250514")
|
||||
|
||||
synthesis = chief.query_with_reasoning(
|
||||
"Identify the top two candidate compounds for KRAS G12C NSCLC that show "
|
||||
@@ -576,7 +576,7 @@ credit_officer = make_desk_agent()
|
||||
committee_chair = make_desk_agent()
|
||||
|
||||
app_id = "LOAN-2025-88421"
|
||||
llm = LiteLLM(model="anthropic/claude-sonnet-5")
|
||||
llm = LiteLLM(model="anthropic/claude-sonnet-4-20250514")
|
||||
|
||||
# --- Risk Desk: PD/LGD/EL analysis ---
|
||||
risk_desk.store(
|
||||
|
||||
@@ -477,7 +477,7 @@ regs = [
|
||||
]
|
||||
|
||||
# Use an LLM to extract the conceptual model from regulatory prose
|
||||
llm_gen = LLMOntologyGenerator(provider="anthropic", model="claude-sonnet-5")
|
||||
llm_gen = LLMOntologyGenerator(provider="anthropic", model="claude-sonnet-4-20250514")
|
||||
ontology = llm_gen.generate_ontology_from_text(
|
||||
"\n\n".join(r.text[:8000] for r in regs) # token-safe excerpt per document
|
||||
)
|
||||
|
||||
@@ -707,22 +707,6 @@ report_dict = report.to_dict()
|
||||
|
||||
---
|
||||
|
||||
## Resource limits
|
||||
|
||||
Live SHACL validation in the Explorer enforces four resource limits, all configurable
|
||||
through environment variables. When a limit trips, the error message names the
|
||||
variable that controls it.
|
||||
|
||||
| Environment variable | Default | What it bounds |
|
||||
| --- | --- | --- |
|
||||
| `SEMANTICA_MAX_SHACL_TURTLE_BYTES` | `262144` (256 KB) | Size of the submitted SHACL Turtle |
|
||||
| `SEMANTICA_MAX_SHACL_TRIPLES` | `1000` | Triple count of the parsed shapes graph |
|
||||
| `SEMANTICA_MAX_SHACL_TIMEOUT` | `15.0` | Validation timeout in seconds |
|
||||
| `SEMANTICA_MAX_SHACL_CONCURRENCY` | `4` | Concurrent validations per process |
|
||||
|
||||
The first three are surfaced in the validation error message when exceeded; the
|
||||
concurrency limit applies as a semaphore and does not appear in responses.
|
||||
|
||||
## Using SHACL validation as a CI/CD gate
|
||||
|
||||
Call this function as a pre-publish gate; exit code 1 blocks the pipeline.
|
||||
|
||||
@@ -183,6 +183,6 @@ Install the [Microsoft Visual C++ Redistributable](https://aka.ms/vs/17/release/
|
||||
|
||||
## Next Steps
|
||||
|
||||
- [Getting Started](/getting-started): understand what Semantica does before you build.
|
||||
- [Build the Pipeline](/quickstart): follow the end-to-end workflow with code.
|
||||
- [Browse Examples](/cookbook): see notebook examples organized by use case.
|
||||
- [Getting Started](/getting-started) — Understand what Semantica does before you build.
|
||||
- [Build the Pipeline](/quickstart) — Follow the end-to-end workflow with code.
|
||||
- [Browse Examples](/cookbook) — See notebook examples organized by use case.
|
||||
|
||||
@@ -9,9 +9,9 @@ Whether you're running your first pipeline or deploying Semantica in production,
|
||||
|
||||
## Learning Paths
|
||||
|
||||
- **Beginner (1–2 hrs)**: new to Semantica and knowledge graphs. [Start with Installation →](/installation)
|
||||
- **Intermediate (4–6 hrs)**: comfortable with basics, building real applications. [Start with Modules →](/modules)
|
||||
- **Advanced (8+ hrs)**: enterprise deployments, customization, and extension. [Start with Architecture →](/architecture)
|
||||
- **Beginner (1–2 hrs)** — New to Semantica and knowledge graphs. [Start with Installation →](/installation)
|
||||
- **Intermediate (4–6 hrs)** — Comfortable with basics, building real applications. [Start with Modules →](/modules)
|
||||
- **Advanced (8+ hrs)** — Enterprise deployments, customization, and extension. [Start with Architecture →](/architecture)
|
||||
|
||||
<Tabs>
|
||||
<Tab title="Beginner (1–2 hrs)">
|
||||
@@ -116,7 +116,7 @@ pip install "semantica[gpu]" # GPU acceleration
|
||||
|
||||
<Accordion title="AuthenticationError" icon="lock">
|
||||
|
||||
Set your API key as an environment variable (never hardcode keys in source files):
|
||||
Set your API key as an environment variable — never hardcode keys in source files:
|
||||
|
||||
```bash
|
||||
export OPENAI_API_KEY="sk-..."
|
||||
@@ -236,6 +236,6 @@ The `blocking_v2`, `hybrid_v2`, and `semantic_v2` strategies reduce O(n²) compa
|
||||
- **Graph exports**: encrypt sensitive exports at rest; use the v0.5.0 SSRF-safe `base_url` validation when configuring custom LLM gateways
|
||||
- **XML ingestion**: always use `XMLIngestor` (v0.5.0), which uses the XXE-safe lxml backend; never parse untrusted XML with the standard library parser
|
||||
|
||||
- [Cookbook](/cookbook): interactive Jupyter notebooks from beginner to advanced.
|
||||
- [FAQ](/faq): common questions answered.
|
||||
- [API Reference](/reference/core): complete technical documentation.
|
||||
- [Cookbook](/cookbook) — Interactive Jupyter notebooks from beginner to advanced.
|
||||
- [FAQ](/faq) — Common questions answered.
|
||||
- [API Reference](/reference/core) — Complete technical documentation.
|
||||
|
||||
+14
-14
@@ -5,23 +5,23 @@ icon: "puzzle-piece"
|
||||
---
|
||||
|
||||
<Info>
|
||||
Jump to the [Module Index](#module-index) for a quick reference.
|
||||
Looking for a quick reference? Jump to the [Module Index](#module-index) at the bottom.
|
||||
</Info>
|
||||
|
||||
<Tip>
|
||||
The [Choose the Right Module](/choose-your-module) guide maps 35+ developer goals to modules with code examples; start there if you're orienting for the first time.
|
||||
Not sure which module to use? The [Choose the Right Module](/choose-your-module) guide maps 35+ developer goals to modules with code examples — start there if you're orienting for the first time.
|
||||
</Tip>
|
||||
|
||||
Semantica is organized into **27 modules** across six logical layers. Each module is independently importable: you never pay for what you don't use.
|
||||
|
||||
## Architecture Overview
|
||||
|
||||
- **Input Layer**: data ingestion and preparation. Modules: `ingest`, `parse`, `split`, `normalize`
|
||||
- **Core Processing**: intelligence and understanding. Modules: `semantic_extract`, `kg`, `ontology`, `reasoning`
|
||||
- **Storage**: persistent data storage. Modules: `embeddings`, `vector_store`, `graph_store`, `triplet_store`
|
||||
- **Quality Assurance**: data quality and consistency. Modules: `deduplication`, `conflicts`
|
||||
- **Context & Memory**: agent memory and decision tracking. Modules: `context`, `provenance`, `change_management`
|
||||
- **Output & Orchestration**: export, visualization, and workflows. Modules: `export`, `visualization`, `pipeline`, `explorer`
|
||||
- **Input Layer** — Data ingestion and preparation. Modules: `ingest`, `parse`, `split`, `normalize`
|
||||
- **Core Processing** — Intelligence and understanding. Modules: `semantic_extract`, `kg`, `ontology`, `reasoning`
|
||||
- **Storage** — Persistent data storage. Modules: `embeddings`, `vector_store`, `graph_store`, `triplet_store`
|
||||
- **Quality Assurance** — Data quality and consistency. Modules: `deduplication`, `conflicts`
|
||||
- **Context & Memory** — Agent memory and decision tracking. Modules: `context`, `provenance`, `change_management`
|
||||
- **Output & Orchestration** — Export, visualization, and workflows. Modules: `export`, `visualization`, `pipeline`, `explorer`
|
||||
|
||||
|
||||
## Input Layer
|
||||
@@ -51,7 +51,7 @@ sources = parquet.ingest("data/events.parquet")
|
||||
xml = XMLIngestor()
|
||||
sources = xml.ingest("data/records/", schema_path="schema.xsd")
|
||||
|
||||
# Enterprise lakehouse/warehouse: Unity Catalog + Delta Lake, or a Snowflake warehouse
|
||||
# Enterprise lakehouse/warehouse — Unity Catalog + Delta Lake, or a Snowflake warehouse
|
||||
databricks = DatabricksIngestor(host="...", token="...", http_path="...")
|
||||
customers = databricks.ingest_table("customers")
|
||||
```
|
||||
@@ -59,7 +59,7 @@ customers = databricks.ingest_table("customers")
|
||||
**Available ingestors:** `FileIngestor`, `WebIngestor`, `ParquetIngestor`, `XMLIngestor`, `RESTIngestor`, `PublicAPIIngestor`, `DBIngestor`, `DatabricksIngestor`, `SnowflakeIngestor`, `EmailIngestor`, `FeedIngestor`, `MCPIngestor`, `OntologyIngestor`, `RepoIngestor`, `StreamIngestor`, `ArrowIngestor`, `CloudStorageIngestor`
|
||||
|
||||
<Note>
|
||||
`DuckDBIngestor`, `ElasticIngestor`, `GDriveIngestor`, `HuggingFaceIngestor`, `MongoIngestor`, and `PandasIngestor` also ship but aren't re-exported from the top-level `semantica.ingest` namespace yet; import them directly, e.g. `from semantica.ingest.duckdb_ingestor import DuckDBIngestor`.
|
||||
`DuckDBIngestor`, `ElasticIngestor`, `GDriveIngestor`, `HuggingFaceIngestor`, `MongoIngestor`, and `PandasIngestor` also ship but aren't re-exported from the top-level `semantica.ingest` namespace yet — import them directly, e.g. `from semantica.ingest.duckdb_ingestor import DuckDBIngestor`.
|
||||
</Note>
|
||||
|
||||
### Parse
|
||||
@@ -463,7 +463,7 @@ Exposes Semantica as an MCP stdio server for IDE and agent integrations.
|
||||
python -m semantica.mcp_server
|
||||
```
|
||||
|
||||
**Integrations:** Claude Desktop, VS Code, Cursor, Windsurf, Cline. 15 MCP tools are exposed.
|
||||
**Integrations:** Claude Desktop, VS Code, Cursor, Windsurf, Cline: 15 MCP tools exposed
|
||||
|
||||
### Seed
|
||||
|
||||
@@ -749,6 +749,6 @@ versioner.create_snapshot(kg, "2024-Q1", author="user@example.com", description=
|
||||
| [core](/reference/core) | Base classes & registry | `Semantica`, `ConfigManager`, `PluginRegistry`, `LifecycleManager` |
|
||||
| [utils](/reference/utils) | Shared utilities | `helpers`, `validators` |
|
||||
|
||||
- [Getting Started](/getting-started): your first knowledge graph in 5 minutes.
|
||||
- [Cookbook](/cookbook): 40+ domain notebooks with real-world examples.
|
||||
- [API Reference](/reference/context): full technical documentation.
|
||||
- [Getting Started](/getting-started) — Your first knowledge graph in 5 minutes.
|
||||
- [Cookbook](/cookbook) — 40+ domain notebooks with real-world examples.
|
||||
- [API Reference](/reference/context) — Full technical documentation.
|
||||
|
||||
@@ -76,5 +76,5 @@ By contributing to Semantica, you agree that your contributions will be licensed
|
||||
|
||||
## See Also
|
||||
|
||||
- [Contributing](/contributing-guide): how to contribute to the project.
|
||||
- [Citation](/citation): how to cite Semantica in research.
|
||||
- [Contributing](/contributing-guide) — How to contribute to the project.
|
||||
- [Citation](/citation) — How to cite Semantica in research.
|
||||
|
||||
+6
-6
@@ -5,7 +5,7 @@ icon: "rocket"
|
||||
---
|
||||
|
||||
<Info>
|
||||
**v0.6.8**: cryptographically signed releases (SLSA provenance + Sigstore), real vector-store enumeration across FAISS/Qdrant/Weaviate/Milvus, and first-class Anthropic/Gemini/Ollama/DeepSeek/Novita LLM provider wrappers. <a href="https://github.com/semantica-agi/semantica/releases" style={{color:"#10B981",fontWeight:600,textDecoration:"none"}}>What's new →</a>
|
||||
**v0.6.7** — first-class LangChain integration, SAP OData ingestor, human-editable Markdown persistence for `ContextGraph`, and a structured Action layer for the reasoning engine. <a href="https://github.com/semantica-agi/semantica/releases" style={{color:"#10B981",fontWeight:600,textDecoration:"none"}}>What's new →</a>
|
||||
</Info>
|
||||
|
||||
This guide walks you through the end-to-end pipeline for building your first knowledge graph. Start here after installation. An LLM API key is optional: pattern-based extraction works out of the box.
|
||||
@@ -35,7 +35,7 @@ Verify:
|
||||
|
||||
```bash
|
||||
python -c "import semantica; print(semantica.__version__)"
|
||||
# 0.6.8
|
||||
# 0.6.7
|
||||
```
|
||||
|
||||
|
||||
@@ -454,7 +454,7 @@ pip install --upgrade semantica
|
||||
|
||||
## Next Steps
|
||||
|
||||
- [Core Concepts](/concepts): knowledge graphs, ontologies, and reasoning engines (the mental model behind Semantica).
|
||||
- [Module Reference](/modules): every module explained with key classes and common chains.
|
||||
- [API Reference](/reference/context): complete documentation for every module, class, and parameter.
|
||||
- [Cookbook](/cookbook): 40+ interactive Jupyter notebooks with real-world datasets.
|
||||
- [Core Concepts](/concepts) — Knowledge graphs, ontologies, reasoning engines: the mental model behind Semantica.
|
||||
- [Module Reference](/modules) — Every module explained with key classes and common chains.
|
||||
- [API Reference](/reference/context) — Complete documentation for every module, class, and parameter.
|
||||
- [Cookbook](/cookbook) — 40+ interactive Jupyter notebooks with real-world datasets.
|
||||
|
||||
@@ -129,7 +129,7 @@ from semantica.llms import Groq, OpenAI, LiteLLM, HuggingFaceLLM
|
||||
from semantica.llms import LiteLLM
|
||||
|
||||
llm = LiteLLM(
|
||||
model="anthropic/claude-sonnet-5",
|
||||
model="anthropic/claude-sonnet-4-20250514",
|
||||
api_key=os.getenv("ANTHROPIC_API_KEY"),
|
||||
temperature=0.0,
|
||||
)
|
||||
@@ -198,7 +198,7 @@ llm = Groq(api_key=os.getenv("GROQ_API_KEY"), model="llama-3.1-8b-instant")
|
||||
# Method 3: Multiple providers via LiteLLM
|
||||
providers = {
|
||||
"fast": LiteLLM(model="groq/llama-3.1-8b-instant", api_key=os.getenv("GROQ_API_KEY")),
|
||||
"smart": LiteLLM(model="anthropic/claude-sonnet-5", api_key=os.getenv("ANTHROPIC_API_KEY"))
|
||||
"smart": LiteLLM(model="anthropic/claude-sonnet-4-20250514", api_key=os.getenv("ANTHROPIC_API_KEY"))
|
||||
}
|
||||
```
|
||||
|
||||
@@ -252,7 +252,7 @@ from semantica.llms import LiteLLM
|
||||
# pip install "semantica[llm-litellm]"
|
||||
|
||||
# Anthropic Claude
|
||||
llm = LiteLLM(model="anthropic/claude-opus-4-7", api_key=os.getenv("ANTHROPIC_API_KEY"))
|
||||
llm = LiteLLM(model="anthropic/claude-opus-4-5", api_key=os.getenv("ANTHROPIC_API_KEY"))
|
||||
|
||||
# Google Gemini
|
||||
llm = LiteLLM(model="gemini/gemini-1.5-pro", api_key=os.getenv("GOOGLE_API_KEY"))
|
||||
@@ -267,7 +267,7 @@ llm = LiteLLM(model="deepseek/deepseek-chat", api_key=os.getenv("DEEP
|
||||
llm = LiteLLM(model="azure/gpt-4o", api_key=os.getenv("AZURE_API_KEY"))
|
||||
|
||||
# AWS Bedrock
|
||||
llm = LiteLLM(model="bedrock/anthropic.claude-sonnet-4-5-20250929-v1:0")
|
||||
llm = LiteLLM(model="bedrock/anthropic.claude-3-5-sonnet-20241022-v2:0")
|
||||
|
||||
# Novita AI
|
||||
llm = LiteLLM(model="novita/deepseek/deepseek-v3.2", api_key=os.getenv("NOVITA_API_KEY"))
|
||||
@@ -297,12 +297,12 @@ from semantica.llms import LiteLLM
|
||||
|
||||
# Pattern: LiteLLM(model="<provider>/<model-name>")
|
||||
providers = {
|
||||
"Anthropic": LiteLLM(model="anthropic/claude-opus-4-7", api_key=os.getenv("ANTHROPIC_API_KEY")),
|
||||
"Anthropic": LiteLLM(model="anthropic/claude-opus-4-5", api_key=os.getenv("ANTHROPIC_API_KEY")),
|
||||
"Gemini": LiteLLM(model="gemini/gemini-1.5-pro", api_key=os.getenv("GOOGLE_API_KEY")),
|
||||
"Ollama": LiteLLM(model="ollama/llama3.2:3b", api_base="http://localhost:11434"),
|
||||
"DeepSeek": LiteLLM(model="deepseek/deepseek-chat", api_key=os.getenv("DEEPSEEK_API_KEY")),
|
||||
"Azure": LiteLLM(model="azure/gpt-4o", api_key=os.getenv("AZURE_API_KEY")),
|
||||
"Bedrock": LiteLLM(model="bedrock/anthropic.claude-sonnet-4-5-20250929-v1:0"),
|
||||
"Bedrock": LiteLLM(model="bedrock/anthropic.claude-3-5-sonnet-20241022-v2:0"),
|
||||
"Cohere": LiteLLM(model="cohere/command-r-plus", api_key=os.getenv("COHERE_API_KEY")),
|
||||
"Novita AI": LiteLLM(model="novita/deepseek/deepseek-v3.2", api_key=os.getenv("NOVITA_API_KEY")),
|
||||
}
|
||||
@@ -416,7 +416,7 @@ for text in texts:
|
||||
| :---------- | :--------------------------- | :----------- |
|
||||
| **Entity Extraction** | `Groq("llama-3.3-70b-versatile")` | Fast, good accuracy for structured tasks |
|
||||
| **Relation Extraction** | `OpenAI("gpt-4o")` | Best at complex relationship reasoning |
|
||||
| **Complex Analysis** | `LiteLLM("anthropic/claude-sonnet-5")` | Highest reasoning capability |
|
||||
| **Complex Analysis** | `LiteLLM("anthropic/claude-sonnet-4-20250514")` | Highest reasoning capability |
|
||||
| **High Volume/Cost** | `LiteLLM("deepseek/deepseek-chat")` | Lowest cost per token |
|
||||
|
||||
### Error Handling
|
||||
|
||||
@@ -22,7 +22,6 @@ icon: "sitemap"
|
||||
| `LLMOntologyGenerator` | LLM-powered ontology generation for complex domains |
|
||||
| `SHACLGenerator` | Generate SHACL shapes from an ontology or KG schema |
|
||||
| `OntologyValidator` | Validate any graph against SHACL shapes: returns `SHACLValidationReport` |
|
||||
| `OntologyQualityGate` | Run deterministic ontology/KG quality checks for CI |
|
||||
| `OWLGenerator` | Serialize ontologies to Turtle, RDF/XML, JSON-LD |
|
||||
| `NamespaceManager` | IRI generation, prefix management, and namespace binding |
|
||||
| `OntologyEvaluator` | Coverage, completeness, and granularity quality metrics |
|
||||
@@ -82,38 +81,9 @@ engine.export_owl(ontology, "ontology.ttl", format="turtle")
|
||||
| :------ | :----------- |
|
||||
| `from_data(data)` | Run the 5-stage pipeline on entity/relationship data |
|
||||
| `validate_graph(kg, ontology=...)` | Check a knowledge graph against generated SHACL shapes |
|
||||
| `quality_check(ontology, graph_data=...)` | Return a deterministic quality report and CI-friendly pass/fail result |
|
||||
| `export_owl(ontology, path, format)` | Serialize to `"turtle"`, `"xml"`, or `"json-ld"` |
|
||||
| `evaluate(ontology, kg)` | Compute coverage, completeness, and granularity metrics |
|
||||
|
||||
### Ontology Quality Gate
|
||||
|
||||
Use the quality gate before export or deployment to catch structural issues
|
||||
without adding a runtime dependency:
|
||||
|
||||
```python
|
||||
from semantica.ontology import ontology_quality_check
|
||||
|
||||
report = ontology_quality_check(
|
||||
ontology,
|
||||
graph_data=kg,
|
||||
thresholds={"min_coverage": 0.8},
|
||||
)
|
||||
|
||||
if not report.passed:
|
||||
for issue in report.issues:
|
||||
print(issue.code, issue.message)
|
||||
```
|
||||
|
||||
The report checks class/property coverage, orphan schema elements, domain and
|
||||
range references, and unresolved KG relationship endpoints. It includes
|
||||
machine-readable issue codes, severity, counts, metrics, and threshold
|
||||
failures. The first version reports findings only; it does not auto-fix data.
|
||||
|
||||
### Thresholds
|
||||
|
||||
`min_coverage` (default `0.0`) sets the minimum required `coverage` score, the average of class and property coverage from `0.0` to `1.0`; the gate fails below it. `max_errors` (default `0.0`) caps how many `error`/`critical` issues are allowed before the gate fails. `max_warnings` (default `None`) caps `warning` issues the same way, and `None` means warnings alone never fail the gate. `fail_on_warnings` is a separate parameter, not a `thresholds` key, passed to `OntologyQualityGate(...)` or `.check(...)` directly; when `True`, a single warning fails the gate regardless of `max_warnings`.
|
||||
|
||||
## OntologyGenerator (5-Stage Pipeline)
|
||||
|
||||
**`OntologyGenerator`** auto-generates a formal ontology from your knowledge graph entities and relationships:
|
||||
|
||||
+2
-2
@@ -236,7 +236,7 @@ def _() -> list[str]:
|
||||
cwd=DOCS,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=600,
|
||||
timeout=300,
|
||||
)
|
||||
# Clean up zip regardless of outcome
|
||||
zip_path = os.path.join(DOCS, "export_ci_check.zip")
|
||||
@@ -263,7 +263,7 @@ def _() -> list[str]:
|
||||
except FileNotFoundError:
|
||||
return ["npx not found — skipping Mintlify export check (Node.js required)"]
|
||||
except subprocess.TimeoutExpired:
|
||||
return ["mintlify export timed out after 600 s"]
|
||||
return ["mintlify export timed out after 300 s"]
|
||||
|
||||
|
||||
# ── Summary ───────────────────────────────────────────────────────────────────
|
||||
|
||||
Generated
-653
@@ -33,9 +33,7 @@
|
||||
"devDependencies": {
|
||||
"@babel/core": "^7.29.6",
|
||||
"@eslint/js": "^9.39.4",
|
||||
"@testing-library/react": "^16.3.3",
|
||||
"@types/babel__core": "^7.20.5",
|
||||
"@types/jsdom": "^21.1.7",
|
||||
"@types/node": "^24.12.0",
|
||||
"@types/react": "^19.2.14",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
@@ -45,34 +43,12 @@
|
||||
"eslint-plugin-react-hooks": "^7.0.1",
|
||||
"eslint-plugin-react-refresh": "^0.5.2",
|
||||
"globals": "^17.4.0",
|
||||
"jsdom": "^26.1.0",
|
||||
"tsx": "^4.21.0",
|
||||
"typescript": "~5.9.3",
|
||||
"typescript-eslint": "^8.57.0",
|
||||
"vite": "^6.4.2"
|
||||
}
|
||||
},
|
||||
"node_modules/@asamuzakjp/css-color": {
|
||||
"version": "3.2.0",
|
||||
"resolved": "https://registry.npmmirror.com/@asamuzakjp/css-color/-/css-color-3.2.0.tgz",
|
||||
"integrity": "sha512-K1A6z8tS3XsmCMM86xoWdn7Fkdn9m6RSVtocUrJYIwZnFVkng/PvkEoWtOWmP+Scc6saYWHWZYbndEEXxl24jw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@csstools/css-calc": "^2.1.3",
|
||||
"@csstools/css-color-parser": "^3.0.9",
|
||||
"@csstools/css-parser-algorithms": "^3.0.4",
|
||||
"@csstools/css-tokenizer": "^3.0.3",
|
||||
"lru-cache": "^10.4.3"
|
||||
}
|
||||
},
|
||||
"node_modules/@asamuzakjp/css-color/node_modules/lru-cache": {
|
||||
"version": "10.4.3",
|
||||
"resolved": "https://registry.npmmirror.com/lru-cache/-/lru-cache-10.4.3.tgz",
|
||||
"integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==",
|
||||
"dev": true,
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/@babel/code-frame": {
|
||||
"version": "7.29.0",
|
||||
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz",
|
||||
@@ -364,121 +340,6 @@
|
||||
"node": ">=6.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@csstools/color-helpers": {
|
||||
"version": "5.1.0",
|
||||
"resolved": "https://registry.npmmirror.com/@csstools/color-helpers/-/color-helpers-5.1.0.tgz",
|
||||
"integrity": "sha512-S11EXWJyy0Mz5SYvRmY8nJYTFFd1LCNV+7cXyAgQtOOuzb4EsgfqDufL+9esx72/eLhsRdGZwaldu/h+E4t4BA==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/csstools"
|
||||
},
|
||||
{
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/csstools"
|
||||
}
|
||||
],
|
||||
"license": "MIT-0",
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@csstools/css-calc": {
|
||||
"version": "2.1.4",
|
||||
"resolved": "https://registry.npmmirror.com/@csstools/css-calc/-/css-calc-2.1.4.tgz",
|
||||
"integrity": "sha512-3N8oaj+0juUw/1H3YwmDDJXCgTB1gKU6Hc/bB502u9zR0q2vd786XJH9QfrKIEgFlZmhZiq6epXl4rHqhzsIgQ==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/csstools"
|
||||
},
|
||||
{
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/csstools"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@csstools/css-parser-algorithms": "^3.0.5",
|
||||
"@csstools/css-tokenizer": "^3.0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/@csstools/css-color-parser": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmmirror.com/@csstools/css-color-parser/-/css-color-parser-3.1.0.tgz",
|
||||
"integrity": "sha512-nbtKwh3a6xNVIp/VRuXV64yTKnb1IjTAEEh3irzS+HkKjAOYLTGNb9pmVNntZ8iVBHcWDA2Dof0QtPgFI1BaTA==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/csstools"
|
||||
},
|
||||
{
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/csstools"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@csstools/color-helpers": "^5.1.0",
|
||||
"@csstools/css-calc": "^2.1.4"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@csstools/css-parser-algorithms": "^3.0.5",
|
||||
"@csstools/css-tokenizer": "^3.0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/@csstools/css-parser-algorithms": {
|
||||
"version": "3.0.5",
|
||||
"resolved": "https://registry.npmmirror.com/@csstools/css-parser-algorithms/-/css-parser-algorithms-3.0.5.tgz",
|
||||
"integrity": "sha512-DaDeUkXZKjdGhgYaHNJTV9pV7Y9B3b644jCLs9Upc3VeNGg6LWARAT6O+Q+/COo+2gg/bM5rhpMAtf70WqfBdQ==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/csstools"
|
||||
},
|
||||
{
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/csstools"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@csstools/css-tokenizer": "^3.0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/@csstools/css-tokenizer": {
|
||||
"version": "3.0.4",
|
||||
"resolved": "https://registry.npmmirror.com/@csstools/css-tokenizer/-/css-tokenizer-3.0.4.tgz",
|
||||
"integrity": "sha512-Vd/9EVDiu6PPJt9yAh6roZP6El1xHrdvIVGjyBsHR0RYwNHgL7FJPyIIW4fANJNG6FtyZfvlRPpFI4ZM/lubvw==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/csstools"
|
||||
},
|
||||
{
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/csstools"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@egjs/hammerjs": {
|
||||
"version": "2.0.17",
|
||||
"resolved": "https://registry.npmjs.org/@egjs/hammerjs/-/hammerjs-2.0.17.tgz",
|
||||
@@ -1612,63 +1473,6 @@
|
||||
"react": "^18 || ^19"
|
||||
}
|
||||
},
|
||||
"node_modules/@testing-library/dom": {
|
||||
"version": "10.4.1",
|
||||
"resolved": "https://registry.npmmirror.com/@testing-library/dom/-/dom-10.4.1.tgz",
|
||||
"integrity": "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@babel/code-frame": "^7.10.4",
|
||||
"@babel/runtime": "^7.12.5",
|
||||
"@types/aria-query": "^5.0.1",
|
||||
"aria-query": "5.3.0",
|
||||
"dom-accessibility-api": "^0.5.9",
|
||||
"lz-string": "^1.5.0",
|
||||
"picocolors": "1.1.1",
|
||||
"pretty-format": "^27.0.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@testing-library/react": {
|
||||
"version": "16.3.3",
|
||||
"resolved": "https://registry.npmmirror.com/@testing-library/react/-/react-16.3.3.tgz",
|
||||
"integrity": "sha512-Uo193NgQbPMz6lrrhtRQQFcMC6Re/ELLFbbuVL30WDlZxlpZf9/lMHTAVxPRLw1q1iu9OJmR1c2BLiENRstdBg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.12.5"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@testing-library/dom": "^10.0.0",
|
||||
"@types/react": "^18.0.0 || ^19.0.0",
|
||||
"@types/react-dom": "^18.0.0 || ^19.0.0",
|
||||
"react": "^18.0.0 || ^19.0.0",
|
||||
"react-dom": "^18.0.0 || ^19.0.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
},
|
||||
"@types/react-dom": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@types/aria-query": {
|
||||
"version": "5.0.4",
|
||||
"resolved": "https://registry.npmmirror.com/@types/aria-query/-/aria-query-5.0.4.tgz",
|
||||
"integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/@types/babel__core": {
|
||||
"version": "7.20.5",
|
||||
"resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz",
|
||||
@@ -1810,18 +1614,6 @@
|
||||
"@types/unist": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/jsdom": {
|
||||
"version": "21.1.7",
|
||||
"resolved": "https://registry.npmmirror.com/@types/jsdom/-/jsdom-21.1.7.tgz",
|
||||
"integrity": "sha512-yOriVnggzrnQ3a9OKOCxaVuSug3w3/SbOj5i7VwXWZEyUNl3bLF9V3MfxGbZKuwqJOQyRfqXyROBB1CoZLFWzA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/node": "*",
|
||||
"@types/tough-cookie": "*",
|
||||
"parse5": "^7.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/json-schema": {
|
||||
"version": "7.0.15",
|
||||
"resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz",
|
||||
@@ -1873,13 +1665,6 @@
|
||||
"@types/react": "^19.2.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/tough-cookie": {
|
||||
"version": "4.0.5",
|
||||
"resolved": "https://registry.npmmirror.com/@types/tough-cookie/-/tough-cookie-4.0.5.tgz",
|
||||
"integrity": "sha512-/Ad8+nIOV7Rl++6f1BdKxFSMgmoqEoYbHRpPcx3JEfv8VRsQe9Z4mCXeJBzxs7mbHY/XOZZuXlRNfhpVPbs6ZA==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/trusted-types": {
|
||||
"version": "2.0.7",
|
||||
"resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz",
|
||||
@@ -2225,16 +2010,6 @@
|
||||
"acorn": "^6.0.0 || ^7.0.0 || ^8.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/agent-base": {
|
||||
"version": "7.1.4",
|
||||
"resolved": "https://registry.npmmirror.com/agent-base/-/agent-base-7.1.4.tgz",
|
||||
"integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 14"
|
||||
}
|
||||
},
|
||||
"node_modules/ajv": {
|
||||
"version": "6.15.0",
|
||||
"resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz",
|
||||
@@ -2252,42 +2027,6 @@
|
||||
"url": "https://github.com/sponsors/epoberezkin"
|
||||
}
|
||||
},
|
||||
"node_modules/ansi-regex": {
|
||||
"version": "5.0.1",
|
||||
"resolved": "https://registry.npmmirror.com/ansi-regex/-/ansi-regex-5.0.1.tgz",
|
||||
"integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/ansi-styles": {
|
||||
"version": "5.2.0",
|
||||
"resolved": "https://registry.npmmirror.com/ansi-styles/-/ansi-styles-5.2.0.tgz",
|
||||
"integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/aria-query": {
|
||||
"version": "5.3.0",
|
||||
"resolved": "https://registry.npmmirror.com/aria-query/-/aria-query-5.3.0.tgz",
|
||||
"integrity": "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"dequal": "^2.0.3"
|
||||
}
|
||||
},
|
||||
"node_modules/attr-accept": {
|
||||
"version": "2.2.5",
|
||||
"resolved": "https://registry.npmjs.org/attr-accept/-/attr-accept-2.2.5.tgz",
|
||||
@@ -2520,20 +2259,6 @@
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/cssstyle": {
|
||||
"version": "4.6.0",
|
||||
"resolved": "https://registry.npmmirror.com/cssstyle/-/cssstyle-4.6.0.tgz",
|
||||
"integrity": "sha512-2z+rWdzbbSZv6/rhtvzvqeZQHrBaqgogqt85sqFNbabZOuFbCVFb8kPeEtZjiKkbrm395irpNKiYeFeLiQnFPg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@asamuzakjp/css-color": "^3.2.0",
|
||||
"rrweb-cssom": "^0.8.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/csstype": {
|
||||
"version": "3.2.3",
|
||||
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
|
||||
@@ -2645,20 +2370,6 @@
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/data-urls": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmmirror.com/data-urls/-/data-urls-5.0.0.tgz",
|
||||
"integrity": "sha512-ZYP5VBHshaDAiVZxjbRVcFJpc+4xGgT0bK3vzy1HLN8jTO975HEbuYzZJcHoQEY5K1a0z8YayJkyVETa08eNTg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"whatwg-mimetype": "^4.0.0",
|
||||
"whatwg-url": "^14.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/debug": {
|
||||
"version": "4.4.3",
|
||||
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
|
||||
@@ -2676,13 +2387,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/decimal.js": {
|
||||
"version": "10.6.0",
|
||||
"resolved": "https://registry.npmmirror.com/decimal.js/-/decimal.js-10.6.0.tgz",
|
||||
"integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/decode-named-character-reference": {
|
||||
"version": "1.3.0",
|
||||
"resolved": "https://registry.npmjs.org/decode-named-character-reference/-/decode-named-character-reference-1.3.0.tgz",
|
||||
@@ -2745,14 +2449,6 @@
|
||||
"@babel/runtime": "^7.9.2"
|
||||
}
|
||||
},
|
||||
"node_modules/dom-accessibility-api": {
|
||||
"version": "0.5.16",
|
||||
"resolved": "https://registry.npmmirror.com/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz",
|
||||
"integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/dompurify": {
|
||||
"version": "3.4.13",
|
||||
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.13.tgz",
|
||||
@@ -2770,19 +2466,6 @@
|
||||
"dev": true,
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/entities": {
|
||||
"version": "6.0.1",
|
||||
"resolved": "https://registry.npmmirror.com/entities/-/entities-6.0.1.tgz",
|
||||
"integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==",
|
||||
"dev": true,
|
||||
"license": "BSD-2-Clause",
|
||||
"engines": {
|
||||
"node": ">=0.12"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/fb55/entities?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/esbuild": {
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.1.tgz",
|
||||
@@ -3375,19 +3058,6 @@
|
||||
"react-is": "^16.7.0"
|
||||
}
|
||||
},
|
||||
"node_modules/html-encoding-sniffer": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmmirror.com/html-encoding-sniffer/-/html-encoding-sniffer-4.0.0.tgz",
|
||||
"integrity": "sha512-Y22oTqIU4uuPgEemfz7NDJz6OeKf12Lsu+QC+s3BVpda64lTiMYCyGwg5ki4vFxkMwQdeZDl2adZoqUgdFuTgQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"whatwg-encoding": "^3.1.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/html-url-attributes": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/html-url-attributes/-/html-url-attributes-3.0.1.tgz",
|
||||
@@ -3398,47 +3068,6 @@
|
||||
"url": "https://opencollective.com/unified"
|
||||
}
|
||||
},
|
||||
"node_modules/http-proxy-agent": {
|
||||
"version": "7.0.2",
|
||||
"resolved": "https://registry.npmmirror.com/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz",
|
||||
"integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"agent-base": "^7.1.0",
|
||||
"debug": "^4.3.4"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 14"
|
||||
}
|
||||
},
|
||||
"node_modules/https-proxy-agent": {
|
||||
"version": "7.0.6",
|
||||
"resolved": "https://registry.npmmirror.com/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz",
|
||||
"integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"agent-base": "^7.1.2",
|
||||
"debug": "4"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 14"
|
||||
}
|
||||
},
|
||||
"node_modules/iconv-lite": {
|
||||
"version": "0.6.3",
|
||||
"resolved": "https://registry.npmmirror.com/iconv-lite/-/iconv-lite-0.6.3.tgz",
|
||||
"integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"safer-buffer": ">= 2.1.2 < 3.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/ignore": {
|
||||
"version": "5.3.2",
|
||||
"resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz",
|
||||
@@ -3544,13 +3173,6 @@
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/is-potential-custom-element-name": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmmirror.com/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz",
|
||||
"integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/isexe": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz",
|
||||
@@ -3564,46 +3186,6 @@
|
||||
"integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/jsdom": {
|
||||
"version": "26.1.0",
|
||||
"resolved": "https://registry.npmmirror.com/jsdom/-/jsdom-26.1.0.tgz",
|
||||
"integrity": "sha512-Cvc9WUhxSMEo4McES3P7oK3QaXldCfNWp7pl2NNeiIFlCoLr3kfq9kb1fxftiwk1FLV7CvpvDfonxtzUDeSOPg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"cssstyle": "^4.2.1",
|
||||
"data-urls": "^5.0.0",
|
||||
"decimal.js": "^10.5.0",
|
||||
"html-encoding-sniffer": "^4.0.0",
|
||||
"http-proxy-agent": "^7.0.2",
|
||||
"https-proxy-agent": "^7.0.6",
|
||||
"is-potential-custom-element-name": "^1.0.1",
|
||||
"nwsapi": "^2.2.16",
|
||||
"parse5": "^7.2.1",
|
||||
"rrweb-cssom": "^0.8.0",
|
||||
"saxes": "^6.0.0",
|
||||
"symbol-tree": "^3.2.4",
|
||||
"tough-cookie": "^5.1.1",
|
||||
"w3c-xmlserializer": "^5.0.0",
|
||||
"webidl-conversions": "^7.0.0",
|
||||
"whatwg-encoding": "^3.1.1",
|
||||
"whatwg-mimetype": "^4.0.0",
|
||||
"whatwg-url": "^14.1.1",
|
||||
"ws": "^8.18.0",
|
||||
"xml-name-validator": "^5.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"canvas": "^3.0.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"canvas": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/jsesc": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz",
|
||||
@@ -3739,17 +3321,6 @@
|
||||
"react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/lz-string": {
|
||||
"version": "1.5.0",
|
||||
"resolved": "https://registry.npmmirror.com/lz-string/-/lz-string-1.5.0.tgz",
|
||||
"integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"lz-string": "bin/bin.js"
|
||||
}
|
||||
},
|
||||
"node_modules/markdown-table": {
|
||||
"version": "3.0.4",
|
||||
"resolved": "https://registry.npmjs.org/markdown-table/-/markdown-table-3.0.4.tgz",
|
||||
@@ -4712,13 +4283,6 @@
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/nwsapi": {
|
||||
"version": "2.2.27",
|
||||
"resolved": "https://registry.npmmirror.com/nwsapi/-/nwsapi-2.2.27.tgz",
|
||||
"integrity": "sha512-gQPNF78qebCQ6tvVFBYrvJdBNOrYZm90ZlXgpIFm06p6qHDHq/XC4TnJftN6OMbxVE0UTBAoRgcsDeJBBooITw==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/object-assign": {
|
||||
"version": "4.1.1",
|
||||
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
|
||||
@@ -4818,19 +4382,6 @@
|
||||
"integrity": "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/parse5": {
|
||||
"version": "7.3.0",
|
||||
"resolved": "https://registry.npmmirror.com/parse5/-/parse5-7.3.0.tgz",
|
||||
"integrity": "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"entities": "^6.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/inikulin/parse5?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/path-exists": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz",
|
||||
@@ -4954,30 +4505,6 @@
|
||||
"node": ">= 0.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/pretty-format": {
|
||||
"version": "27.5.1",
|
||||
"resolved": "https://registry.npmmirror.com/pretty-format/-/pretty-format-27.5.1.tgz",
|
||||
"integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"ansi-regex": "^5.0.1",
|
||||
"ansi-styles": "^5.0.0",
|
||||
"react-is": "^17.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/pretty-format/node_modules/react-is": {
|
||||
"version": "17.0.2",
|
||||
"resolved": "https://registry.npmmirror.com/react-is/-/react-is-17.0.2.tgz",
|
||||
"integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/prop-types": {
|
||||
"version": "15.8.1",
|
||||
"resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz",
|
||||
@@ -5290,33 +4817,6 @@
|
||||
"fsevents": "~2.3.2"
|
||||
}
|
||||
},
|
||||
"node_modules/rrweb-cssom": {
|
||||
"version": "0.8.0",
|
||||
"resolved": "https://registry.npmmirror.com/rrweb-cssom/-/rrweb-cssom-0.8.0.tgz",
|
||||
"integrity": "sha512-guoltQEx+9aMf2gDZ0s62EcV8lsXR+0w8915TC3ITdn2YueuNjdAYh/levpU9nFaoChh9RUS5ZdQMrKfVEN9tw==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/safer-buffer": {
|
||||
"version": "2.1.2",
|
||||
"resolved": "https://registry.npmmirror.com/safer-buffer/-/safer-buffer-2.1.2.tgz",
|
||||
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/saxes": {
|
||||
"version": "6.0.0",
|
||||
"resolved": "https://registry.npmmirror.com/saxes/-/saxes-6.0.0.tgz",
|
||||
"integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"xmlchars": "^2.2.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=v12.22.7"
|
||||
}
|
||||
},
|
||||
"node_modules/scheduler": {
|
||||
"version": "0.27.0",
|
||||
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz",
|
||||
@@ -5424,13 +4924,6 @@
|
||||
"inline-style-parser": "0.2.7"
|
||||
}
|
||||
},
|
||||
"node_modules/symbol-tree": {
|
||||
"version": "3.2.4",
|
||||
"resolved": "https://registry.npmmirror.com/symbol-tree/-/symbol-tree-3.2.4.tgz",
|
||||
"integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/tinyglobby": {
|
||||
"version": "0.2.16",
|
||||
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.16.tgz",
|
||||
@@ -5448,52 +4941,6 @@
|
||||
"url": "https://github.com/sponsors/SuperchupuDev"
|
||||
}
|
||||
},
|
||||
"node_modules/tldts": {
|
||||
"version": "6.1.86",
|
||||
"resolved": "https://registry.npmmirror.com/tldts/-/tldts-6.1.86.tgz",
|
||||
"integrity": "sha512-WMi/OQ2axVTf/ykqCQgXiIct+mSQDFdH2fkwhPwgEwvJ1kSzZRiinb0zF2Xb8u4+OqPChmyI6MEu4EezNJz+FQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"tldts-core": "^6.1.86"
|
||||
},
|
||||
"bin": {
|
||||
"tldts": "bin/cli.js"
|
||||
}
|
||||
},
|
||||
"node_modules/tldts-core": {
|
||||
"version": "6.1.86",
|
||||
"resolved": "https://registry.npmmirror.com/tldts-core/-/tldts-core-6.1.86.tgz",
|
||||
"integrity": "sha512-Je6p7pkk+KMzMv2XXKmAE3McmolOQFdxkKw0R8EYNr7sELW46JqnNeTX8ybPiQgvg1ymCoF8LXs5fzFaZvJPTA==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/tough-cookie": {
|
||||
"version": "5.1.2",
|
||||
"resolved": "https://registry.npmmirror.com/tough-cookie/-/tough-cookie-5.1.2.tgz",
|
||||
"integrity": "sha512-FVDYdxtnj0G6Qm/DhNPSb8Ju59ULcup3tuJxkFb5K8Bv2pUXILbf0xZWU8PX8Ov19OXljbUyveOFwRMwkXzO+A==",
|
||||
"dev": true,
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"tldts": "^6.1.32"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=16"
|
||||
}
|
||||
},
|
||||
"node_modules/tr46": {
|
||||
"version": "5.1.1",
|
||||
"resolved": "https://registry.npmmirror.com/tr46/-/tr46-5.1.1.tgz",
|
||||
"integrity": "sha512-hdF5ZgjTqgAntKkklYw0R03MG2x/bSzTtkxmIRw/sTNV8YXsCJ1tfLAX23lhxhHJlEf3CRCOCGGWw3vI3GaSPw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"punycode": "^2.3.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/trim-lines": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/trim-lines/-/trim-lines-3.0.1.tgz",
|
||||
@@ -5917,67 +5364,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/w3c-xmlserializer": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmmirror.com/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz",
|
||||
"integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"xml-name-validator": "^5.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/webidl-conversions": {
|
||||
"version": "7.0.0",
|
||||
"resolved": "https://registry.npmmirror.com/webidl-conversions/-/webidl-conversions-7.0.0.tgz",
|
||||
"integrity": "sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==",
|
||||
"dev": true,
|
||||
"license": "BSD-2-Clause",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/whatwg-encoding": {
|
||||
"version": "3.1.1",
|
||||
"resolved": "https://registry.npmmirror.com/whatwg-encoding/-/whatwg-encoding-3.1.1.tgz",
|
||||
"integrity": "sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==",
|
||||
"deprecated": "Use @exodus/bytes instead for a more spec-conformant and faster implementation",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"iconv-lite": "0.6.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/whatwg-mimetype": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmmirror.com/whatwg-mimetype/-/whatwg-mimetype-4.0.0.tgz",
|
||||
"integrity": "sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/whatwg-url": {
|
||||
"version": "14.2.0",
|
||||
"resolved": "https://registry.npmmirror.com/whatwg-url/-/whatwg-url-14.2.0.tgz",
|
||||
"integrity": "sha512-De72GdQZzNTUBBChsXueQUnPKDkg/5A5zp7pFDuQAj5UFoENpiACU0wlCvzpAGnTkj++ihpKwKyYewn/XNUbKw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"tr46": "^5.1.0",
|
||||
"webidl-conversions": "^7.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/which": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
|
||||
@@ -6004,45 +5390,6 @@
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/ws": {
|
||||
"version": "8.21.3",
|
||||
"resolved": "https://registry.npmmirror.com/ws/-/ws-8.21.3.tgz",
|
||||
"integrity": "sha512-201TZ/kPWxoPr/OKWjquZR1SWKXcvxdH+e1xrx89b3YbmzLMFCLfnaG1HFIgWzJOEWZ7MvpK++odZufgYR50Rw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=10.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"bufferutil": "^4.0.1",
|
||||
"utf-8-validate": ">=5.0.2"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"bufferutil": {
|
||||
"optional": true
|
||||
},
|
||||
"utf-8-validate": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/xml-name-validator": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmmirror.com/xml-name-validator/-/xml-name-validator-5.0.0.tgz",
|
||||
"integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/xmlchars": {
|
||||
"version": "2.2.0",
|
||||
"resolved": "https://registry.npmmirror.com/xmlchars/-/xmlchars-2.2.0.tgz",
|
||||
"integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/xss": {
|
||||
"version": "1.0.15",
|
||||
"resolved": "https://registry.npmjs.org/xss/-/xss-1.0.15.tgz",
|
||||
|
||||
@@ -9,7 +9,6 @@
|
||||
"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/markdownEditorInteraction.test.tsx tests/markdownEditorState.test.ts tests/nodeMarkdownSync.test.ts tests/graphSceneState.display.test.ts tests/temporalLifecycle.test.ts tests/deterministicExplorerRendering.test.ts tests/explorerCapabilities.test.tsx tests/smallGraphLayout.test.ts tests/realtimeGraphAttributes.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 tests/smallGraphLayout.test.ts tests/realtimeGraphAttributes.test.ts tests/ontologyEditorModel.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"
|
||||
@@ -40,9 +39,7 @@
|
||||
"devDependencies": {
|
||||
"@babel/core": "^7.29.6",
|
||||
"@eslint/js": "^9.39.4",
|
||||
"@testing-library/react": "^16.3.3",
|
||||
"@types/babel__core": "^7.20.5",
|
||||
"@types/jsdom": "^21.1.7",
|
||||
"@types/node": "^24.12.0",
|
||||
"@types/react": "^19.2.14",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
@@ -52,7 +49,6 @@
|
||||
"eslint-plugin-react-hooks": "^7.0.1",
|
||||
"eslint-plugin-react-refresh": "^0.5.2",
|
||||
"globals": "^17.4.0",
|
||||
"jsdom": "^26.1.0",
|
||||
"tsx": "^4.21.0",
|
||||
"typescript": "~5.9.3",
|
||||
"typescript-eslint": "^8.57.0",
|
||||
|
||||
+14
-45
@@ -17,13 +17,10 @@ import {
|
||||
type LucideIcon,
|
||||
} from 'lucide-react';
|
||||
import { ErrorBoundary } from './ErrorBoundary';
|
||||
import { ExploreWorkspaceTabs, type ExploreView } from './ExploreWorkspaceTabs';
|
||||
import { fetchAgentMemoryAvailability } from './explorerCapabilities';
|
||||
|
||||
const DecisionWorkspace = lazy(() => import('./workspaces/DecisionWorkspace/DecisionWorkspace').then((module) => ({ default: module.DecisionWorkspace })));
|
||||
const DiffMergeWorkspace = lazy(() => import('./workspaces/DiffMergeWorkspace/DiffMergeWorkspace').then((module) => ({ default: module.DiffMergeWorkspace })));
|
||||
const GraphWorkspace = lazy(() => import('./workspaces/GraphWorkspace/GraphWorkspace').then((module) => ({ default: module.GraphWorkspace })));
|
||||
const MemoryWorkspace = lazy(() => import('./workspaces/MemoryWorkspace').then((module) => ({ default: module.MemoryWorkspace })));
|
||||
const ImportExportWorkspace = lazy(() => import('./workspaces/ImportExportWorkspace/ImportExportWorkspace').then((module) => ({ default: module.ImportExportWorkspace })));
|
||||
const LineageDiagram = lazy(() => import('./workspaces/LineageWorkspace/LineageDiagram').then((module) => ({ default: module.LineageDiagram })));
|
||||
const ReasoningWorkspace = lazy(() => import('./workspaces/ReasoningWorkspace').then((module) => ({ default: module.ReasoningWorkspace })));
|
||||
@@ -36,6 +33,7 @@ const OntologySummaryTab = lazy(() => import('./workspaces/ManageWorkspace/Ontol
|
||||
const OntologyWorkspace = lazy(() => import('./workspaces/OntologyWorkspace').then((module) => ({ default: module.OntologyWorkspace })));
|
||||
|
||||
type WorkspaceId = 'welcome' | 'explore' | 'analyze' | 'decisions' | 'enrich' | 'manage' | 'ontology-hub';
|
||||
type ExploreView = 'graph' | 'vocabulary';
|
||||
type AnalyzeView = 'sparql' | 'reasoning';
|
||||
type EnrichView = 'import' | 'merge' | 'registry' | 'resolve';
|
||||
type ManageView = 'lineage' | 'kg-overview' | 'ontology';
|
||||
@@ -1793,37 +1791,6 @@ export default function App() {
|
||||
const [enrichView, setEnrichView] = useState<EnrichView>('import');
|
||||
const [manageView, setManageView] = useState<ManageView>('lineage');
|
||||
const [graphFocusRequest, setGraphFocusRequest] = useState<{ nodeId: string; token: number } | null>(null);
|
||||
const [exploreDraftDirty, setExploreDraftDirty] = useState(false);
|
||||
const [agentMemoryAvailable, setAgentMemoryAvailable] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
let active = true;
|
||||
void fetchAgentMemoryAvailability().then((available) => {
|
||||
if (active) setAgentMemoryAvailable(available);
|
||||
});
|
||||
return () => {
|
||||
active = false;
|
||||
};
|
||||
}, []);
|
||||
|
||||
const confirmDiscardExploreDraft = () => (
|
||||
!exploreDraftDirty
|
||||
|| window.confirm("Discard the unapplied Markdown draft and leave this resource?")
|
||||
);
|
||||
|
||||
const switchExploreView = (nextView: ExploreView) => {
|
||||
if (nextView === exploreView) return;
|
||||
if (!confirmDiscardExploreDraft()) return;
|
||||
setExploreDraftDirty(false);
|
||||
setExploreView(nextView);
|
||||
};
|
||||
|
||||
const switchWorkspace = (nextWorkspace: WorkspaceId) => {
|
||||
if (nextWorkspace === activeWorkspace) return;
|
||||
if (activeWorkspace === "explore" && !confirmDiscardExploreDraft()) return;
|
||||
setExploreDraftDirty(false);
|
||||
setActiveWorkspace(nextWorkspace);
|
||||
};
|
||||
|
||||
|
||||
const renderWorkspace = () => {
|
||||
@@ -1856,15 +1823,18 @@ export default function App() {
|
||||
return (
|
||||
<WorkspaceShell
|
||||
title="Explore"
|
||||
subtitle={exploreView === 'graph' ? undefined : exploreView === 'memories' ? "Browse and edit canonical AgentMemory documents." : "Browse the graph and switch views without leaving the workspace."}
|
||||
kicker={exploreView === 'graph' ? 'Graph Studio' : exploreView === 'memories' ? 'Memory Browser' : 'Vocabulary Browser'}
|
||||
subtitle={exploreView === 'graph' ? undefined : "Browse the graph and switch views without leaving the workspace."}
|
||||
kicker={exploreView === 'graph' ? 'Graph Studio' : 'Vocabulary Browser'}
|
||||
compact
|
||||
tabs={
|
||||
<ExploreWorkspaceTabs
|
||||
activeView={exploreView}
|
||||
agentMemoryAvailable={agentMemoryAvailable}
|
||||
onSelect={switchExploreView}
|
||||
/>
|
||||
<>
|
||||
<button className="workspace-tab" data-active={exploreView === 'graph'} onClick={() => setExploreView('graph')}>
|
||||
Semantica Explorer
|
||||
</button>
|
||||
<button className="workspace-tab" data-active={exploreView === 'vocabulary'} onClick={() => setExploreView('vocabulary')}>
|
||||
Vocabulary Browser
|
||||
</button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<ErrorBoundary key={`explore-${exploreView}`}>
|
||||
@@ -1873,9 +1843,8 @@ export default function App() {
|
||||
<GraphWorkspace
|
||||
externalFocusNodeId={graphFocusRequest?.nodeId}
|
||||
externalFocusToken={graphFocusRequest?.token}
|
||||
onDirtyChange={setExploreDraftDirty}
|
||||
/>
|
||||
) : exploreView === 'memories' ? <MemoryWorkspace onDirtyChange={setExploreDraftDirty} /> : <VocabularyWorkspace />}
|
||||
) : <VocabularyWorkspace />}
|
||||
</Suspense>
|
||||
</ErrorBoundary>
|
||||
</WorkspaceShell>
|
||||
@@ -2020,13 +1989,13 @@ export default function App() {
|
||||
<style>{shellStyles}</style>
|
||||
<div className="app-shell">
|
||||
<aside className="app-rail">
|
||||
<button className="brand-pill" title="Semantica Knowledge Explorer" onClick={() => switchWorkspace('welcome')} style={{ cursor: 'pointer', border: '1px solid rgba(127,208,255,0.18)' }}>SKE</button>
|
||||
<button className="brand-pill" title="Semantica Knowledge Explorer" onClick={() => setActiveWorkspace('welcome')} style={{ cursor: 'pointer', border: '1px solid rgba(127,208,255,0.18)' }}>SKE</button>
|
||||
{navItems.map(({ id, label, hint, icon: Icon }) => (
|
||||
<button
|
||||
key={id}
|
||||
className="nav-button"
|
||||
data-active={activeWorkspace === id}
|
||||
onClick={() => switchWorkspace(id)}
|
||||
onClick={() => setActiveWorkspace(id)}
|
||||
title={hint}
|
||||
>
|
||||
<Icon size={20} />
|
||||
|
||||
@@ -1,29 +0,0 @@
|
||||
export type ExploreView = 'graph' | 'memories' | 'vocabulary';
|
||||
|
||||
type ExploreWorkspaceTabsProps = {
|
||||
activeView: ExploreView;
|
||||
agentMemoryAvailable: boolean;
|
||||
onSelect: (view: ExploreView) => void;
|
||||
};
|
||||
|
||||
export function ExploreWorkspaceTabs({
|
||||
activeView,
|
||||
agentMemoryAvailable,
|
||||
onSelect,
|
||||
}: ExploreWorkspaceTabsProps) {
|
||||
return (
|
||||
<>
|
||||
<button className="workspace-tab" data-active={activeView === 'graph'} onClick={() => onSelect('graph')}>
|
||||
Semantica Explorer
|
||||
</button>
|
||||
{agentMemoryAvailable ? (
|
||||
<button className="workspace-tab" data-active={activeView === 'memories'} onClick={() => onSelect('memories')}>
|
||||
Memories
|
||||
</button>
|
||||
) : null}
|
||||
<button className="workspace-tab" data-active={activeView === 'vocabulary'} onClick={() => onSelect('vocabulary')}>
|
||||
Vocabulary Browser
|
||||
</button>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,24 +0,0 @@
|
||||
type Fetcher = (
|
||||
input: RequestInfo | URL,
|
||||
init?: RequestInit,
|
||||
) => Promise<Response>;
|
||||
|
||||
type ExplorerInfo = {
|
||||
capabilities?: {
|
||||
agent_memory?: boolean;
|
||||
};
|
||||
};
|
||||
|
||||
export async function fetchAgentMemoryAvailability(
|
||||
fetcher: Fetcher = fetch,
|
||||
): Promise<boolean> {
|
||||
try {
|
||||
const response = await fetcher('/api/info');
|
||||
if (!response.ok) return false;
|
||||
|
||||
const info = await response.json() as ExplorerInfo;
|
||||
return info.capabilities?.agent_memory === true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -15,7 +15,6 @@ export type RegistryEntryOp =
|
||||
| "export"
|
||||
| "merge"
|
||||
| "add-node"
|
||||
| "update-node"
|
||||
| "add-edge"
|
||||
| "delete"
|
||||
| "infer"
|
||||
|
||||
@@ -16,7 +16,6 @@ const OP_META: Record<
|
||||
export: { label: "EXPORT", color: "#8fa8c6", bg: "rgba(143,168,198,0.08)", border: "rgba(143,168,198,0.18)" },
|
||||
merge: { label: "MERGE", color: "#f2b66d", bg: "rgba(242,182,109,0.12)", border: "rgba(242,182,109,0.28)" },
|
||||
"add-node": { label: "ADD NODE", color: "#4cc38a", bg: "rgba(76,195,138,0.12)", border: "rgba(76,195,138,0.28)" },
|
||||
"update-node": { label: "UPDATE NODE", color: "#79c0ff", bg: "rgba(121,192,255,0.10)", border: "rgba(121,192,255,0.24)" },
|
||||
"add-edge": { label: "ADD EDGE", color: "#4cc38a", bg: "rgba(76,195,138,0.10)", border: "rgba(76,195,138,0.22)" },
|
||||
delete: { label: "DELETE", color: "#ff7b72", bg: "rgba(255,123,114,0.12)", border: "rgba(255,123,114,0.28)" },
|
||||
infer: { label: "INFER", color: "#d2a8ff", bg: "rgba(210,168,255,0.12)", border: "rgba(210,168,255,0.28)" },
|
||||
@@ -24,7 +23,7 @@ const OP_META: Record<
|
||||
};
|
||||
|
||||
const ALL_OPS: (RegistryEntryOp | "all")[] = [
|
||||
"all", "import", "export", "merge", "add-node", "update-node", "add-edge", "infer", "delete", "vocab-import",
|
||||
"all", "import", "export", "merge", "add-node", "add-edge", "infer", "delete", "vocab-import",
|
||||
];
|
||||
|
||||
function formatTimestamp(date: Date): string {
|
||||
|
||||
@@ -4,7 +4,6 @@ import { graph } from "../../store/graphStore";
|
||||
import { GRAPH_THEME, withAlpha } from "./graphTheme";
|
||||
import type { GraphSelectedNodeKind } from "./types";
|
||||
import { MarkdownContentViewer } from "./MarkdownContentViewer";
|
||||
import type { MarkdownApplyResult } from "./markdownResourceClient";
|
||||
|
||||
export type LinkPrediction = {
|
||||
target: string;
|
||||
@@ -45,8 +44,6 @@ export interface GraphInspectorPanelProps {
|
||||
pathResult: PathResponse | null;
|
||||
onDownloadProvenance: (format: "json" | "markdown") => void;
|
||||
onFocusNode?: (nodeId: string) => void;
|
||||
onMarkdownApplied?: (result: MarkdownApplyResult) => void;
|
||||
onMarkdownDirtyChange?: (dirty: boolean) => void;
|
||||
}
|
||||
|
||||
const PROVENANCE_KEYS = ["source", "source_url", "pmid", "pmids", "evidence", "provenance", "confidence"] as const;
|
||||
@@ -307,8 +304,6 @@ export function GraphInspectorPanel({
|
||||
pathResult,
|
||||
onDownloadProvenance,
|
||||
onFocusNode,
|
||||
onMarkdownApplied,
|
||||
onMarkdownDirtyChange,
|
||||
}: GraphInspectorPanelProps) {
|
||||
if (!nodeId) {
|
||||
return (
|
||||
@@ -419,18 +414,19 @@ export function GraphInspectorPanel({
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{/* Canonical nodes remain editable even when their current body is empty. */}
|
||||
<details className="node-panel-collapse" open>
|
||||
<summary className="node-panel-summary">Content</summary>
|
||||
<div className="node-panel-body" style={{ marginTop: 8 }}>
|
||||
<MarkdownContentViewer
|
||||
content={nodeContent}
|
||||
resource={{ kind: "context-node", id: effectiveNodeId }}
|
||||
onApplied={onMarkdownApplied}
|
||||
onDirtyChange={onMarkdownDirtyChange}
|
||||
/>
|
||||
</div>
|
||||
</details>
|
||||
{/* Content Section — only rendered when the node carries actual content.
|
||||
This matches the existing inspector convention: sections that have no
|
||||
data for the current node are either hidden (temporal bounds) or closed
|
||||
by default (Source Attribution, Properties). Always showing an open
|
||||
empty panel would add noise for every relationship/predicate node. */}
|
||||
{nodeContent && (
|
||||
<details className="node-panel-collapse" open>
|
||||
<summary className="node-panel-summary">Content</summary>
|
||||
<div className="node-panel-body" style={{ marginTop: 8 }}>
|
||||
<MarkdownContentViewer content={nodeContent} />
|
||||
</div>
|
||||
</details>
|
||||
)}
|
||||
|
||||
{/* Actions */}
|
||||
<section style={sectionStyle}>
|
||||
|
||||
@@ -44,12 +44,6 @@ import { createTemporalSnapshotGuards, type TemporalSnapshotResponse } from "./t
|
||||
import { SMALL_GRAPH_MAX_NODES } from "./smallGraphLayout";
|
||||
import { buildRealtimeEdgeAttributes } from "./realtimeGraphAttributes";
|
||||
import type { LinkPrediction, PathResponse } from "./GraphInspectorPanel";
|
||||
import type { MarkdownApplyResult } from "./markdownResourceClient";
|
||||
import {
|
||||
NodeMarkdownRefreshGuard,
|
||||
buildNodeMarkdownAttributeUpdate,
|
||||
readNodeMarkdownAttributeUpdate,
|
||||
} from "./nodeMarkdownSync";
|
||||
import type { GraphSceneHandle, GraphSceneRuntime } from "./scene";
|
||||
import type {
|
||||
GraphAnalyticsSnapshot,
|
||||
@@ -1247,10 +1241,9 @@ function collectPluginOverlays(
|
||||
interface GraphWorkspaceProps {
|
||||
externalFocusNodeId?: string;
|
||||
externalFocusToken?: number;
|
||||
onDirtyChange?: (dirty: boolean) => void;
|
||||
}
|
||||
|
||||
export function GraphWorkspace({ externalFocusNodeId, externalFocusToken, onDirtyChange }: GraphWorkspaceProps = {}) {
|
||||
export function GraphWorkspace({ externalFocusNodeId, externalFocusToken }: GraphWorkspaceProps = {}) {
|
||||
const [selectedNodeId, setSelectedNodeId] = useState("");
|
||||
const [focusedNodeId, setFocusedNodeId] = useState("");
|
||||
const [lastGroupedSelectedNodeId, setLastGroupedSelectedNodeId] = useState("");
|
||||
@@ -1258,12 +1251,6 @@ export function GraphWorkspace({ externalFocusNodeId, externalFocusToken, onDirt
|
||||
const [isLayoutRunning, setIsLayoutRunning] = useState(false);
|
||||
const [graphReady, setGraphReady] = useState(false);
|
||||
const [graphVersion, setGraphVersion] = useState(0);
|
||||
const [markdownDraftDirty, setMarkdownDraftDirty] = useState(false);
|
||||
const markdownRefreshGuard = useMemo(() => new NodeMarkdownRefreshGuard(), []);
|
||||
const handleMarkdownDirtyChange = useCallback((dirty: boolean) => {
|
||||
setMarkdownDraftDirty(dirty);
|
||||
onDirtyChange?.(dirty);
|
||||
}, [onDirtyChange]);
|
||||
const [viewMode, setViewMode] = useState<GraphViewMode>("full");
|
||||
const [aggregationEnabled] = useState(true);
|
||||
const [collapsedNeighborhoodNodeIds, setCollapsedNeighborhoodNodeIds] = useState<string[]>([]);
|
||||
@@ -1641,17 +1628,8 @@ export function GraphWorkspace({ externalFocusNodeId, externalFocusToken, onDirt
|
||||
: null,
|
||||
[viewMode, aggregationEnabled, collapsedNeighborhoodNodeIds, graphVersion],
|
||||
);
|
||||
const confirmDiscardMarkdownDraft = useCallback(() => {
|
||||
if (!markdownDraftDirty) return true;
|
||||
const discard = window.confirm(
|
||||
"Discard the unapplied Markdown draft and leave this node?",
|
||||
);
|
||||
return discard;
|
||||
}, [markdownDraftDirty]);
|
||||
|
||||
|
||||
const requestViewMode = useCallback((nextViewMode: GraphViewMode) => {
|
||||
if (nextViewMode !== viewMode && !confirmDiscardMarkdownDraft()) return;
|
||||
if (nextViewMode === "focused") {
|
||||
const resolution = resolveNodeIdForFocusedMode(selectedNodeId, pluginRuntimeRef.current?.displayGraph);
|
||||
if (!resolution.resolvedNodeId) {
|
||||
@@ -1704,7 +1682,6 @@ export function GraphWorkspace({ externalFocusNodeId, externalFocusToken, onDirt
|
||||
));
|
||||
setViewMode("full");
|
||||
}, [
|
||||
confirmDiscardMarkdownDraft,
|
||||
aggregationEnabled,
|
||||
collapsedNeighborhoodNodeIds,
|
||||
focusedNodeId,
|
||||
@@ -1715,11 +1692,9 @@ export function GraphWorkspace({ externalFocusNodeId, externalFocusToken, onDirt
|
||||
lastGroupedSelectedNodeId,
|
||||
resolveNodeIdForFocusedMode,
|
||||
selectedNodeId,
|
||||
viewMode,
|
||||
]);
|
||||
|
||||
const focusNode = useCallback((nodeId: string) => {
|
||||
if (nodeId !== selectedNodeId && !confirmDiscardMarkdownDraft()) return;
|
||||
if (!nodeId) {
|
||||
setSelectedNodeId("");
|
||||
setSelectedEdgeId("");
|
||||
@@ -1745,18 +1720,14 @@ export function GraphWorkspace({ externalFocusNodeId, externalFocusToken, onDirt
|
||||
setFocusedNodeId(nextSelectedNodeId);
|
||||
setIsLayoutRunning(false);
|
||||
}
|
||||
}, [confirmDiscardMarkdownDraft, selectedNodeId, viewMode]); // Note: ego/heatmap/distanceMode effects re-run automatically when selectedNodeId changes
|
||||
}, [viewMode]); // Note: ego/heatmap/distanceMode effects re-run automatically when selectedNodeId changes
|
||||
|
||||
useEffect(() => {
|
||||
if (!externalFocusNodeId || externalFocusToken == null) return;
|
||||
if (lastExternalFocusTokenRef.current === externalFocusToken) return;
|
||||
if (!graphReady || !graph.hasNode(externalFocusNodeId)) return;
|
||||
if (
|
||||
externalFocusNodeId !== selectedNodeId
|
||||
&& !confirmDiscardMarkdownDraft()
|
||||
) return;
|
||||
lastExternalFocusTokenRef.current = externalFocusToken;
|
||||
|
||||
lastExternalFocusTokenRef.current = externalFocusToken;
|
||||
// Set state directly instead of going through focusNode(), which captures
|
||||
// a stale viewMode in its closure. setViewMode is called first so the node
|
||||
// is visible in the full graph before the scene pans to it.
|
||||
@@ -1766,13 +1737,7 @@ export function GraphWorkspace({ externalFocusNodeId, externalFocusToken, onDirt
|
||||
window.setTimeout(() => {
|
||||
sceneRef.current?.focusNode(externalFocusNodeId);
|
||||
}, 0);
|
||||
}, [
|
||||
confirmDiscardMarkdownDraft,
|
||||
externalFocusNodeId,
|
||||
externalFocusToken,
|
||||
graphReady,
|
||||
selectedNodeId,
|
||||
]);
|
||||
}, [externalFocusNodeId, externalFocusToken, graphReady]);
|
||||
|
||||
const handleEdgeSelect = useCallback((edgeId: string) => {
|
||||
setSelectedEdgeId(edgeId);
|
||||
@@ -1878,37 +1843,6 @@ export function GraphWorkspace({ externalFocusNodeId, externalFocusToken, onDirt
|
||||
document.body.removeChild(anchor);
|
||||
}, [inspectableNodeId]);
|
||||
|
||||
const handleMarkdownApplied = useCallback((result: MarkdownApplyResult) => {
|
||||
if (result.resource.kind !== "context-node") return;
|
||||
if (!graph.hasNode(result.resource.id)) return;
|
||||
const syncGeneration = markdownRefreshGuard.begin(result.resource.id);
|
||||
const attributes = graph.getNodeAttributes(result.resource.id) as NodeAttributes;
|
||||
graph.mergeNodeAttributes(
|
||||
result.resource.id,
|
||||
buildNodeMarkdownAttributeUpdate(
|
||||
result.resource.id,
|
||||
result.body,
|
||||
attributes.properties ?? {},
|
||||
),
|
||||
);
|
||||
setGraphVersion((current) => current + 1);
|
||||
sceneRef.current?.getRuntime()?.requestRender();
|
||||
|
||||
void readNodeMarkdownAttributeUpdate(result.resource.id)
|
||||
.then((savedAttributes) => {
|
||||
if (
|
||||
!markdownRefreshGuard.isCurrent(result.resource.id, syncGeneration)
|
||||
|| !graph.hasNode(result.resource.id)
|
||||
) return;
|
||||
graph.mergeNodeAttributes(result.resource.id, savedAttributes);
|
||||
setGraphVersion((current) => current + 1);
|
||||
sceneRef.current?.getRuntime()?.requestRender();
|
||||
})
|
||||
.catch((syncError) => {
|
||||
console.error("[GraphWorkspace] applied node refresh failed", syncError);
|
||||
});
|
||||
}, [markdownRefreshGuard]);
|
||||
|
||||
useEffect(() => {
|
||||
const protocol = window.location.protocol === "https:" ? "wss:" : "ws:";
|
||||
const socket = new WebSocket(`${protocol}//${window.location.host}/ws/graph-updates`);
|
||||
@@ -1939,25 +1873,6 @@ export function GraphWorkspace({ externalFocusNodeId, externalFocusToken, onDirt
|
||||
setGraphVersion((current) => current + 1);
|
||||
sceneRef.current?.getRuntime()?.requestRender();
|
||||
}
|
||||
if (eventType === "UPDATE_NODE" && payload?.id && graph.hasNode(payload.id)) {
|
||||
markdownRefreshGuard.invalidate(payload.id);
|
||||
const properties = payload.properties ?? {};
|
||||
const current = graph.getNodeAttributes(payload.id) as NodeAttributes;
|
||||
const content = typeof properties.content === "string" ? properties.content : "";
|
||||
graph.mergeNodeAttributes(payload.id, {
|
||||
...buildNodeMarkdownAttributeUpdate(payload.id, content, properties),
|
||||
nodeType: payload.type ?? current.nodeType,
|
||||
valid_from: properties.valid_from ?? null,
|
||||
valid_until: properties.valid_until ?? null,
|
||||
});
|
||||
logEvent(
|
||||
"update-node",
|
||||
`Updated node ${payload.id} via realtime ws`,
|
||||
{ nodeId: payload.id, nodeType: payload.type },
|
||||
);
|
||||
setGraphVersion((version) => version + 1);
|
||||
sceneRef.current?.getRuntime()?.requestRender();
|
||||
}
|
||||
if (eventType === "ADD_EDGE") {
|
||||
const isSmallGraph = smallGraphModeRef.current;
|
||||
batchMergeEdges([
|
||||
@@ -1984,7 +1899,7 @@ export function GraphWorkspace({ externalFocusNodeId, externalFocusToken, onDirt
|
||||
return () => {
|
||||
socket.close();
|
||||
};
|
||||
}, [markdownRefreshGuard]);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
setCollapsedNeighborhoodNodeIds([]);
|
||||
@@ -2257,29 +2172,28 @@ export function GraphWorkspace({ externalFocusNodeId, externalFocusToken, onDirt
|
||||
}, [collapsedNeighborhoodNodeIds, focusedNodeId, selectedNodeId, viewMode]);
|
||||
const structuralActivePath = structuralSelectedNodeId ? activePath : EMPTY_PATH;
|
||||
const structuralActivePathEdgeIds = structuralSelectedNodeId ? activePathEdgeIds : EMPTY_PATH;
|
||||
const displayResult = useMemo(() => {
|
||||
// The displayed graph is an aggregated clone. Rebuild it after domain
|
||||
// mutations so applied Markdown labels do not remain stale on the canvas.
|
||||
void graphVersion;
|
||||
return viewMode === "grouped"
|
||||
? (groupedDisplayCandidate ?? resolveDisplayGraph("", EMPTY_PATH, EMPTY_PATH, "grouped", {
|
||||
aggregationEnabled,
|
||||
collapsedNeighborhoodNodeIds,
|
||||
}))
|
||||
: resolveDisplayGraph(structuralSelectedNodeId, structuralActivePath, structuralActivePathEdgeIds, viewMode, {
|
||||
aggregationEnabled,
|
||||
collapsedNeighborhoodNodeIds,
|
||||
});
|
||||
}, [
|
||||
aggregationEnabled,
|
||||
collapsedNeighborhoodNodeIds,
|
||||
graphVersion,
|
||||
groupedDisplayCandidate,
|
||||
structuralActivePath,
|
||||
structuralActivePathEdgeIds,
|
||||
structuralSelectedNodeId,
|
||||
viewMode,
|
||||
]);
|
||||
const displayResult = useMemo(
|
||||
() => (
|
||||
viewMode === "grouped"
|
||||
? (groupedDisplayCandidate ?? resolveDisplayGraph("", EMPTY_PATH, EMPTY_PATH, "grouped", {
|
||||
aggregationEnabled,
|
||||
collapsedNeighborhoodNodeIds,
|
||||
}))
|
||||
: resolveDisplayGraph(structuralSelectedNodeId, structuralActivePath, structuralActivePathEdgeIds, viewMode, {
|
||||
aggregationEnabled,
|
||||
collapsedNeighborhoodNodeIds,
|
||||
})
|
||||
),
|
||||
[
|
||||
aggregationEnabled,
|
||||
collapsedNeighborhoodNodeIds,
|
||||
groupedDisplayCandidate,
|
||||
structuralActivePath,
|
||||
structuralActivePathEdgeIds,
|
||||
structuralSelectedNodeId,
|
||||
viewMode,
|
||||
],
|
||||
);
|
||||
const displayState = useMemo(
|
||||
() => (
|
||||
viewMode === "grouped"
|
||||
@@ -3381,8 +3295,6 @@ export function GraphWorkspace({ externalFocusNodeId, externalFocusToken, onDirt
|
||||
pathResult={pathResult}
|
||||
onDownloadProvenance={(format) => void handleDownloadProvenance(format)}
|
||||
onFocusNode={focusNode}
|
||||
onMarkdownApplied={handleMarkdownApplied}
|
||||
onMarkdownDirtyChange={handleMarkdownDirtyChange}
|
||||
/>
|
||||
</Suspense>
|
||||
</div>
|
||||
|
||||
@@ -1,243 +1,125 @@
|
||||
import {
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
type CSSProperties,
|
||||
} from "react";
|
||||
import { useState, useRef, useEffect, useMemo, type CSSProperties } from "react";
|
||||
import ReactMarkdown, { type Components } from "react-markdown";
|
||||
import remarkGfm from "remark-gfm";
|
||||
import {
|
||||
Check,
|
||||
Code2,
|
||||
Copy,
|
||||
Eye,
|
||||
ExternalLink,
|
||||
Image as ImageIcon,
|
||||
Loader2,
|
||||
Pencil,
|
||||
RefreshCw,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import { Check, Copy, Code2, Eye, ExternalLink, Image as ImageIcon } from "lucide-react";
|
||||
import { GRAPH_THEME } from "./graphTheme";
|
||||
import type { MarkdownApplyResult } from "./markdownResourceClient";
|
||||
import type { MarkdownResourceRef } from "./markdownEditorState";
|
||||
import { isSafeUrl } from "./markdownUrlSafety";
|
||||
import { useMarkdownEditor } from "./useMarkdownEditor";
|
||||
|
||||
export interface MarkdownContentViewerProps {
|
||||
content?: string | null;
|
||||
resource?: MarkdownResourceRef;
|
||||
onApplied?: (result: MarkdownApplyResult) => void;
|
||||
onDirtyChange?: (dirty: boolean) => void;
|
||||
className?: string;
|
||||
defaultMode?: "preview" | "source";
|
||||
}
|
||||
|
||||
export function MarkdownContentViewer({
|
||||
content,
|
||||
resource,
|
||||
onApplied,
|
||||
onDirtyChange,
|
||||
className,
|
||||
defaultMode = "preview",
|
||||
}: MarkdownContentViewerProps) {
|
||||
const [activeMode, setActiveMode] = useState<"preview" | "source">(defaultMode);
|
||||
const [copied, setCopied] = useState(false);
|
||||
const modeBeforeEditRef = useRef<"preview" | "source">(defaultMode);
|
||||
const resourceKey = resource ? `${resource.kind}:${resource.id}` : "";
|
||||
const [activeResourceKey, setActiveResourceKey] = useState(resourceKey);
|
||||
const editor = useMarkdownEditor({ resource, onApplied, onDirtyChange });
|
||||
const {
|
||||
session,
|
||||
error,
|
||||
dirty,
|
||||
editing,
|
||||
saving,
|
||||
loading,
|
||||
} = editor;
|
||||
|
||||
if (activeResourceKey !== resourceKey) {
|
||||
setActiveResourceKey(resourceKey);
|
||||
setCopied(false);
|
||||
setActiveMode(defaultMode);
|
||||
}
|
||||
|
||||
// Track the content value for which the copied indicator is valid.
|
||||
// When content changes (i.e. the user selects a different node), reset the
|
||||
// copied indicator inline during render rather than in a useEffect — this
|
||||
// avoids a cascading-render lint error and is the React-recommended pattern
|
||||
// for resetting derived visual state on prop changes.
|
||||
const [copiedForContent, setCopiedForContent] = useState<string | null | undefined>(content);
|
||||
if (copiedForContent !== content) {
|
||||
setCopiedForContent(content);
|
||||
if (copied) setCopied(false);
|
||||
if (copied) {
|
||||
// Clear the stale indicator synchronously so the new node's copy button
|
||||
// never shows "Copied" from the previous selection.
|
||||
setCopied(false);
|
||||
}
|
||||
}
|
||||
|
||||
const copyTimeoutRef = useRef<number | undefined>(undefined);
|
||||
const copyTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
// Clean up any outstanding timeout on unmount.
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
clearTimeout(copyTimeoutRef.current);
|
||||
if (copyTimeoutRef.current) {
|
||||
clearTimeout(copyTimeoutRef.current);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
const rawContent = editor.editing
|
||||
? editor.session?.draft ?? ""
|
||||
: (typeof content === "string" ? content : "");
|
||||
const previewContent = useMemo(() => {
|
||||
if (!editor.editing) return rawContent;
|
||||
const lines = rawContent.split(/\r?\n/);
|
||||
if (lines[0] !== "---") return rawContent;
|
||||
const closingIndex = lines.findIndex((line, index) => index > 0 && line === "---");
|
||||
return closingIndex < 0 ? rawContent : lines.slice(closingIndex + 1).join("\n").replace(/^\n/, "");
|
||||
}, [editor.editing, rawContent]);
|
||||
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}>
|
||||
{previewContent}
|
||||
{rawContent}
|
||||
</ReactMarkdown>
|
||||
),
|
||||
[previewContent],
|
||||
[rawContent],
|
||||
);
|
||||
|
||||
const handleCopy = async () => {
|
||||
if (!hasContent) return;
|
||||
try {
|
||||
await navigator.clipboard.writeText(rawContent);
|
||||
clearTimeout(copyTimeoutRef.current);
|
||||
if (copyTimeoutRef.current) {
|
||||
clearTimeout(copyTimeoutRef.current);
|
||||
}
|
||||
setCopied(true);
|
||||
copyTimeoutRef.current = window.setTimeout(() => setCopied(false), 1500);
|
||||
copyTimeoutRef.current = setTimeout(() => setCopied(false), 1500);
|
||||
} catch {
|
||||
// Clipboard write unavailable.
|
||||
}
|
||||
};
|
||||
|
||||
const handleEdit = async () => {
|
||||
modeBeforeEditRef.current = activeMode;
|
||||
setActiveMode("source");
|
||||
if (!await editor.beginEdit()) {
|
||||
setActiveMode(modeBeforeEditRef.current);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCancel = () => {
|
||||
editor.discard();
|
||||
setActiveMode(modeBeforeEditRef.current);
|
||||
};
|
||||
|
||||
const handleApply = async () => {
|
||||
if (await editor.save()) {
|
||||
setActiveMode("preview");
|
||||
// Clipboard write unavailable
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={className} style={viewerContainerStyle}>
|
||||
<div style={viewerHeaderStyle}>
|
||||
<div style={{ display: "flex", gap: 4 }} role="tablist" aria-label="Markdown view">
|
||||
<div style={{ display: "flex", gap: 4 }} role="tablist">
|
||||
<button
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={activeMode === "preview"}
|
||||
aria-controls="markdown-viewer-panel"
|
||||
onClick={() => setActiveMode("preview")}
|
||||
style={{ ...tabBtnStyle, ...(activeMode === "preview" ? activeTabBtnStyle : {}) }}
|
||||
>
|
||||
<Eye size={12} style={{ marginRight: 5 }} aria-hidden="true" />
|
||||
<Eye size={12} style={{ marginRight: 5 }} />
|
||||
Preview
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={activeMode === "source"}
|
||||
aria-controls="markdown-viewer-panel"
|
||||
onClick={() => setActiveMode("source")}
|
||||
style={{ ...tabBtnStyle, ...(activeMode === "source" ? activeTabBtnStyle : {}) }}
|
||||
>
|
||||
<Code2 size={12} style={{ marginRight: 5 }} aria-hidden="true" />
|
||||
<Code2 size={12} style={{ marginRight: 5 }} />
|
||||
Source
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 6 }}>
|
||||
{hasContent && (
|
||||
<button type="button" onClick={() => void handleCopy()} style={copyBtnStyle} title="Copy raw content">
|
||||
{copied ? (
|
||||
<>
|
||||
<Check size={12} color="#3fb950" style={{ marginRight: 4 }} aria-hidden="true" />
|
||||
<span style={{ color: "#3fb950", fontSize: 11 }}>Copied</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Copy size={12} style={{ marginRight: 4 }} aria-hidden="true" />
|
||||
<span style={{ fontSize: 11 }}>Copy</span>
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
{resource && !editing && !loading ? (
|
||||
<button type="button" onClick={() => void handleEdit()} style={copyBtnStyle}>
|
||||
<Pencil size={12} style={{ marginRight: 4 }} aria-hidden="true" />
|
||||
Edit
|
||||
</button>
|
||||
) : null}
|
||||
{loading ? (
|
||||
<button type="button" disabled style={{ ...copyBtnStyle, opacity: 0.65 }}>
|
||||
<Loader2 size={12} className="animate-spin" style={{ marginRight: 4 }} aria-hidden="true" />
|
||||
Loading…
|
||||
</button>
|
||||
) : null}
|
||||
{editing ? (
|
||||
<>
|
||||
<button type="button" onClick={handleCancel} disabled={saving} style={copyBtnStyle}>
|
||||
<X size={12} style={{ marginRight: 4 }} aria-hidden="true" />
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void handleApply()}
|
||||
disabled={saving || !dirty}
|
||||
title={!dirty ? "Make a change before applying" : undefined}
|
||||
style={{ ...saveBtnStyle, opacity: saving || !dirty ? 0.55 : 1 }}
|
||||
>
|
||||
{saving ? (
|
||||
<Loader2 size={12} className="animate-spin" style={{ marginRight: 4 }} aria-hidden="true" />
|
||||
) : (
|
||||
<Check size={12} style={{ marginRight: 4 }} aria-hidden="true" />
|
||||
)}
|
||||
{saving ? "Applying…" : "Apply"}
|
||||
</button>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
{hasContent && (
|
||||
<button type="button" onClick={() => void handleCopy()} style={copyBtnStyle} title="Copy raw content">
|
||||
{copied ? (
|
||||
<>
|
||||
<Check size={12} color="#3fb950" style={{ marginRight: 4 }} />
|
||||
<span style={{ color: "#3fb950", fontSize: 11 }}>Copied</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Copy size={12} style={{ marginRight: 4 }} />
|
||||
<span style={{ fontSize: 11 }}>Copy</span>
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{error ? (
|
||||
<div id="markdown-editor-error" role="alert" style={errorStyle}>
|
||||
<span>{error.message}</span>
|
||||
{error.kind === "conflict" ? (
|
||||
<button type="button" onClick={() => void editor.reloadLatest()} style={errorActionStyle}>
|
||||
<RefreshCw size={12} style={{ marginRight: 4 }} aria-hidden="true" />
|
||||
Reload latest
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div
|
||||
id="markdown-viewer-panel"
|
||||
role="tabpanel"
|
||||
aria-busy={saving || loading}
|
||||
style={viewerBodyStyle}
|
||||
>
|
||||
{activeMode === "source" && editing ? (
|
||||
<textarea
|
||||
aria-label="Markdown source"
|
||||
aria-describedby={error ? "markdown-editor-error" : undefined}
|
||||
aria-invalid={error?.kind === "validation" || undefined}
|
||||
value={session?.draft ?? ""}
|
||||
onChange={(event) => editor.changeDraft(event.target.value)}
|
||||
disabled={saving}
|
||||
spellCheck={false}
|
||||
style={editorStyle}
|
||||
/>
|
||||
) : !hasContent ? (
|
||||
<div style={viewerBodyStyle}>
|
||||
{!hasContent ? (
|
||||
<div style={emptyTextStyle}>No content available for this node.</div>
|
||||
) : activeMode === "source" ? (
|
||||
<pre style={sourcePreStyle}>
|
||||
@@ -356,8 +238,6 @@ const viewerHeaderStyle: CSSProperties = {
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
gap: 8,
|
||||
flexWrap: "wrap",
|
||||
padding: "6px 10px",
|
||||
background: "rgba(0, 0, 0, 0.2)",
|
||||
borderBottom: `1px solid ${GRAPH_THEME.ui.surface.panelBorder}`,
|
||||
@@ -395,56 +275,12 @@ const copyBtnStyle: CSSProperties = {
|
||||
cursor: "pointer",
|
||||
};
|
||||
|
||||
const saveBtnStyle: CSSProperties = {
|
||||
...copyBtnStyle,
|
||||
background: GRAPH_THEME.ui.control.primaryBg,
|
||||
border: `1px solid ${GRAPH_THEME.ui.control.primaryBorder}`,
|
||||
color: GRAPH_THEME.ui.control.primaryText,
|
||||
fontWeight: 700,
|
||||
};
|
||||
|
||||
const errorStyle: CSSProperties = {
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
gap: 8,
|
||||
padding: "8px 12px",
|
||||
color: "#ffb4ad",
|
||||
background: "rgba(248, 81, 73, 0.1)",
|
||||
borderBottom: "1px solid rgba(248, 81, 73, 0.25)",
|
||||
fontSize: 12,
|
||||
lineHeight: 1.5,
|
||||
};
|
||||
|
||||
const errorActionStyle: CSSProperties = {
|
||||
...copyBtnStyle,
|
||||
flexShrink: 0,
|
||||
color: "#ffb4ad",
|
||||
border: "1px solid rgba(248, 81, 73, 0.32)",
|
||||
};
|
||||
|
||||
const viewerBodyStyle: CSSProperties = {
|
||||
padding: 12,
|
||||
maxHeight: 380,
|
||||
overflowY: "auto",
|
||||
};
|
||||
|
||||
const editorStyle: CSSProperties = {
|
||||
display: "block",
|
||||
boxSizing: "border-box",
|
||||
width: "100%",
|
||||
minHeight: 280,
|
||||
resize: "vertical",
|
||||
padding: 10,
|
||||
borderRadius: 8,
|
||||
border: `1px solid ${GRAPH_THEME.ui.control.activeBorder}`,
|
||||
background: "rgba(0, 0, 0, 0.3)",
|
||||
color: GRAPH_THEME.ui.text.strong,
|
||||
fontFamily: "'JetBrains Mono', 'Fira Code', monospace",
|
||||
fontSize: 12,
|
||||
lineHeight: 1.6,
|
||||
};
|
||||
|
||||
const emptyTextStyle: CSSProperties = {
|
||||
color: GRAPH_THEME.ui.text.muted,
|
||||
fontSize: 12,
|
||||
|
||||
@@ -1,114 +0,0 @@
|
||||
export type MarkdownResourceRef =
|
||||
| { kind: "context-node"; id: string }
|
||||
| { kind: "agent-memory"; id: string };
|
||||
|
||||
export type EditorStatus =
|
||||
| "viewing"
|
||||
| "loading-document"
|
||||
| "editing"
|
||||
| "saving"
|
||||
| "validation-error"
|
||||
| "save-error"
|
||||
| "conflict";
|
||||
|
||||
export interface MarkdownEditorError {
|
||||
kind: "validation" | "conflict" | "save" | "network";
|
||||
message: string;
|
||||
field?: string;
|
||||
currentRevision?: string;
|
||||
}
|
||||
|
||||
export interface MarkdownEditSession {
|
||||
resource: MarkdownResourceRef;
|
||||
baseSource: string;
|
||||
baseRevision: string;
|
||||
draft: string;
|
||||
status: EditorStatus;
|
||||
error: MarkdownEditorError | null;
|
||||
}
|
||||
|
||||
export interface MarkdownSavedDocument {
|
||||
source: string;
|
||||
revision: string;
|
||||
}
|
||||
|
||||
export function createLoadingSession(resource: MarkdownResourceRef): MarkdownEditSession {
|
||||
return {
|
||||
resource,
|
||||
baseSource: "",
|
||||
baseRevision: "",
|
||||
draft: "",
|
||||
status: "loading-document",
|
||||
error: null,
|
||||
};
|
||||
}
|
||||
|
||||
export function createEditSession(
|
||||
resource: MarkdownResourceRef,
|
||||
document: MarkdownSavedDocument,
|
||||
): MarkdownEditSession {
|
||||
return {
|
||||
resource,
|
||||
baseSource: document.source,
|
||||
baseRevision: document.revision,
|
||||
draft: document.source,
|
||||
status: "editing",
|
||||
error: null,
|
||||
};
|
||||
}
|
||||
|
||||
export function updateDraft(
|
||||
session: MarkdownEditSession,
|
||||
draft: string,
|
||||
): MarkdownEditSession {
|
||||
return {
|
||||
...session,
|
||||
draft,
|
||||
status: "editing",
|
||||
error: null,
|
||||
};
|
||||
}
|
||||
|
||||
export function isDirty(session: MarkdownEditSession | null): boolean {
|
||||
return session !== null && session.draft !== session.baseSource;
|
||||
}
|
||||
|
||||
export function saveStarted(session: MarkdownEditSession): MarkdownEditSession {
|
||||
if (!isDirty(session)) return session;
|
||||
return { ...session, status: "saving", error: null };
|
||||
}
|
||||
|
||||
export function saveSucceeded(
|
||||
session: MarkdownEditSession,
|
||||
document: MarkdownSavedDocument,
|
||||
): MarkdownEditSession {
|
||||
return {
|
||||
...session,
|
||||
baseSource: document.source,
|
||||
baseRevision: document.revision,
|
||||
draft: document.source,
|
||||
status: "viewing",
|
||||
error: null,
|
||||
};
|
||||
}
|
||||
|
||||
export function saveFailed(
|
||||
session: MarkdownEditSession,
|
||||
error: MarkdownEditorError,
|
||||
): MarkdownEditSession {
|
||||
const status: EditorStatus =
|
||||
error.kind === "validation"
|
||||
? "validation-error"
|
||||
: error.kind === "conflict"
|
||||
? "conflict"
|
||||
: "save-error";
|
||||
return { ...session, status, error };
|
||||
}
|
||||
|
||||
export function cancelEdit(): null {
|
||||
return null;
|
||||
}
|
||||
|
||||
export function shouldConfirmDiscard(session: MarkdownEditSession | null): boolean {
|
||||
return isDirty(session) && session?.status !== "saving";
|
||||
}
|
||||
@@ -1,103 +0,0 @@
|
||||
import type {
|
||||
MarkdownEditorError,
|
||||
MarkdownResourceRef,
|
||||
} from "./markdownEditorState";
|
||||
|
||||
export interface MarkdownDocument {
|
||||
resource: MarkdownResourceRef;
|
||||
source: string;
|
||||
body: string;
|
||||
revision: string;
|
||||
editable: boolean;
|
||||
}
|
||||
|
||||
export interface MarkdownApplyResult extends MarkdownDocument {
|
||||
changed: boolean;
|
||||
}
|
||||
|
||||
type ErrorDetail = {
|
||||
code?: string;
|
||||
message?: string;
|
||||
field?: string;
|
||||
current_revision?: string;
|
||||
};
|
||||
|
||||
export class MarkdownClientError extends Error implements MarkdownEditorError {
|
||||
readonly kind: MarkdownEditorError["kind"];
|
||||
readonly field?: string;
|
||||
readonly currentRevision?: string;
|
||||
|
||||
constructor(error: MarkdownEditorError) {
|
||||
super(error.message);
|
||||
this.name = "MarkdownClientError";
|
||||
this.kind = error.kind;
|
||||
this.field = error.field;
|
||||
this.currentRevision = error.currentRevision;
|
||||
}
|
||||
}
|
||||
|
||||
function resourceUrl(ref: MarkdownResourceRef): string {
|
||||
return `/api/markdown/${ref.kind}/${encodeURIComponent(ref.id)}`;
|
||||
}
|
||||
|
||||
async function responseError(response: Response): Promise<MarkdownClientError> {
|
||||
let detail: ErrorDetail = {};
|
||||
try {
|
||||
const payload = (await response.json()) as { detail?: ErrorDetail };
|
||||
if (payload.detail && typeof payload.detail === "object") {
|
||||
detail = payload.detail;
|
||||
}
|
||||
} catch {
|
||||
// A non-JSON response is mapped from its status below.
|
||||
}
|
||||
|
||||
const kind: MarkdownEditorError["kind"] =
|
||||
response.status === 422
|
||||
? "validation"
|
||||
: response.status === 409
|
||||
? "conflict"
|
||||
: "save";
|
||||
return new MarkdownClientError({
|
||||
kind,
|
||||
message: detail.message || `Markdown request failed (${response.status}).`,
|
||||
field: detail.field,
|
||||
currentRevision: detail.current_revision,
|
||||
});
|
||||
}
|
||||
|
||||
async function request<T>(input: RequestInfo | URL, init?: RequestInit): Promise<T> {
|
||||
try {
|
||||
const response = await fetch(input, init);
|
||||
if (!response.ok) {
|
||||
throw await responseError(response);
|
||||
}
|
||||
return (await response.json()) as T;
|
||||
} catch (error) {
|
||||
if (error instanceof MarkdownClientError) throw error;
|
||||
throw new MarkdownClientError({
|
||||
kind: "network",
|
||||
message: "The Markdown service could not be reached. Your draft was kept.",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export function readMarkdownResource(
|
||||
ref: MarkdownResourceRef,
|
||||
): Promise<MarkdownDocument> {
|
||||
return request<MarkdownDocument>(resourceUrl(ref));
|
||||
}
|
||||
|
||||
export function applyMarkdownResource(
|
||||
ref: MarkdownResourceRef,
|
||||
markdown: string,
|
||||
expectedRevision: string,
|
||||
): Promise<MarkdownApplyResult> {
|
||||
return request<MarkdownApplyResult>(resourceUrl(ref), {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
markdown,
|
||||
expected_revision: expectedRevision,
|
||||
}),
|
||||
});
|
||||
}
|
||||
@@ -1,81 +0,0 @@
|
||||
export interface NodeMarkdownAttributeUpdate {
|
||||
label: string;
|
||||
content: string;
|
||||
properties: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface GraphNodeMarkdownSnapshot {
|
||||
id: string;
|
||||
type: string;
|
||||
content: string;
|
||||
properties: Record<string, unknown>;
|
||||
valid_from: string | null;
|
||||
valid_until: string | null;
|
||||
}
|
||||
|
||||
export interface SavedNodeMarkdownAttributeUpdate extends NodeMarkdownAttributeUpdate {
|
||||
nodeType: string;
|
||||
valid_from: string | null;
|
||||
valid_until: string | null;
|
||||
}
|
||||
|
||||
export class NodeMarkdownRefreshGuard {
|
||||
private readonly generations = new Map<string, number>();
|
||||
|
||||
begin(nodeId: string): number {
|
||||
const generation = (this.generations.get(nodeId) ?? 0) + 1;
|
||||
this.generations.set(nodeId, generation);
|
||||
return generation;
|
||||
}
|
||||
|
||||
invalidate(nodeId: string): void {
|
||||
this.begin(nodeId);
|
||||
}
|
||||
|
||||
isCurrent(nodeId: string, generation: number): boolean {
|
||||
return this.generations.get(nodeId) === generation;
|
||||
}
|
||||
}
|
||||
|
||||
export function buildNodeMarkdownAttributeUpdate(
|
||||
nodeId: string,
|
||||
content: string,
|
||||
properties: Record<string, unknown>,
|
||||
): NodeMarkdownAttributeUpdate {
|
||||
return {
|
||||
label: content || nodeId,
|
||||
content,
|
||||
properties: {
|
||||
...properties,
|
||||
content,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export async function readNodeMarkdownAttributeUpdate(
|
||||
nodeId: string,
|
||||
fetcher: typeof fetch = fetch,
|
||||
): Promise<SavedNodeMarkdownAttributeUpdate> {
|
||||
const response = await fetcher(
|
||||
`/api/graph/node?node_id=${encodeURIComponent(nodeId)}`,
|
||||
);
|
||||
if (!response.ok) {
|
||||
throw new Error(`Graph node refresh failed (${response.status}).`);
|
||||
}
|
||||
|
||||
const node = await response.json() as GraphNodeMarkdownSnapshot;
|
||||
if (node.id !== nodeId) {
|
||||
throw new Error("Graph node refresh returned a different resource.");
|
||||
}
|
||||
|
||||
return {
|
||||
...buildNodeMarkdownAttributeUpdate(
|
||||
node.id,
|
||||
node.content,
|
||||
node.properties ?? {},
|
||||
),
|
||||
nodeType: node.type,
|
||||
valid_from: node.valid_from ?? null,
|
||||
valid_until: node.valid_until ?? null,
|
||||
};
|
||||
}
|
||||
@@ -1,177 +0,0 @@
|
||||
import {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useLayoutEffect,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import {
|
||||
MarkdownClientError,
|
||||
applyMarkdownResource,
|
||||
readMarkdownResource,
|
||||
type MarkdownApplyResult,
|
||||
} from "./markdownResourceClient";
|
||||
import {
|
||||
cancelEdit,
|
||||
createEditSession,
|
||||
createLoadingSession,
|
||||
isDirty,
|
||||
saveFailed,
|
||||
saveStarted,
|
||||
saveSucceeded,
|
||||
updateDraft,
|
||||
type MarkdownEditorError,
|
||||
type MarkdownEditSession,
|
||||
type MarkdownResourceRef,
|
||||
} from "./markdownEditorState";
|
||||
|
||||
interface MarkdownEditorOptions {
|
||||
resource?: MarkdownResourceRef;
|
||||
onApplied?: (result: MarkdownApplyResult) => void;
|
||||
onDirtyChange?: (dirty: boolean) => void;
|
||||
}
|
||||
|
||||
interface KeyedError {
|
||||
resourceKey: string;
|
||||
error: MarkdownEditorError;
|
||||
}
|
||||
|
||||
function keyOf(resource?: MarkdownResourceRef): string {
|
||||
return resource ? `${resource.kind}:${resource.id}` : "";
|
||||
}
|
||||
|
||||
function normalizeError(failure: unknown): MarkdownEditorError {
|
||||
if (failure instanceof MarkdownClientError) return failure;
|
||||
return {
|
||||
kind: "network",
|
||||
message: "The Markdown service could not be reached. Your draft was kept.",
|
||||
};
|
||||
}
|
||||
|
||||
export function useMarkdownEditor({
|
||||
resource,
|
||||
onApplied,
|
||||
onDirtyChange,
|
||||
}: MarkdownEditorOptions) {
|
||||
const resourceKey = keyOf(resource);
|
||||
const [session, setSession] = useState<MarkdownEditSession | null>(null);
|
||||
const [viewError, setViewError] = useState<KeyedError | null>(null);
|
||||
const [renderedResourceKey, setRenderedResourceKey] = useState(resourceKey);
|
||||
const loadGenerationRef = useRef(0);
|
||||
|
||||
if (renderedResourceKey !== resourceKey) {
|
||||
setRenderedResourceKey(resourceKey);
|
||||
setSession(null);
|
||||
setViewError(null);
|
||||
}
|
||||
|
||||
useLayoutEffect(() => {
|
||||
loadGenerationRef.current += 1;
|
||||
}, [resourceKey]);
|
||||
|
||||
const activeSession = session && keyOf(session.resource) === resourceKey
|
||||
? session
|
||||
: null;
|
||||
const dirty = isDirty(activeSession);
|
||||
const editing = activeSession !== null
|
||||
&& activeSession.status !== "viewing"
|
||||
&& activeSession.status !== "loading-document";
|
||||
const saving = activeSession?.status === "saving";
|
||||
const loading = activeSession?.status === "loading-document";
|
||||
const error = activeSession?.error
|
||||
?? (viewError?.resourceKey === resourceKey ? viewError.error : null);
|
||||
|
||||
useEffect(() => {
|
||||
onDirtyChange?.(dirty);
|
||||
return () => {
|
||||
if (dirty) onDirtyChange?.(false);
|
||||
};
|
||||
}, [dirty, onDirtyChange]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!dirty) return;
|
||||
const protectDraft = (event: BeforeUnloadEvent) => {
|
||||
event.preventDefault();
|
||||
event.returnValue = "";
|
||||
};
|
||||
window.addEventListener("beforeunload", protectDraft);
|
||||
return () => window.removeEventListener("beforeunload", protectDraft);
|
||||
}, [dirty]);
|
||||
|
||||
const beginEdit = useCallback(async () => {
|
||||
if (!resource) return false;
|
||||
const loadGeneration = ++loadGenerationRef.current;
|
||||
setViewError(null);
|
||||
setSession(createLoadingSession(resource));
|
||||
try {
|
||||
const document = await readMarkdownResource(resource);
|
||||
if (loadGeneration !== loadGenerationRef.current) return false;
|
||||
setSession(createEditSession(resource, document));
|
||||
return true;
|
||||
} catch (failure) {
|
||||
if (loadGeneration !== loadGenerationRef.current) return false;
|
||||
setSession(null);
|
||||
setViewError({ resourceKey, error: normalizeError(failure) });
|
||||
return false;
|
||||
}
|
||||
}, [resource, resourceKey]);
|
||||
|
||||
const discard = useCallback(() => {
|
||||
if (!activeSession || saving) return;
|
||||
setSession(cancelEdit());
|
||||
setViewError(null);
|
||||
}, [activeSession, saving]);
|
||||
|
||||
const save = useCallback(async () => {
|
||||
if (!activeSession || saving || !dirty) return false;
|
||||
const resourceGeneration = loadGenerationRef.current;
|
||||
const pending = saveStarted(activeSession);
|
||||
setSession(pending);
|
||||
try {
|
||||
const result = await applyMarkdownResource(
|
||||
pending.resource,
|
||||
pending.draft,
|
||||
pending.baseRevision,
|
||||
);
|
||||
if (resourceGeneration !== loadGenerationRef.current) return false;
|
||||
setSession(saveSucceeded(pending, result));
|
||||
onApplied?.(result);
|
||||
return true;
|
||||
} catch (failure) {
|
||||
if (resourceGeneration !== loadGenerationRef.current) return false;
|
||||
setSession(saveFailed(pending, normalizeError(failure)));
|
||||
return false;
|
||||
}
|
||||
}, [activeSession, dirty, onApplied, saving]);
|
||||
|
||||
const reloadLatest = useCallback(async () => {
|
||||
if (!resource || saving) return;
|
||||
if (
|
||||
dirty
|
||||
&& !window.confirm("Discard this draft and reload the latest applied version?")
|
||||
) return;
|
||||
await beginEdit();
|
||||
}, [beginEdit, dirty, resource, saving]);
|
||||
|
||||
const changeDraft = useCallback((draft: string) => {
|
||||
setSession((current) => (
|
||||
current && keyOf(current.resource) === resourceKey
|
||||
? updateDraft(current, draft)
|
||||
: current
|
||||
));
|
||||
}, [resourceKey]);
|
||||
|
||||
return {
|
||||
session: activeSession,
|
||||
error,
|
||||
dirty,
|
||||
editing,
|
||||
saving,
|
||||
loading,
|
||||
beginEdit,
|
||||
discard,
|
||||
save,
|
||||
reloadLatest,
|
||||
changeDraft,
|
||||
};
|
||||
}
|
||||
@@ -1,464 +0,0 @@
|
||||
import { useCallback, useEffect, useRef, useState, type CSSProperties } from "react";
|
||||
import { Brain, RefreshCw } from "lucide-react";
|
||||
|
||||
import { MarkdownContentViewer } from "./GraphWorkspace/MarkdownContentViewer";
|
||||
import {
|
||||
readMarkdownResource,
|
||||
type MarkdownApplyResult,
|
||||
} from "./GraphWorkspace/markdownResourceClient";
|
||||
import { GRAPH_THEME } from "./GraphWorkspace/graphTheme";
|
||||
|
||||
interface MemorySummary {
|
||||
id: string;
|
||||
type: string;
|
||||
excerpt: string;
|
||||
updated_at: string | null;
|
||||
}
|
||||
|
||||
interface MemoryListResponse {
|
||||
items: MemorySummary[];
|
||||
total: number;
|
||||
skip: number;
|
||||
limit: number;
|
||||
}
|
||||
interface MemoryWorkspaceProps {
|
||||
onDirtyChange?: (dirty: boolean) => void;
|
||||
}
|
||||
|
||||
const MEMORY_PAGE_SIZE = 100;
|
||||
|
||||
|
||||
function responseMessage(payload: unknown, fallback: string): string {
|
||||
if (!payload || typeof payload !== "object" || !("detail" in payload)) {
|
||||
return fallback;
|
||||
}
|
||||
return typeof payload.detail === "string" ? payload.detail : fallback;
|
||||
}
|
||||
|
||||
|
||||
async function fetchMemoryList(skip = 0): Promise<MemoryListResponse> {
|
||||
const response = await fetch(`/api/memories?skip=${skip}&limit=${MEMORY_PAGE_SIZE}`);
|
||||
if (!response.ok) {
|
||||
const payload = await response.json().catch(() => null);
|
||||
throw new Error(responseMessage(payload, `Memory list failed (${response.status}).`));
|
||||
}
|
||||
return response.json() as Promise<MemoryListResponse>;
|
||||
}
|
||||
|
||||
async function fetchLoadedMemoryPages(endOffset: number): Promise<{
|
||||
items: MemorySummary[];
|
||||
total: number;
|
||||
nextOffset: number;
|
||||
}> {
|
||||
const items: MemorySummary[] = [];
|
||||
let total = 0;
|
||||
let nextOffset = 0;
|
||||
const targetOffset = Math.max(endOffset, MEMORY_PAGE_SIZE);
|
||||
|
||||
while (nextOffset < targetOffset) {
|
||||
const payload = await fetchMemoryList(nextOffset);
|
||||
items.push(...payload.items);
|
||||
total = payload.total;
|
||||
nextOffset = payload.skip + payload.items.length;
|
||||
if (payload.items.length === 0 || nextOffset >= total) break;
|
||||
}
|
||||
|
||||
return { items, total, nextOffset };
|
||||
}
|
||||
|
||||
export function MemoryWorkspace({ onDirtyChange }: MemoryWorkspaceProps = {}) {
|
||||
const [items, setItems] = useState<MemorySummary[]>([]);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [nextOffset, setNextOffset] = useState(0);
|
||||
const [selectedId, setSelectedId] = useState("");
|
||||
const [selectedBody, setSelectedBody] = useState("");
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [loadingMore, setLoadingMore] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
const [dirty, setDirty] = useState(false);
|
||||
const [reloadToken, setReloadToken] = useState(0);
|
||||
const listGenerationRef = useRef(0);
|
||||
const selectionGenerationRef = useRef(0);
|
||||
const handleDirtyChange = useCallback((nextDirty: boolean) => {
|
||||
setDirty(nextDirty);
|
||||
onDirtyChange?.(nextDirty);
|
||||
}, [onDirtyChange]);
|
||||
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
const listGeneration = ++listGenerationRef.current;
|
||||
const selectionGeneration = ++selectionGenerationRef.current;
|
||||
const isCurrent = () => (
|
||||
!cancelled
|
||||
&& listGeneration === listGenerationRef.current
|
||||
&& selectionGeneration === selectionGenerationRef.current
|
||||
);
|
||||
const load = async () => {
|
||||
setLoading(true);
|
||||
setLoadingMore(false);
|
||||
setError("");
|
||||
try {
|
||||
const payload = await fetchMemoryList();
|
||||
if (!isCurrent()) return;
|
||||
setItems(payload.items);
|
||||
setTotal(payload.total);
|
||||
setNextOffset(payload.skip + payload.items.length);
|
||||
const first = payload.items[0];
|
||||
if (!first) {
|
||||
setSelectedId("");
|
||||
setSelectedBody("");
|
||||
return;
|
||||
}
|
||||
const document = await readMarkdownResource({
|
||||
kind: "agent-memory",
|
||||
id: first.id,
|
||||
});
|
||||
if (!isCurrent()) return;
|
||||
setSelectedId(first.id);
|
||||
setSelectedBody(document.body);
|
||||
} catch (failure) {
|
||||
if (isCurrent()) {
|
||||
setError(failure instanceof Error ? failure.message : "Memories could not be loaded.");
|
||||
}
|
||||
} finally {
|
||||
if (isCurrent()) setLoading(false);
|
||||
}
|
||||
};
|
||||
void load();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
listGenerationRef.current += 1;
|
||||
selectionGenerationRef.current += 1;
|
||||
};
|
||||
}, [reloadToken]);
|
||||
|
||||
const selectMemory = async (memoryId: string) => {
|
||||
if (memoryId === selectedId) return;
|
||||
if (dirty && !window.confirm("Discard the unapplied Markdown draft and open another memory?")) return;
|
||||
const selectionGeneration = ++selectionGenerationRef.current;
|
||||
// Do NOT call handleDirtyChange(false) here: the editor's own onDirtyChange
|
||||
// callback fires automatically when MarkdownContentViewer re-renders with
|
||||
// the new resource prop and its session is cleared.
|
||||
setLoading(true);
|
||||
setError("");
|
||||
try {
|
||||
const document = await readMarkdownResource({
|
||||
kind: "agent-memory",
|
||||
id: memoryId,
|
||||
});
|
||||
if (selectionGeneration !== selectionGenerationRef.current) return;
|
||||
setSelectedId(memoryId);
|
||||
setSelectedBody(document.body);
|
||||
} catch (failure) {
|
||||
if (selectionGeneration === selectionGenerationRef.current) {
|
||||
setError(failure instanceof Error ? failure.message : "The memory could not be loaded.");
|
||||
}
|
||||
} finally {
|
||||
if (selectionGeneration === selectionGenerationRef.current) {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const loadMoreMemories = useCallback(async () => {
|
||||
if (loadingMore || nextOffset >= total) return;
|
||||
const listGeneration = ++listGenerationRef.current;
|
||||
setLoadingMore(true);
|
||||
setError("");
|
||||
try {
|
||||
const payload = await fetchMemoryList(nextOffset);
|
||||
if (listGeneration !== listGenerationRef.current) return;
|
||||
setItems((current) => {
|
||||
const knownIds = new Set(current.map((item) => item.id));
|
||||
return [
|
||||
...current,
|
||||
...payload.items.filter((item) => !knownIds.has(item.id)),
|
||||
];
|
||||
});
|
||||
setTotal(payload.total);
|
||||
setNextOffset(payload.skip + payload.items.length);
|
||||
} catch (failure) {
|
||||
if (listGeneration === listGenerationRef.current) {
|
||||
setError(failure instanceof Error ? failure.message : "More memories could not be loaded.");
|
||||
}
|
||||
} finally {
|
||||
if (listGeneration === listGenerationRef.current) {
|
||||
setLoadingMore(false);
|
||||
}
|
||||
}
|
||||
}, [loadingMore, nextOffset, total]);
|
||||
|
||||
const refreshMemorySummaries = useCallback(async () => {
|
||||
const listGeneration = ++listGenerationRef.current;
|
||||
try {
|
||||
const payload = await fetchLoadedMemoryPages(nextOffset);
|
||||
if (listGeneration !== listGenerationRef.current) return;
|
||||
setItems(payload.items);
|
||||
setTotal(payload.total);
|
||||
setNextOffset(payload.nextOffset);
|
||||
} catch {
|
||||
if (listGeneration === listGenerationRef.current) {
|
||||
setError("Memory was applied, but its summary could not be refreshed.");
|
||||
}
|
||||
} finally {
|
||||
if (listGeneration === listGenerationRef.current) {
|
||||
setLoadingMore(false);
|
||||
}
|
||||
}
|
||||
}, [nextOffset]);
|
||||
|
||||
const applyMemory = useCallback((result: MarkdownApplyResult) => {
|
||||
setSelectedBody(result.body);
|
||||
handleDirtyChange(false);
|
||||
setItems((current) => current.map((item) => (
|
||||
item.id === result.resource.id
|
||||
? { ...item, excerpt: result.body.replace(/\s+/g, " ").slice(0, 160) }
|
||||
: item
|
||||
)));
|
||||
void refreshMemorySummaries();
|
||||
}, [handleDirtyChange, refreshMemorySummaries]);
|
||||
|
||||
return (
|
||||
<div style={workspaceStyle}>
|
||||
<aside style={listPanelStyle} aria-label="Agent memories">
|
||||
<div style={listHeaderStyle}>
|
||||
<div>
|
||||
<div style={listTitleStyle}>AgentMemory</div>
|
||||
<div style={listCountStyle}>{items.length} of {total} loaded</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Refresh memories"
|
||||
onClick={() => setReloadToken((value) => value + 1)}
|
||||
disabled={loading || loadingMore || dirty}
|
||||
title={dirty ? "Apply or cancel the current draft before refreshing" : "Refresh memories"}
|
||||
style={{ ...iconButtonStyle, opacity: loading || loadingMore || dirty ? 0.55 : 1 }}
|
||||
>
|
||||
<RefreshCw size={14} aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
<div style={memoryListStyle}>
|
||||
{items.map((item) => (
|
||||
<button
|
||||
type="button"
|
||||
key={item.id}
|
||||
onClick={() => void selectMemory(item.id)}
|
||||
aria-current={item.id === selectedId ? "true" : undefined}
|
||||
style={{
|
||||
...memoryButtonStyle,
|
||||
...(item.id === selectedId ? selectedMemoryButtonStyle : {}),
|
||||
}}
|
||||
>
|
||||
<span style={memoryTypeStyle}>{item.type}</span>
|
||||
<span style={memoryIdStyle}>{item.id}</span>
|
||||
<span style={memoryExcerptStyle}>{item.excerpt || "Empty memory"}</span>
|
||||
</button>
|
||||
))}
|
||||
{nextOffset < total ? (
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Load more memories"
|
||||
onClick={() => void loadMoreMemories()}
|
||||
disabled={loading || loadingMore}
|
||||
style={{ ...retryButtonStyle, opacity: loading || loadingMore ? 0.55 : 1 }}
|
||||
>
|
||||
{loadingMore ? "Loading…" : "Load more"}
|
||||
</button>
|
||||
) : null}
|
||||
{!loading && items.length === 0 ? (
|
||||
<div style={emptyStyle}>
|
||||
<Brain size={22} aria-hidden="true" />
|
||||
<span>No AgentMemory items are available.</span>
|
||||
<button type="button" onClick={() => setReloadToken((value) => value + 1)} style={retryButtonStyle}>
|
||||
Refresh
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<main style={editorPanelStyle}>
|
||||
{error ? <div role="alert" style={alertStyle}>{error}</div> : null}
|
||||
{loading ? (
|
||||
<div role="status" style={emptyStyle}>Loading memories…</div>
|
||||
) : selectedId ? (
|
||||
<>
|
||||
<div style={selectionHeaderStyle}>
|
||||
<span style={selectionLabelStyle}>Selected memory</span>
|
||||
<strong style={selectionIdStyle}>{selectedId}</strong>
|
||||
</div>
|
||||
<MarkdownContentViewer
|
||||
content={selectedBody}
|
||||
resource={{ kind: "agent-memory", id: selectedId }}
|
||||
onApplied={applyMemory}
|
||||
onDirtyChange={handleDirtyChange}
|
||||
/>
|
||||
</>
|
||||
) : null}
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const workspaceStyle: CSSProperties = {
|
||||
display: "grid",
|
||||
gridTemplateColumns: "minmax(220px, 300px) minmax(0, 1fr)",
|
||||
height: "100%",
|
||||
minHeight: 0,
|
||||
background: GRAPH_THEME.ui.surface.stage,
|
||||
};
|
||||
|
||||
const listPanelStyle: CSSProperties = {
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
minHeight: 0,
|
||||
borderRight: `1px solid ${GRAPH_THEME.ui.surface.panelBorder}`,
|
||||
background: GRAPH_THEME.ui.surface.panel,
|
||||
};
|
||||
|
||||
const listHeaderStyle: CSSProperties = {
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
gap: 12,
|
||||
padding: 16,
|
||||
borderBottom: `1px solid ${GRAPH_THEME.ui.surface.panelBorder}`,
|
||||
};
|
||||
|
||||
const listTitleStyle: CSSProperties = {
|
||||
color: GRAPH_THEME.ui.text.strong,
|
||||
fontSize: 14,
|
||||
fontWeight: 700,
|
||||
};
|
||||
|
||||
const listCountStyle: CSSProperties = {
|
||||
color: GRAPH_THEME.ui.text.muted,
|
||||
fontSize: 11,
|
||||
marginTop: 3,
|
||||
};
|
||||
|
||||
const iconButtonStyle: CSSProperties = {
|
||||
display: "inline-grid",
|
||||
placeItems: "center",
|
||||
width: 30,
|
||||
height: 30,
|
||||
borderRadius: 8,
|
||||
border: `1px solid ${GRAPH_THEME.ui.surface.panelBorder}`,
|
||||
background: "rgba(255, 255, 255, 0.04)",
|
||||
color: GRAPH_THEME.ui.text.body,
|
||||
cursor: "pointer",
|
||||
};
|
||||
|
||||
const memoryListStyle: CSSProperties = {
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: 6,
|
||||
minHeight: 0,
|
||||
padding: 10,
|
||||
overflowY: "auto",
|
||||
};
|
||||
|
||||
const memoryButtonStyle: CSSProperties = {
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
alignItems: "flex-start",
|
||||
gap: 5,
|
||||
padding: 10,
|
||||
borderRadius: 9,
|
||||
border: "1px solid transparent",
|
||||
background: "transparent",
|
||||
color: GRAPH_THEME.ui.text.body,
|
||||
textAlign: "left",
|
||||
cursor: "pointer",
|
||||
};
|
||||
|
||||
const selectedMemoryButtonStyle: CSSProperties = {
|
||||
border: `1px solid ${GRAPH_THEME.ui.control.activeBorder}`,
|
||||
background: GRAPH_THEME.ui.timeline.playheadSoft,
|
||||
};
|
||||
|
||||
const memoryTypeStyle: CSSProperties = {
|
||||
color: GRAPH_THEME.ui.timeline.playhead,
|
||||
fontSize: 10,
|
||||
fontWeight: 700,
|
||||
textTransform: "uppercase",
|
||||
};
|
||||
|
||||
const memoryIdStyle: CSSProperties = {
|
||||
maxWidth: "100%",
|
||||
overflow: "hidden",
|
||||
color: GRAPH_THEME.ui.text.strong,
|
||||
fontFamily: "monospace",
|
||||
fontSize: 12,
|
||||
textOverflow: "ellipsis",
|
||||
whiteSpace: "nowrap",
|
||||
};
|
||||
|
||||
const memoryExcerptStyle: CSSProperties = {
|
||||
display: "-webkit-box",
|
||||
overflow: "hidden",
|
||||
color: GRAPH_THEME.ui.text.muted,
|
||||
fontSize: 11,
|
||||
lineHeight: 1.45,
|
||||
WebkitBoxOrient: "vertical",
|
||||
WebkitLineClamp: 2,
|
||||
};
|
||||
|
||||
const editorPanelStyle: CSSProperties = {
|
||||
minWidth: 0,
|
||||
minHeight: 0,
|
||||
padding: 20,
|
||||
overflowY: "auto",
|
||||
};
|
||||
|
||||
const selectionHeaderStyle: CSSProperties = {
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: 4,
|
||||
marginBottom: 12,
|
||||
};
|
||||
|
||||
const selectionLabelStyle: CSSProperties = {
|
||||
color: GRAPH_THEME.ui.text.muted,
|
||||
fontSize: 11,
|
||||
};
|
||||
|
||||
const selectionIdStyle: CSSProperties = {
|
||||
color: GRAPH_THEME.ui.text.strong,
|
||||
fontFamily: "monospace",
|
||||
fontSize: 14,
|
||||
wordBreak: "break-all",
|
||||
};
|
||||
|
||||
const emptyStyle: CSSProperties = {
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
gap: 10,
|
||||
minHeight: 160,
|
||||
padding: 20,
|
||||
color: GRAPH_THEME.ui.text.muted,
|
||||
fontSize: 12,
|
||||
textAlign: "center",
|
||||
};
|
||||
|
||||
const retryButtonStyle: CSSProperties = {
|
||||
padding: "6px 10px",
|
||||
borderRadius: 7,
|
||||
border: `1px solid ${GRAPH_THEME.ui.control.activeBorder}`,
|
||||
background: GRAPH_THEME.ui.timeline.playheadSoft,
|
||||
color: GRAPH_THEME.ui.timeline.playhead,
|
||||
cursor: "pointer",
|
||||
};
|
||||
|
||||
const alertStyle: CSSProperties = {
|
||||
marginBottom: 12,
|
||||
padding: "10px 12px",
|
||||
borderRadius: 8,
|
||||
border: "1px solid rgba(248, 81, 73, 0.28)",
|
||||
background: "rgba(248, 81, 73, 0.1)",
|
||||
color: "#ffb4ad",
|
||||
fontSize: 12,
|
||||
};
|
||||
@@ -42,9 +42,6 @@ async function startVite(): Promise<void> {
|
||||
}
|
||||
|
||||
async function installApiFixture(page: Page): Promise<void> {
|
||||
await page.route("**/api/info", async (route) => {
|
||||
await route.fulfill({ json: { capabilities: { agent_memory: false } } });
|
||||
});
|
||||
await page.route("**/api/graph/**", async (route) => {
|
||||
const pathname = new URL(route.request().url()).pathname;
|
||||
if (pathname === "/api/graph/stats") {
|
||||
|
||||
@@ -1,74 +0,0 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import { JSDOM } from "jsdom";
|
||||
import React from "react";
|
||||
|
||||
import { fetchAgentMemoryAvailability } from "../src/explorerCapabilities.ts";
|
||||
|
||||
(globalThis as typeof globalThis & { React: typeof React }).React = React;
|
||||
const dom = new JSDOM("<!doctype html><html><body></body></html>", {
|
||||
url: "http://localhost",
|
||||
});
|
||||
Object.assign(globalThis, {
|
||||
window: dom.window,
|
||||
document: dom.window.document,
|
||||
HTMLElement: dom.window.HTMLElement,
|
||||
Node: dom.window.Node,
|
||||
});
|
||||
Object.defineProperty(globalThis, "navigator", {
|
||||
configurable: true,
|
||||
value: dom.window.navigator,
|
||||
});
|
||||
|
||||
const { cleanup, render } = await import("@testing-library/react");
|
||||
const { ExploreWorkspaceTabs } = await import("../src/ExploreWorkspaceTabs.tsx");
|
||||
|
||||
test.afterEach(cleanup);
|
||||
|
||||
test("reports AgentMemory when the Explorer host provides it", async () => {
|
||||
const available = await fetchAgentMemoryAvailability(async () => (
|
||||
new Response(
|
||||
JSON.stringify({ capabilities: { agent_memory: true } }),
|
||||
{ status: 200, headers: { "content-type": "application/json" } },
|
||||
)
|
||||
));
|
||||
|
||||
assert.equal(available, true);
|
||||
});
|
||||
|
||||
test("keeps AgentMemory hidden when the capability is absent or unavailable", async () => {
|
||||
const absent = await fetchAgentMemoryAvailability(async () => (
|
||||
new Response(JSON.stringify({ status: "active" }), { status: 200 })
|
||||
));
|
||||
const unavailable = await fetchAgentMemoryAvailability(async () => {
|
||||
throw new Error("network unavailable");
|
||||
});
|
||||
|
||||
assert.equal(absent, false);
|
||||
assert.equal(unavailable, false);
|
||||
});
|
||||
|
||||
test("shows the Memories tab only when the host provides AgentMemory", () => {
|
||||
const availableView = render(
|
||||
<ExploreWorkspaceTabs
|
||||
activeView="graph"
|
||||
agentMemoryAvailable
|
||||
onSelect={() => undefined}
|
||||
/>,
|
||||
);
|
||||
assert.ok(availableView.getByRole("button", { name: "Memories" }));
|
||||
cleanup();
|
||||
|
||||
const unavailableView = render(
|
||||
<ExploreWorkspaceTabs
|
||||
activeView="graph"
|
||||
agentMemoryAvailable={false}
|
||||
onSelect={() => undefined}
|
||||
/>,
|
||||
);
|
||||
assert.equal(
|
||||
unavailableView.queryByRole("button", { name: "Memories" }),
|
||||
null,
|
||||
);
|
||||
});
|
||||
@@ -3,7 +3,7 @@ import assert from "node:assert/strict";
|
||||
import React from "react";
|
||||
import { renderToString } from "react-dom/server";
|
||||
|
||||
(globalThis as typeof globalThis & { React: typeof React }).React = React;
|
||||
(globalThis as any).React = React;
|
||||
|
||||
import { MarkdownContentViewer } from "../src/workspaces/GraphWorkspace/MarkdownContentViewer.tsx";
|
||||
import { isSafeUrl } from "../src/workspaces/GraphWorkspace/markdownUrlSafety.ts";
|
||||
@@ -60,29 +60,6 @@ test("renders Preview mode with formatted Markdown elements and tabs", () => {
|
||||
assert.equal(html.includes("Item B"), true);
|
||||
});
|
||||
|
||||
test("stays read-only without a resource and exposes Edit for canonical resources", () => {
|
||||
const readOnly = renderToString(React.createElement(MarkdownContentViewer, {
|
||||
content: "Read-only body",
|
||||
}));
|
||||
const editable = renderToString(React.createElement(MarkdownContentViewer, {
|
||||
content: "Editable body",
|
||||
resource: { kind: "context-node", id: "node-1" },
|
||||
}));
|
||||
|
||||
assert.equal(readOnly.includes(">Edit</button>"), false);
|
||||
assert.equal(editable.includes(">Edit</button>"), true);
|
||||
});
|
||||
|
||||
test("empty canonical resources still expose Edit", () => {
|
||||
const html = renderToString(React.createElement(MarkdownContentViewer, {
|
||||
content: "",
|
||||
resource: { kind: "context-node", id: "empty-node" },
|
||||
}));
|
||||
|
||||
assert.equal(html.includes("No content available for this node."), true);
|
||||
assert.equal(html.includes(">Edit</button>"), true);
|
||||
});
|
||||
|
||||
test("renders Source mode with exact unmodified text inside pre/code", () => {
|
||||
const markdown = `# Title 🚀\n\n * Indented item\n\n\`\`\`python\ndef test():\n return "α + β"\n\`\`\``;
|
||||
const html = renderToString(React.createElement(MarkdownContentViewer, { content: markdown, defaultMode: "source" }));
|
||||
|
||||
@@ -1,639 +0,0 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import { JSDOM } from "jsdom";
|
||||
import React from "react";
|
||||
|
||||
(globalThis as typeof globalThis & { React: typeof React }).React = React;
|
||||
const dom = new JSDOM("<!doctype html><html><body></body></html>", {
|
||||
url: "http://localhost",
|
||||
});
|
||||
Object.assign(globalThis, {
|
||||
window: dom.window,
|
||||
document: dom.window.document,
|
||||
HTMLElement: dom.window.HTMLElement,
|
||||
Node: dom.window.Node,
|
||||
});
|
||||
Object.defineProperty(globalThis, "navigator", {
|
||||
configurable: true,
|
||||
value: dom.window.navigator,
|
||||
});
|
||||
dom.window.confirm = () => true;
|
||||
|
||||
// Testing Library and the components must load after the jsdom globals above.
|
||||
const { act, cleanup, fireEvent, render, waitFor } = await import(
|
||||
"@testing-library/react"
|
||||
);
|
||||
const { MarkdownContentViewer } = await import(
|
||||
"../src/workspaces/GraphWorkspace/MarkdownContentViewer.tsx"
|
||||
);
|
||||
const { MemoryWorkspace } = await import(
|
||||
"../src/workspaces/MemoryWorkspace.tsx"
|
||||
);
|
||||
|
||||
test.afterEach(() => {
|
||||
cleanup();
|
||||
dom.window.confirm = () => true;
|
||||
});
|
||||
|
||||
const resource = { kind: "context-node" as const, id: "node-1" };
|
||||
const originalSource = "---\nid: node-1\ntype: Note\n---\n\nOriginal";
|
||||
|
||||
function jsonResponse(body: unknown, status = 200): Response {
|
||||
return new Response(JSON.stringify(body), {
|
||||
status,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
}
|
||||
|
||||
test("Edit and Apply send canonical Markdown and publish the applied result", async () => {
|
||||
const requests: Array<{ url: string; init?: RequestInit }> = [];
|
||||
let appliedBody = "";
|
||||
globalThis.fetch = async (input, init) => {
|
||||
requests.push({ url: String(input), init });
|
||||
if (init?.method === "PUT") {
|
||||
return jsonResponse({
|
||||
resource,
|
||||
source: originalSource.replace("Original", "Updated"),
|
||||
body: "Updated",
|
||||
revision: "sha256:updated",
|
||||
editable: true,
|
||||
changed: true,
|
||||
});
|
||||
}
|
||||
return jsonResponse({
|
||||
resource,
|
||||
source: originalSource,
|
||||
body: "Original",
|
||||
revision: "sha256:original",
|
||||
editable: true,
|
||||
});
|
||||
};
|
||||
|
||||
const view = render(
|
||||
<MarkdownContentViewer
|
||||
content="Original"
|
||||
resource={resource}
|
||||
onApplied={(result) => { appliedBody = result.body; }}
|
||||
/>,
|
||||
);
|
||||
fireEvent.click(view.getByRole("button", { name: "Edit" }));
|
||||
const textarea = await view.findByRole("textbox", { name: "Markdown source" });
|
||||
fireEvent.input(textarea, {
|
||||
target: { value: originalSource.replace("Original", "Updated") },
|
||||
});
|
||||
fireEvent.click(view.getByRole("button", { name: "Apply" }));
|
||||
|
||||
await waitFor(() => assert.equal(appliedBody, "Updated"));
|
||||
assert.deepEqual(requests.map(({ init }) => init?.method ?? "GET"), ["GET", "PUT"]);
|
||||
assert.equal(
|
||||
JSON.parse(String(requests[1].init?.body)).expected_revision,
|
||||
"sha256:original",
|
||||
);
|
||||
assert.equal(
|
||||
view.getByRole("tab", { name: "Preview" }).getAttribute("aria-selected"),
|
||||
"true",
|
||||
);
|
||||
});
|
||||
|
||||
test("Cancel restores the previous view and never sends a PUT", async () => {
|
||||
const methods: string[] = [];
|
||||
globalThis.fetch = async (_input, init) => {
|
||||
methods.push(init?.method ?? "GET");
|
||||
return jsonResponse({
|
||||
resource,
|
||||
source: originalSource,
|
||||
body: "Original",
|
||||
revision: "sha256:original",
|
||||
editable: true,
|
||||
});
|
||||
};
|
||||
|
||||
const view = render(<MarkdownContentViewer content="Original" resource={resource} />);
|
||||
fireEvent.click(view.getByRole("button", { name: "Edit" }));
|
||||
const textarea = await view.findByRole("textbox", { name: "Markdown source" });
|
||||
fireEvent.input(textarea, {
|
||||
target: { value: originalSource.replace("Original", "Draft") },
|
||||
});
|
||||
fireEvent.click(view.getByRole("button", { name: "Cancel" }));
|
||||
|
||||
assert.deepEqual(methods, ["GET"]);
|
||||
assert.equal(view.queryByRole("textbox", { name: "Markdown source" }), null);
|
||||
assert.equal(
|
||||
view.getByRole("tab", { name: "Preview" }).getAttribute("aria-selected"),
|
||||
"true",
|
||||
);
|
||||
});
|
||||
|
||||
test("validation failures keep the draft visible for correction", async () => {
|
||||
globalThis.fetch = async (_input, init) => {
|
||||
if (init?.method === "PUT") {
|
||||
return jsonResponse({
|
||||
detail: {
|
||||
code: "invalid_markdown_frontmatter",
|
||||
message: "Markdown frontmatter contains invalid YAML.",
|
||||
},
|
||||
}, 422);
|
||||
}
|
||||
return jsonResponse({
|
||||
resource,
|
||||
source: originalSource,
|
||||
body: "Original",
|
||||
revision: "sha256:original",
|
||||
editable: true,
|
||||
});
|
||||
};
|
||||
|
||||
const view = render(<MarkdownContentViewer content="Original" resource={resource} />);
|
||||
fireEvent.click(view.getByRole("button", { name: "Edit" }));
|
||||
const textarea = await view.findByRole("textbox", { name: "Markdown source" });
|
||||
const invalidDraft = "---\nid: [\n---\n\nDraft";
|
||||
fireEvent.input(textarea, { target: { value: invalidDraft } });
|
||||
fireEvent.click(view.getByRole("button", { name: "Apply" }));
|
||||
|
||||
const alert = await view.findByRole("alert");
|
||||
assert.match(alert.textContent ?? "", /invalid YAML/);
|
||||
assert.equal(
|
||||
(view.getByRole("textbox", { name: "Markdown source" }) as HTMLTextAreaElement)
|
||||
.value,
|
||||
invalidDraft,
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
test("resource changes discard the previous editor session", async () => {
|
||||
globalThis.fetch = async (input) => {
|
||||
const id = String(input).endsWith("node-2") ? "node-2" : "node-1";
|
||||
return jsonResponse({
|
||||
resource: { kind: "context-node", id },
|
||||
source: `---\nid: ${id}\ntype: Note\n---\n\n${id}`,
|
||||
body: id,
|
||||
revision: `sha256:${id}`,
|
||||
editable: true,
|
||||
});
|
||||
};
|
||||
|
||||
const view = render(
|
||||
<MarkdownContentViewer content="node-1" resource={resource} />,
|
||||
);
|
||||
fireEvent.click(view.getByRole("button", { name: "Edit" }));
|
||||
const textarea = await view.findByRole("textbox", { name: "Markdown source" });
|
||||
fireEvent.input(textarea, {
|
||||
target: { value: `${(textarea as HTMLTextAreaElement).value}\nDraft` },
|
||||
});
|
||||
|
||||
view.rerender(
|
||||
<MarkdownContentViewer
|
||||
content="node-2"
|
||||
resource={{ kind: "context-node", id: "node-2" }}
|
||||
/>,
|
||||
);
|
||||
await waitFor(() => assert.equal(
|
||||
view.queryByRole("textbox", { name: "Markdown source" }),
|
||||
null,
|
||||
));
|
||||
|
||||
view.rerender(
|
||||
<MarkdownContentViewer content="node-1" resource={resource} />,
|
||||
);
|
||||
await waitFor(() => assert.equal(
|
||||
view.queryByRole("textbox", { name: "Markdown source" }),
|
||||
null,
|
||||
));
|
||||
assert.ok(view.getByRole("button", { name: "Edit" }));
|
||||
});
|
||||
|
||||
|
||||
test("unmounting a dirty editor clears the parent dirty guard", async () => {
|
||||
const dirtyStates: boolean[] = [];
|
||||
globalThis.fetch = async () => jsonResponse({
|
||||
resource,
|
||||
source: originalSource,
|
||||
body: "Original",
|
||||
revision: "sha256:original",
|
||||
editable: true,
|
||||
});
|
||||
|
||||
const view = render(
|
||||
<MarkdownContentViewer
|
||||
content="Original"
|
||||
resource={resource}
|
||||
onDirtyChange={(dirty) => dirtyStates.push(dirty)}
|
||||
/>,
|
||||
);
|
||||
fireEvent.click(view.getByRole("button", { name: "Edit" }));
|
||||
const textarea = await view.findByRole("textbox", { name: "Markdown source" });
|
||||
fireEvent.input(textarea, {
|
||||
target: { value: originalSource.replace("Original", "Draft") },
|
||||
});
|
||||
await waitFor(() => assert.equal(dirtyStates.at(-1), true));
|
||||
|
||||
view.unmount();
|
||||
|
||||
assert.equal(dirtyStates.at(-1), false);
|
||||
});
|
||||
|
||||
test("MemoryWorkspace protects a dirty memory draft when selection changes", async () => {
|
||||
const requestedUrls: string[] = [];
|
||||
globalThis.fetch = async (input) => {
|
||||
const url = String(input);
|
||||
requestedUrls.push(url);
|
||||
if (url.startsWith("/api/memories")) {
|
||||
return jsonResponse({
|
||||
items: [
|
||||
{ id: "mem-1", type: "note", excerpt: "First", updated_at: null },
|
||||
{ id: "mem-2", type: "note", excerpt: "Second", updated_at: null },
|
||||
],
|
||||
total: 2,
|
||||
skip: 0,
|
||||
limit: 100,
|
||||
});
|
||||
}
|
||||
const id = url.endsWith("mem-2") ? "mem-2" : "mem-1";
|
||||
return jsonResponse({
|
||||
resource: { kind: "agent-memory", id },
|
||||
source: `---\nid: ${id}\ntype: note\n---\n\n${id}`,
|
||||
body: id,
|
||||
revision: `sha256:${id}`,
|
||||
editable: true,
|
||||
});
|
||||
};
|
||||
|
||||
const view = render(<MemoryWorkspace />);
|
||||
await view.findByText("Selected memory");
|
||||
fireEvent.click(view.getByRole("button", { name: "Edit" }));
|
||||
const textarea = await view.findByRole("textbox", { name: "Markdown source" });
|
||||
fireEvent.input(textarea, {
|
||||
target: { value: `${(textarea as HTMLTextAreaElement).value}\nDraft` },
|
||||
});
|
||||
|
||||
dom.window.confirm = () => false;
|
||||
fireEvent.click(view.getByRole("button", { name: /mem-2/ }));
|
||||
|
||||
assert.equal(requestedUrls.some((url) => url.endsWith("mem-2")), false);
|
||||
assert.equal(view.getByText("mem-1", { selector: "strong" }).textContent, "mem-1");
|
||||
});
|
||||
|
||||
|
||||
test("MemoryWorkspace loads memories beyond the first server page", async () => {
|
||||
const requestedUrls: string[] = [];
|
||||
const firstPage = Array.from({ length: 100 }, (_, index) => ({
|
||||
id: `mem-${index + 1}`,
|
||||
type: "note",
|
||||
excerpt: `Memory ${index + 1}`,
|
||||
updated_at: null,
|
||||
}));
|
||||
globalThis.fetch = async (input) => {
|
||||
const url = String(input);
|
||||
requestedUrls.push(url);
|
||||
if (url === "/api/memories?skip=0&limit=100") {
|
||||
return jsonResponse({
|
||||
items: firstPage,
|
||||
total: 101,
|
||||
skip: 0,
|
||||
limit: 100,
|
||||
});
|
||||
}
|
||||
if (url === "/api/memories?skip=100&limit=100") {
|
||||
return jsonResponse({
|
||||
items: [{
|
||||
id: "mem-101",
|
||||
type: "note",
|
||||
excerpt: "Memory 101",
|
||||
updated_at: null,
|
||||
}],
|
||||
total: 101,
|
||||
skip: 100,
|
||||
limit: 100,
|
||||
});
|
||||
}
|
||||
return jsonResponse({
|
||||
resource: { kind: "agent-memory", id: "mem-1" },
|
||||
source: "---\nid: mem-1\ntype: note\n---\n\nmem-1",
|
||||
body: "mem-1",
|
||||
revision: "sha256:mem-1",
|
||||
editable: true,
|
||||
});
|
||||
};
|
||||
|
||||
const view = render(<MemoryWorkspace />);
|
||||
await view.findByText("Selected memory");
|
||||
fireEvent.click(view.getByRole("button", { name: "Load more memories" }));
|
||||
|
||||
await view.findByRole("button", { name: /mem-101/ });
|
||||
assert.ok(requestedUrls.includes("/api/memories?skip=100&limit=100"));
|
||||
assert.equal(view.getByText("101 of 101 loaded").textContent, "101 of 101 loaded");
|
||||
});
|
||||
|
||||
|
||||
test("MemoryWorkspace ignores stale selection responses", async () => {
|
||||
let resolveMem2: ((response: Response) => void) | undefined;
|
||||
let resolveMem3: ((response: Response) => void) | undefined;
|
||||
const mem2Response = new Promise<Response>((resolve) => {
|
||||
resolveMem2 = resolve;
|
||||
});
|
||||
const mem3Response = new Promise<Response>((resolve) => {
|
||||
resolveMem3 = resolve;
|
||||
});
|
||||
|
||||
globalThis.fetch = async (input) => {
|
||||
const url = String(input);
|
||||
if (url.startsWith("/api/memories")) {
|
||||
return jsonResponse({
|
||||
items: [
|
||||
{ id: "mem-1", type: "note", excerpt: "First", updated_at: null },
|
||||
{ id: "mem-2", type: "note", excerpt: "Second", updated_at: null },
|
||||
{ id: "mem-3", type: "note", excerpt: "Third", updated_at: null },
|
||||
],
|
||||
total: 3,
|
||||
skip: 0,
|
||||
limit: 100,
|
||||
});
|
||||
}
|
||||
if (url.endsWith("mem-2")) return mem2Response;
|
||||
if (url.endsWith("mem-3")) return mem3Response;
|
||||
return jsonResponse({
|
||||
resource: { kind: "agent-memory", id: "mem-1" },
|
||||
source: "---\nid: mem-1\ntype: note\n---\n\nmem-1",
|
||||
body: "mem-1",
|
||||
revision: "sha256:mem-1",
|
||||
editable: true,
|
||||
});
|
||||
};
|
||||
|
||||
const view = render(<MemoryWorkspace />);
|
||||
await view.findByText("Selected memory");
|
||||
fireEvent.click(view.getByRole("button", { name: /mem-2/ }));
|
||||
fireEvent.click(view.getByRole("button", { name: /mem-3/ }));
|
||||
|
||||
await act(async () => {
|
||||
resolveMem3?.(jsonResponse({
|
||||
resource: { kind: "agent-memory", id: "mem-3" },
|
||||
source: "---\nid: mem-3\ntype: note\n---\n\nmem-3",
|
||||
body: "mem-3",
|
||||
revision: "sha256:mem-3",
|
||||
editable: true,
|
||||
}));
|
||||
await mem3Response;
|
||||
});
|
||||
await waitFor(() => assert.equal(
|
||||
view.getByText("mem-3", { selector: "strong" }).textContent,
|
||||
"mem-3",
|
||||
));
|
||||
|
||||
await act(async () => {
|
||||
resolveMem2?.(jsonResponse({
|
||||
resource: { kind: "agent-memory", id: "mem-2" },
|
||||
source: "---\nid: mem-2\ntype: note\n---\n\nmem-2",
|
||||
body: "mem-2",
|
||||
revision: "sha256:mem-2",
|
||||
editable: true,
|
||||
}));
|
||||
await mem2Response;
|
||||
});
|
||||
|
||||
assert.equal(
|
||||
view.getByText("mem-3", { selector: "strong" }).textContent,
|
||||
"mem-3",
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
test("MemoryWorkspace refreshes frontmatter summaries after apply", async () => {
|
||||
let listRequests = 0;
|
||||
globalThis.fetch = async (input, init) => {
|
||||
const url = String(input);
|
||||
if (url.startsWith("/api/memories")) {
|
||||
listRequests += 1;
|
||||
const saved = listRequests > 1;
|
||||
return jsonResponse({
|
||||
items: [{
|
||||
id: "mem-1",
|
||||
type: saved ? "decision" : "note",
|
||||
excerpt: saved ? "Updated memory" : "Original memory",
|
||||
updated_at: saved ? "2026-09-01T12:00:00+00:00" : null,
|
||||
}],
|
||||
total: 1,
|
||||
skip: 0,
|
||||
limit: 100,
|
||||
});
|
||||
}
|
||||
if (init?.method === "PUT") {
|
||||
return jsonResponse({
|
||||
resource: { kind: "agent-memory", id: "mem-1" },
|
||||
source: "---\nid: mem-1\ntype: decision\n---\n\nUpdated memory",
|
||||
body: "Updated memory",
|
||||
revision: "sha256:updated",
|
||||
editable: true,
|
||||
changed: true,
|
||||
});
|
||||
}
|
||||
return jsonResponse({
|
||||
resource: { kind: "agent-memory", id: "mem-1" },
|
||||
source: "---\nid: mem-1\ntype: note\n---\n\nOriginal memory",
|
||||
body: "Original memory",
|
||||
revision: "sha256:original",
|
||||
editable: true,
|
||||
});
|
||||
};
|
||||
|
||||
const view = render(<MemoryWorkspace />);
|
||||
await view.findByText("Selected memory");
|
||||
fireEvent.click(view.getByRole("button", { name: "Edit" }));
|
||||
const textarea = await view.findByRole("textbox", { name: "Markdown source" });
|
||||
fireEvent.input(textarea, {
|
||||
target: {
|
||||
value: (textarea as HTMLTextAreaElement).value
|
||||
.replace("type: note", "type: decision")
|
||||
.replace("Original memory", "Updated memory"),
|
||||
},
|
||||
});
|
||||
fireEvent.click(view.getByRole("button", { name: "Apply" }));
|
||||
|
||||
await view.findByText("decision");
|
||||
assert.equal(listRequests, 2);
|
||||
assert.equal(view.getAllByText("Updated memory").length, 2);
|
||||
});
|
||||
|
||||
test("HTTP 409 conflict preserves draft and shows conflict error with reload option", async () => {
|
||||
// After a 409, the user's draft must be kept and a recovery path available.
|
||||
const requests: Array<{ method: string; body?: unknown }> = [];
|
||||
let fetchCount = 0;
|
||||
|
||||
globalThis.fetch = async (input, init) => {
|
||||
fetchCount += 1;
|
||||
const method = init?.method ?? "GET";
|
||||
let parsedBody: unknown = undefined;
|
||||
if (init?.body) {
|
||||
try { parsedBody = JSON.parse(String(init.body)); } catch { /* ignore */ }
|
||||
}
|
||||
requests.push({ method, body: parsedBody });
|
||||
|
||||
if (method === "PUT") {
|
||||
// First PUT returns 409 with current_revision
|
||||
return jsonResponse({
|
||||
detail: {
|
||||
code: "markdown_revision_conflict",
|
||||
message: "This item changed after editing began. Reload the latest version before applying.",
|
||||
current_revision: "sha256:newer",
|
||||
},
|
||||
}, 409);
|
||||
}
|
||||
// All GETs return the canonical document
|
||||
return jsonResponse({
|
||||
resource,
|
||||
source: originalSource,
|
||||
body: "Original",
|
||||
revision: "sha256:original",
|
||||
editable: true,
|
||||
});
|
||||
};
|
||||
|
||||
const view = render(<MarkdownContentViewer content="Original" resource={resource} />);
|
||||
|
||||
// Enter edit mode
|
||||
fireEvent.click(view.getByRole("button", { name: "Edit" }));
|
||||
const textarea = await view.findByRole("textbox", { name: "Markdown source" });
|
||||
const draftValue = originalSource.replace("Original", "My draft");
|
||||
fireEvent.input(textarea, { target: { value: draftValue } });
|
||||
|
||||
// Apply → receives 409
|
||||
fireEvent.click(view.getByRole("button", { name: "Apply" }));
|
||||
|
||||
// Conflict error must appear
|
||||
const alert = await view.findByRole("alert");
|
||||
assert.match(
|
||||
alert.textContent ?? "",
|
||||
/changed after editing|Reload/i,
|
||||
"conflict error message must be shown",
|
||||
);
|
||||
|
||||
// Draft must be preserved in the textarea
|
||||
const textareaAfterConflict = view.getByRole("textbox", { name: "Markdown source" }) as HTMLTextAreaElement;
|
||||
assert.equal(textareaAfterConflict.value, draftValue, "draft must be preserved after 409");
|
||||
|
||||
// A reload / recovery action must be available
|
||||
const reloadButton = view.queryByRole("button", { name: /reload latest/i });
|
||||
assert.ok(reloadButton !== null, "a 'Reload latest' recovery button must be shown");
|
||||
|
||||
// Click reload — should re-fetch the latest canonical document
|
||||
await act(async () => {
|
||||
fireEvent.click(reloadButton!);
|
||||
});
|
||||
|
||||
// After reload the editor is re-initialized with the server's canonical source
|
||||
await waitFor(() => {
|
||||
const refreshedTextarea = view.queryByRole("textbox", { name: "Markdown source" });
|
||||
assert.ok(refreshedTextarea !== null, "editor must still be open after reload");
|
||||
assert.equal(
|
||||
(refreshedTextarea as HTMLTextAreaElement).value,
|
||||
originalSource,
|
||||
"editor must show the server canonical source after reload",
|
||||
);
|
||||
});
|
||||
|
||||
// Reload must have triggered exactly one more GET
|
||||
const getCount = requests.filter((r) => r.method === "GET").length;
|
||||
assert.ok(getCount >= 2, "reload must issue a new GET to fetch the latest canonical document");
|
||||
});
|
||||
|
||||
|
||||
test("successful retry after 422 uses the original revision and persists changes", async () => {
|
||||
// After a 422 (validation failure), the baseRevision must remain valid so that
|
||||
// correcting the draft and re-applying succeeds without re-fetching the document.
|
||||
let putCallCount = 0;
|
||||
|
||||
globalThis.fetch = async (_input, init) => {
|
||||
const method = init?.method ?? "GET";
|
||||
if (method === "PUT") {
|
||||
putCallCount += 1;
|
||||
if (putCallCount === 1) {
|
||||
// First PUT: validation failure — resource is unchanged
|
||||
return jsonResponse({
|
||||
detail: {
|
||||
code: "invalid_markdown_frontmatter",
|
||||
message: "Markdown frontmatter contains invalid YAML.",
|
||||
},
|
||||
}, 422);
|
||||
}
|
||||
// Second PUT: success with the corrected Markdown
|
||||
const body = JSON.parse(String(init?.body ?? "{}")) as { markdown: string };
|
||||
const correctedBody = body.markdown.includes("Corrected") ? "Corrected body" : "body";
|
||||
return jsonResponse({
|
||||
resource,
|
||||
source: originalSource.replace("Original", "Corrected"),
|
||||
body: correctedBody,
|
||||
revision: "sha256:after-retry",
|
||||
editable: true,
|
||||
changed: true,
|
||||
});
|
||||
}
|
||||
return jsonResponse({
|
||||
resource,
|
||||
source: originalSource,
|
||||
body: "Original",
|
||||
revision: "sha256:original",
|
||||
editable: true,
|
||||
});
|
||||
};
|
||||
|
||||
let appliedRevision = "";
|
||||
const view = render(
|
||||
<MarkdownContentViewer
|
||||
content="Original"
|
||||
resource={resource}
|
||||
onApplied={(result) => { appliedRevision = result.revision; }}
|
||||
/>,
|
||||
);
|
||||
|
||||
// Enter edit mode
|
||||
fireEvent.click(view.getByRole("button", { name: "Edit" }));
|
||||
const textarea = await view.findByRole("textbox", { name: "Markdown source" });
|
||||
|
||||
// First attempt: create an invalid draft
|
||||
const invalidDraft = "---\nid: [\n---\n\nInvalid body";
|
||||
fireEvent.input(textarea, { target: { value: invalidDraft } });
|
||||
fireEvent.click(view.getByRole("button", { name: "Apply" }));
|
||||
|
||||
// 422 error appears, draft is preserved
|
||||
const alert = await view.findByRole("alert");
|
||||
assert.match(alert.textContent ?? "", /invalid YAML/i);
|
||||
assert.equal(
|
||||
(view.getByRole("textbox", { name: "Markdown source" }) as HTMLTextAreaElement).value,
|
||||
invalidDraft,
|
||||
"invalid draft must be preserved after 422",
|
||||
);
|
||||
|
||||
// Correct the draft
|
||||
const correctedDraft = originalSource.replace("Original", "Corrected");
|
||||
fireEvent.input(view.getByRole("textbox", { name: "Markdown source" }), {
|
||||
target: { value: correctedDraft },
|
||||
});
|
||||
|
||||
// Apply is re-enabled (still dirty)
|
||||
const applyButton = view.getByRole("button", { name: "Apply" });
|
||||
assert.equal(
|
||||
(applyButton as HTMLButtonElement).disabled,
|
||||
false,
|
||||
"Apply must be re-enabled after correcting the draft",
|
||||
);
|
||||
|
||||
// Second attempt: apply corrected draft
|
||||
fireEvent.click(applyButton);
|
||||
|
||||
// Must succeed — server returns new revision
|
||||
await waitFor(() => assert.equal(appliedRevision, "sha256:after-retry"));
|
||||
|
||||
// Editor returns to preview mode after successful save
|
||||
assert.equal(
|
||||
view.getByRole("tab", { name: "Preview" }).getAttribute("aria-selected"),
|
||||
"true",
|
||||
"editor must return to preview after successful retry",
|
||||
);
|
||||
|
||||
// Error is cleared
|
||||
assert.equal(view.queryByRole("alert"), null, "error banner must be cleared after success");
|
||||
|
||||
// Both PUT attempts were made — retry used original revision (no extra GET between attempts)
|
||||
assert.equal(putCallCount, 2, "exactly two PUT requests must be made (failed + successful retry)");
|
||||
});
|
||||
@@ -1,132 +0,0 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import {
|
||||
cancelEdit,
|
||||
createEditSession,
|
||||
createLoadingSession,
|
||||
isDirty,
|
||||
saveFailed,
|
||||
saveStarted,
|
||||
saveSucceeded,
|
||||
shouldConfirmDiscard,
|
||||
updateDraft,
|
||||
} from "../src/workspaces/GraphWorkspace/markdownEditorState.ts";
|
||||
|
||||
const resource = { kind: "context-node" as const, id: "node-1" };
|
||||
|
||||
|
||||
test("enters loading and editing with the canonical source", () => {
|
||||
const loading = createLoadingSession(resource);
|
||||
const editing = createEditSession(resource, {
|
||||
source: "---\nid: node-1\n---\n\nBody",
|
||||
revision: "sha256:one",
|
||||
});
|
||||
|
||||
assert.equal(loading.status, "loading-document");
|
||||
assert.equal(editing.status, "editing");
|
||||
assert.equal(editing.draft, editing.baseSource);
|
||||
assert.equal(isDirty(editing), false);
|
||||
});
|
||||
|
||||
|
||||
test("draft changes derive dirty state and clear prior errors", () => {
|
||||
const session = createEditSession(resource, {
|
||||
source: "base",
|
||||
revision: "sha256:one",
|
||||
});
|
||||
const failed = saveFailed(session, {
|
||||
kind: "validation",
|
||||
message: "Invalid",
|
||||
});
|
||||
const edited = updateDraft(failed, "draft");
|
||||
|
||||
assert.equal(edited.status, "editing");
|
||||
assert.equal(edited.error, null);
|
||||
assert.equal(isDirty(edited), true);
|
||||
assert.equal(shouldConfirmDiscard(edited), true);
|
||||
});
|
||||
|
||||
|
||||
test("cancel discards the edit session without saving", () => {
|
||||
const session = updateDraft(
|
||||
createEditSession(resource, { source: "base", revision: "sha256:one" }),
|
||||
"draft",
|
||||
);
|
||||
|
||||
assert.equal(isDirty(session), true);
|
||||
assert.equal(cancelEdit(), null);
|
||||
});
|
||||
|
||||
|
||||
test("no-op save never enters saving state", () => {
|
||||
const session = createEditSession(resource, {
|
||||
source: "base",
|
||||
revision: "sha256:one",
|
||||
});
|
||||
|
||||
assert.equal(saveStarted(session), session);
|
||||
assert.equal(isDirty(session), false);
|
||||
});
|
||||
|
||||
|
||||
test("save success replaces the base source and revision", () => {
|
||||
const session = saveStarted(
|
||||
updateDraft(
|
||||
createEditSession(resource, { source: "base", revision: "sha256:one" }),
|
||||
"draft",
|
||||
),
|
||||
);
|
||||
const saved = saveSucceeded(session, {
|
||||
source: "canonical saved",
|
||||
revision: "sha256:two",
|
||||
});
|
||||
|
||||
assert.equal(saved.status, "viewing");
|
||||
assert.equal(saved.baseSource, "canonical saved");
|
||||
assert.equal(saved.draft, "canonical saved");
|
||||
assert.equal(saved.baseRevision, "sha256:two");
|
||||
assert.equal(isDirty(saved), false);
|
||||
});
|
||||
|
||||
|
||||
test("validation, conflict, and save failures retain the draft for retry", () => {
|
||||
const draft = updateDraft(
|
||||
createEditSession(resource, { source: "base", revision: "sha256:one" }),
|
||||
"draft",
|
||||
);
|
||||
|
||||
const validation = saveFailed(draft, {
|
||||
kind: "validation",
|
||||
message: "Invalid",
|
||||
});
|
||||
const conflict = saveFailed(draft, {
|
||||
kind: "conflict",
|
||||
message: "Stale",
|
||||
currentRevision: "sha256:two",
|
||||
});
|
||||
const network = saveFailed(draft, {
|
||||
kind: "network",
|
||||
message: "Offline",
|
||||
});
|
||||
|
||||
assert.equal(validation.status, "validation-error");
|
||||
assert.equal(conflict.status, "conflict");
|
||||
assert.equal(network.status, "save-error");
|
||||
assert.equal(validation.draft, "draft");
|
||||
assert.equal(conflict.draft, "draft");
|
||||
assert.equal(network.draft, "draft");
|
||||
assert.equal(saveStarted(network).status, "saving");
|
||||
});
|
||||
|
||||
|
||||
test("saving sessions do not allow a competing discard confirmation", () => {
|
||||
const saving = saveStarted(
|
||||
updateDraft(
|
||||
createEditSession(resource, { source: "base", revision: "sha256:one" }),
|
||||
"draft",
|
||||
),
|
||||
);
|
||||
|
||||
assert.equal(shouldConfirmDiscard(saving), false);
|
||||
});
|
||||
@@ -1,91 +0,0 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import {
|
||||
NodeMarkdownRefreshGuard,
|
||||
buildNodeMarkdownAttributeUpdate,
|
||||
readNodeMarkdownAttributeUpdate,
|
||||
} from "../src/workspaces/GraphWorkspace/nodeMarkdownSync.ts";
|
||||
|
||||
|
||||
test("saved Markdown updates graph content and its visible label", () => {
|
||||
const update = buildNodeMarkdownAttributeUpdate(
|
||||
"issue-1327",
|
||||
"# Issue 1328888\n\nUpdated body",
|
||||
{ status: "implemented" },
|
||||
);
|
||||
|
||||
assert.equal(update.content, "# Issue 1328888\n\nUpdated body");
|
||||
assert.equal(update.label, "# Issue 1328888\n\nUpdated body");
|
||||
assert.deepEqual(update.properties, {
|
||||
status: "implemented",
|
||||
content: "# Issue 1328888\n\nUpdated body",
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
test("empty Markdown falls back to the stable node id label", () => {
|
||||
const update = buildNodeMarkdownAttributeUpdate("issue-1327", "", {});
|
||||
|
||||
assert.equal(update.label, "issue-1327");
|
||||
assert.equal(update.content, "");
|
||||
});
|
||||
|
||||
|
||||
test("saved frontmatter is refreshed from the canonical graph node", async () => {
|
||||
const update = await readNodeMarkdownAttributeUpdate(
|
||||
"node/1",
|
||||
async (input) => {
|
||||
assert.equal(String(input), "/api/graph/node?node_id=node%2F1");
|
||||
return new Response(JSON.stringify({
|
||||
id: "node/1",
|
||||
type: "Decision",
|
||||
content: "Updated body",
|
||||
properties: {
|
||||
content: "Updated body",
|
||||
status: "accepted",
|
||||
valid_from: "2026-09-01T00:00:00Z",
|
||||
},
|
||||
valid_from: "2026-09-01T00:00:00Z",
|
||||
valid_until: null,
|
||||
}), {
|
||||
status: 200,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
},
|
||||
);
|
||||
assert.deepEqual(update, {
|
||||
label: "Updated body",
|
||||
content: "Updated body",
|
||||
properties: {
|
||||
content: "Updated body",
|
||||
status: "accepted",
|
||||
valid_from: "2026-09-01T00:00:00Z",
|
||||
},
|
||||
nodeType: "Decision",
|
||||
valid_from: "2026-09-01T00:00:00Z",
|
||||
valid_until: null,
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
test("realtime updates invalidate an older local-save refresh", () => {
|
||||
const guard = new NodeMarkdownRefreshGuard();
|
||||
const localSaveRefresh = guard.begin("node-1");
|
||||
|
||||
guard.invalidate("node-1");
|
||||
|
||||
assert.equal(guard.isCurrent("node-1", localSaveRefresh), false);
|
||||
});
|
||||
|
||||
|
||||
test("refresh invalidation is scoped to one node", () => {
|
||||
const guard = new NodeMarkdownRefreshGuard();
|
||||
const firstNodeRefresh = guard.begin("node-1");
|
||||
const secondNodeRefresh = guard.begin("node-2");
|
||||
|
||||
guard.invalidate("node-1");
|
||||
|
||||
assert.equal(guard.isCurrent("node-1", firstNodeRefresh), false);
|
||||
assert.equal(guard.isCurrent("node-2", secondNodeRefresh), true);
|
||||
});
|
||||
@@ -1,270 +0,0 @@
|
||||
# Semantica Google ADK Integration
|
||||
|
||||
Google ADK integration for [Semantica](https://github.com/semantica-agi/semantica).
|
||||
|
||||
This integration provides:
|
||||
|
||||
- Google ADK `FunctionTool` wrappers for Semantica's knowledge graph
|
||||
- Decision recording and querying tools
|
||||
- A graph-backed Google ADK `BaseSessionService`
|
||||
- Shared `ContextGraph` state across ADK agents and sub-agents
|
||||
|
||||
Google ADK is an optional dependency.
|
||||
|
||||
## Installation
|
||||
|
||||
Install Semantica with the Google ADK integration:
|
||||
|
||||
```bash
|
||||
pip install semantica[google-adk]
|
||||
```
|
||||
|
||||
Or install Google ADK separately:
|
||||
|
||||
```bash
|
||||
pip install google-adk
|
||||
```
|
||||
|
||||
## Knowledge Graph Tools
|
||||
|
||||
Create a shared `ContextGraph` and expose it through ADK tools:
|
||||
|
||||
```python
|
||||
from google.adk.agents import Agent
|
||||
|
||||
from semantica.context import ContextGraph
|
||||
from integrations.google_adk import semantica_kg_tools
|
||||
|
||||
|
||||
graph = ContextGraph()
|
||||
|
||||
agent = Agent(
|
||||
name="researcher",
|
||||
model="gemini-2.0-flash",
|
||||
tools=semantica_kg_tools(graph),
|
||||
)
|
||||
```
|
||||
|
||||
The tool factory provides:
|
||||
|
||||
- `extract_entities`
|
||||
- `extract_relations`
|
||||
- `add_to_shared_graph`
|
||||
- `query_shared_graph`
|
||||
|
||||
The graph passed to `semantica_kg_tools()` is shared by all returned tools.
|
||||
|
||||
## Decision Tools
|
||||
|
||||
Decision intelligence can use the same graph:
|
||||
|
||||
```python
|
||||
from integrations.google_adk import semantica_decision_tools
|
||||
|
||||
decision_tools = semantica_decision_tools(graph)
|
||||
|
||||
agent = Agent(
|
||||
name="decision_agent",
|
||||
model="gemini-2.0-flash",
|
||||
tools=decision_tools,
|
||||
)
|
||||
```
|
||||
|
||||
The returned tools provide:
|
||||
|
||||
- `record_shared_decision`
|
||||
- `query_shared_decisions`
|
||||
|
||||
This allows decisions made by one agent to be queried later by another agent using the same `ContextGraph`.
|
||||
|
||||
## Combining Knowledge and Decision Tools
|
||||
|
||||
Both tool groups can be supplied to the same ADK agent:
|
||||
|
||||
```python
|
||||
from google.adk.agents import Agent
|
||||
|
||||
from semantica.context import ContextGraph
|
||||
from integrations.google_adk import (
|
||||
semantica_kg_tools,
|
||||
semantica_decision_tools,
|
||||
)
|
||||
|
||||
|
||||
graph = ContextGraph()
|
||||
|
||||
tools = (
|
||||
semantica_kg_tools(graph)
|
||||
+ semantica_decision_tools(graph)
|
||||
)
|
||||
|
||||
agent = Agent(
|
||||
name="researcher",
|
||||
model="gemini-2.0-flash",
|
||||
tools=tools,
|
||||
)
|
||||
```
|
||||
|
||||
This gives the agent access to both the shared knowledge graph and decision history.
|
||||
|
||||
## Graph-Backed Session Service
|
||||
|
||||
`SemanticaSessionService` implements Google ADK's session service interface while storing session information in a Semantica `ContextGraph`.
|
||||
|
||||
```python
|
||||
from semantica.context import ContextGraph
|
||||
from integrations.google_adk import SemanticaSessionService
|
||||
|
||||
|
||||
graph = ContextGraph()
|
||||
|
||||
session_service = SemanticaSessionService(graph)
|
||||
```
|
||||
|
||||
The same graph can be shared with the KG and decision tools:
|
||||
|
||||
```python
|
||||
from google.adk.agents import Agent
|
||||
|
||||
from semantica.context import ContextGraph
|
||||
from integrations.google_adk import (
|
||||
SemanticaSessionService,
|
||||
semantica_kg_tools,
|
||||
semantica_decision_tools,
|
||||
)
|
||||
|
||||
|
||||
graph = ContextGraph()
|
||||
|
||||
session_service = SemanticaSessionService(graph)
|
||||
|
||||
tools = (
|
||||
semantica_kg_tools(graph)
|
||||
+ semantica_decision_tools(graph)
|
||||
)
|
||||
|
||||
agent = Agent(
|
||||
name="researcher",
|
||||
model="gemini-2.0-flash",
|
||||
tools=tools,
|
||||
)
|
||||
```
|
||||
|
||||
Session information and tool-generated knowledge can therefore share the same graph-backed context store.
|
||||
|
||||
## Optional Dependency
|
||||
|
||||
Importing the integration does not require Google ADK to be installed:
|
||||
|
||||
```python
|
||||
from integrations.google_adk import ADK_AVAILABLE
|
||||
|
||||
print(ADK_AVAILABLE)
|
||||
```
|
||||
|
||||
If Google ADK is unavailable, attempting to construct ADK-specific tools or the session service raises an informative `ImportError`.
|
||||
|
||||
## Shared ContextGraph
|
||||
|
||||
A major purpose of this integration is allowing multiple ADK agents or sub-agents to share one Semantica graph:
|
||||
|
||||
```text
|
||||
ContextGraph
|
||||
|
|
||||
+--------------+--------------+
|
||||
| | |
|
||||
Researcher Planner Reviewer
|
||||
Agent Agent Agent
|
||||
| | |
|
||||
+--------------+--------------+
|
||||
|
|
||||
Shared knowledge
|
||||
+ decisions
|
||||
+ session state
|
||||
```
|
||||
|
||||
This makes information extracted during an earlier stage of an agent workflow available to later stages without requiring the information to be extracted again.
|
||||
|
||||
## Example Workflow
|
||||
|
||||
```python
|
||||
from google.adk.agents import SequentialAgent, Agent
|
||||
|
||||
from semantica.context import ContextGraph
|
||||
from integrations.google_adk import (
|
||||
semantica_kg_tools,
|
||||
semantica_decision_tools,
|
||||
)
|
||||
|
||||
|
||||
graph = ContextGraph()
|
||||
|
||||
researcher = Agent(
|
||||
name="researcher",
|
||||
model="gemini-2.0-flash",
|
||||
tools=semantica_kg_tools(graph),
|
||||
)
|
||||
|
||||
planner = Agent(
|
||||
name="planner",
|
||||
model="gemini-2.0-flash",
|
||||
tools=(
|
||||
semantica_kg_tools(graph)
|
||||
+ semantica_decision_tools(graph)
|
||||
),
|
||||
)
|
||||
|
||||
workflow = SequentialAgent(
|
||||
name="research_workflow",
|
||||
sub_agents=[
|
||||
researcher,
|
||||
planner,
|
||||
],
|
||||
)
|
||||
```
|
||||
|
||||
The researcher can add entities and relationships to the graph. The planner can then query the same graph and record decisions against it.
|
||||
|
||||
## API
|
||||
|
||||
### `semantica_kg_tools(graph=None)`
|
||||
|
||||
Returns Google ADK `FunctionTool` instances for Semantica knowledge graph operations.
|
||||
|
||||
### `semantica_decision_tools(graph=None)`
|
||||
|
||||
Returns Google ADK `FunctionTool` instances for recording and querying decisions.
|
||||
|
||||
### `SemanticaSessionService(graph=None)`
|
||||
|
||||
Creates a Google ADK-compatible session service backed by a Semantica `ContextGraph`.
|
||||
|
||||
### `ADK_AVAILABLE`
|
||||
|
||||
Boolean indicating whether Google ADK is installed.
|
||||
|
||||
### `__version__`
|
||||
|
||||
Version of the Semantica Google ADK integration.
|
||||
|
||||
## Development
|
||||
|
||||
Run the Google ADK integration tests with:
|
||||
|
||||
```bash
|
||||
pytest tests/integrations/google_adk -v
|
||||
```
|
||||
|
||||
Tests that require Google ADK should use:
|
||||
|
||||
```python
|
||||
import pytest
|
||||
|
||||
pytest.importorskip("google.adk")
|
||||
```
|
||||
|
||||
This keeps the integration optional for environments that do not install Google ADK.
|
||||
|
||||
## License
|
||||
|
||||
This integration follows the license of the Semantica project.
|
||||
@@ -1,50 +0,0 @@
|
||||
"""
|
||||
Google ADK integration for Semantica.
|
||||
|
||||
Google ADK is an optional dependency. The integration can be imported
|
||||
without google-adk installed, but ADK-specific functionality requires it.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
try:
|
||||
import google.adk # noqa: F401
|
||||
|
||||
ADK_AVAILABLE = True
|
||||
except ImportError:
|
||||
ADK_AVAILABLE = False
|
||||
|
||||
|
||||
from .kg_tools import (
|
||||
extract_entities,
|
||||
extract_relations,
|
||||
add_to_graph,
|
||||
query_graph,
|
||||
semantica_kg_tools,
|
||||
)
|
||||
|
||||
from .decision_tools import (
|
||||
record_decision,
|
||||
query_decisions,
|
||||
semantica_decision_tools,
|
||||
)
|
||||
|
||||
from .session_service import SemanticaSessionService
|
||||
|
||||
|
||||
__version__ = "0.1.0"
|
||||
|
||||
|
||||
__all__ = [
|
||||
"ADK_AVAILABLE",
|
||||
"__version__",
|
||||
"extract_entities",
|
||||
"extract_relations",
|
||||
"add_to_graph",
|
||||
"query_graph",
|
||||
"semantica_kg_tools",
|
||||
"record_decision",
|
||||
"query_decisions",
|
||||
"semantica_decision_tools",
|
||||
"SemanticaSessionService",
|
||||
]
|
||||
@@ -1,45 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import threading
|
||||
from typing import Any, Dict
|
||||
|
||||
# Shared between kg_tools.py and decision_tools.py so that a ContextGraph
|
||||
# passed to both semantica_kg_tools() and semantica_decision_tools() (the
|
||||
# combined-tools use case documented in the README) is locked and defaulted
|
||||
# consistently across both tool sets rather than each module keeping its
|
||||
# own independent registry.
|
||||
|
||||
_graph_locks_guard = threading.Lock()
|
||||
_graph_locks: Dict[int, threading.RLock] = {}
|
||||
|
||||
|
||||
def graph_lock(graph: Any) -> threading.RLock:
|
||||
"""Return the mutation lock associated with a ContextGraph instance."""
|
||||
key = id(graph)
|
||||
|
||||
with _graph_locks_guard:
|
||||
lock = _graph_locks.get(key)
|
||||
|
||||
if lock is None:
|
||||
lock = threading.RLock()
|
||||
_graph_locks[key] = lock
|
||||
|
||||
return lock
|
||||
|
||||
|
||||
_default_graph: Any = None
|
||||
_default_graph_lock = threading.Lock()
|
||||
|
||||
|
||||
def get_default_graph() -> Any:
|
||||
"""Create or return the cached process-local default ContextGraph."""
|
||||
global _default_graph
|
||||
|
||||
if _default_graph is None:
|
||||
with _default_graph_lock:
|
||||
if _default_graph is None:
|
||||
from semantica.context import ContextGraph
|
||||
|
||||
_default_graph = ContextGraph()
|
||||
|
||||
return _default_graph
|
||||
@@ -1,305 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Any, List, Optional
|
||||
import uuid
|
||||
|
||||
from ._shared import graph_lock as _graph_lock
|
||||
from ._shared import get_default_graph as _get_default_graph
|
||||
|
||||
|
||||
try:
|
||||
from google.adk.tools import FunctionTool
|
||||
|
||||
ADK_AVAILABLE = True
|
||||
except ImportError:
|
||||
FunctionTool = None
|
||||
ADK_AVAILABLE = False
|
||||
|
||||
|
||||
def _get_decision_models() -> Any:
|
||||
"""Import Semantica decision models lazily."""
|
||||
from semantica.context.decision_models import Decision
|
||||
|
||||
return Decision
|
||||
|
||||
|
||||
def _get_decision_recorder(graph: Any) -> Any:
|
||||
"""Create a DecisionRecorder backed by the supplied graph."""
|
||||
from semantica.context import DecisionRecorder
|
||||
|
||||
return DecisionRecorder(graph_store=graph)
|
||||
|
||||
|
||||
def _decision_to_dict(decision: Any) -> dict:
|
||||
"""Convert a Semantica Decision model into a serializable dictionary."""
|
||||
if hasattr(decision, "model_dump"):
|
||||
return decision.model_dump()
|
||||
|
||||
if hasattr(decision, "dict"):
|
||||
return decision.dict()
|
||||
|
||||
if isinstance(decision, dict):
|
||||
return decision
|
||||
|
||||
return {
|
||||
key: value
|
||||
for key, value in vars(decision).items()
|
||||
if not key.startswith("_")
|
||||
}
|
||||
|
||||
|
||||
def record_decision(
|
||||
category: str,
|
||||
scenario: str,
|
||||
reasoning: str,
|
||||
outcome: str,
|
||||
confidence: float = 1.0,
|
||||
decision_maker: str = "agent",
|
||||
entities: Optional[List[str]] = None,
|
||||
source_documents: Optional[List[str]] = None,
|
||||
) -> dict:
|
||||
"""
|
||||
Record a decision using Semantica's DecisionRecorder.
|
||||
|
||||
Args:
|
||||
category: Decision category such as "research", "planning", or
|
||||
"approval".
|
||||
scenario: Situation in which the decision was made.
|
||||
reasoning: Explanation for the decision.
|
||||
outcome: Result or selected action.
|
||||
confidence: Confidence score between 0 and 1.
|
||||
decision_maker: Agent, user, or system responsible for the decision.
|
||||
entities: Optional entity IDs related to the decision.
|
||||
source_documents: Optional source document IDs supporting the decision.
|
||||
|
||||
Returns:
|
||||
Dictionary containing the recorded decision ID and decision metadata.
|
||||
"""
|
||||
return _record_decision(
|
||||
category=category,
|
||||
scenario=scenario,
|
||||
reasoning=reasoning,
|
||||
outcome=outcome,
|
||||
confidence=confidence,
|
||||
decision_maker=decision_maker,
|
||||
entities=entities or [],
|
||||
source_documents=source_documents or [],
|
||||
graph=_get_default_graph(),
|
||||
)
|
||||
|
||||
|
||||
def _record_decision(
|
||||
category: str,
|
||||
scenario: str,
|
||||
reasoning: str,
|
||||
outcome: str,
|
||||
confidence: float,
|
||||
decision_maker: str,
|
||||
entities: List[str],
|
||||
source_documents: List[str],
|
||||
graph: Any,
|
||||
) -> dict:
|
||||
"""Internal implementation of decision recording."""
|
||||
try:
|
||||
confidence = max(0.0, min(1.0, float(confidence)))
|
||||
|
||||
Decision = _get_decision_models()
|
||||
|
||||
decision = Decision(
|
||||
decision_id=str(uuid.uuid4()),
|
||||
category=category,
|
||||
scenario=scenario,
|
||||
reasoning=reasoning,
|
||||
outcome=outcome,
|
||||
confidence=confidence,
|
||||
decision_maker=decision_maker,
|
||||
timestamp=datetime.now(),
|
||||
)
|
||||
|
||||
recorder = _get_decision_recorder(graph)
|
||||
|
||||
with _graph_lock(graph):
|
||||
decision_id = recorder.record_decision(
|
||||
decision=decision,
|
||||
entities=entities,
|
||||
source_documents=source_documents,
|
||||
)
|
||||
|
||||
return {
|
||||
"decision_id": decision_id,
|
||||
"category": category,
|
||||
"scenario": scenario,
|
||||
"outcome": outcome,
|
||||
"confidence": confidence,
|
||||
"decision_maker": decision_maker,
|
||||
}
|
||||
|
||||
except Exception as exc:
|
||||
return {
|
||||
"decision_id": "",
|
||||
"error": str(exc),
|
||||
}
|
||||
|
||||
|
||||
def query_decisions(query: str) -> dict:
|
||||
"""
|
||||
Query previously recorded decisions by keyword.
|
||||
"""
|
||||
return _query_decisions(query, _get_default_graph())
|
||||
|
||||
def _query_decisions(query: str, graph: Any) -> dict:
|
||||
"""Internal decision query implementation."""
|
||||
if not isinstance(query, str):
|
||||
return {
|
||||
"query": query,
|
||||
"decisions": [],
|
||||
"count": 0,
|
||||
"error": "query must be a string",
|
||||
}
|
||||
|
||||
query = query.strip()
|
||||
|
||||
if not query:
|
||||
return {
|
||||
"query": query,
|
||||
"decisions": [],
|
||||
"count": 0,
|
||||
}
|
||||
|
||||
try:
|
||||
query_lower = query.lower()
|
||||
decisions = []
|
||||
seen = set()
|
||||
|
||||
for node in graph.find_nodes() or []:
|
||||
if not isinstance(node, dict):
|
||||
continue
|
||||
|
||||
node_type = node.get("type")
|
||||
|
||||
if str(node_type).lower() != "decision":
|
||||
continue
|
||||
|
||||
node_id = str(node.get("id") or "")
|
||||
|
||||
if not node_id or node_id in seen:
|
||||
continue
|
||||
|
||||
metadata = node.get("metadata") or {}
|
||||
|
||||
category = metadata.get("category", "")
|
||||
scenario = metadata.get("scenario", "")
|
||||
reasoning = metadata.get("reasoning", "")
|
||||
outcome = metadata.get("outcome", "")
|
||||
decision_maker = metadata.get("decision_maker", "")
|
||||
|
||||
searchable = " ".join(
|
||||
str(value or "")
|
||||
for value in (
|
||||
node_id,
|
||||
category,
|
||||
scenario,
|
||||
reasoning,
|
||||
outcome,
|
||||
decision_maker,
|
||||
)
|
||||
).lower()
|
||||
|
||||
if query_lower not in searchable:
|
||||
continue
|
||||
|
||||
seen.add(node_id)
|
||||
|
||||
decisions.append(
|
||||
{
|
||||
"decision_id": node_id,
|
||||
"category": str(category or ""),
|
||||
"scenario": str(scenario or ""),
|
||||
"reasoning": str(reasoning or "")[:1000],
|
||||
"outcome": str(outcome or ""),
|
||||
"decision_maker": str(
|
||||
decision_maker or ""
|
||||
),
|
||||
}
|
||||
)
|
||||
|
||||
return {
|
||||
"query": query,
|
||||
"decisions": decisions,
|
||||
"count": len(decisions),
|
||||
}
|
||||
|
||||
except Exception as exc:
|
||||
return {
|
||||
"query": query,
|
||||
"decisions": [],
|
||||
"count": 0,
|
||||
"error": str(exc),
|
||||
}
|
||||
|
||||
def semantica_decision_tools(
|
||||
graph: Optional[Any] = None,
|
||||
) -> List[Any]:
|
||||
"""
|
||||
Return Google ADK FunctionTools bound to a shared ContextGraph.
|
||||
|
||||
Args:
|
||||
graph:
|
||||
Optional ContextGraph shared by the ADK agent and other
|
||||
Semantica tools.
|
||||
|
||||
Returns:
|
||||
ADK FunctionTools for recording and querying decisions.
|
||||
|
||||
Raises:
|
||||
ImportError:
|
||||
If google-adk is not installed.
|
||||
"""
|
||||
if not ADK_AVAILABLE or FunctionTool is None:
|
||||
raise ImportError(
|
||||
"Google ADK is required for semantica_decision_tools(). "
|
||||
"Install it with: pip install semantica[google-adk]"
|
||||
)
|
||||
|
||||
shared_graph = graph if graph is not None else _get_default_graph()
|
||||
|
||||
def record_shared_decision(
|
||||
category: str,
|
||||
scenario: str,
|
||||
reasoning: str,
|
||||
outcome: str,
|
||||
confidence: float = 1.0,
|
||||
decision_maker: str = "agent",
|
||||
entities: Optional[List[str]] = None,
|
||||
source_documents: Optional[List[str]] = None,
|
||||
) -> dict:
|
||||
"""Record a decision in the shared Semantica knowledge graph."""
|
||||
return _record_decision(
|
||||
category=category,
|
||||
scenario=scenario,
|
||||
reasoning=reasoning,
|
||||
outcome=outcome,
|
||||
confidence=confidence,
|
||||
decision_maker=decision_maker,
|
||||
entities=entities or [],
|
||||
source_documents=source_documents or [],
|
||||
graph=shared_graph,
|
||||
)
|
||||
|
||||
def query_shared_decisions(query: str) -> dict:
|
||||
"""Query decisions stored in the shared Semantica knowledge graph."""
|
||||
return _query_decisions(query, shared_graph)
|
||||
|
||||
return [
|
||||
FunctionTool(record_shared_decision),
|
||||
FunctionTool(query_shared_decisions),
|
||||
]
|
||||
|
||||
|
||||
__all__ = [
|
||||
"ADK_AVAILABLE",
|
||||
"record_decision",
|
||||
"query_decisions",
|
||||
"semantica_decision_tools",
|
||||
]
|
||||
@@ -1,659 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from ._shared import graph_lock as _graph_lock
|
||||
from ._shared import get_default_graph as _get_default_graph
|
||||
|
||||
try:
|
||||
from google.adk.tools import FunctionTool
|
||||
|
||||
ADK_AVAILABLE = True
|
||||
except ImportError:
|
||||
FunctionTool = None # type: ignore
|
||||
ADK_AVAILABLE = False
|
||||
|
||||
|
||||
def _get_ner_extractor() -> Any:
|
||||
"""Create Semantica default NER extractor."""
|
||||
from semantica.semantic_extract import NERExtractor
|
||||
|
||||
return NERExtractor()
|
||||
|
||||
|
||||
def _get_relation_extractor() -> Any:
|
||||
"""Create Semantica default relation extractor."""
|
||||
from semantica.semantic_extract import RelationExtractor
|
||||
|
||||
return RelationExtractor()
|
||||
|
||||
|
||||
def _first_string(obj: Any, attributes: tuple[str, ...]) -> str:
|
||||
"""Return the first non-empty string from an object or dictionary."""
|
||||
if obj is None:
|
||||
return ""
|
||||
|
||||
if isinstance(obj, dict):
|
||||
for attribute in attributes:
|
||||
value = obj.get(attribute)
|
||||
|
||||
if isinstance(value, str) and value.strip():
|
||||
return value.strip()
|
||||
|
||||
return ""
|
||||
|
||||
for attribute in attributes:
|
||||
value = getattr(obj, attribute, None)
|
||||
|
||||
if isinstance(value, str) and value.strip():
|
||||
return value.strip()
|
||||
|
||||
return ""
|
||||
|
||||
|
||||
def _entity_name(entity: Any) -> str:
|
||||
"""Return a best-effort name for an extracted entity."""
|
||||
return _first_string(
|
||||
entity,
|
||||
(
|
||||
"name",
|
||||
"text",
|
||||
"label",
|
||||
"node_id",
|
||||
"id",
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def _entity_type(entity: Any) -> str:
|
||||
"""Return a best-effort type for an extracted entity."""
|
||||
return (
|
||||
_first_string(
|
||||
entity,
|
||||
(
|
||||
"type",
|
||||
"label",
|
||||
),
|
||||
)
|
||||
or "Entity"
|
||||
)
|
||||
|
||||
|
||||
def _entity_confidence(entity: Any) -> float:
|
||||
"""Normalize an entity confidence value."""
|
||||
try:
|
||||
confidence = (
|
||||
entity.get("confidence")
|
||||
if isinstance(entity, dict)
|
||||
else getattr(entity, "confidence", None)
|
||||
)
|
||||
|
||||
if confidence is None:
|
||||
return 1.0
|
||||
|
||||
return round(float(confidence), 4)
|
||||
|
||||
except (TypeError, ValueError):
|
||||
return 1.0
|
||||
|
||||
|
||||
def _relation_source(relation: Any) -> str:
|
||||
"""Return the source entity of an extracted relation."""
|
||||
source = _first_string(
|
||||
relation,
|
||||
(
|
||||
"source",
|
||||
"source_id",
|
||||
),
|
||||
)
|
||||
|
||||
if source:
|
||||
return source
|
||||
|
||||
if isinstance(relation, dict):
|
||||
return _entity_name(relation.get("subject"))
|
||||
|
||||
return _entity_name(getattr(relation, "subject", None))
|
||||
|
||||
|
||||
def _relation_target(relation: Any) -> str:
|
||||
"""Return the target entity of an extracted relation."""
|
||||
target = _first_string(
|
||||
relation,
|
||||
(
|
||||
"target",
|
||||
"target_id",
|
||||
),
|
||||
)
|
||||
|
||||
if target:
|
||||
return target
|
||||
|
||||
if isinstance(relation, dict):
|
||||
return _entity_name(relation.get("object"))
|
||||
|
||||
return _entity_name(getattr(relation, "object", None))
|
||||
|
||||
|
||||
def _relation_type(relation: Any) -> str:
|
||||
"""Return the relation predicate/type."""
|
||||
return (
|
||||
_first_string(
|
||||
relation,
|
||||
(
|
||||
"type",
|
||||
"relation",
|
||||
"predicate",
|
||||
),
|
||||
)
|
||||
or "related_to"
|
||||
)
|
||||
|
||||
|
||||
def _json_safe(value: Any) -> Any:
|
||||
"""
|
||||
Convert common Semantica objects into values suitable for ADK tool output.
|
||||
|
||||
ADK tools should return values that can be serialized into the tool
|
||||
response sent back to the model.
|
||||
"""
|
||||
if value is None or isinstance(value, (str, int, float, bool)):
|
||||
return value
|
||||
|
||||
if isinstance(value, dict):
|
||||
return {
|
||||
str(key): _json_safe(item)
|
||||
for key, item in value.items()
|
||||
}
|
||||
|
||||
if isinstance(value, (list, tuple, set)):
|
||||
return [_json_safe(item) for item in value]
|
||||
|
||||
if hasattr(value, "to_dict"):
|
||||
try:
|
||||
return _json_safe(value.to_dict())
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if hasattr(value, "model_dump"):
|
||||
try:
|
||||
return _json_safe(value.model_dump())
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return str(value)
|
||||
|
||||
|
||||
def extract_entities(text: str) -> dict:
|
||||
"""Extract named entities from text using Semantica's NER pipeline."""
|
||||
if not isinstance(text, str):
|
||||
return {
|
||||
"entities": [],
|
||||
"count": 0,
|
||||
"error": "text must be a string",
|
||||
}
|
||||
|
||||
try:
|
||||
extractor = _get_ner_extractor()
|
||||
raw_entities = extractor.extract_entities(text) or []
|
||||
|
||||
entities: List[Dict[str, Any]] = []
|
||||
|
||||
for entity in raw_entities:
|
||||
name = _entity_name(entity)
|
||||
|
||||
if not name:
|
||||
continue
|
||||
|
||||
entities.append(
|
||||
{
|
||||
"name": name,
|
||||
"type": _entity_type(entity),
|
||||
"confidence": _entity_confidence(entity),
|
||||
}
|
||||
)
|
||||
|
||||
return {
|
||||
"entities": entities,
|
||||
"count": len(entities),
|
||||
}
|
||||
|
||||
except Exception as exc:
|
||||
return {
|
||||
"entities": [],
|
||||
"count": 0,
|
||||
"error": str(exc),
|
||||
}
|
||||
|
||||
|
||||
def extract_relations(text: str) -> dict:
|
||||
"""Extract relationships between entities from text using Semantica."""
|
||||
if not isinstance(text, str):
|
||||
return {
|
||||
"relations": [],
|
||||
"count": 0,
|
||||
"error": "text must be a string",
|
||||
}
|
||||
|
||||
try:
|
||||
ner_extractor = _get_ner_extractor()
|
||||
entities = ner_extractor.extract_entities(text)
|
||||
|
||||
relation_extractor = _get_relation_extractor()
|
||||
raw_relations = relation_extractor.extract_relations(text, entities=entities) or []
|
||||
|
||||
relations: List[Dict[str, Any]] = []
|
||||
|
||||
for relation in raw_relations:
|
||||
source = _relation_source(relation)
|
||||
target = _relation_target(relation)
|
||||
|
||||
if not source or not target:
|
||||
continue
|
||||
|
||||
relations.append(
|
||||
{
|
||||
"source": source,
|
||||
"relation": _relation_type(relation),
|
||||
"target": target,
|
||||
"confidence": _entity_confidence(relation),
|
||||
}
|
||||
)
|
||||
|
||||
return {
|
||||
"relations": relations,
|
||||
"count": len(relations),
|
||||
}
|
||||
|
||||
except Exception as exc:
|
||||
return {
|
||||
"relations": [],
|
||||
"count": 0,
|
||||
"error": str(exc),
|
||||
}
|
||||
|
||||
|
||||
def add_to_graph(text: str) -> dict:
|
||||
"""
|
||||
Extract entities and relationships from text and add them to a ContextGraph.
|
||||
|
||||
This standalone function uses a process-local default graph. For a shared
|
||||
graph across ADK agents, use ``semantica_kg_tools(graph=shared_graph)``.
|
||||
"""
|
||||
return _add_to_graph(text, _get_default_graph())
|
||||
|
||||
|
||||
def query_graph(query: str) -> dict:
|
||||
"""
|
||||
Query the shared Semantica knowledge graph by keyword.
|
||||
|
||||
This standalone function uses a process-local default graph. For a shared
|
||||
graph, use ``semantica_kg_tools(graph=shared_graph)``.
|
||||
"""
|
||||
return _query_graph(query, _get_default_graph())
|
||||
|
||||
|
||||
def _add_to_graph(text: str, graph: Any) -> dict:
|
||||
"""Internal graph mutation implementation."""
|
||||
if not isinstance(text, str):
|
||||
return {
|
||||
"nodes_added": 0,
|
||||
"edges_added": 0,
|
||||
"error": "text must be a string",
|
||||
}
|
||||
|
||||
try:
|
||||
ner_extractor = _get_ner_extractor()
|
||||
relation_extractor = _get_relation_extractor()
|
||||
|
||||
nodes_added = 0
|
||||
edges_added = 0
|
||||
|
||||
with _graph_lock(graph):
|
||||
existing_nodes = set()
|
||||
|
||||
for node in graph.find_nodes() or []:
|
||||
if isinstance(node, dict):
|
||||
node_id = node.get("id") or node.get("node_id")
|
||||
else:
|
||||
node_id = getattr(
|
||||
node,
|
||||
"id",
|
||||
getattr(node, "node_id", None),
|
||||
)
|
||||
|
||||
if node_id:
|
||||
existing_nodes.add(str(node_id))
|
||||
|
||||
existing_edges = set()
|
||||
|
||||
for edge in graph.find_edges() or []:
|
||||
if isinstance(edge, dict):
|
||||
source = edge.get("source") or edge.get("source_id")
|
||||
target = edge.get("target") or edge.get("target_id")
|
||||
edge_type = edge.get("type") or edge.get("edge_type")
|
||||
else:
|
||||
source = getattr(
|
||||
edge,
|
||||
"source_id",
|
||||
getattr(edge, "source", None),
|
||||
)
|
||||
target = getattr(
|
||||
edge,
|
||||
"target_id",
|
||||
getattr(edge, "target", None),
|
||||
)
|
||||
edge_type = getattr(
|
||||
edge,
|
||||
"edge_type",
|
||||
getattr(edge, "type", None),
|
||||
)
|
||||
|
||||
if source and target:
|
||||
existing_edges.add(
|
||||
(
|
||||
str(source),
|
||||
str(edge_type or "related_to"),
|
||||
str(target),
|
||||
)
|
||||
)
|
||||
|
||||
raw_entities = ner_extractor.extract_entities(text) or []
|
||||
|
||||
entities: List[Any] = []
|
||||
seen_entities = set()
|
||||
|
||||
for entity in raw_entities:
|
||||
name = _entity_name(entity)
|
||||
entity_type = _entity_type(entity)
|
||||
|
||||
if not name or name in seen_entities:
|
||||
continue
|
||||
|
||||
seen_entities.add(name)
|
||||
entities.append(entity)
|
||||
|
||||
if name in existing_nodes:
|
||||
continue
|
||||
|
||||
try:
|
||||
added = graph.add_node(
|
||||
node_id=name,
|
||||
node_type=entity_type,
|
||||
)
|
||||
|
||||
if added:
|
||||
nodes_added += 1
|
||||
existing_nodes.add(name)
|
||||
|
||||
except Exception:
|
||||
# Do not fail the entire tool because one node could not
|
||||
# be inserted.
|
||||
continue
|
||||
|
||||
raw_relations = relation_extractor.extract_relations(
|
||||
text,
|
||||
entities=entities,
|
||||
) or []
|
||||
|
||||
for relation in raw_relations:
|
||||
source = _relation_source(relation)
|
||||
target = _relation_target(relation)
|
||||
relation_type = _relation_type(relation)
|
||||
|
||||
if not source or not target:
|
||||
continue
|
||||
|
||||
edge_key = (
|
||||
source,
|
||||
relation_type,
|
||||
target,
|
||||
)
|
||||
|
||||
if edge_key in existing_edges:
|
||||
continue
|
||||
|
||||
try:
|
||||
added = graph.add_edge(
|
||||
source_id=source,
|
||||
target_id=target,
|
||||
edge_type=relation_type,
|
||||
)
|
||||
|
||||
if added:
|
||||
edges_added += 1
|
||||
existing_edges.add(edge_key)
|
||||
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
return {
|
||||
"nodes_added": nodes_added,
|
||||
"edges_added": edges_added,
|
||||
}
|
||||
|
||||
except Exception as exc:
|
||||
return {
|
||||
"nodes_added": 0,
|
||||
"edges_added": 0,
|
||||
"error": str(exc),
|
||||
}
|
||||
|
||||
|
||||
def _query_graph(query: str, graph: Any) -> dict:
|
||||
"""Internal graph query implementation."""
|
||||
if not isinstance(query, str):
|
||||
return {
|
||||
"query": query,
|
||||
"results": [],
|
||||
"count": 0,
|
||||
"error": "query must be a string",
|
||||
}
|
||||
|
||||
query = query.strip()
|
||||
|
||||
if not query:
|
||||
return {
|
||||
"query": query,
|
||||
"results": [],
|
||||
"count": 0,
|
||||
}
|
||||
|
||||
try:
|
||||
results: List[Dict[str, Any]] = []
|
||||
seen = set()
|
||||
|
||||
# Prefer ContextGraph.query() when available because it can provide
|
||||
# richer semantic/structural results.
|
||||
query_method = getattr(graph, "query", None)
|
||||
|
||||
if callable(query_method):
|
||||
try:
|
||||
matches = query_method(query) or []
|
||||
|
||||
for match in matches:
|
||||
if not isinstance(match, dict):
|
||||
continue
|
||||
|
||||
node = match.get("node") or {}
|
||||
|
||||
if not isinstance(node, dict):
|
||||
node = _json_safe(node)
|
||||
|
||||
node_id = (
|
||||
node.get("id")
|
||||
or node.get("node_id")
|
||||
or match.get("id")
|
||||
)
|
||||
|
||||
if not node_id:
|
||||
continue
|
||||
|
||||
node_id = str(node_id)
|
||||
|
||||
if node_id in seen:
|
||||
continue
|
||||
|
||||
seen.add(node_id)
|
||||
|
||||
results.append(
|
||||
{
|
||||
"id": node_id,
|
||||
"type": (
|
||||
node.get("type")
|
||||
or node.get("node_type")
|
||||
or ""
|
||||
),
|
||||
"content": str(
|
||||
match.get("content")
|
||||
or node.get("content")
|
||||
or (
|
||||
node.get("properties") or {}
|
||||
).get("content", "")
|
||||
)[:500],
|
||||
"score": round(
|
||||
float(match.get("score") or 0.0),
|
||||
4,
|
||||
),
|
||||
}
|
||||
)
|
||||
|
||||
except Exception:
|
||||
# Fall back to deterministic keyword search below.
|
||||
pass
|
||||
|
||||
# Deterministic fallback/search enrichment.
|
||||
query_lower = query.lower()
|
||||
|
||||
for node in graph.find_nodes() or []:
|
||||
if isinstance(node, dict):
|
||||
node_id = (
|
||||
node.get("id")
|
||||
or node.get("node_id")
|
||||
or ""
|
||||
)
|
||||
node_type = (
|
||||
node.get("type")
|
||||
or node.get("node_type")
|
||||
or ""
|
||||
)
|
||||
|
||||
properties = node.get("properties") or {}
|
||||
|
||||
content = (
|
||||
node.get("content")
|
||||
or properties.get("content")
|
||||
or ""
|
||||
)
|
||||
|
||||
else:
|
||||
node_id = getattr(
|
||||
node,
|
||||
"id",
|
||||
getattr(node, "node_id", ""),
|
||||
)
|
||||
node_type = getattr(
|
||||
node,
|
||||
"node_type",
|
||||
getattr(node, "type", ""),
|
||||
)
|
||||
content = getattr(node, "content", "")
|
||||
|
||||
node_id = str(node_id or "")
|
||||
node_type = str(node_type or "")
|
||||
content = str(content or "")
|
||||
|
||||
if not node_id or node_id in seen:
|
||||
continue
|
||||
|
||||
haystack = " ".join(
|
||||
(
|
||||
node_id,
|
||||
node_type,
|
||||
content,
|
||||
)
|
||||
).lower()
|
||||
|
||||
if query_lower in haystack:
|
||||
seen.add(node_id)
|
||||
|
||||
results.append(
|
||||
{
|
||||
"id": node_id,
|
||||
"type": node_type,
|
||||
"content": content[:500],
|
||||
"score": 1.0,
|
||||
}
|
||||
)
|
||||
|
||||
return {
|
||||
"query": query,
|
||||
"results": results,
|
||||
"count": len(results),
|
||||
}
|
||||
|
||||
except Exception as exc:
|
||||
return {
|
||||
"query": query,
|
||||
"results": [],
|
||||
"count": 0,
|
||||
"error": str(exc),
|
||||
}
|
||||
|
||||
|
||||
def semantica_kg_tools(
|
||||
graph: Optional[Any] = None,
|
||||
) -> List[Any]:
|
||||
"""
|
||||
Return Google ADK FunctionTools bound to a shared ContextGraph instance.
|
||||
|
||||
Args:
|
||||
graph:
|
||||
Optional Semantica ContextGraph. When supplied, all returned tools
|
||||
operate on this same graph instance.
|
||||
|
||||
Returns:
|
||||
A list containing FunctionTools for:
|
||||
- extract_entities
|
||||
- extract_relations
|
||||
- add_to_graph
|
||||
- query_graph
|
||||
|
||||
Raises:
|
||||
ImportError:
|
||||
If google-adk is not installed.
|
||||
"""
|
||||
if not ADK_AVAILABLE or FunctionTool is None:
|
||||
raise ImportError(
|
||||
"Google ADK is required for semantica_kg_tools(). "
|
||||
"Install it with: pip install semantica[google-adk]"
|
||||
)
|
||||
|
||||
shared_graph = graph if graph is not None else _get_default_graph()
|
||||
|
||||
def add_to_shared_graph(text: str) -> dict:
|
||||
"""Extract entities and relationships from text and add them to the shared Semantica graph."""
|
||||
return _add_to_graph(text, shared_graph)
|
||||
|
||||
def query_shared_graph(query: str) -> dict:
|
||||
"""Query the shared Semantica knowledge graph by keyword."""
|
||||
return _query_graph(query, shared_graph)
|
||||
|
||||
# FunctionTool derives the tool name/schema from the wrapped callable and
|
||||
# its docstring, which is exactly the ADK convention we want.
|
||||
return [
|
||||
FunctionTool(extract_entities),
|
||||
FunctionTool(extract_relations),
|
||||
FunctionTool(add_to_shared_graph),
|
||||
FunctionTool(query_shared_graph),
|
||||
]
|
||||
|
||||
|
||||
__all__ = [
|
||||
"ADK_AVAILABLE",
|
||||
"extract_entities",
|
||||
"extract_relations",
|
||||
"add_to_graph",
|
||||
"query_graph",
|
||||
"semantica_kg_tools",
|
||||
]
|
||||
@@ -1,646 +0,0 @@
|
||||
"""
|
||||
Semantica-backed Google ADK session service.
|
||||
|
||||
Session metadata, state, and event history are represented as nodes in a
|
||||
Semantica ContextGraph instead of being kept only in ADK's in-memory store.
|
||||
|
||||
Google ADK is an optional dependency.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import copy
|
||||
import inspect
|
||||
import threading
|
||||
import urllib.parse
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
from typing import Any, List, Optional
|
||||
|
||||
try:
|
||||
from google.adk.events import Event
|
||||
from google.adk.sessions import BaseSessionService, Session
|
||||
try:
|
||||
from google.adk.sessions import ListSessionsResponse
|
||||
except ImportError:
|
||||
# Not every google-adk release re-exports ListSessionsResponse from
|
||||
# the sessions package __init__; it always lives in
|
||||
# base_session_service.
|
||||
from google.adk.sessions.base_session_service import ListSessionsResponse
|
||||
try:
|
||||
from google.adk.sessions import GetSessionConfig
|
||||
except ImportError:
|
||||
from google.adk.sessions.base_session_service import GetSessionConfig
|
||||
ADK_AVAILABLE = True
|
||||
except (ImportError, ModuleNotFoundError):
|
||||
ADK_AVAILABLE = False
|
||||
BaseSessionService = object
|
||||
Session = Any
|
||||
Event = Any
|
||||
ListSessionsResponse = Any
|
||||
GetSessionConfig = Any
|
||||
|
||||
|
||||
class SemanticaSessionService(BaseSessionService):
|
||||
"""
|
||||
Google ADK SessionService backed by a Semantica ContextGraph.
|
||||
|
||||
Graph structure:
|
||||
|
||||
ADKSession
|
||||
|
|
||||
+-- HAS_EVENT --> ADKEvent
|
||||
|
||||
Session metadata and state are stored in the ContextGraph node metadata.
|
||||
"""
|
||||
|
||||
def __init__(self, graph: Optional[Any] = None) -> None:
|
||||
if not ADK_AVAILABLE:
|
||||
raise ImportError(
|
||||
"Google ADK is required for SemanticaSessionService. "
|
||||
"Install it with: pip install semantica[google-adk]"
|
||||
)
|
||||
|
||||
super().__init__()
|
||||
|
||||
if graph is None:
|
||||
from semantica.context import ContextGraph
|
||||
|
||||
graph = ContextGraph()
|
||||
|
||||
self.graph = graph
|
||||
self._lock = threading.RLock()
|
||||
|
||||
# Graph helpers
|
||||
|
||||
@staticmethod
|
||||
def _node_id(app_name: str, user_id: str, session_id: str) -> str:
|
||||
"""Return the internal ContextGraph node ID for a session.
|
||||
|
||||
Each component is percent-encoded before joining so a ':' inside
|
||||
app_name/user_id/session_id can never be mistaken for the
|
||||
separator: without this, distinct identities such as
|
||||
(app_name="tenant:A", user_id="alice") and
|
||||
(app_name="tenant", user_id="A:alice") would collide on the same
|
||||
node ID.
|
||||
"""
|
||||
parts = (
|
||||
urllib.parse.quote(part, safe="")
|
||||
for part in (app_name, user_id, session_id)
|
||||
)
|
||||
return "adk-session:" + ":".join(parts)
|
||||
|
||||
@staticmethod
|
||||
def _event_node_id(event: Any) -> str:
|
||||
"""Return the internal ContextGraph node ID for an event."""
|
||||
event_id = getattr(event, "id", None)
|
||||
|
||||
if event_id:
|
||||
return f"adk-event:{event_id}"
|
||||
|
||||
return f"adk-event:{uuid.uuid4()}"
|
||||
|
||||
@staticmethod
|
||||
def _safe_dict(value: Any) -> dict:
|
||||
"""Convert common Python/Pydantic objects into a dictionary."""
|
||||
if value is None:
|
||||
return {}
|
||||
|
||||
if isinstance(value, dict):
|
||||
return copy.deepcopy(value)
|
||||
|
||||
if hasattr(value, "model_dump"):
|
||||
try:
|
||||
return copy.deepcopy(value.model_dump())
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if hasattr(value, "dict"):
|
||||
try:
|
||||
return copy.deepcopy(value.dict())
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
try:
|
||||
return {
|
||||
key: copy.deepcopy(item)
|
||||
for key, item in vars(value).items()
|
||||
if not key.startswith("_")
|
||||
}
|
||||
except Exception:
|
||||
return {}
|
||||
|
||||
@staticmethod
|
||||
def _node_properties(node: Any) -> dict:
|
||||
"""
|
||||
Extract application properties from a ContextGraph node.
|
||||
"""
|
||||
if isinstance(node, dict):
|
||||
metadata = node.get("metadata")
|
||||
|
||||
if isinstance(metadata, dict):
|
||||
return copy.deepcopy(metadata)
|
||||
|
||||
properties = node.get("properties")
|
||||
|
||||
if isinstance(properties, dict):
|
||||
return copy.deepcopy(properties)
|
||||
|
||||
return {}
|
||||
|
||||
metadata = getattr(node, "metadata", None)
|
||||
|
||||
if isinstance(metadata, dict):
|
||||
return copy.deepcopy(metadata)
|
||||
|
||||
properties = getattr(node, "properties", None)
|
||||
|
||||
if isinstance(properties, dict):
|
||||
return copy.deepcopy(properties)
|
||||
|
||||
return {}
|
||||
|
||||
def _find_session_node(
|
||||
self,
|
||||
app_name: str,
|
||||
user_id: str,
|
||||
session_id: str,
|
||||
) -> Optional[Any]:
|
||||
"""Find a session node by its logical ADK session ID."""
|
||||
expected_node_id = self._node_id(app_name, user_id, session_id)
|
||||
|
||||
for node in self.graph.find_nodes() or []:
|
||||
if not isinstance(node, dict):
|
||||
continue
|
||||
|
||||
# Fast path: ContextGraph node ID.
|
||||
if str(node.get("id")) == expected_node_id:
|
||||
return node
|
||||
|
||||
# Fallback: logical ID stored in metadata.
|
||||
metadata = node.get("metadata")
|
||||
|
||||
if (
|
||||
isinstance(metadata, dict)
|
||||
and str(metadata.get("session_id")) == str(session_id)
|
||||
and str(metadata.get("app_name")) == str(app_name)
|
||||
and str(metadata.get("user_id")) == str(user_id)
|
||||
):
|
||||
return node
|
||||
|
||||
return None
|
||||
|
||||
def _find_node_by_id(
|
||||
self,
|
||||
node_id: str,
|
||||
) -> Optional[Any]:
|
||||
"""Find a ContextGraph node by graph node ID."""
|
||||
for node in self.graph.find_nodes() or []:
|
||||
if isinstance(node, dict) and str(node.get("id")) == str(node_id):
|
||||
return node
|
||||
|
||||
return None
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Event helpers
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
@staticmethod
|
||||
def _serialize_event(event: Any) -> dict:
|
||||
"""Serialize an ADK Event into ContextGraph metadata."""
|
||||
data = SemanticaSessionService._safe_dict(event)
|
||||
|
||||
for field in (
|
||||
"id",
|
||||
"invocation_id",
|
||||
"author",
|
||||
"timestamp",
|
||||
"partial",
|
||||
"turn_complete",
|
||||
"branch",
|
||||
):
|
||||
if field not in data and hasattr(event, field):
|
||||
value = getattr(event, field)
|
||||
|
||||
if isinstance(value, datetime):
|
||||
value = value.isoformat()
|
||||
|
||||
data[field] = copy.deepcopy(value)
|
||||
|
||||
return data
|
||||
|
||||
def _event_nodes(
|
||||
self,
|
||||
app_name: str,
|
||||
user_id: str,
|
||||
session_id: str,
|
||||
) -> List[Any]:
|
||||
"""Return all event nodes connected to a session."""
|
||||
session_node_id = self._node_id(app_name, user_id, session_id)
|
||||
|
||||
events: List[Any] = []
|
||||
|
||||
for edge in self.graph.find_edges() or []:
|
||||
if not isinstance(edge, dict):
|
||||
continue
|
||||
|
||||
if edge.get("source") != session_node_id:
|
||||
continue
|
||||
|
||||
if edge.get("type") != "HAS_EVENT":
|
||||
continue
|
||||
|
||||
target = edge.get("target")
|
||||
|
||||
if target is None:
|
||||
continue
|
||||
|
||||
node = self._find_node_by_id(str(target))
|
||||
|
||||
if node is not None:
|
||||
events.append(node)
|
||||
|
||||
return events
|
||||
|
||||
@staticmethod
|
||||
def _event_timestamp(node: Any) -> str:
|
||||
"""Return a sortable timestamp for an event node."""
|
||||
properties = SemanticaSessionService._node_properties(node)
|
||||
timestamp = properties.get("timestamp")
|
||||
|
||||
if timestamp is None:
|
||||
return ""
|
||||
|
||||
return str(timestamp)
|
||||
|
||||
def _event_from_node(
|
||||
self,
|
||||
node: Any,
|
||||
) -> Any:
|
||||
"""
|
||||
Reconstruct an ADK Event from its stored metadata.
|
||||
"""
|
||||
properties = self._node_properties(node)
|
||||
|
||||
graph_node_id = node.get("id") if isinstance(node, dict) else None
|
||||
event_id = properties.get("id")
|
||||
|
||||
if not event_id and graph_node_id:
|
||||
graph_node_id = str(graph_node_id)
|
||||
if graph_node_id.startswith("adk-event:"):
|
||||
event_id = graph_node_id[len("adk-event:"):]
|
||||
|
||||
if event_id:
|
||||
properties["id"] = event_id
|
||||
|
||||
# ContextGraph-specific values should never become Event fields.
|
||||
properties.pop("session_id", None)
|
||||
properties.pop("app_name", None)
|
||||
properties.pop("user_id", None)
|
||||
|
||||
try:
|
||||
return Event(**properties)
|
||||
except Exception:
|
||||
return properties
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Session helpers
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
@staticmethod
|
||||
def _session_kwargs(
|
||||
app_name: str,
|
||||
user_id: str,
|
||||
session_id: str,
|
||||
state: Optional[dict],
|
||||
events: Optional[List[Any]],
|
||||
) -> dict:
|
||||
"""Build kwargs for the ADK Session model."""
|
||||
return {
|
||||
"app_name": app_name,
|
||||
"user_id": user_id,
|
||||
"id": session_id,
|
||||
"state": copy.deepcopy(state or {}),
|
||||
"events": list(events or []),
|
||||
}
|
||||
|
||||
def _session_from_node(
|
||||
self,
|
||||
node: Any,
|
||||
) -> Session:
|
||||
"""Reconstruct an ADK Session from a ContextGraph node."""
|
||||
properties = self._node_properties(node)
|
||||
|
||||
session_id = str(properties.get("session_id") or "")
|
||||
app_name = str(properties.get("app_name") or "")
|
||||
user_id = str(properties.get("user_id") or "")
|
||||
|
||||
# Fallback to the graph node ID.
|
||||
if not session_id:
|
||||
graph_node_id = node.get("id") if isinstance(node, dict) else None
|
||||
|
||||
if graph_node_id:
|
||||
graph_node_id = str(graph_node_id)
|
||||
if graph_node_id.startswith("adk-session:"):
|
||||
# Each component is percent-encoded by _node_id(), so
|
||||
# splitting on ':' after the prefix always yields
|
||||
# exactly 3 parts regardless of what characters the
|
||||
# original app_name/user_id/session_id contained.
|
||||
parts = graph_node_id[len("adk-session:"):].split(":")
|
||||
if len(parts) == 3:
|
||||
decoded = [urllib.parse.unquote(part) for part in parts]
|
||||
app_name = app_name or decoded[0]
|
||||
user_id = user_id or decoded[1]
|
||||
session_id = decoded[2]
|
||||
else:
|
||||
session_id = graph_node_id[len("adk-session:"):]
|
||||
else:
|
||||
session_id = graph_node_id
|
||||
|
||||
state = properties.get("state") or {}
|
||||
|
||||
if not isinstance(state, dict):
|
||||
state = {}
|
||||
|
||||
event_nodes = self._event_nodes(app_name, user_id, session_id)
|
||||
event_nodes.sort(key=self._event_timestamp)
|
||||
|
||||
events = [self._event_from_node(node) for node in event_nodes]
|
||||
|
||||
return Session(
|
||||
**self._session_kwargs(
|
||||
app_name=app_name,
|
||||
user_id=user_id,
|
||||
session_id=session_id,
|
||||
state=state,
|
||||
events=events,
|
||||
)
|
||||
)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# ADK SessionService implementation
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
async def create_session(
|
||||
self,
|
||||
*,
|
||||
app_name: str,
|
||||
user_id: str,
|
||||
state: Optional[dict[str, Any]] = None,
|
||||
session_id: Optional[str] = None,
|
||||
) -> Session:
|
||||
"""Create and persist an ADK session."""
|
||||
return await asyncio.to_thread(
|
||||
self._create_session_sync, app_name, user_id, state, session_id
|
||||
)
|
||||
|
||||
def _create_session_sync(
|
||||
self,
|
||||
app_name: str,
|
||||
user_id: str,
|
||||
state: Optional[dict[str, Any]],
|
||||
session_id: Optional[str],
|
||||
) -> Session:
|
||||
with self._lock:
|
||||
session_id = session_id or str(uuid.uuid4())
|
||||
|
||||
if self._find_session_node(app_name, user_id, session_id) is not None:
|
||||
raise ValueError(f"Session already exists: {session_id}")
|
||||
|
||||
self.graph.add_node(
|
||||
node_id=self._node_id(app_name, user_id, session_id),
|
||||
node_type="ADKSession",
|
||||
app_name=app_name,
|
||||
user_id=user_id,
|
||||
session_id=session_id,
|
||||
state=copy.deepcopy(state or {}),
|
||||
created_at=datetime.now().isoformat(),
|
||||
updated_at=datetime.now().isoformat(),
|
||||
)
|
||||
|
||||
return Session(
|
||||
**self._session_kwargs(
|
||||
app_name=app_name,
|
||||
user_id=user_id,
|
||||
session_id=session_id,
|
||||
state=state,
|
||||
events=[],
|
||||
)
|
||||
)
|
||||
|
||||
async def get_session(
|
||||
self,
|
||||
*,
|
||||
app_name: str,
|
||||
user_id: str,
|
||||
session_id: str,
|
||||
config: Optional[GetSessionConfig] = None,
|
||||
) -> Optional[Session]:
|
||||
"""Retrieve an ADK session from ContextGraph."""
|
||||
return await asyncio.to_thread(
|
||||
self._get_session_sync, app_name, user_id, session_id, config
|
||||
)
|
||||
|
||||
def _get_session_sync(
|
||||
self,
|
||||
app_name: str,
|
||||
user_id: str,
|
||||
session_id: str,
|
||||
config: Optional[GetSessionConfig],
|
||||
) -> Optional[Session]:
|
||||
with self._lock:
|
||||
node = self._find_session_node(app_name, user_id, session_id)
|
||||
|
||||
if node is None:
|
||||
return None
|
||||
|
||||
properties = self._node_properties(node)
|
||||
if properties.get("app_name") != app_name:
|
||||
return None
|
||||
if properties.get("user_id") != user_id:
|
||||
return None
|
||||
|
||||
session = self._session_from_node(node)
|
||||
|
||||
# Bound the returned event history the same way ADK's own
|
||||
# InMemorySessionService does, outside the lock since it only
|
||||
# trims the already-built Session object.
|
||||
if config:
|
||||
if config.num_recent_events:
|
||||
session.events = session.events[-config.num_recent_events:]
|
||||
if config.after_timestamp:
|
||||
i = len(session.events) - 1
|
||||
while i >= 0:
|
||||
if session.events[i].timestamp < config.after_timestamp:
|
||||
break
|
||||
i -= 1
|
||||
if i >= 0:
|
||||
session.events = session.events[i + 1:]
|
||||
|
||||
return session
|
||||
|
||||
async def append_event(
|
||||
self,
|
||||
session: Session,
|
||||
event: Event,
|
||||
) -> Event:
|
||||
"""Persist an ADK event and associate it with a session."""
|
||||
# ADK's own base implementation is a no-op for partial/streaming
|
||||
# events (it returns before touching session.events or state), so
|
||||
# a graph-backed session must not persist them either -- otherwise
|
||||
# every intermediate chunk of a streamed response becomes a
|
||||
# permanent event node.
|
||||
if getattr(event, "partial", False):
|
||||
return event
|
||||
|
||||
await asyncio.to_thread(self._append_event_sync, session, event)
|
||||
return event
|
||||
|
||||
def _append_event_sync(self, session: Session, event: Event) -> None:
|
||||
with self._lock:
|
||||
session_id = str(session.id)
|
||||
app_name = str(session.app_name)
|
||||
user_id = str(session.user_id)
|
||||
|
||||
session_node = self._find_session_node(app_name, user_id, session_id)
|
||||
|
||||
if session_node is None:
|
||||
raise ValueError(f"Session does not exist: {session_id}")
|
||||
|
||||
# Verify cross-tenant security
|
||||
properties = self._node_properties(session_node)
|
||||
if properties.get("app_name") != app_name or properties.get("user_id") != user_id:
|
||||
raise ValueError("Cross-tenant session write denied: app_name or user_id mismatch.")
|
||||
|
||||
# Apply ADK in-memory event and state delta semantics. This
|
||||
# runs inside asyncio.to_thread's worker thread, which has no
|
||||
# event loop of its own, so a coroutine base implementation is
|
||||
# driven with a private event loop scoped to this one call.
|
||||
base_append = getattr(super(), "append_event", None)
|
||||
if base_append is not None:
|
||||
if inspect.iscoroutinefunction(base_append):
|
||||
asyncio.run(base_append(session, event))
|
||||
else:
|
||||
base_append(session, event)
|
||||
else:
|
||||
if hasattr(session, "events"):
|
||||
session.events.append(event)
|
||||
|
||||
event_node_id = self._event_node_id(event)
|
||||
event_data = self._serialize_event(event)
|
||||
|
||||
self.graph.add_node(
|
||||
node_id=event_node_id,
|
||||
node_type="ADKEvent",
|
||||
session_id=session_id,
|
||||
**event_data,
|
||||
)
|
||||
|
||||
self.graph.add_edge(
|
||||
source_id=self._node_id(app_name, user_id, session_id),
|
||||
target_id=event_node_id,
|
||||
edge_type="HAS_EVENT",
|
||||
)
|
||||
|
||||
# ContextGraph's supported mutation API is add_node_attribute().
|
||||
self.graph.add_node_attribute(
|
||||
self._node_id(app_name, user_id, session_id),
|
||||
{
|
||||
"state": self._safe_dict(getattr(session, "state", {})),
|
||||
"updated_at": (datetime.now().isoformat()),
|
||||
},
|
||||
)
|
||||
|
||||
async def delete_session(
|
||||
self,
|
||||
*,
|
||||
app_name: str,
|
||||
user_id: str,
|
||||
session_id: str,
|
||||
) -> None:
|
||||
"""Delete a session and all of its graph-backed events."""
|
||||
await asyncio.to_thread(
|
||||
self._delete_session_sync, app_name, user_id, session_id
|
||||
)
|
||||
|
||||
def _delete_session_sync(
|
||||
self,
|
||||
app_name: str,
|
||||
user_id: str,
|
||||
session_id: str,
|
||||
) -> None:
|
||||
with self._lock:
|
||||
session_node = self._find_session_node(app_name, user_id, session_id)
|
||||
if session_node is None:
|
||||
return
|
||||
|
||||
properties = self._node_properties(session_node)
|
||||
|
||||
if properties.get("app_name") != app_name:
|
||||
return
|
||||
if properties.get("user_id") != user_id:
|
||||
return
|
||||
|
||||
session_node_id = self._node_id(app_name, user_id, session_id)
|
||||
event_node_ids = []
|
||||
|
||||
for edge in self.graph.find_edges() or []:
|
||||
if not isinstance(edge, dict):
|
||||
continue
|
||||
|
||||
if (
|
||||
edge.get("source") == session_node_id
|
||||
and edge.get("type") == "HAS_EVENT"
|
||||
and edge.get("target")
|
||||
):
|
||||
event_node_ids.append(str(edge["target"]))
|
||||
|
||||
for event_node_id in event_node_ids:
|
||||
self.graph.purge_node(event_node_id)
|
||||
|
||||
self.graph.purge_node(session_node_id)
|
||||
|
||||
async def list_sessions(
|
||||
self,
|
||||
*,
|
||||
app_name: str,
|
||||
user_id: Optional[str] = None,
|
||||
) -> ListSessionsResponse:
|
||||
"""List sessions for an app, optionally scoped to one user."""
|
||||
return await asyncio.to_thread(self._list_sessions_sync, app_name, user_id)
|
||||
|
||||
def _list_sessions_sync(
|
||||
self,
|
||||
app_name: str,
|
||||
user_id: Optional[str],
|
||||
) -> ListSessionsResponse:
|
||||
with self._lock:
|
||||
sessions: List[Session] = []
|
||||
|
||||
for node in self.graph.find_nodes(node_type="ADKSession") or []:
|
||||
if not isinstance(node, dict):
|
||||
continue
|
||||
|
||||
properties = self._node_properties(node)
|
||||
|
||||
if properties.get("app_name") != app_name:
|
||||
continue
|
||||
if user_id is not None and properties.get("user_id") != user_id:
|
||||
continue
|
||||
if not properties.get("session_id"):
|
||||
continue
|
||||
|
||||
sessions.append(self._session_from_node(node))
|
||||
|
||||
# Return the wrapped ListSessionsResponse
|
||||
if ListSessionsResponse is not Any and ListSessionsResponse is not object:
|
||||
return ListSessionsResponse(sessions=sessions)
|
||||
|
||||
return sessions
|
||||
|
||||
|
||||
__all__ = [
|
||||
"ADK_AVAILABLE",
|
||||
"SemanticaSessionService",
|
||||
]
|
||||
@@ -0,0 +1,5 @@
|
||||
"""Entry point: python -m mcp.server"""
|
||||
from mcp.server import main
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -10,8 +10,8 @@ from __future__ import annotations
|
||||
import json
|
||||
import logging
|
||||
|
||||
from .. import __version__
|
||||
from ..session import get_graph
|
||||
from mcp import __version__
|
||||
from mcp.session import get_graph
|
||||
|
||||
log = logging.getLogger("semantica.mcp.resources")
|
||||
|
||||
@@ -6,8 +6,8 @@ Implements the Model Context Protocol so any MCP-compatible AI tool
|
||||
can interact with the Semantica knowledge graph.
|
||||
|
||||
Run:
|
||||
python -m semantica_mcp.mcp # via __main__.py
|
||||
python -m semantica_mcp.mcp.server # direct
|
||||
python -m mcp # via __main__.py
|
||||
python -m mcp.server # direct
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -17,9 +17,9 @@ import logging
|
||||
import sys
|
||||
from typing import Any
|
||||
|
||||
from . import __version__
|
||||
from .resources import RESOURCE_DEFINITIONS, handle_resource_read
|
||||
from .tools import TOOL_DEFINITIONS
|
||||
from mcp import __version__
|
||||
from mcp.resources import RESOURCE_DEFINITIONS, handle_resource_read
|
||||
from mcp.tools import TOOL_DEFINITIONS
|
||||
|
||||
log = logging.getLogger("semantica.mcp.server")
|
||||
|
||||
@@ -7,14 +7,14 @@ from __future__ import annotations
|
||||
import logging
|
||||
import os
|
||||
|
||||
from ..schemas import (
|
||||
from mcp.schemas import (
|
||||
ANALYZE_DECISION_IMPACT,
|
||||
FIND_PRECEDENTS,
|
||||
GET_CAUSAL_CHAIN,
|
||||
QUERY_DECISIONS,
|
||||
RECORD_DECISION,
|
||||
)
|
||||
from ..session import get_graph, is_persistence_safe
|
||||
from mcp.session import get_graph, is_persistence_safe
|
||||
|
||||
log = logging.getLogger("semantica.mcp.tools.decisions")
|
||||
|
||||
@@ -6,8 +6,8 @@ from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
from ..schemas import EXPORT_GRAPH, GET_PROVENANCE
|
||||
from ..session import get_graph
|
||||
from mcp.schemas import EXPORT_GRAPH, GET_PROVENANCE
|
||||
from mcp.session import get_graph
|
||||
|
||||
log = logging.getLogger("semantica.mcp.tools.export")
|
||||
|
||||
@@ -7,7 +7,7 @@ from __future__ import annotations
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from ..schemas import EXTRACT_ALL, EXTRACT_ENTITIES, EXTRACT_RELATIONS
|
||||
from mcp.schemas import EXTRACT_ALL, EXTRACT_ENTITIES, EXTRACT_RELATIONS
|
||||
|
||||
log = logging.getLogger("semantica.mcp.tools.extraction")
|
||||
|
||||
@@ -7,8 +7,8 @@ from __future__ import annotations
|
||||
import logging
|
||||
import os
|
||||
|
||||
from ..schemas import ADD_ENTITY, ADD_RELATIONSHIP, EMPTY, GET_ANALYTICS, SEARCH_GRAPH
|
||||
from ..session import get_graph, is_persistence_safe
|
||||
from mcp.schemas import ADD_ENTITY, ADD_RELATIONSHIP, EMPTY, GET_ANALYTICS, SEARCH_GRAPH
|
||||
from mcp.session import get_graph, is_persistence_safe
|
||||
|
||||
log = logging.getLogger("semantica.mcp.tools.graph")
|
||||
|
||||
@@ -6,7 +6,7 @@ from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
from ..schemas import ABDUCTIVE_REASONING, RUN_REASONING
|
||||
from mcp.schemas import ABDUCTIVE_REASONING, RUN_REASONING
|
||||
|
||||
log = logging.getLogger("semantica.mcp.tools.reasoning")
|
||||
|
||||
+11
-42
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "semantica"
|
||||
version = "0.6.8"
|
||||
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" }
|
||||
@@ -47,11 +47,7 @@ dependencies = [
|
||||
"numpy>=2.0.2",
|
||||
"pandas>=1.3.0",
|
||||
"scipy>=1.13.1",
|
||||
# scikit-learn dropped Python 3.9 support at 1.7.0 (requires_python >=3.10),
|
||||
# so an unqualified >=1.7.2 floor is unsatisfiable on 3.9. Cap 3.9 to the
|
||||
# last 3.9-compatible release line; 3.10+ is left unconstrained.
|
||||
"scikit-learn>=1.6.1,<1.7.0; python_version < '3.10'",
|
||||
"scikit-learn>=1.7.2; python_version >= '3.10'",
|
||||
"scikit-learn>=1.7.2",
|
||||
"umap-learn>=0.5.12",
|
||||
# thinc (spacy's core dep) dropped Python 3.9 wheels at 8.3.10, and later
|
||||
# spacy patch releases (3.8.8+) require thinc>=8.3.9-only-on-3.10+ ranges,
|
||||
@@ -70,49 +66,24 @@ dependencies = [
|
||||
"seaborn>=0.13.2",
|
||||
"plotly>=6.8.0",
|
||||
"ipywidgets>=8.0.0",
|
||||
# requests dropped Python 3.9 support at 2.33.0 (requires_python >=3.10),
|
||||
# so an unqualified >=2.34.2 floor is unsatisfiable on 3.9. Cap 3.9 to the
|
||||
# last 3.9-compatible release; 3.10+ is left unconstrained.
|
||||
"requests>=2.32.5,<2.33.0; python_version < '3.10'",
|
||||
"requests>=2.34.2; python_version >= '3.10'",
|
||||
"requests>=2.34.2",
|
||||
"GitPython>=3.1.58",
|
||||
# chardet dropped Python 3.9 support at 6.0.0 (requires_python >=3.10), so
|
||||
# an unqualified >=7.4.3 floor is unsatisfiable on 3.9. Cap 3.9 to the last
|
||||
# 3.9-compatible release; 3.10+ is left unconstrained.
|
||||
"chardet>=5.2.0,<6.0.0; python_version < '3.10'",
|
||||
"chardet>=7.4.3; python_version >= '3.10'",
|
||||
"chardet>=7.4.3",
|
||||
"protobuf>=5.29.1,<8.0",
|
||||
# grpcio dropped Python 3.9 support at 1.81.0 (requires_python >=3.10), so
|
||||
# an unqualified >=1.81.1 floor is unsatisfiable on 3.9. Cap 3.9 to the last
|
||||
# 3.9-compatible release; 3.10+ is left unconstrained.
|
||||
"grpcio>=1.80.0,<1.81.0; python_version < '3.10'",
|
||||
"grpcio>=1.81.1; python_version >= '3.10'",
|
||||
"grpcio>=1.81.1",
|
||||
"beautifulsoup4>=4.15.0",
|
||||
"lxml>=6.1.1",
|
||||
"python-docx>=1.2.0",
|
||||
"openpyxl>=3.1.5",
|
||||
# pillow dropped Python 3.9 support at 12.0.0 (requires_python >=3.10), so
|
||||
# an unqualified >=12.2.0 floor is unsatisfiable on 3.9. Cap 3.9 to the last
|
||||
# 3.9-compatible release; 3.10+ is left unconstrained.
|
||||
"pillow>=11.3.0,<12.0.0; python_version < '3.10'",
|
||||
"pillow>=12.2.0; python_version >= '3.10'",
|
||||
"pillow>=12.2.0",
|
||||
"librosa>=0.9.0",
|
||||
"opencv-python>=4.13.0.92",
|
||||
"faiss-cpu>=1.7.0",
|
||||
"fastembed>=0.2.0",
|
||||
# onnxruntime stopped shipping cp39 wheels at 1.20.0 (its PyPI metadata
|
||||
# still claims requires_python >=3.9, but no matching wheel exists), so an
|
||||
# unqualified >=1.20.1 floor is unsatisfiable on 3.9. Cap 3.9 to the last
|
||||
# release with a cp39 wheel; 3.10+ is left unconstrained.
|
||||
"onnxruntime>=1.19.2,<1.20.0; python_version < '3.10'",
|
||||
"onnxruntime>=1.20.1; python_version >= '3.10'",
|
||||
"onnxruntime>=1.20.1",
|
||||
"tokenizers>=0.15.0",
|
||||
"pydantic>=2.13.4",
|
||||
# click dropped Python 3.9 support at 8.2.0 (requires_python >=3.10), so an
|
||||
# unqualified >=8.4.2 floor is unsatisfiable on 3.9. Cap 3.9 to the last
|
||||
# 3.9-compatible release; 3.10+ is left unconstrained.
|
||||
"click>=8.1.8,<8.2.0; python_version < '3.10'",
|
||||
"click>=8.4.2; python_version >= '3.10'",
|
||||
"click>=8.4.2",
|
||||
"rich>=12.5.0",
|
||||
"tqdm>=4.68.3",
|
||||
"pyyaml>=6.0",
|
||||
@@ -192,7 +163,7 @@ tripletstore-oxigraph = ["pyoxigraph>=0.5.0"]
|
||||
# ---- Vector Store Backends ----
|
||||
vectorstore-qdrant = ["qdrant-client>=1.0.0"]
|
||||
vectorstore-weaviate = ["weaviate-client>=4.0.0"]
|
||||
vectorstore-pinecone = ["pinecone>=3.0.0"]
|
||||
vectorstore-pinecone = ["pinecone-client>=3.0.0"]
|
||||
vectorstore-milvus = ["pymilvus>=2.0.0"]
|
||||
vectorstore-pgvector = ["psycopg[binary,pool]>=3.0.0", "pgvector>=0.2.0"]
|
||||
vectorstore-sqlite = ["sqlite-vec>=0.1.1"]
|
||||
@@ -246,7 +217,6 @@ agno = ["agno>=1.0.0"]
|
||||
# duplicate the prebuilt tooling users can install separately.
|
||||
crewai = ["crewai>=0.80.0"]
|
||||
langchain = ["langchain-core>=0.3.0"]
|
||||
google-adk = ["google-adk>=1.27.0; python_version >= '3.10'"]
|
||||
|
||||
# ---- File Watching ----
|
||||
watch = ["watchdog>=6.0.0"]
|
||||
@@ -277,7 +247,6 @@ dev = [
|
||||
# Explorer Dashboard
|
||||
explorer = [
|
||||
"fastapi>=0.109.2",
|
||||
"starlette>=0.53.0",
|
||||
"uvicorn[standard]>=0.22.0",
|
||||
"websockets>=15.0.1",
|
||||
"python-multipart>=0.0.7",
|
||||
@@ -295,7 +264,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,langchain,google-adk]"
|
||||
"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 ----------------
|
||||
@@ -309,7 +278,7 @@ semantica-mcp = "semantica.mcp_server:main"
|
||||
# ---------------- TOOLING ----------------
|
||||
[tool.setuptools.packages.find]
|
||||
where = ["."]
|
||||
include = ["semantica*", "integrations*", "semantica_mcp*"]
|
||||
include = ["semantica*", "integrations*"]
|
||||
|
||||
[tool.setuptools.package-data]
|
||||
# Explicit patterns are more reliable than **/* across setuptools versions.
|
||||
|
||||
+1860
-2178
File diff suppressed because it is too large
Load Diff
@@ -10,7 +10,7 @@ Main exports:
|
||||
- Config: Configuration management
|
||||
"""
|
||||
|
||||
__version__ = "0.6.8"
|
||||
__version__ = "0.6.7"
|
||||
__author__ = "Semantica Contributors"
|
||||
__license__ = "MIT"
|
||||
|
||||
|
||||
+3
-3
@@ -4571,7 +4571,7 @@ def mcp_start(cli_ctx: CLIContext, transport: str, port: int) -> None:
|
||||
|
||||
def _action() -> None:
|
||||
import subprocess as sp
|
||||
cmd = [sys.executable, "-m", "semantica_mcp.mcp.server"]
|
||||
cmd = [sys.executable, "-m", "mcp.server"]
|
||||
if transport == "http":
|
||||
cmd += ["--port", str(port)]
|
||||
proc = sp.Popen(cmd)
|
||||
@@ -4614,7 +4614,7 @@ def mcp_list_tools(cli_ctx: CLIContext, local_json: bool) -> None:
|
||||
|
||||
def _action() -> None:
|
||||
try:
|
||||
from semantica_mcp.mcp.tools import __all__ as tools
|
||||
from mcp.tools import __all__ as tools
|
||||
except ImportError:
|
||||
tools = [
|
||||
"extract_entities", "extract_relations", "build_graph",
|
||||
@@ -4655,7 +4655,7 @@ def mcp_call(cli_ctx: CLIContext, tool_name: str, args: str, local_json: bool) -
|
||||
except json.JSONDecodeError as exc:
|
||||
raise click.ClickException(f"Invalid JSON in --args: {exc}") from exc
|
||||
try:
|
||||
from semantica_mcp.mcp.session import MCPSession
|
||||
from mcp.session import MCPSession
|
||||
session = MCPSession(config=cli_ctx.config.to_dict())
|
||||
result = session.call_tool(tool_name, **tool_args)
|
||||
except ImportError as exc:
|
||||
|
||||
@@ -65,11 +65,9 @@ import os
|
||||
import re
|
||||
import stat
|
||||
import tempfile
|
||||
import threading
|
||||
from collections import deque
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import date, datetime, timedelta, timezone
|
||||
from functools import wraps
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List, Optional, Tuple, Union
|
||||
|
||||
@@ -80,12 +78,6 @@ from ..utils.logging import get_logger
|
||||
from ..utils.progress_tracker import get_progress_tracker
|
||||
from ..utils.types import EntityDict, RelationshipDict
|
||||
from ._markdown_filesystem import find_filesystem_link
|
||||
from .markdown import (
|
||||
MarkdownIdentityError,
|
||||
MarkdownResourceNotFoundError,
|
||||
MarkdownRevisionConflictError,
|
||||
markdown_document_revision,
|
||||
)
|
||||
|
||||
|
||||
class _UniqueKeySafeLoader(yaml.SafeLoader):
|
||||
@@ -166,17 +158,6 @@ class MemoryItem:
|
||||
)
|
||||
|
||||
|
||||
def _with_memory_lock(method):
|
||||
"""Serialize AgentMemory state mutations and Markdown revision checks."""
|
||||
|
||||
@wraps(method)
|
||||
def locked(self, *args, **kwargs):
|
||||
with self._memory_lock:
|
||||
return method(self, *args, **kwargs)
|
||||
|
||||
return locked
|
||||
|
||||
|
||||
class AgentMemory:
|
||||
"""
|
||||
Agent memory manager with RAG integration and Hierarchical Memory.
|
||||
@@ -216,7 +197,6 @@ class AgentMemory:
|
||||
self.logger = get_logger("agent_memory")
|
||||
self.config = config or {}
|
||||
self.config.update(kwargs)
|
||||
self._memory_lock = threading.RLock()
|
||||
|
||||
self.vector_store = self.config.get("vector_store")
|
||||
self.knowledge_graph = self.config.get("knowledge_graph")
|
||||
@@ -269,7 +249,6 @@ class AgentMemory:
|
||||
|
||||
self.logger.info(f"Saved agent memory to {path}")
|
||||
|
||||
@_with_memory_lock
|
||||
def load(self, path: str) -> None:
|
||||
"""
|
||||
Load memory state from disk.
|
||||
@@ -319,7 +298,6 @@ class AgentMemory:
|
||||
|
||||
self.logger.info(f"Loaded agent memory from {path}")
|
||||
|
||||
@_with_memory_lock
|
||||
def store(
|
||||
self,
|
||||
content: str,
|
||||
@@ -596,7 +574,6 @@ class AgentMemory:
|
||||
"relationships": memory_item.relationships,
|
||||
}
|
||||
|
||||
@_with_memory_lock
|
||||
def delete_memory(self, memory_id: str, *, skip_vector: bool = False) -> bool:
|
||||
"""
|
||||
Delete memory item.
|
||||
@@ -648,7 +625,6 @@ class AgentMemory:
|
||||
self.logger.debug(f"Deleted memory item: {memory_id}")
|
||||
return True
|
||||
|
||||
@_with_memory_lock
|
||||
def vector_ids_for(self, memory_id: str) -> List[str]:
|
||||
"""Return the vector-store ids owned by a memory item.
|
||||
|
||||
@@ -1160,7 +1136,6 @@ class AgentMemory:
|
||||
"""
|
||||
return self.get_memory(memory_id)
|
||||
|
||||
@_with_memory_lock
|
||||
def update(
|
||||
self,
|
||||
memory_id: str,
|
||||
@@ -1441,13 +1416,6 @@ class AgentMemory:
|
||||
|
||||
return results
|
||||
|
||||
@_with_memory_lock
|
||||
def list_snapshot(
|
||||
self, *, limit: int = 100, offset: int = 0
|
||||
) -> Tuple[List[Dict[str, Any]], int]:
|
||||
"""Return one memory page and its total from the same locked state."""
|
||||
return self.list(limit=limit, offset=offset), len(self.memory_items)
|
||||
|
||||
def get_by_conversation(
|
||||
self, conversation_id: str, limit: int = 100
|
||||
) -> List[Dict[str, Any]]:
|
||||
@@ -1663,88 +1631,6 @@ class AgentMemory:
|
||||
updated += 1
|
||||
return updated
|
||||
|
||||
@_with_memory_lock
|
||||
def export_item_markdown(self, memory_id: str) -> str:
|
||||
"""Return one existing memory item as canonical Markdown.
|
||||
|
||||
Args:
|
||||
memory_id: Stable identifier of the memory item to export.
|
||||
|
||||
Returns:
|
||||
Canonical Markdown containing the memory frontmatter and body.
|
||||
|
||||
Raises:
|
||||
MarkdownResourceNotFoundError: If ``memory_id`` does not exist.
|
||||
"""
|
||||
memory = self.get(memory_id)
|
||||
if memory is None:
|
||||
raise MarkdownResourceNotFoundError(
|
||||
f"AgentMemory item {memory_id!r} was not found."
|
||||
)
|
||||
return self._memory_to_markdown(memory)
|
||||
|
||||
@_with_memory_lock
|
||||
def apply_item_markdown(
|
||||
self,
|
||||
memory_id: str,
|
||||
document: str,
|
||||
*,
|
||||
expected_revision: Optional[str] = None,
|
||||
) -> bool:
|
||||
"""Validate and atomically replace one existing memory item.
|
||||
|
||||
Args:
|
||||
memory_id: Stable identifier of the memory item to update.
|
||||
document: Canonical Markdown containing the replacement item.
|
||||
expected_revision: Optional revision returned by
|
||||
:meth:`export_item_markdown`. A mismatch rejects stale edits.
|
||||
|
||||
Returns:
|
||||
``True`` when the item changed, otherwise ``False``.
|
||||
|
||||
Raises:
|
||||
ValueError: If the Markdown or frontmatter is invalid.
|
||||
MarkdownIdentityError: If the frontmatter changes the memory ID.
|
||||
MarkdownResourceNotFoundError: If ``memory_id`` does not exist.
|
||||
MarkdownRevisionConflictError: If ``expected_revision`` is stale.
|
||||
RuntimeError: If the validated item cannot be persisted.
|
||||
"""
|
||||
memory = self._markdown_to_memory_dict(document, source=f"memory {memory_id!r}")
|
||||
document_id = memory["memory_id"]
|
||||
if document_id != memory_id:
|
||||
raise MarkdownIdentityError(
|
||||
f"Frontmatter id {document_id!r} does not match resource id "
|
||||
f"{memory_id!r}."
|
||||
)
|
||||
if not self.exists(memory_id):
|
||||
raise MarkdownResourceNotFoundError(
|
||||
f"AgentMemory item {memory_id!r} was not found."
|
||||
)
|
||||
if expected_revision is not None:
|
||||
# _memory_lock is an RLock; this re-entrant call into
|
||||
# export_item_markdown (also @_with_memory_lock) is intentional
|
||||
# and safe because RLock allows the same thread to re-acquire.
|
||||
current_revision = markdown_document_revision(
|
||||
self.export_item_markdown(memory_id)
|
||||
)
|
||||
if current_revision != expected_revision:
|
||||
raise MarkdownRevisionConflictError(current_revision)
|
||||
if self._markdown_record_matches(memory_id, memory):
|
||||
return False
|
||||
|
||||
success = self._replace_memory_item(
|
||||
memory_id,
|
||||
memory["content"],
|
||||
metadata=memory["metadata"],
|
||||
entities=memory["entities"],
|
||||
relationships=memory["relationships"],
|
||||
timestamp=memory["timestamp"],
|
||||
skip_graph=True,
|
||||
)
|
||||
if not success:
|
||||
raise RuntimeError(f"AgentMemory item {memory_id!r} could not be replaced.")
|
||||
return True
|
||||
|
||||
# Export/Import
|
||||
def export(
|
||||
self,
|
||||
@@ -1793,7 +1679,6 @@ class AgentMemory:
|
||||
return self._export_markdown(memories, destination=destination)
|
||||
return export_data
|
||||
|
||||
@_with_memory_lock
|
||||
def import_data(
|
||||
self, data: Union[str, Path, Dict[str, Any]], format: str = "json"
|
||||
) -> int:
|
||||
|
||||
@@ -131,12 +131,6 @@ from ..utils.progress_tracker import get_progress_tracker
|
||||
from ..utils.skos import is_skos_hierarchy_edge, validate_skos_hierarchy
|
||||
from ._markdown_filesystem import find_filesystem_link
|
||||
from .entity_linker import EntityLinker
|
||||
from .markdown import (
|
||||
MarkdownIdentityError,
|
||||
MarkdownResourceNotFoundError,
|
||||
MarkdownRevisionConflictError,
|
||||
markdown_document_revision,
|
||||
)
|
||||
|
||||
|
||||
class _UniqueKeySafeLoader(yaml.SafeLoader):
|
||||
@@ -249,42 +243,6 @@ def _normalize_temporal_input(value: Optional[Union[str, int, float, datetime]])
|
||||
raise ValueError("Temporal values must be datetime, epoch seconds, ISO strings, or None")
|
||||
|
||||
|
||||
def normalize_temporal_input(
|
||||
value: Optional[Union[str, int, float, datetime]]
|
||||
) -> Optional[str]:
|
||||
"""Normalize a temporal value to a tz-naive UTC ISO-8601 string.
|
||||
|
||||
This is the public surface of the normalization logic used throughout
|
||||
:class:`ContextGraph` for retraction, purge, and decision timestamps.
|
||||
Exposing it lets sibling modules (e.g. :mod:`erasure`) share the same
|
||||
normalization without importing the private ``_normalize_temporal_input``.
|
||||
|
||||
Args:
|
||||
value: Any of the following:
|
||||
|
||||
* ``None`` — returned as-is (no timestamp).
|
||||
* :class:`~datetime.datetime` — converted to UTC if tz-aware,
|
||||
then serialized as a tz-naive ISO string
|
||||
(e.g. ``"2026-01-01T07:00:00"``).
|
||||
* :class:`int` or :class:`float` — interpreted as a POSIX epoch
|
||||
seconds value, converted to UTC, serialized as above.
|
||||
* :class:`str` — must be a valid ISO-8601 datetime string;
|
||||
offset-aware values (including ``Z``) are converted to UTC
|
||||
before serialization. Year-only (``"2026"``) and date-only
|
||||
(``"2026-01-15"``) shorthand forms are also accepted.
|
||||
|
||||
Returns:
|
||||
A tz-naive UTC ISO-8601 string (e.g. ``"2026-01-01T12:00:00"``),
|
||||
or ``None`` when *value* is ``None``.
|
||||
|
||||
Raises:
|
||||
ValueError: If *value* is a string that cannot be parsed as an
|
||||
ISO-8601 datetime, or if *value* is a type that is not
|
||||
supported (e.g. a :class:`~datetime.date` object).
|
||||
"""
|
||||
return _normalize_temporal_input(value)
|
||||
|
||||
|
||||
def _closing_valid_until(current: Optional[str], at_iso: str) -> str:
|
||||
"""Return the earlier of an existing end bound and a retraction time.
|
||||
|
||||
@@ -1209,157 +1167,6 @@ class ContextGraph:
|
||||
)
|
||||
)
|
||||
|
||||
def export_node_markdown(self, node_id: str) -> str:
|
||||
"""Return one existing node as canonical Markdown.
|
||||
|
||||
Args:
|
||||
node_id: Stable identifier of the node to export.
|
||||
|
||||
Returns:
|
||||
Canonical Markdown containing the node frontmatter and body.
|
||||
|
||||
Raises:
|
||||
MarkdownResourceNotFoundError: If ``node_id`` does not exist.
|
||||
"""
|
||||
with self._lock:
|
||||
node = self.nodes.get(node_id)
|
||||
if node is None:
|
||||
raise MarkdownResourceNotFoundError(
|
||||
f"ContextGraph node {node_id!r} was not found."
|
||||
)
|
||||
return self._node_markdown_source(node)
|
||||
|
||||
def _node_markdown_source(self, node: ContextNode) -> str:
|
||||
frontmatter = {
|
||||
"id": node.node_id,
|
||||
"type": node.node_type,
|
||||
"properties": copy.deepcopy(node.properties),
|
||||
"metadata": copy.deepcopy(node.metadata),
|
||||
"valid_from": node.valid_from,
|
||||
"valid_until": node.valid_until,
|
||||
}
|
||||
return self._render_markdown_document(
|
||||
frontmatter, node.content, f"node {node.node_id!r}"
|
||||
)
|
||||
|
||||
def apply_node_markdown(
|
||||
self,
|
||||
node_id: str,
|
||||
document: str,
|
||||
*,
|
||||
expected_revision: Optional[str] = None,
|
||||
) -> bool:
|
||||
"""Validate and atomically replace one existing node.
|
||||
|
||||
Args:
|
||||
node_id: Stable identifier of the node to update.
|
||||
document: Canonical Markdown containing the replacement node.
|
||||
expected_revision: Optional revision returned by
|
||||
:meth:`export_node_markdown`. A mismatch rejects stale edits.
|
||||
|
||||
Returns:
|
||||
``True`` when the node changed, otherwise ``False``.
|
||||
|
||||
Raises:
|
||||
ValueError: If the Markdown or frontmatter is invalid.
|
||||
MarkdownIdentityError: If the frontmatter changes the node ID.
|
||||
MarkdownResourceNotFoundError: If ``node_id`` does not exist.
|
||||
MarkdownRevisionConflictError: If ``expected_revision`` is stale.
|
||||
"""
|
||||
source = f"node {node_id!r}"
|
||||
frontmatter, body = self._parse_markdown_document(document, source)
|
||||
candidate = self._parse_markdown_node(frontmatter, body, source)
|
||||
if candidate.node_id != node_id:
|
||||
raise MarkdownIdentityError(
|
||||
f"Frontmatter id {candidate.node_id!r} does not match resource id "
|
||||
f"{node_id!r}."
|
||||
)
|
||||
|
||||
with self._lock:
|
||||
existing = self.nodes.get(node_id)
|
||||
if existing is None:
|
||||
raise MarkdownResourceNotFoundError(
|
||||
f"ContextGraph node {node_id!r} was not found."
|
||||
)
|
||||
if expected_revision is not None:
|
||||
current_revision = markdown_document_revision(
|
||||
self._node_markdown_source(existing)
|
||||
)
|
||||
if current_revision != expected_revision:
|
||||
raise MarkdownRevisionConflictError(current_revision)
|
||||
if existing == candidate:
|
||||
return False
|
||||
|
||||
# Decision index rebuilding can still reject YAML-valid property
|
||||
# shapes, so retain every affected structure until commit succeeds.
|
||||
decision_state_before = {}
|
||||
if (
|
||||
existing.node_type.lower() == "decision"
|
||||
or candidate.node_type.lower() == "decision"
|
||||
):
|
||||
for attribute in (
|
||||
"_decisions",
|
||||
"_decision_index",
|
||||
"_entity_index",
|
||||
"_temporal_index",
|
||||
):
|
||||
if not hasattr(self, attribute):
|
||||
decision_state_before[attribute] = None
|
||||
elif attribute in {"_decision_index", "_entity_index"}:
|
||||
decision_state_before[attribute] = {
|
||||
key: set(values)
|
||||
for key, values in getattr(self, attribute).items()
|
||||
}
|
||||
elif attribute == "_temporal_index":
|
||||
decision_state_before[attribute] = list(
|
||||
getattr(self, attribute)
|
||||
)
|
||||
else:
|
||||
decision_state_before[attribute] = dict(
|
||||
getattr(self, attribute)
|
||||
)
|
||||
|
||||
old_type = existing.node_type
|
||||
try:
|
||||
old_bucket = self.node_type_index.get(old_type)
|
||||
if old_bucket is not None:
|
||||
old_bucket.discard(node_id)
|
||||
if not old_bucket:
|
||||
del self.node_type_index[old_type]
|
||||
|
||||
self.nodes[node_id] = candidate
|
||||
self.node_type_index[candidate.node_type].add(node_id)
|
||||
if (
|
||||
old_type.lower() == "decision"
|
||||
or candidate.node_type.lower() == "decision"
|
||||
):
|
||||
self._sync_decision_from_node(node_id)
|
||||
payload = candidate.to_dict()
|
||||
self._analytics_cache.clear()
|
||||
except Exception:
|
||||
self.nodes[node_id] = existing
|
||||
candidate_bucket = self.node_type_index.get(candidate.node_type)
|
||||
if candidate_bucket is not None:
|
||||
candidate_bucket.discard(node_id)
|
||||
if not candidate_bucket:
|
||||
del self.node_type_index[candidate.node_type]
|
||||
self.node_type_index[old_type].add(node_id)
|
||||
for attribute, state in decision_state_before.items():
|
||||
if state is None:
|
||||
if hasattr(self, attribute):
|
||||
delattr(self, attribute)
|
||||
else:
|
||||
restored = (
|
||||
defaultdict(set, state)
|
||||
if attribute in {"_decision_index", "_entity_index"}
|
||||
else state
|
||||
)
|
||||
setattr(self, attribute, restored)
|
||||
raise
|
||||
|
||||
self._emit_mutation("UPDATE_NODE", node_id, payload)
|
||||
return True
|
||||
|
||||
def save_to_file(
|
||||
self, path: Union[str, Path], format: str = "json"
|
||||
) -> None:
|
||||
@@ -5140,42 +4947,32 @@ class ContextGraph:
|
||||
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 and after
|
||||
``apply_node_markdown`` replaces a node whose old or new type is
|
||||
``"decision"``, so that ``_decisions`` and the derived indexes stay
|
||||
consistent without requiring a full rebuild of all decisions.
|
||||
|
||||
Temporal index cleanup (``_temporal_index``) runs unconditionally
|
||||
before the node-type guard so that stale entries are removed even when
|
||||
transitioning a decision node to a non-decision type. Callers are
|
||||
expected to ensure this is only invoked when at least one of the
|
||||
current or previous node types is ``"decision"``; callers that bypass
|
||||
that invariant will have ``node_id`` silently removed from
|
||||
``_temporal_index`` even if it was never a decision node.
|
||||
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 before deciding whether the current node
|
||||
# still belongs in the decision indexes.
|
||||
old = self._decisions.pop(node_id, None)
|
||||
# Remove stale index entries for this decision ID.
|
||||
old = self._decisions.get(node_id)
|
||||
if old:
|
||||
old_cat = old.get("category", "")
|
||||
if old_cat:
|
||||
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
|
||||
]
|
||||
|
||||
if node is None:
|
||||
return
|
||||
if (getattr(node, "node_type", None) or "").lower() != "decision":
|
||||
return
|
||||
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] = {}
|
||||
|
||||
@@ -40,7 +40,7 @@ from datetime import datetime, timezone
|
||||
from typing import Any, Dict, Iterable, List, Optional, Sequence, Tuple, Union
|
||||
|
||||
from ..utils.logging import get_logger
|
||||
from .context_graph import normalize_temporal_input
|
||||
from .context_graph import _normalize_temporal_input
|
||||
|
||||
__all__ = [
|
||||
"ErasureCoordinator",
|
||||
@@ -593,7 +593,7 @@ def _normalize_timestamp(at: Optional[Union[str, int, float, datetime]]) -> str:
|
||||
default path gets one timestamp for both records instead of two ``now()``
|
||||
calls separated by the length of the cascade.
|
||||
"""
|
||||
return normalize_temporal_input(
|
||||
return _normalize_temporal_input(
|
||||
at if at is not None else datetime.now(timezone.utc)
|
||||
)
|
||||
|
||||
|
||||
@@ -1,25 +0,0 @@
|
||||
"""Shared revision helpers and errors for single-resource Markdown operations."""
|
||||
|
||||
import hashlib
|
||||
|
||||
|
||||
class MarkdownResourceNotFoundError(KeyError):
|
||||
"""Raised when a Markdown operation targets a missing resource."""
|
||||
|
||||
|
||||
class MarkdownIdentityError(ValueError):
|
||||
"""Raised when frontmatter changes a resource's stable identity."""
|
||||
|
||||
|
||||
class MarkdownRevisionConflictError(ValueError):
|
||||
"""Raised when a resource changed after an edit session began."""
|
||||
|
||||
def __init__(self, current_revision: str) -> None:
|
||||
super().__init__("Markdown resource revision does not match.")
|
||||
self.current_revision = current_revision
|
||||
|
||||
|
||||
def markdown_document_revision(source: str) -> str:
|
||||
"""Return the stable revision token for a canonical Markdown document."""
|
||||
digest = hashlib.sha256(source.encode("utf-8")).hexdigest()
|
||||
return f"sha256:{digest}"
|
||||
+74
-30
@@ -8,19 +8,16 @@ from contextlib import asynccontextmanager
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import Depends, FastAPI, HTTPException, Request
|
||||
from fastapi import Depends, FastAPI, HTTPException, Request, WebSocket, WebSocketDisconnect
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.responses import FileResponse, HTMLResponse, JSONResponse
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
|
||||
from .. import __version__
|
||||
from ..context.agent_memory import AgentMemory
|
||||
from ..context.context_graph import ContextGraph
|
||||
from .dependencies import anonymous_access_allowed, get_expected_api_key, require_auth
|
||||
from .markdown_resources import MarkdownResourceRegistry
|
||||
from .runtime import explorer_capabilities, install_mutation_bridge
|
||||
from .dependencies import anonymous_access_allowed, get_expected_api_key, is_valid_api_key, require_auth
|
||||
from .session import GraphSession
|
||||
from .ws import ConnectionManager, install_graph_updates_websocket
|
||||
from .ws import ConnectionManager
|
||||
|
||||
|
||||
def _read_int_env(name: str, default: int) -> int:
|
||||
@@ -56,23 +53,37 @@ def _read_explorer_settings() -> dict:
|
||||
}
|
||||
|
||||
|
||||
def _install_mutation_bridge(app: FastAPI, session: GraphSession) -> None:
|
||||
if getattr(session.graph, "_mutation_bridge_installed", False):
|
||||
return
|
||||
session.graph._mutation_bridge_installed = True
|
||||
previous_callback = getattr(session.graph, "mutation_callback", None)
|
||||
|
||||
def on_mutation(event_type: str, entity_id: str, payload: dict) -> None:
|
||||
session.handle_graph_mutation(event_type, entity_id, payload)
|
||||
if callable(previous_callback):
|
||||
previous_callback(event_type, entity_id, payload)
|
||||
loop = getattr(app.state, "event_loop", None)
|
||||
manager = getattr(app.state, "ws_manager", None)
|
||||
if loop is None or manager is None or loop.is_closed():
|
||||
return
|
||||
message = {
|
||||
"event_type": event_type,
|
||||
"entity_id": entity_id,
|
||||
"payload": payload,
|
||||
}
|
||||
asyncio.run_coroutine_threadsafe(
|
||||
manager.broadcast("graph_mutation", message),
|
||||
loop,
|
||||
)
|
||||
|
||||
session.graph.mutation_callback = on_mutation
|
||||
|
||||
|
||||
def create_app(
|
||||
session: Optional[GraphSession] = None,
|
||||
provenance_storage_path: Optional[str] = None,
|
||||
agent_memory: Optional[AgentMemory] = None,
|
||||
) -> FastAPI:
|
||||
"""Create an Explorer application over live graph and memory objects.
|
||||
|
||||
Args:
|
||||
session: Graph session exposed by the Explorer. A new in-memory graph
|
||||
session is created when omitted.
|
||||
provenance_storage_path: Optional per-app provenance database path.
|
||||
agent_memory: Existing AgentMemory instance to expose in the Memories
|
||||
workspace. The workspace is unavailable when omitted.
|
||||
|
||||
Returns:
|
||||
Configured FastAPI application.
|
||||
"""
|
||||
settings = _read_explorer_settings()
|
||||
prov_path = provenance_storage_path or settings.get("provenance_storage_path")
|
||||
if session is None:
|
||||
@@ -84,7 +95,6 @@ def create_app(
|
||||
active_session = session
|
||||
if prov_path is not None:
|
||||
active_session.set_provenance_storage_path(prov_path)
|
||||
markdown_resources = MarkdownResourceRegistry(active_session.graph, agent_memory)
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
@@ -107,9 +117,7 @@ def create_app(
|
||||
app.state.event_loop = asyncio.get_running_loop()
|
||||
app.state.ws_manager = ConnectionManager()
|
||||
app.state.session = active_session
|
||||
app.state.agent_memory = agent_memory
|
||||
app.state.markdown_resources = markdown_resources
|
||||
install_mutation_bridge(app, active_session)
|
||||
_install_mutation_bridge(app, active_session)
|
||||
yield
|
||||
|
||||
app = FastAPI(
|
||||
@@ -131,7 +139,7 @@ def create_app(
|
||||
CORSMiddleware,
|
||||
allow_origins=settings["allowed_origins"],
|
||||
allow_credentials=_allow_credentials,
|
||||
allow_methods=["GET", "POST", "PUT", "DELETE", "OPTIONS"],
|
||||
allow_methods=["GET", "POST", "DELETE", "OPTIONS"],
|
||||
allow_headers=["Content-Type", "Authorization", "X-API-Key"],
|
||||
max_age=600,
|
||||
)
|
||||
@@ -162,8 +170,6 @@ def create_app(
|
||||
from .routes.enrich import router as enrich_router
|
||||
from .routes.export_import import router as export_import_router
|
||||
from .routes.graph import router as graph_router
|
||||
from .routes.markdown import router as markdown_router
|
||||
from .routes.memories import router as memories_router
|
||||
from .routes.ontology import router as ontology_router
|
||||
from .routes.provenance import router as provenance_router
|
||||
from .routes.sparql import router as sparql_router
|
||||
@@ -177,15 +183,54 @@ def create_app(
|
||||
app.include_router(temporal_router, dependencies=_auth)
|
||||
app.include_router(enrich_router, dependencies=_auth)
|
||||
app.include_router(export_import_router, dependencies=_auth)
|
||||
app.include_router(markdown_router, dependencies=_auth)
|
||||
app.include_router(memories_router, dependencies=_auth)
|
||||
app.include_router(annotations_router, dependencies=_auth)
|
||||
app.include_router(sparql_router, dependencies=_auth)
|
||||
app.include_router(provenance_router, dependencies=_auth)
|
||||
app.include_router(vocabulary_router, dependencies=_auth)
|
||||
app.include_router(ontology_router, dependencies=_auth)
|
||||
|
||||
install_graph_updates_websocket(app, settings["allowed_origins"])
|
||||
_WS_MAX_MESSAGE_BYTES = 64 * 1024 # 64 KB — control messages only
|
||||
|
||||
@app.websocket("/ws/graph-updates")
|
||||
async def websocket_endpoint(websocket: WebSocket):
|
||||
# CORSMiddleware doesn't cover WebSocket handshakes (Starlette's
|
||||
# CORS support only wraps HTTP), so under SEMANTICA_ALLOW_ANONYMOUS
|
||||
# the key check below accepts any origin — loopback binding isn't a
|
||||
# boundary against a browser, since any page the operator has open
|
||||
# can still reach ws://localhost:.../ws/graph-updates directly.
|
||||
# Reject a foreign Origin explicitly here, against the same
|
||||
# allowlist CORSMiddleware already enforces for HTTP
|
||||
# (GHSA-4643-wpgq-w329). Browsers always send Origin on a
|
||||
# cross-origin WebSocket handshake; native/CLI clients omit it
|
||||
# entirely, so a missing Origin is allowed through — the browser is
|
||||
# the only threat this check is closing.
|
||||
origin = websocket.headers.get("origin")
|
||||
allowed_origins = app.state.explorer_settings["allowed_origins"]
|
||||
if origin is not None and origin not in allowed_origins:
|
||||
await websocket.close(code=4403) # forbidden
|
||||
return
|
||||
|
||||
# Browsers can't set custom headers on a WebSocket handshake, so
|
||||
# accept the key via header (non-browser clients) or query param
|
||||
# (browser clients), same SEMANTICA_API_KEY the REST routes check.
|
||||
candidate = websocket.headers.get("x-api-key") or websocket.query_params.get("api_key")
|
||||
if not is_valid_api_key(candidate):
|
||||
await websocket.close(code=4401) # unauthorized
|
||||
return
|
||||
|
||||
manager: ConnectionManager = app.state.ws_manager
|
||||
await manager.connect(websocket)
|
||||
await manager.send_personal(websocket, "connection_ack", {"connected": True})
|
||||
try:
|
||||
while True:
|
||||
message = await websocket.receive_text()
|
||||
if len(message) > _WS_MAX_MESSAGE_BYTES:
|
||||
await websocket.close(code=1009) # 1009 = message too big
|
||||
break
|
||||
if message.strip().lower() == "ping":
|
||||
await manager.send_personal(websocket, "pong", {"ok": True})
|
||||
except WebSocketDisconnect:
|
||||
manager.disconnect(websocket)
|
||||
|
||||
@app.get("/", include_in_schema=False)
|
||||
async def root():
|
||||
@@ -224,7 +269,6 @@ def create_app(
|
||||
"name": "Semantica Knowledge Explorer",
|
||||
"version": __version__,
|
||||
"status": "active",
|
||||
"capabilities": explorer_capabilities(agent_memory),
|
||||
}
|
||||
|
||||
static_dir = Path(__file__).resolve().parent.parent / "static"
|
||||
|
||||
@@ -14,8 +14,6 @@ from typing import Optional
|
||||
from fastapi import Request, HTTPException, Security, status
|
||||
from fastapi.security.api_key import APIKeyHeader
|
||||
|
||||
from ..context.agent_memory import AgentMemory
|
||||
from .markdown_resources import MarkdownResourceRegistry
|
||||
from .session import GraphSession
|
||||
|
||||
_api_key_header = APIKeyHeader(name="X-API-Key", auto_error=False)
|
||||
@@ -82,25 +80,3 @@ def get_session(request: Request) -> GraphSession:
|
||||
detail="GraphSession not initialized."
|
||||
)
|
||||
return request.app.state.session
|
||||
|
||||
|
||||
def get_markdown_resources(request: Request) -> MarkdownResourceRegistry:
|
||||
"""Retrieve the Markdown resource registry stored on ``app.state``."""
|
||||
resources = getattr(request.app.state, "markdown_resources", None)
|
||||
if resources is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||
detail="Markdown resources are not initialized.",
|
||||
)
|
||||
return resources
|
||||
|
||||
|
||||
def get_agent_memory(request: Request) -> AgentMemory:
|
||||
"""Retrieve the optional AgentMemory configured for Explorer."""
|
||||
memory = getattr(request.app.state, "agent_memory", None)
|
||||
if memory is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||
detail="AgentMemory is not configured for this Explorer instance.",
|
||||
)
|
||||
return memory
|
||||
|
||||
@@ -1,283 +0,0 @@
|
||||
"""Single-resource Markdown access for Explorer."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from enum import Enum
|
||||
from typing import Callable, Dict, Optional, Protocol, TypeVar
|
||||
|
||||
import yaml
|
||||
|
||||
from ..context.agent_memory import AgentMemory
|
||||
from ..context.context_graph import ContextGraph
|
||||
from ..context.markdown import (
|
||||
MarkdownIdentityError,
|
||||
MarkdownResourceNotFoundError,
|
||||
MarkdownRevisionConflictError,
|
||||
markdown_document_revision,
|
||||
)
|
||||
|
||||
|
||||
class MarkdownResourceKind(str, Enum):
|
||||
CONTEXT_NODE = "context-node"
|
||||
AGENT_MEMORY = "agent-memory"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class MarkdownResourceRef:
|
||||
kind: MarkdownResourceKind
|
||||
id: str
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class MarkdownDocument:
|
||||
resource: MarkdownResourceRef
|
||||
source: str
|
||||
body: str
|
||||
revision: str
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class MarkdownApplyResult(MarkdownDocument):
|
||||
changed: bool
|
||||
|
||||
|
||||
class MarkdownResourceError(Exception):
|
||||
"""Base class for safe, structured Explorer Markdown failures."""
|
||||
|
||||
code = "markdown_resource_error"
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
message: str,
|
||||
*,
|
||||
field: Optional[str] = None,
|
||||
current_revision: Optional[str] = None,
|
||||
) -> None:
|
||||
super().__init__(message)
|
||||
self.message = message
|
||||
self.field = field
|
||||
self.current_revision = current_revision
|
||||
|
||||
|
||||
class MarkdownResourceNotFound(MarkdownResourceError):
|
||||
code = "markdown_resource_not_found"
|
||||
|
||||
|
||||
class InvalidMarkdownFrontmatter(MarkdownResourceError):
|
||||
code = "invalid_markdown_frontmatter"
|
||||
|
||||
|
||||
class ResourceIdentityMismatch(MarkdownResourceError):
|
||||
code = "resource_identity_mismatch"
|
||||
|
||||
|
||||
class MarkdownRevisionConflict(MarkdownResourceError):
|
||||
code = "markdown_revision_conflict"
|
||||
|
||||
|
||||
class MarkdownSaveFailed(MarkdownResourceError):
|
||||
code = "markdown_save_failed"
|
||||
|
||||
|
||||
class MarkdownAdapter(Protocol):
|
||||
def export(self, resource_id: str) -> str:
|
||||
...
|
||||
|
||||
def apply(self, resource_id: str, source: str, expected_revision: str) -> bool:
|
||||
...
|
||||
|
||||
|
||||
_Result = TypeVar("_Result")
|
||||
|
||||
|
||||
def _translate_domain_errors(operation: Callable[[], _Result]) -> _Result:
|
||||
try:
|
||||
return operation()
|
||||
except MarkdownResourceNotFoundError as exc:
|
||||
raise MarkdownResourceNotFound(str(exc.args[0])) from exc
|
||||
except MarkdownRevisionConflictError as exc:
|
||||
raise MarkdownRevisionConflict(
|
||||
"This item changed after editing began. Reload the latest "
|
||||
"version before applying.",
|
||||
current_revision=exc.current_revision,
|
||||
) from exc
|
||||
except MarkdownIdentityError as exc:
|
||||
raise ResourceIdentityMismatch(str(exc), field="id") from exc
|
||||
except ValueError as exc:
|
||||
raise InvalidMarkdownFrontmatter(_safe_validation_message(exc)) from exc
|
||||
|
||||
|
||||
class ContextGraphNodeMarkdownAdapter:
|
||||
def __init__(self, graph: ContextGraph) -> None:
|
||||
self._graph = graph
|
||||
|
||||
def export(self, resource_id: str) -> str:
|
||||
return _translate_domain_errors(
|
||||
lambda: self._graph.export_node_markdown(resource_id)
|
||||
)
|
||||
|
||||
def apply(self, resource_id: str, source: str, expected_revision: str) -> bool:
|
||||
return _translate_domain_errors(
|
||||
lambda: self._graph.apply_node_markdown(
|
||||
resource_id,
|
||||
source,
|
||||
expected_revision=expected_revision,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
class AgentMemoryItemMarkdownAdapter:
|
||||
def __init__(self, memory: AgentMemory) -> None:
|
||||
self._memory = memory
|
||||
|
||||
def export(self, resource_id: str) -> str:
|
||||
return _translate_domain_errors(
|
||||
lambda: self._memory.export_item_markdown(resource_id)
|
||||
)
|
||||
|
||||
def apply(self, resource_id: str, source: str, expected_revision: str) -> bool:
|
||||
return _translate_domain_errors(
|
||||
lambda: self._memory.apply_item_markdown(
|
||||
resource_id,
|
||||
source,
|
||||
expected_revision=expected_revision,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def _safe_validation_message(exc: ValueError) -> str:
|
||||
if isinstance(exc.__cause__, yaml.YAMLError):
|
||||
return "Markdown frontmatter contains invalid YAML."
|
||||
return str(exc)
|
||||
|
||||
|
||||
def document_revision(source: str) -> str:
|
||||
"""Return the stable revision token for canonical Markdown.
|
||||
|
||||
Args:
|
||||
source: Canonical Markdown source.
|
||||
|
||||
Returns:
|
||||
A SHA-256 revision token suitable for optimistic concurrency checks.
|
||||
"""
|
||||
return markdown_document_revision(source)
|
||||
|
||||
|
||||
def markdown_body(source: str) -> str:
|
||||
"""Extract the body from canonical Markdown emitted by a domain model."""
|
||||
lines = source.splitlines(keepends=True)
|
||||
if not lines or lines[0].rstrip("\r\n") != "---":
|
||||
raise MarkdownSaveFailed("The resource produced invalid canonical Markdown.")
|
||||
closing_index = next(
|
||||
(
|
||||
index
|
||||
for index, line in enumerate(lines[1:], start=1)
|
||||
if line.rstrip("\r\n") == "---"
|
||||
),
|
||||
None,
|
||||
)
|
||||
if closing_index is None:
|
||||
raise MarkdownSaveFailed("The resource produced invalid canonical Markdown.")
|
||||
body = "".join(lines[closing_index + 1 :])
|
||||
if body.startswith("\r\n"):
|
||||
return body[2:]
|
||||
if body.startswith("\n"):
|
||||
return body[1:]
|
||||
return body
|
||||
|
||||
|
||||
class MarkdownResourceRegistry:
|
||||
"""Route Markdown operations to their owning domain models."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
context_graph: ContextGraph,
|
||||
agent_memory: Optional[AgentMemory] = None,
|
||||
) -> None:
|
||||
self._adapters: Dict[MarkdownResourceKind, MarkdownAdapter] = {
|
||||
MarkdownResourceKind.CONTEXT_NODE: ContextGraphNodeMarkdownAdapter(
|
||||
context_graph
|
||||
)
|
||||
}
|
||||
if agent_memory is not None:
|
||||
self._adapters[
|
||||
MarkdownResourceKind.AGENT_MEMORY
|
||||
] = AgentMemoryItemMarkdownAdapter(agent_memory)
|
||||
|
||||
def _adapter(self, kind: MarkdownResourceKind) -> MarkdownAdapter:
|
||||
adapter = self._adapters.get(kind)
|
||||
if adapter is None:
|
||||
raise MarkdownResourceNotFound(
|
||||
f"Markdown resource kind {kind.value!r} is not available."
|
||||
)
|
||||
return adapter
|
||||
|
||||
def read(self, ref: MarkdownResourceRef) -> MarkdownDocument:
|
||||
"""Read one resource as canonical Markdown.
|
||||
|
||||
Args:
|
||||
ref: Explicit resource kind and stable identifier.
|
||||
|
||||
Returns:
|
||||
The canonical source, body, and current revision.
|
||||
|
||||
Raises:
|
||||
MarkdownResourceError: If the resource is unavailable or invalid.
|
||||
"""
|
||||
try:
|
||||
source = self._adapter(ref.kind).export(ref.id)
|
||||
except MarkdownResourceError:
|
||||
raise
|
||||
except Exception as exc:
|
||||
raise MarkdownSaveFailed(
|
||||
"The Markdown resource could not be read."
|
||||
) from exc
|
||||
return MarkdownDocument(
|
||||
resource=ref,
|
||||
source=source,
|
||||
body=markdown_body(source),
|
||||
revision=document_revision(source),
|
||||
)
|
||||
|
||||
def apply(
|
||||
self,
|
||||
ref: MarkdownResourceRef,
|
||||
markdown: str,
|
||||
expected_revision: str,
|
||||
) -> MarkdownApplyResult:
|
||||
"""Apply validated Markdown to one existing resource.
|
||||
|
||||
Args:
|
||||
ref: Explicit resource kind and stable identifier.
|
||||
markdown: Replacement canonical Markdown.
|
||||
expected_revision: Revision observed when editing began.
|
||||
|
||||
Returns:
|
||||
The saved canonical document and whether it changed.
|
||||
|
||||
Raises:
|
||||
MarkdownResourceError: If validation, identity, persistence, or
|
||||
revision checks fail.
|
||||
"""
|
||||
try:
|
||||
changed = self._adapter(ref.kind).apply(
|
||||
ref.id,
|
||||
markdown,
|
||||
expected_revision,
|
||||
)
|
||||
except MarkdownResourceError:
|
||||
raise
|
||||
except Exception as exc:
|
||||
raise MarkdownSaveFailed(
|
||||
"The edit could not be applied. The existing item was not changed."
|
||||
) from exc
|
||||
|
||||
saved = self.read(ref)
|
||||
return MarkdownApplyResult(
|
||||
resource=saved.resource,
|
||||
source=saved.source,
|
||||
body=saved.body,
|
||||
revision=saved.revision,
|
||||
changed=changed,
|
||||
)
|
||||
@@ -87,13 +87,6 @@ def _node_response(node: dict) -> NodeResponse:
|
||||
return NodeResponse(**node)
|
||||
|
||||
|
||||
async def _get_node_or_404(node_id: str, session: GraphSession) -> NodeResponse:
|
||||
node = await asyncio.to_thread(session.get_node, node_id)
|
||||
if node is None:
|
||||
raise HTTPException(status_code=404, detail=f"Node '{node_id}' not found")
|
||||
return _node_response(node)
|
||||
|
||||
|
||||
def _edge_response(edge: dict) -> EdgeResponse:
|
||||
return EdgeResponse(**edge)
|
||||
|
||||
@@ -128,20 +121,15 @@ async def list_nodes(
|
||||
)
|
||||
|
||||
|
||||
@router.get("/node", response_model=NodeResponse)
|
||||
async def get_node_by_query(
|
||||
node_id: str = Query(..., description="Exact node ID"),
|
||||
session: GraphSession = Depends(get_session),
|
||||
):
|
||||
return await _get_node_or_404(node_id, session)
|
||||
|
||||
|
||||
@router.get("/node/{node_id}", response_model=NodeResponse)
|
||||
async def get_node(
|
||||
node_id: str,
|
||||
session: GraphSession = Depends(get_session),
|
||||
):
|
||||
return await _get_node_or_404(node_id, session)
|
||||
node = await asyncio.to_thread(session.get_node, node_id)
|
||||
if node is None:
|
||||
raise HTTPException(status_code=404, detail=f"Node '{node_id}' not found")
|
||||
return _node_response(node)
|
||||
|
||||
|
||||
@router.get("/node/{node_id}/neighbors", response_model=list[NeighborResponse])
|
||||
@@ -453,7 +441,7 @@ async def distance_matrix(
|
||||
embeddings = _get_cached_embeddings(session)
|
||||
src_embedding = embeddings.get(src)
|
||||
tgt_embedding = embeddings.get(tgt)
|
||||
|
||||
|
||||
if src_embedding is None or tgt_embedding is None:
|
||||
val = None
|
||||
else:
|
||||
@@ -463,7 +451,7 @@ async def distance_matrix(
|
||||
tgt_vec = np.array(tgt_embedding)
|
||||
sim = np.dot(src_vec, tgt_vec) / (np.linalg.norm(src_vec) * np.linalg.norm(tgt_vec))
|
||||
val = 1.0 - float(sim) if isinstance(sim, (int, float)) else None
|
||||
|
||||
|
||||
matrix[i][j] = val
|
||||
matrix[j][i] = val
|
||||
elif path_finder is not None:
|
||||
|
||||
@@ -1,115 +0,0 @@
|
||||
"""Explorer routes for canonical Markdown resources."""
|
||||
|
||||
from typing import NoReturn
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
|
||||
from ..dependencies import get_markdown_resources
|
||||
from ..markdown_resources import (
|
||||
InvalidMarkdownFrontmatter,
|
||||
MarkdownApplyResult,
|
||||
MarkdownDocument,
|
||||
MarkdownResourceError,
|
||||
MarkdownResourceKind,
|
||||
MarkdownResourceNotFound,
|
||||
MarkdownResourceRef,
|
||||
MarkdownResourceRegistry,
|
||||
MarkdownRevisionConflict,
|
||||
ResourceIdentityMismatch,
|
||||
)
|
||||
from ..schemas import (
|
||||
MarkdownApplyRequest,
|
||||
MarkdownApplyResponse,
|
||||
MarkdownDocumentResponse,
|
||||
)
|
||||
|
||||
router = APIRouter(prefix="/api/markdown", tags=["markdown"])
|
||||
|
||||
|
||||
def _resource_ref(kind: str, resource_id: str) -> MarkdownResourceRef:
|
||||
try:
|
||||
resource_kind = MarkdownResourceKind(kind)
|
||||
except ValueError:
|
||||
_raise_http_error(
|
||||
MarkdownResourceNotFound(
|
||||
f"Markdown resource kind {kind!r} is not available."
|
||||
)
|
||||
)
|
||||
return MarkdownResourceRef(kind=resource_kind, id=resource_id)
|
||||
|
||||
|
||||
def _error_detail(error: MarkdownResourceError) -> dict:
|
||||
detail = {"code": error.code, "message": error.message}
|
||||
if error.field is not None:
|
||||
detail["field"] = error.field
|
||||
if error.current_revision is not None:
|
||||
detail["current_revision"] = error.current_revision
|
||||
return detail
|
||||
|
||||
|
||||
def _raise_http_error(error: MarkdownResourceError) -> NoReturn:
|
||||
if isinstance(error, MarkdownResourceNotFound):
|
||||
status_code = status.HTTP_404_NOT_FOUND
|
||||
elif isinstance(error, MarkdownRevisionConflict):
|
||||
status_code = status.HTTP_409_CONFLICT
|
||||
elif isinstance(error, (InvalidMarkdownFrontmatter, ResourceIdentityMismatch)):
|
||||
status_code = status.HTTP_422_UNPROCESSABLE_ENTITY
|
||||
else:
|
||||
status_code = status.HTTP_500_INTERNAL_SERVER_ERROR
|
||||
raise HTTPException(status_code=status_code, detail=_error_detail(error)) from error
|
||||
|
||||
|
||||
def _document_response(
|
||||
document: MarkdownDocument,
|
||||
) -> MarkdownDocumentResponse:
|
||||
return MarkdownDocumentResponse(
|
||||
resource={
|
||||
"kind": document.resource.kind.value,
|
||||
"id": document.resource.id,
|
||||
},
|
||||
source=document.source,
|
||||
body=document.body,
|
||||
revision=document.revision,
|
||||
editable=True,
|
||||
)
|
||||
|
||||
|
||||
def _apply_response(result: MarkdownApplyResult) -> MarkdownApplyResponse:
|
||||
document = _document_response(result)
|
||||
return MarkdownApplyResponse(**document.model_dump(), changed=result.changed)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/{kind}/{resource_id:path}",
|
||||
response_model=MarkdownDocumentResponse,
|
||||
)
|
||||
def read_markdown_resource(
|
||||
kind: str,
|
||||
resource_id: str,
|
||||
resources: MarkdownResourceRegistry = Depends(get_markdown_resources),
|
||||
) -> MarkdownDocumentResponse:
|
||||
try:
|
||||
return _document_response(resources.read(_resource_ref(kind, resource_id)))
|
||||
except MarkdownResourceError as error:
|
||||
_raise_http_error(error)
|
||||
|
||||
|
||||
@router.put(
|
||||
"/{kind}/{resource_id:path}",
|
||||
response_model=MarkdownApplyResponse,
|
||||
)
|
||||
def apply_markdown_resource(
|
||||
kind: str,
|
||||
resource_id: str,
|
||||
request: MarkdownApplyRequest,
|
||||
resources: MarkdownResourceRegistry = Depends(get_markdown_resources),
|
||||
) -> MarkdownApplyResponse:
|
||||
try:
|
||||
result = resources.apply(
|
||||
_resource_ref(kind, resource_id),
|
||||
request.markdown,
|
||||
request.expected_revision,
|
||||
)
|
||||
return _apply_response(result)
|
||||
except MarkdownResourceError as error:
|
||||
_raise_http_error(error)
|
||||
@@ -1,41 +0,0 @@
|
||||
"""Minimal AgentMemory selection surface for Explorer."""
|
||||
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
|
||||
from ...context.agent_memory import AgentMemory
|
||||
from ..dependencies import get_agent_memory
|
||||
from ..schemas import MemoryListResponse, MemorySummaryResponse
|
||||
|
||||
router = APIRouter(prefix="/api/memories", tags=["memories"])
|
||||
|
||||
|
||||
@router.get("", response_model=MemoryListResponse)
|
||||
def list_memories(
|
||||
skip: int = Query(default=0, ge=0),
|
||||
limit: int = Query(default=100, ge=1, le=100),
|
||||
memory: AgentMemory = Depends(get_agent_memory),
|
||||
) -> MemoryListResponse:
|
||||
records, total = memory.list_snapshot(offset=skip, limit=limit)
|
||||
items = []
|
||||
for record in records:
|
||||
metadata = record.get("metadata") or {}
|
||||
content = record.get("content") or ""
|
||||
excerpt = " ".join(str(content).split())[:160]
|
||||
items.append(
|
||||
MemorySummaryResponse(
|
||||
id=record["memory_id"],
|
||||
type=str(metadata.get("type") or "general"),
|
||||
excerpt=excerpt,
|
||||
updated_at=(
|
||||
str(metadata["updated_at"])
|
||||
if metadata.get("updated_at") is not None
|
||||
else None
|
||||
),
|
||||
)
|
||||
)
|
||||
return MemoryListResponse(
|
||||
items=items,
|
||||
total=total,
|
||||
skip=skip,
|
||||
limit=limit,
|
||||
)
|
||||
@@ -2877,8 +2877,7 @@ async def validate_shacl(
|
||||
status="error",
|
||||
message=(
|
||||
f"SHACL Turtle size ({len(_shacl_bytes)} bytes) "
|
||||
f"exceeds maximum allowed size ({_MAX_SHACL_TURTLE_BYTES} bytes); "
|
||||
f"set SEMANTICA_MAX_SHACL_TURTLE_BYTES to raise the limit."
|
||||
f"exceeds maximum allowed size ({_MAX_SHACL_TURTLE_BYTES} bytes)."
|
||||
),
|
||||
violations=[],
|
||||
)
|
||||
@@ -2895,8 +2894,7 @@ async def validate_shacl(
|
||||
status="error",
|
||||
message=(
|
||||
f"SHACL graph triple count ({len(g)}) "
|
||||
f"exceeds maximum allowed limit ({_MAX_SHACL_TRIPLES}); "
|
||||
f"set SEMANTICA_MAX_SHACL_TRIPLES to raise the limit."
|
||||
f"exceeds maximum allowed limit ({_MAX_SHACL_TRIPLES})."
|
||||
),
|
||||
violations=[],
|
||||
)
|
||||
@@ -2947,8 +2945,7 @@ async def validate_shacl(
|
||||
conforms=False,
|
||||
status="error",
|
||||
message=(
|
||||
f"SHACL validation timed out after {_MAX_SHACL_TIMEOUT_SECONDS} seconds; "
|
||||
f"set SEMANTICA_MAX_SHACL_TIMEOUT to raise the timeout."
|
||||
f"SHACL validation timed out after {_MAX_SHACL_TIMEOUT_SECONDS} seconds."
|
||||
),
|
||||
violations=[],
|
||||
)
|
||||
|
||||
@@ -1,45 +0,0 @@
|
||||
"""Shared runtime assembly for Explorer entry points."""
|
||||
|
||||
import asyncio
|
||||
from typing import Dict, Optional
|
||||
|
||||
from fastapi import FastAPI
|
||||
|
||||
from ..context.agent_memory import AgentMemory
|
||||
from .session import GraphSession
|
||||
|
||||
|
||||
def explorer_capabilities(agent_memory: Optional[AgentMemory]) -> Dict[str, bool]:
|
||||
"""Describe optional Explorer features exposed by the current host."""
|
||||
return {"agent_memory": agent_memory is not None}
|
||||
|
||||
|
||||
def install_mutation_bridge(app: FastAPI, session: GraphSession) -> None:
|
||||
"""Keep Explorer indexes and WebSocket clients in sync with graph writes."""
|
||||
if getattr(app.state, "_semantica_mutation_bridge_session", None) is session:
|
||||
return
|
||||
previous_callback = getattr(session.graph, "mutation_callback", None)
|
||||
|
||||
def on_mutation(event_type: str, entity_id: str, payload: dict) -> None:
|
||||
session.handle_graph_mutation(event_type, entity_id, payload)
|
||||
if callable(previous_callback):
|
||||
previous_callback(event_type, entity_id, payload)
|
||||
loop = getattr(app.state, "event_loop", None)
|
||||
manager = getattr(app.state, "ws_manager", None)
|
||||
if loop is None or manager is None or loop.is_closed():
|
||||
return
|
||||
asyncio.run_coroutine_threadsafe(
|
||||
manager.broadcast(
|
||||
"graph_mutation",
|
||||
{
|
||||
"event_type": event_type,
|
||||
"entity_id": entity_id,
|
||||
"payload": payload,
|
||||
},
|
||||
),
|
||||
loop,
|
||||
)
|
||||
|
||||
session.graph.mutation_callback = on_mutation
|
||||
app.state._semantica_mutation_bridge = on_mutation
|
||||
app.state._semantica_mutation_bridge_session = session
|
||||
@@ -428,39 +428,3 @@ class DistanceExportRequest(BaseModel):
|
||||
include: List[str] = Field(
|
||||
default_factory=lambda: ["source_id", "target_id", "hop_count", "distance_band"],
|
||||
)
|
||||
|
||||
|
||||
class MarkdownResourceRefResponse(BaseModel):
|
||||
kind: Literal["context-node", "agent-memory"]
|
||||
id: str
|
||||
|
||||
|
||||
class MarkdownDocumentResponse(BaseModel):
|
||||
resource: MarkdownResourceRefResponse
|
||||
source: str
|
||||
body: str
|
||||
revision: str
|
||||
editable: bool = True
|
||||
|
||||
|
||||
class MarkdownApplyRequest(BaseModel):
|
||||
markdown: str
|
||||
expected_revision: str = Field(..., pattern=r"^sha256:[0-9a-f]{64}$")
|
||||
|
||||
|
||||
class MarkdownApplyResponse(MarkdownDocumentResponse):
|
||||
changed: bool
|
||||
|
||||
|
||||
class MemorySummaryResponse(BaseModel):
|
||||
id: str
|
||||
type: str
|
||||
excerpt: str
|
||||
updated_at: Optional[str] = None
|
||||
|
||||
|
||||
class MemoryListResponse(BaseModel):
|
||||
items: List[MemorySummaryResponse]
|
||||
total: int
|
||||
skip: int
|
||||
limit: int
|
||||
|
||||
@@ -9,53 +9,9 @@ import asyncio
|
||||
import json
|
||||
import threading
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any, Dict, List, Sequence, Set
|
||||
from typing import Any, Dict, Set
|
||||
|
||||
from fastapi import FastAPI, WebSocket, WebSocketDisconnect
|
||||
|
||||
from .dependencies import is_valid_api_key
|
||||
|
||||
_WS_MAX_MESSAGE_BYTES = 64 * 1024
|
||||
|
||||
|
||||
def install_graph_updates_websocket(
|
||||
app: FastAPI,
|
||||
allowed_origins: Sequence[str],
|
||||
) -> None:
|
||||
"""Install the authenticated graph-mutation WebSocket endpoint."""
|
||||
allowed_origin_set = frozenset(allowed_origins)
|
||||
|
||||
@app.websocket("/ws/graph-updates")
|
||||
async def websocket_endpoint(websocket: WebSocket) -> None:
|
||||
# CORSMiddleware does not cover WebSocket handshakes. Reject foreign
|
||||
# browser origins against the same allowlist used for HTTP CORS.
|
||||
origin = websocket.headers.get("origin")
|
||||
if origin is not None and origin not in allowed_origin_set:
|
||||
await websocket.close(code=4403)
|
||||
return
|
||||
|
||||
# Browser clients pass the API key as a query parameter because the
|
||||
# WebSocket API cannot set custom headers.
|
||||
candidate = websocket.headers.get("x-api-key") or websocket.query_params.get(
|
||||
"api_key"
|
||||
)
|
||||
if not is_valid_api_key(candidate):
|
||||
await websocket.close(code=4401)
|
||||
return
|
||||
|
||||
manager: ConnectionManager = app.state.ws_manager
|
||||
await manager.connect(websocket)
|
||||
await manager.send_personal(websocket, "connection_ack", {"connected": True})
|
||||
try:
|
||||
while True:
|
||||
message = await websocket.receive_text()
|
||||
if len(message) > _WS_MAX_MESSAGE_BYTES:
|
||||
await websocket.close(code=1009)
|
||||
break
|
||||
if message.strip().lower() == "ping":
|
||||
await manager.send_personal(websocket, "pong", {"ok": True})
|
||||
except WebSocketDisconnect:
|
||||
manager.disconnect(websocket)
|
||||
from fastapi import WebSocket
|
||||
|
||||
|
||||
class ConnectionManager:
|
||||
@@ -107,7 +63,8 @@ class ConnectionManager:
|
||||
with self._lock:
|
||||
connections = set(self._active_connections)
|
||||
|
||||
disconnected: List[WebSocket] = []
|
||||
|
||||
disconnected: list[WebSocket] = []
|
||||
for ws in connections:
|
||||
try:
|
||||
await ws.send_text(message)
|
||||
|
||||
@@ -301,10 +301,7 @@ class GraphValidator:
|
||||
code="ORPHAN_NODES",
|
||||
message=f"Found {len(isolates)} orphan nodes (no relationships).",
|
||||
severity=ValidationSeverity.WARNING,
|
||||
details={
|
||||
"count": len(isolates),
|
||||
"ids": sorted(isolates, key=str)[:10],
|
||||
} # Limit output deterministically
|
||||
details={"count": len(isolates), "ids": isolates[:10]} # Limit output
|
||||
))
|
||||
|
||||
except Exception as e:
|
||||
|
||||
@@ -35,7 +35,7 @@ Example Usage:
|
||||
>>> llm = LiteLLM(model="openai/gpt-4o", api_key="your-key")
|
||||
>>> response = llm.generate("Hello, world!")
|
||||
>>> # Or use other providers via LiteLLM
|
||||
>>> llm = LiteLLM(model="anthropic/claude-sonnet-5")
|
||||
>>> llm = LiteLLM(model="anthropic/claude-sonnet-4-20250514")
|
||||
>>> response = llm.generate("Hello, world!")
|
||||
>>>
|
||||
>>> # Anthropic provider
|
||||
|
||||
@@ -31,7 +31,7 @@ class LiteLLM:
|
||||
Provides unified interface to 100+ LLM providers through LiteLLM library.
|
||||
Supports providers like OpenAI, Anthropic, Groq, Azure, Bedrock, Vertex AI, etc.
|
||||
|
||||
Model format: "provider/model-name" (e.g., "openai/gpt-4o", "anthropic/claude-sonnet-5", "groq/llama-3.1-8b-instant")
|
||||
Model format: "provider/model-name" (e.g., "openai/gpt-4o", "anthropic/claude-sonnet-4-20250514", "groq/llama-3.1-8b-instant")
|
||||
|
||||
Example:
|
||||
>>> from semantica.llms import LiteLLM
|
||||
@@ -39,7 +39,7 @@ class LiteLLM:
|
||||
>>> response = llm.generate("What is AI?")
|
||||
>>>
|
||||
>>> # Use with different providers
|
||||
>>> llm = LiteLLM(model="anthropic/claude-sonnet-5")
|
||||
>>> llm = LiteLLM(model="anthropic/claude-sonnet-4-20250514")
|
||||
>>> response = llm.generate("Hello!")
|
||||
"""
|
||||
|
||||
@@ -54,7 +54,7 @@ class LiteLLM:
|
||||
|
||||
Args:
|
||||
model: Model identifier in format "provider/model-name"
|
||||
Examples: "openai/gpt-4o", "anthropic/claude-sonnet-5",
|
||||
Examples: "openai/gpt-4o", "anthropic/claude-sonnet-4-20250514",
|
||||
"groq/llama-3.1-8b-instant", "azure/gpt-4", etc.
|
||||
api_key: API key (optional, can use environment variables)
|
||||
**kwargs: Additional LiteLLM options (temperature, max_tokens, etc.)
|
||||
|
||||
@@ -162,13 +162,6 @@ from .ontology_validator import (
|
||||
run_shacl_validation,
|
||||
validate_ontology,
|
||||
)
|
||||
from .quality_gate import (
|
||||
OntologyQualityGate,
|
||||
OntologyQualityReport,
|
||||
QualityIssue,
|
||||
QualitySeverity,
|
||||
ontology_quality_check,
|
||||
)
|
||||
from .owl_generator import OWLGenerator
|
||||
from .property_generator import PropertyGenerator
|
||||
from .registry import MethodRegistry, method_registry
|
||||
@@ -201,11 +194,6 @@ __all__ = [
|
||||
"SHACLValidationReport",
|
||||
"SHACLViolation",
|
||||
"run_shacl_validation",
|
||||
"OntologyQualityGate",
|
||||
"OntologyQualityReport",
|
||||
"QualityIssue",
|
||||
"QualitySeverity",
|
||||
"ontology_quality_check",
|
||||
# OWL/RDF generation
|
||||
"OWLGenerator",
|
||||
# Requirements and competency questions
|
||||
|
||||
@@ -9,7 +9,6 @@ from .property_generator import PropertyGenerator
|
||||
from .owl_generator import OWLGenerator
|
||||
from .ontology_evaluator import OntologyEvaluator
|
||||
from .ontology_validator import OntologyValidator
|
||||
from .quality_gate import OntologyQualityGate, OntologyQualityReport
|
||||
from .llm_generator import LLMOntologyGenerator
|
||||
from ..semantic_extract.triplet_extractor import Triplet
|
||||
|
||||
@@ -26,9 +25,6 @@ class OntologyEngine:
|
||||
self.owl = OWLGenerator(**config)
|
||||
self.evaluator = OntologyEvaluator(**config)
|
||||
self.validator = OntologyValidator(**config)
|
||||
self.quality_gate = OntologyQualityGate(
|
||||
validator=self.validator, evaluator=self.evaluator
|
||||
)
|
||||
self.llm = LLMOntologyGenerator(**config)
|
||||
self.store = config.get("store")
|
||||
|
||||
@@ -597,15 +593,6 @@ class OntologyEngine:
|
||||
def validate(self, ontology: Dict[str, Any], **options):
|
||||
return self.validator.validate(ontology, **options)
|
||||
|
||||
def quality_check(
|
||||
self,
|
||||
ontology: Dict[str, Any],
|
||||
graph_data: Optional[Dict[str, Any]] = None,
|
||||
**options,
|
||||
) -> OntologyQualityReport:
|
||||
"""Run deterministic ontology quality checks suitable for CI."""
|
||||
return self.quality_gate.check(ontology, graph_data=graph_data, **options)
|
||||
|
||||
def to_owl(self, ontology: Dict[str, Any], format: str = "turtle", **options):
|
||||
return self.owl.generate_owl(ontology, format=format, **options)
|
||||
|
||||
|
||||
@@ -1,855 +0,0 @@
|
||||
"""Deterministic quality checks for ontology and KG pipelines."""
|
||||
|
||||
import copy
|
||||
import math
|
||||
from dataclasses import dataclass, field
|
||||
from enum import Enum
|
||||
from typing import Any, Dict, Iterable, List, Mapping, Optional, Set, Tuple
|
||||
|
||||
from ..kg.graph_validator import GraphValidator
|
||||
from .ontology_evaluator import OntologyEvaluator
|
||||
from .ontology_validator import OntologyValidator
|
||||
|
||||
|
||||
class QualitySeverity(str, Enum):
|
||||
"""Severity assigned to a quality finding."""
|
||||
|
||||
INFO = "info"
|
||||
WARNING = "warning"
|
||||
ERROR = "error"
|
||||
CRITICAL = "critical"
|
||||
|
||||
|
||||
@dataclass
|
||||
class QualityIssue:
|
||||
"""A single machine-readable ontology quality finding."""
|
||||
|
||||
code: str
|
||||
message: str
|
||||
severity: QualitySeverity
|
||||
element_id: Optional[str] = None
|
||||
element_type: Optional[str] = None
|
||||
details: Dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
"""Return a JSON-friendly representation of the issue."""
|
||||
return {
|
||||
"code": self.code,
|
||||
"message": self.message,
|
||||
"severity": self.severity.value,
|
||||
"element_id": self.element_id,
|
||||
"element_type": self.element_type,
|
||||
"details": self.details,
|
||||
}
|
||||
|
||||
|
||||
@dataclass
|
||||
class OntologyQualityReport:
|
||||
"""Result returned by :class:`OntologyQualityGate`."""
|
||||
|
||||
passed: bool
|
||||
issues: List[QualityIssue] = field(default_factory=list)
|
||||
stats: Dict[str, int] = field(default_factory=dict)
|
||||
metrics: Dict[str, float] = field(default_factory=dict)
|
||||
thresholds: Dict[str, Optional[float]] = field(default_factory=dict)
|
||||
threshold_failures: List[str] = field(default_factory=list)
|
||||
|
||||
@property
|
||||
def error_count(self) -> int:
|
||||
"""Number of error and critical findings."""
|
||||
return sum(
|
||||
issue.severity in (QualitySeverity.ERROR, QualitySeverity.CRITICAL)
|
||||
for issue in self.issues
|
||||
)
|
||||
|
||||
@property
|
||||
def warning_count(self) -> int:
|
||||
"""Number of warning findings."""
|
||||
return sum(issue.severity == QualitySeverity.WARNING for issue in self.issues)
|
||||
|
||||
@property
|
||||
def info_count(self) -> int:
|
||||
"""Number of informational findings."""
|
||||
return sum(issue.severity == QualitySeverity.INFO for issue in self.issues)
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
"""Return a JSON-friendly representation of the report."""
|
||||
return {
|
||||
"passed": self.passed,
|
||||
"issues": [issue.to_dict() for issue in self.issues],
|
||||
"stats": {
|
||||
**self.stats,
|
||||
"issues": len(self.issues),
|
||||
"errors": self.error_count,
|
||||
"warnings": self.warning_count,
|
||||
"infos": self.info_count,
|
||||
},
|
||||
"metrics": self.metrics,
|
||||
"thresholds": self.thresholds,
|
||||
"threshold_failures": self.threshold_failures,
|
||||
}
|
||||
|
||||
|
||||
class OntologyQualityGate:
|
||||
"""Run deterministic, CI-friendly ontology quality checks."""
|
||||
|
||||
DEFAULT_THRESHOLDS: Dict[str, Optional[float]] = {
|
||||
"min_coverage": 0.0,
|
||||
"max_errors": 0.0,
|
||||
"max_warnings": None,
|
||||
}
|
||||
_DATA_PROPERTY_TYPES = {"data", "datatype", "data_property", "literal"}
|
||||
_OBJECT_PROPERTY_TYPES = {"object", "object_property", "relationship"}
|
||||
_KNOWN_DATATYPES = {
|
||||
"string",
|
||||
"boolean",
|
||||
"decimal",
|
||||
"float",
|
||||
"double",
|
||||
"integer",
|
||||
"int",
|
||||
"long",
|
||||
"short",
|
||||
"byte",
|
||||
"date",
|
||||
"datetime",
|
||||
"datetimestamp",
|
||||
"time",
|
||||
"duration",
|
||||
"anyuri",
|
||||
}
|
||||
_BUILTIN_CLASSES = {
|
||||
"owl:thing",
|
||||
"rdfs:resource",
|
||||
"http://www.w3.org/2002/07/owl#thing",
|
||||
"http://www.w3.org/2000/01/rdf-schema#resource",
|
||||
}
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
validator: Optional[OntologyValidator] = None,
|
||||
evaluator: Optional[OntologyEvaluator] = None,
|
||||
thresholds: Optional[Mapping[str, Optional[float]]] = None,
|
||||
fail_on_warnings: bool = False,
|
||||
) -> None:
|
||||
self.validator = validator or OntologyValidator()
|
||||
self.evaluator = evaluator or OntologyEvaluator()
|
||||
self.thresholds = dict(self.DEFAULT_THRESHOLDS)
|
||||
if thresholds:
|
||||
self.thresholds.update(thresholds)
|
||||
self.fail_on_warnings = fail_on_warnings
|
||||
|
||||
def check(
|
||||
self,
|
||||
ontology: Any,
|
||||
graph_data: Optional[Dict[str, Any]] = None,
|
||||
*,
|
||||
thresholds: Optional[Mapping[str, Optional[float]]] = None,
|
||||
fail_on_warnings: Optional[bool] = None,
|
||||
competency_questions: Optional[List[str]] = None,
|
||||
) -> OntologyQualityReport:
|
||||
"""Check an ontology and optionally its instance graph.
|
||||
|
||||
``graph_data`` is optional because an ontology can be checked before
|
||||
instances are available. When omitted, embedded ``entities`` and
|
||||
``relationships`` are checked when present.
|
||||
"""
|
||||
active_thresholds = dict(self.thresholds)
|
||||
if thresholds:
|
||||
active_thresholds.update(thresholds)
|
||||
min_coverage, max_errors, max_warnings = self._validate_thresholds(
|
||||
active_thresholds
|
||||
)
|
||||
should_fail_on_warnings = (
|
||||
self.fail_on_warnings if fail_on_warnings is None else fail_on_warnings
|
||||
)
|
||||
issues: List[QualityIssue] = []
|
||||
|
||||
if not isinstance(ontology, dict):
|
||||
self._add_issue(
|
||||
issues,
|
||||
"INVALID_ONTOLOGY",
|
||||
"Ontology must be a dictionary.",
|
||||
QualitySeverity.CRITICAL,
|
||||
element_type="ontology",
|
||||
)
|
||||
return self._build_report(
|
||||
issues,
|
||||
classes=0,
|
||||
properties=0,
|
||||
entities=0,
|
||||
relationships=0,
|
||||
metrics={
|
||||
"coverage": 0.0,
|
||||
"class_coverage": 0.0,
|
||||
"property_coverage": 0.0,
|
||||
},
|
||||
thresholds=active_thresholds,
|
||||
min_coverage=min_coverage,
|
||||
max_errors=max_errors,
|
||||
max_warnings=max_warnings,
|
||||
fail_on_warnings=should_fail_on_warnings,
|
||||
)
|
||||
|
||||
validation = self.validator.validate(ontology)
|
||||
for message in getattr(validation, "errors", []) or []:
|
||||
self._add_issue(
|
||||
issues,
|
||||
"VALIDATOR_ERROR",
|
||||
str(message),
|
||||
QualitySeverity.ERROR,
|
||||
element_type="ontology",
|
||||
)
|
||||
for message in getattr(validation, "warnings", []) or []:
|
||||
self._add_issue(
|
||||
issues,
|
||||
"VALIDATOR_WARNING",
|
||||
str(message),
|
||||
QualitySeverity.WARNING,
|
||||
element_type="ontology",
|
||||
)
|
||||
|
||||
classes = self._read_collection(ontology, "classes", issues)
|
||||
properties = self._read_collection(ontology, "properties", issues)
|
||||
class_aliases, class_ids = self._index_elements(
|
||||
classes, "class", "MISSING_CLASS_ID", issues
|
||||
)
|
||||
referenced_classes: Set[str] = set()
|
||||
self._mark_hierarchy(classes, class_aliases, referenced_classes)
|
||||
|
||||
property_with_endpoints = 0
|
||||
for index, prop in enumerate(properties):
|
||||
if not isinstance(prop, dict):
|
||||
self._add_issue(
|
||||
issues,
|
||||
"INVALID_PROPERTY",
|
||||
f"Property at index {index} must be a dictionary.",
|
||||
QualitySeverity.ERROR,
|
||||
element_type="property",
|
||||
details={"index": index},
|
||||
)
|
||||
continue
|
||||
|
||||
prop_id = self._identifier(prop)
|
||||
if prop_id is None:
|
||||
prop_id = f"property[{index}]"
|
||||
self._add_issue(
|
||||
issues,
|
||||
"MISSING_PROPERTY_ID",
|
||||
f"Property at index {index} has no name or URI.",
|
||||
QualitySeverity.ERROR,
|
||||
element_type="property",
|
||||
details={"index": index},
|
||||
)
|
||||
|
||||
raw_prop_type = prop.get("type")
|
||||
prop_type = (
|
||||
str(raw_prop_type).strip().lower() if raw_prop_type is not None else ""
|
||||
)
|
||||
if not prop_type:
|
||||
self._add_issue(
|
||||
issues,
|
||||
"MISSING_PROPERTY_TYPE",
|
||||
f"Property '{prop_id}' has no type.",
|
||||
QualitySeverity.ERROR,
|
||||
element_id=prop_id,
|
||||
element_type="property",
|
||||
)
|
||||
elif (
|
||||
prop_type not in self._DATA_PROPERTY_TYPES | self._OBJECT_PROPERTY_TYPES
|
||||
):
|
||||
self._add_issue(
|
||||
issues,
|
||||
"UNKNOWN_PROPERTY_TYPE",
|
||||
f"Property '{prop_id}' has unknown type '{prop_type}'.",
|
||||
QualitySeverity.WARNING,
|
||||
element_id=prop_id,
|
||||
element_type="property",
|
||||
)
|
||||
|
||||
domains = self._values(prop, "domain")
|
||||
ranges = self._values(prop, "range")
|
||||
if domains or ranges:
|
||||
property_with_endpoints += 1
|
||||
else:
|
||||
self._add_issue(
|
||||
issues,
|
||||
"ORPHAN_PROPERTY",
|
||||
f"Property '{prop_id}' has no domain or range.",
|
||||
QualitySeverity.WARNING,
|
||||
element_id=prop_id,
|
||||
element_type="property",
|
||||
)
|
||||
if not domains:
|
||||
self._add_issue(
|
||||
issues,
|
||||
"MISSING_DOMAIN",
|
||||
f"Property '{prop_id}' has no domain.",
|
||||
QualitySeverity.WARNING,
|
||||
element_id=prop_id,
|
||||
element_type="property",
|
||||
)
|
||||
for domain in domains:
|
||||
matched = self._match_class(domain, class_aliases)
|
||||
if matched:
|
||||
referenced_classes.add(matched)
|
||||
elif not self._is_builtin_class(domain):
|
||||
self._add_issue(
|
||||
issues,
|
||||
"UNKNOWN_DOMAIN",
|
||||
f"Property '{prop_id}' references unknown domain '{domain}'.",
|
||||
QualitySeverity.ERROR,
|
||||
element_id=prop_id,
|
||||
element_type="property",
|
||||
details={"domain": domain},
|
||||
)
|
||||
|
||||
if not ranges:
|
||||
self._add_issue(
|
||||
issues,
|
||||
"MISSING_RANGE",
|
||||
f"Property '{prop_id}' has no range.",
|
||||
QualitySeverity.WARNING,
|
||||
element_id=prop_id,
|
||||
element_type="property",
|
||||
)
|
||||
for range_value in ranges:
|
||||
matched = self._match_class(range_value, class_aliases)
|
||||
if matched:
|
||||
referenced_classes.add(matched)
|
||||
self._check_range(
|
||||
prop_id,
|
||||
prop_type,
|
||||
range_value,
|
||||
matched is not None,
|
||||
issues,
|
||||
)
|
||||
|
||||
graph = graph_data
|
||||
if graph is None and ("entities" in ontology or "relationships" in ontology):
|
||||
graph = ontology
|
||||
graph_entities, graph_relationships = self._read_graph(graph, issues)
|
||||
if self._has_valid_graph_shape(graph):
|
||||
self._check_graph(graph, issues)
|
||||
self._mark_graph_types(graph_entities, class_aliases, referenced_classes)
|
||||
|
||||
for class_id in class_ids:
|
||||
if class_id not in referenced_classes:
|
||||
self._add_issue(
|
||||
issues,
|
||||
"ORPHAN_CLASS",
|
||||
f"Class '{class_id}' is not connected to a property, hierarchy, or graph entity type.",
|
||||
QualitySeverity.WARNING,
|
||||
element_id=class_id,
|
||||
element_type="class",
|
||||
)
|
||||
|
||||
class_coverage = (
|
||||
len(referenced_classes & set(class_ids)) / len(class_ids)
|
||||
if class_ids
|
||||
else 0.0
|
||||
)
|
||||
property_coverage = (
|
||||
property_with_endpoints / len(properties) if properties else 1.0
|
||||
)
|
||||
metrics: Dict[str, float] = {
|
||||
"coverage": (
|
||||
(class_coverage + property_coverage) / 2
|
||||
if classes or properties
|
||||
else 0.0
|
||||
),
|
||||
"class_coverage": class_coverage,
|
||||
"property_coverage": property_coverage,
|
||||
"validator_valid": 1.0 if getattr(validation, "valid", True) else 0.0,
|
||||
}
|
||||
if competency_questions is not None:
|
||||
evaluation_ontology = self._prepare_for_evaluation(
|
||||
ontology, classes, properties
|
||||
)
|
||||
evaluation = self._evaluate_competency_questions(
|
||||
evaluation_ontology, competency_questions=competency_questions
|
||||
)
|
||||
metrics["competency_question_coverage"] = evaluation.coverage_score
|
||||
metrics["completeness"] = evaluation.completeness_score
|
||||
|
||||
return self._build_report(
|
||||
issues,
|
||||
classes=len(classes),
|
||||
properties=len(properties),
|
||||
entities=len(graph_entities),
|
||||
relationships=len(graph_relationships),
|
||||
metrics=metrics,
|
||||
thresholds=active_thresholds,
|
||||
min_coverage=min_coverage,
|
||||
max_errors=max_errors,
|
||||
max_warnings=max_warnings,
|
||||
fail_on_warnings=should_fail_on_warnings,
|
||||
)
|
||||
|
||||
def _check_range(
|
||||
self,
|
||||
prop_id: str,
|
||||
prop_type: str,
|
||||
range_value: Any,
|
||||
is_class: bool,
|
||||
issues: List[QualityIssue],
|
||||
) -> None:
|
||||
if prop_type in self._DATA_PROPERTY_TYPES:
|
||||
if not self._is_known_datatype(range_value):
|
||||
self._add_issue(
|
||||
issues,
|
||||
"INVALID_DATATYPE_RANGE",
|
||||
f"Data property '{prop_id}' has invalid range '{range_value}'.",
|
||||
QualitySeverity.ERROR,
|
||||
element_id=prop_id,
|
||||
element_type="property",
|
||||
details={"range": range_value},
|
||||
)
|
||||
elif prop_type in self._OBJECT_PROPERTY_TYPES:
|
||||
if not is_class and not self._is_builtin_class(range_value):
|
||||
self._add_issue(
|
||||
issues,
|
||||
"UNKNOWN_RANGE",
|
||||
f"Object property '{prop_id}' references unknown range '{range_value}'.",
|
||||
QualitySeverity.ERROR,
|
||||
element_id=prop_id,
|
||||
element_type="property",
|
||||
details={"range": range_value},
|
||||
)
|
||||
elif (
|
||||
not is_class
|
||||
and not self._is_builtin_class(range_value)
|
||||
and not self._is_known_datatype(range_value)
|
||||
):
|
||||
self._add_issue(
|
||||
issues,
|
||||
"UNKNOWN_RANGE",
|
||||
f"Property '{prop_id}' references unknown range '{range_value}'.",
|
||||
QualitySeverity.ERROR,
|
||||
element_id=prop_id,
|
||||
element_type="property",
|
||||
details={"range": range_value},
|
||||
)
|
||||
|
||||
def _build_report(
|
||||
self,
|
||||
issues: List[QualityIssue],
|
||||
*,
|
||||
classes: int,
|
||||
properties: int,
|
||||
entities: int,
|
||||
relationships: int,
|
||||
metrics: Dict[str, float],
|
||||
thresholds: Dict[str, Optional[float]],
|
||||
min_coverage: float,
|
||||
max_errors: float,
|
||||
max_warnings: Optional[float],
|
||||
fail_on_warnings: bool,
|
||||
) -> OntologyQualityReport:
|
||||
failures: List[str] = []
|
||||
error_count = sum(
|
||||
issue.severity in (QualitySeverity.ERROR, QualitySeverity.CRITICAL)
|
||||
for issue in issues
|
||||
)
|
||||
warning_count = sum(
|
||||
issue.severity == QualitySeverity.WARNING for issue in issues
|
||||
)
|
||||
if error_count > max_errors:
|
||||
failures.append("max_errors")
|
||||
if max_warnings is not None and warning_count > max_warnings:
|
||||
failures.append("max_warnings")
|
||||
if fail_on_warnings and warning_count:
|
||||
failures.append("fail_on_warnings")
|
||||
if metrics.get("coverage", 0.0) < min_coverage:
|
||||
failures.append("min_coverage")
|
||||
return OntologyQualityReport(
|
||||
passed=not failures,
|
||||
issues=issues,
|
||||
stats={
|
||||
"classes": classes,
|
||||
"properties": properties,
|
||||
"entities": entities,
|
||||
"relationships": relationships,
|
||||
},
|
||||
metrics=metrics,
|
||||
thresholds=thresholds,
|
||||
threshold_failures=failures,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _validate_thresholds(
|
||||
thresholds: Mapping[str, Optional[float]],
|
||||
) -> Tuple[float, float, Optional[float]]:
|
||||
min_coverage = float(thresholds.get("min_coverage", 0.0) or 0.0)
|
||||
max_errors = float(thresholds.get("max_errors", 0.0) or 0.0)
|
||||
warning_value = thresholds.get("max_warnings")
|
||||
max_warnings = None if warning_value is None else float(warning_value)
|
||||
if not all(
|
||||
math.isfinite(value)
|
||||
for value in (min_coverage, max_errors)
|
||||
if value is not None
|
||||
) or (max_warnings is not None and not math.isfinite(max_warnings)):
|
||||
raise ValueError("quality thresholds must be finite numbers")
|
||||
if not 0.0 <= min_coverage <= 1.0:
|
||||
raise ValueError("min_coverage must be between 0.0 and 1.0")
|
||||
if max_errors < 0 or (max_warnings is not None and max_warnings < 0):
|
||||
raise ValueError("error and warning thresholds cannot be negative")
|
||||
return min_coverage, max_errors, max_warnings
|
||||
|
||||
@classmethod
|
||||
def _prepare_for_evaluation(
|
||||
cls,
|
||||
ontology: Dict[str, Any],
|
||||
classes: Iterable[Any],
|
||||
properties: Iterable[Any],
|
||||
) -> Dict[str, Any]:
|
||||
"""Make a shallow, evaluator-safe view without changing caller data."""
|
||||
prepared = dict(ontology)
|
||||
prepared["classes"] = [
|
||||
cls._prepare_element(element)
|
||||
for element in classes
|
||||
if isinstance(element, dict)
|
||||
]
|
||||
prepared["properties"] = [
|
||||
cls._prepare_element(element)
|
||||
for element in properties
|
||||
if isinstance(element, dict)
|
||||
]
|
||||
return prepared
|
||||
|
||||
@classmethod
|
||||
def _prepare_element(cls, element: Dict[str, Any]) -> Dict[str, Any]:
|
||||
prepared = dict(element)
|
||||
identifier = cls._identifier(prepared)
|
||||
if identifier is not None and not str(prepared.get("name", "")).strip():
|
||||
prepared["name"] = identifier
|
||||
return prepared
|
||||
|
||||
def _evaluate_competency_questions(
|
||||
self, ontology: Dict[str, Any], competency_questions: List[str]
|
||||
) -> Any:
|
||||
"""Evaluate with an isolated question manager for repeatable checks."""
|
||||
evaluator = copy.copy(self.evaluator)
|
||||
manager = getattr(self.evaluator, "competency_questions_manager", None)
|
||||
if manager is not None and hasattr(manager, "questions"):
|
||||
isolated_manager = copy.copy(manager)
|
||||
isolated_manager.questions = []
|
||||
evaluator.competency_questions_manager = isolated_manager
|
||||
return evaluator.evaluate_ontology(
|
||||
ontology, competency_questions=competency_questions
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def _read_collection(
|
||||
cls, ontology: Dict[str, Any], key: str, issues: List[QualityIssue]
|
||||
) -> List[Any]:
|
||||
if key not in ontology:
|
||||
cls._add_issue(
|
||||
issues,
|
||||
f"MISSING_{key.upper()}",
|
||||
f"Ontology has no {key} defined.",
|
||||
QualitySeverity.WARNING,
|
||||
element_type="ontology",
|
||||
)
|
||||
return []
|
||||
value = ontology[key]
|
||||
if not isinstance(value, list):
|
||||
cls._add_issue(
|
||||
issues,
|
||||
f"INVALID_{key.upper()}",
|
||||
f"Ontology '{key}' must be a list.",
|
||||
QualitySeverity.ERROR,
|
||||
element_type="ontology",
|
||||
)
|
||||
return []
|
||||
return value
|
||||
|
||||
@classmethod
|
||||
def _index_elements(
|
||||
cls,
|
||||
elements: Iterable[Any],
|
||||
element_type: str,
|
||||
missing_code: str,
|
||||
issues: List[QualityIssue],
|
||||
) -> Tuple[Dict[str, str], List[str]]:
|
||||
aliases: Dict[str, str] = {}
|
||||
identifiers: List[str] = []
|
||||
for index, element in enumerate(elements):
|
||||
identifier = cls._identifier(element)
|
||||
if identifier is None:
|
||||
cls._add_issue(
|
||||
issues,
|
||||
missing_code,
|
||||
f"{element_type.title()} at index {index} has no name or URI.",
|
||||
QualitySeverity.ERROR,
|
||||
element_type=element_type,
|
||||
details={"index": index},
|
||||
)
|
||||
continue
|
||||
identifiers.append(identifier)
|
||||
aliases_for_element: Set[str] = set()
|
||||
for candidate in cls._identifiers(element):
|
||||
aliases_for_element.update(cls._term_aliases(candidate))
|
||||
for alias in sorted(aliases_for_element):
|
||||
aliases.setdefault(alias, identifier)
|
||||
return aliases, identifiers
|
||||
|
||||
@classmethod
|
||||
def _mark_hierarchy(
|
||||
cls,
|
||||
classes: Iterable[Any],
|
||||
aliases: Mapping[str, str],
|
||||
referenced: Set[str],
|
||||
) -> None:
|
||||
for class_entry in classes:
|
||||
if not isinstance(class_entry, dict):
|
||||
continue
|
||||
identifier = cls._identifier(class_entry)
|
||||
for key in (
|
||||
"subClassOf",
|
||||
"subclassOf",
|
||||
"parent",
|
||||
"superclass",
|
||||
"superclasses",
|
||||
):
|
||||
parents = cls._values(class_entry, key)
|
||||
if parents and identifier:
|
||||
referenced.add(identifier)
|
||||
for parent in parents:
|
||||
matched = cls._match_class(parent, aliases)
|
||||
if matched:
|
||||
referenced.add(matched)
|
||||
|
||||
@classmethod
|
||||
def _mark_graph_types(
|
||||
cls,
|
||||
entities: Iterable[Any],
|
||||
aliases: Mapping[str, str],
|
||||
referenced: Set[str],
|
||||
) -> None:
|
||||
for entity in entities:
|
||||
if not isinstance(entity, dict):
|
||||
continue
|
||||
entity_type = entity.get("type") or entity.get("entity_type")
|
||||
matched = cls._match_class(entity_type, aliases)
|
||||
if matched:
|
||||
referenced.add(matched)
|
||||
|
||||
@classmethod
|
||||
def _check_graph(cls, graph: Dict[str, Any], issues: List[QualityIssue]) -> None:
|
||||
safe_graph = cls._prepare_graph_for_validation(graph, issues)
|
||||
result = GraphValidator().validate(safe_graph)
|
||||
code_map = {
|
||||
"DANGLING_EDGE": "UNRESOLVED_RELATIONSHIP_ENDPOINT",
|
||||
"ORPHAN_NODES": "ORPHAN_ENTITY",
|
||||
}
|
||||
severity_map = {
|
||||
"info": QualitySeverity.INFO,
|
||||
"warning": QualitySeverity.WARNING,
|
||||
"error": QualitySeverity.ERROR,
|
||||
"critical": QualitySeverity.CRITICAL,
|
||||
}
|
||||
for graph_issue in result.issues:
|
||||
severity = severity_map.get(
|
||||
graph_issue.severity.value, QualitySeverity.ERROR
|
||||
)
|
||||
details = dict(graph_issue.details or {})
|
||||
if graph_issue.code == "ORPHAN_NODES" and "ids" in details:
|
||||
details["ids"] = sorted(details["ids"], key=str)
|
||||
cls._add_issue(
|
||||
issues,
|
||||
code_map.get(graph_issue.code, graph_issue.code),
|
||||
graph_issue.message,
|
||||
severity,
|
||||
element_id=graph_issue.element_id,
|
||||
element_type=graph_issue.element_type,
|
||||
details=details,
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def _prepare_graph_for_validation(
|
||||
cls, graph: Dict[str, Any], issues: List[QualityIssue]
|
||||
) -> Dict[str, List[Dict[str, Any]]]:
|
||||
"""Normalize supported graph aliases and isolate malformed members."""
|
||||
entities: List[Dict[str, Any]] = []
|
||||
raw_entities = graph.get("entities", [])
|
||||
raw_relationships = graph.get("relationships", [])
|
||||
for index, entity in enumerate(
|
||||
raw_entities if isinstance(raw_entities, list) else []
|
||||
):
|
||||
if not isinstance(entity, dict):
|
||||
cls._add_issue(
|
||||
issues,
|
||||
"INVALID_ENTITY",
|
||||
f"Entity at index {index} must be a dictionary.",
|
||||
QualitySeverity.ERROR,
|
||||
element_type="entity",
|
||||
details={"index": index},
|
||||
)
|
||||
continue
|
||||
normalized = dict(entity)
|
||||
if not normalized.get("name") and normalized.get("text") is not None:
|
||||
normalized["name"] = normalized["text"]
|
||||
entities.append(normalized)
|
||||
|
||||
relationships: List[Dict[str, Any]] = []
|
||||
for index, relationship in enumerate(
|
||||
raw_relationships if isinstance(raw_relationships, list) else []
|
||||
):
|
||||
if not isinstance(relationship, dict):
|
||||
cls._add_issue(
|
||||
issues,
|
||||
"INVALID_RELATIONSHIP",
|
||||
f"Relationship at index {index} must be a dictionary.",
|
||||
QualitySeverity.ERROR,
|
||||
element_type="relationship",
|
||||
details={"index": index},
|
||||
)
|
||||
continue
|
||||
relationships.append(dict(relationship))
|
||||
|
||||
return {"entities": entities, "relationships": relationships}
|
||||
|
||||
@classmethod
|
||||
def _read_graph(
|
||||
cls, graph: Optional[Dict[str, Any]], issues: List[QualityIssue]
|
||||
) -> Tuple[List[Any], List[Any]]:
|
||||
if graph is None:
|
||||
return [], []
|
||||
if not isinstance(graph, dict):
|
||||
cls._add_issue(
|
||||
issues,
|
||||
"INVALID_GRAPH",
|
||||
"Graph data must be a dictionary.",
|
||||
QualitySeverity.ERROR,
|
||||
element_type="graph",
|
||||
)
|
||||
return [], []
|
||||
entities = graph.get("entities", [])
|
||||
relationships = graph.get("relationships", [])
|
||||
if not isinstance(entities, list) or not isinstance(relationships, list):
|
||||
cls._add_issue(
|
||||
issues,
|
||||
"INVALID_GRAPH",
|
||||
"Graph entities and relationships must be lists.",
|
||||
QualitySeverity.ERROR,
|
||||
element_type="graph",
|
||||
)
|
||||
return [], []
|
||||
return entities, relationships
|
||||
|
||||
@staticmethod
|
||||
def _has_valid_graph_shape(graph: Optional[Dict[str, Any]]) -> bool:
|
||||
return bool(
|
||||
isinstance(graph, dict)
|
||||
and isinstance(graph.get("entities", []), list)
|
||||
and isinstance(graph.get("relationships", []), list)
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _identifier(element: Any) -> Optional[str]:
|
||||
identifiers = OntologyQualityGate._identifiers(element)
|
||||
return identifiers[0] if identifiers else None
|
||||
|
||||
@staticmethod
|
||||
def _identifiers(element: Any) -> List[str]:
|
||||
if isinstance(element, dict):
|
||||
values = []
|
||||
for key in ("name", "uri", "id", "@id"):
|
||||
value = element.get(key)
|
||||
if value is not None and str(value).strip():
|
||||
values.append(str(value).strip())
|
||||
return values
|
||||
if isinstance(element, str) and element.strip():
|
||||
return [element.strip()]
|
||||
return []
|
||||
|
||||
@staticmethod
|
||||
def _values(element: Dict[str, Any], key: str) -> List[Any]:
|
||||
value = element.get(key)
|
||||
if value is None:
|
||||
return []
|
||||
if isinstance(value, (list, tuple, set)):
|
||||
values = [item for item in value if item is not None and str(item).strip()]
|
||||
return sorted(values, key=str) if isinstance(value, set) else values
|
||||
return [value] if str(value).strip() else []
|
||||
|
||||
@classmethod
|
||||
def _term_aliases(cls, value: Any) -> Set[str]:
|
||||
if isinstance(value, dict):
|
||||
value = cls._identifier(value)
|
||||
if value is None:
|
||||
return set()
|
||||
text = str(value).strip().strip("<>")
|
||||
if not text:
|
||||
return set()
|
||||
aliases = {text, text.lower()}
|
||||
for separator in ("#", "/"):
|
||||
if separator in text:
|
||||
local = text.rstrip("/").rsplit(separator, 1)[-1]
|
||||
aliases.update({local, local.lower()})
|
||||
if ":" in text and not text.startswith(("http://", "https://")):
|
||||
local = text.rsplit(":", 1)[-1]
|
||||
aliases.update({local, local.lower()})
|
||||
return aliases
|
||||
|
||||
@classmethod
|
||||
def _match_class(cls, value: Any, aliases: Mapping[str, str]) -> Optional[str]:
|
||||
for alias in sorted(cls._term_aliases(value)):
|
||||
if alias in aliases:
|
||||
return aliases[alias]
|
||||
return None
|
||||
|
||||
@classmethod
|
||||
def _is_builtin_class(cls, value: Any) -> bool:
|
||||
return any(alias in cls._BUILTIN_CLASSES for alias in cls._term_aliases(value))
|
||||
|
||||
@classmethod
|
||||
def _is_known_datatype(cls, value: Any) -> bool:
|
||||
return any(alias in cls._KNOWN_DATATYPES for alias in cls._term_aliases(value))
|
||||
|
||||
@staticmethod
|
||||
def _add_issue(
|
||||
issues: List[QualityIssue],
|
||||
code: str,
|
||||
message: str,
|
||||
severity: QualitySeverity,
|
||||
*,
|
||||
element_id: Optional[str] = None,
|
||||
element_type: Optional[str] = None,
|
||||
details: Optional[Dict[str, Any]] = None,
|
||||
) -> None:
|
||||
issues.append(
|
||||
QualityIssue(
|
||||
code=code,
|
||||
message=message,
|
||||
severity=severity,
|
||||
element_id=element_id,
|
||||
element_type=element_type,
|
||||
details=details or {},
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def ontology_quality_check(
|
||||
ontology: Any,
|
||||
graph_data: Optional[Dict[str, Any]] = None,
|
||||
*,
|
||||
thresholds: Optional[Mapping[str, Optional[float]]] = None,
|
||||
fail_on_warnings: bool = False,
|
||||
competency_questions: Optional[List[str]] = None,
|
||||
validator: Optional[OntologyValidator] = None,
|
||||
evaluator: Optional[OntologyEvaluator] = None,
|
||||
) -> OntologyQualityReport:
|
||||
"""Convenience wrapper around :class:`OntologyQualityGate`."""
|
||||
gate = OntologyQualityGate(
|
||||
validator=validator,
|
||||
evaluator=evaluator,
|
||||
thresholds=thresholds,
|
||||
fail_on_warnings=fail_on_warnings,
|
||||
)
|
||||
return gate.check(
|
||||
ontology,
|
||||
graph_data=graph_data,
|
||||
competency_questions=competency_questions,
|
||||
)
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user