Compare commits

..
Author SHA1 Message Date
Zohaib Hassnain 820f7257f2 docs(decision-intelligence): fix broken add_decision pattern and hybrid search description (#1466) 2026-09-05 00:38:55 +05:00
Mohd Kaif 230d91d794 Update README to refine project description
Removed 'open-source' from the description to streamline the message.
2026-09-04 23:30:53 +05:30
Mohd Kaif 652c50b45d Revise project description in README
Updated the description to emphasize developer-first approach and open-source nature.
2026-09-04 23:27:13 +05:30
Mohd Kaif 97ae95c352 Merge pull request #1437 from pkupt/fix/1430-shacl-limit-env
explorer: name SHACL limit env vars in error messages
2026-09-04 21:23:53 +05:30
Mohd Kaif 60c8bac866 Merge branch 'main' into fix/1430-shacl-limit-env 2026-09-04 20:58:12 +05:30
b82ae98417 docs(setup): tighten prose across quickstart, installation, and CLI s… (#1459)
* docs(setup): tighten prose across quickstart, installation, and CLI setup guides

* fix(cli-setup): correct semantica-server default binding address

The server.py main() binds to 127.0.0.1 by default and documents
SEMANTICA_HOST as the override to expose beyond localhost.
The previous documentation (from main and carried through this PR)
incorrectly stated 0.0.0.0:8000, which would lead users to believe
the server is network-accessible by default.

Fix all three occurrences in cli-setup.md:
- Installed Commands table
- When to Use Each Command prose bullet
- REST server usage example code comment

---------

Co-authored-by: AutoHarness Bot <bot@autoharness.local>
Co-authored-by: Mohd Kaif <98801504+KaifAhmad1@users.noreply.github.com>
Co-authored-by: Sameer Kadam <sskadam6305@gmail.com>
2026-09-04 20:35:05 +05:30
4568aeba2c docs: tighten prose in community and governance pages (#1442)
Co-authored-by: AutoHarness Bot <bot@autoharness.local>
Co-authored-by: Sameer Kadam <sskadam6305@gmail.com>
2026-09-04 20:13:20 +05:30
08d6ec62dc docs(cookbook): tighten prose across notebook catalogue (#1457)
* docs(cookbook): tighten prose across notebook catalogue

* Resolved comments

---------

Co-authored-by: AutoHarness Bot <bot@autoharness.local>
Co-authored-by: Sameer Kadam <sskadam6305@gmail.com>
2026-09-04 20:04:53 +05:30
6c40dea0c7 docs(architecture): tighten core principles and scalability prose (#1458)
Co-authored-by: AutoHarness Bot <bot@autoharness.local>
Co-authored-by: Sameer Kadam <sskadam6305@gmail.com>
2026-09-04 19:51:22 +05:30
478b00243b docs(resources): tighten prose across FAQ, learning paths, citation, … (#1456)
* docs(resources): tighten prose across FAQ, learning paths, citation, and license

* docs(faq): cut two leftover chained/dramatic-pause colons

"unstructured data: documents, APIs, databases: into structured..."
chained two colons in one sentence, and "...reached a conclusion:
not just what it said" is the exact "X: not Y" pattern #1426 flags
for removal. Neither was touched by this PR's original pass.

* docs_check: raise Mintlify export timeout from 300s to 600s

The "Validate Documentation" CI check has been timing out at exactly
300s on this branch, on main, and on an unrelated branch in the same
window (3 consecutive retries here, all with the same "timed out
after 300 s" error and no other diagnostic output). Local runs finish
well under the limit, so this isn't a content problem — the export
step just has no headroom left as the docs site has grown to 27
modules. Doubling the timeout gives it room without masking real
export failures, which still fail immediately with their own error.

---------

Co-authored-by: AutoHarness Bot <bot@autoharness.local>
Co-authored-by: KaifAhmad1 <kaifahmad087@gmail.com>
2026-09-04 17:19:47 +05:30
66cf952047 docs(glossary): tighten prose and remove colon pauses in glossary (#1454)
* docs(glossary): tighten prose and remove colon pauses

* Resolved Comments

---------

Co-authored-by: AutoHarness Bot <bot@autoharness.local>
Co-authored-by: Mohd Kaif <98801504+KaifAhmad1@users.noreply.github.com>
2026-09-04 16:37:01 +05:30
Mohd Kaif c2edf7e7ab Merge pull request #1453 from Deep070203/docs-modules-selection-guidance
docs(modules): tighten prose, remove chained colons, and use develope…
2026-09-04 16:31:31 +05:30
KaifAhmad1 24668f1d85 docs(modules): cut leftover chained colon in MCP Server integrations line
"Claude Desktop, ..., Cline: 15 MCP tools exposed" chained a second
colon onto the "Integrations:" label colon, the exact pattern #1426
asks this cleanup pass to remove. Split into two sentences.
2026-09-04 16:13:59 +05:30
Hitesh_GandZohaib Hassnain [109234410+ZohaibHassan16@users.noreply.github.com](mailto:109234410+ZohaibHassan16@users.noreply.github.com) 6b8122d757 This is resubmit of the pr for issue feat(integrations): add Google ADK support (#1312)
Adds Google ADK (Agent Development Kit) support to Semantica.

`semantica_kg_tools()` and `semantica_decision_tools()` expose entity/relation extraction, graph updates, and decision recording as ADK `FunctionTool`s. `SemanticaSessionService` implements ADK session storage on top of a Semantica `ContextGraph`, so session state, events, and knowledge graph data can live in the same graph instead of keeping sessions in memory.

There were also a number of dependency and CI fixes needed to get the integration working reliably. `google-adk` is pinned to a range that avoids the CI `websockets` conflict, the deprecated `pinecone-client` dependency was replaced with `pinecone`, Windows-only dependencies now have the appropriate platform markers, and `requirements-ci.txt` was regenerated to match. A `pip-audit` pass also required updates to `google-adk` and `starlette` for known CVEs.

Some unrelated `pyproject.toml` changes had slipped in during rebases, so the previous version, dependency bounds, `ingest-sap`/LangChain entries, and package-data settings were restored.

A few bugs in the initial ADK implementation were fixed during review:

* `extract_relations()` was calling `RelationExtractor.extract_entities()`, which doesn't exist on that extractor. The failure was being caught and returned in the tool's `error` field, leaving callers with an empty relation list. It now calls the correct extraction path.
* The repo's top-level `mcp/` package shadowed the third-party `mcp` package imported by `google.adk`, causing `google.adk` imports to fail from a normal repo checkout. The local package was moved to `semantica_mcp/mcp/`.

The MCP move needed a follow-up as well. `semantica/cli.py` and four existing tests were still importing from `mcp.*`, and the modules under `semantica_mcp/mcp/` still used the old absolute imports internally. `semantica_mcp` was also missing from the setuptools package include list and had no `__init__.py`, so it wouldn't have been included in an installed package. Those imports and packaging settings are fixed now.

The session service and ADK tools also had a few other problems:

* `list_sessions()` returned a plain list instead of ADK's `ListSessionsResponse`. The original import for that type doesn't work against the installed `google-adk` package, so it was silently falling back to a stub. `user_id` was also incorrectly required instead of being optional.
* Session node IDs were built by joining `app_name`, `user_id`, and `session_id` with unescaped colons, which allowed different identities to produce the same graph node ID. Each component is now encoded before joining.
* `kg_tools.py` and `decision_tools.py` each had their own lock registry and default graph instance. Sharing a graph between the two modules therefore didn't share the lock, and using both factories without an explicit graph produced two different defaults. The shared state now lives in one module used by both.
* `add_to_graph` had a `TypeError` compatibility fallback that couldn't succeed with the current `RelationExtractor` API and could hide the original extraction error. That fallback was removed.
* `append_event` persisted partial streaming events even though ADK's base session service skips them.
* `get_session()` ignored its `config` argument, so `num_recent_events` and `after_timestamp` had no effect.
* The async session-service methods performed synchronous graph scans while holding a `threading.RLock` on the event loop thread. That work now runs in worker threads with `asyncio.to_thread()` so a slow or contended graph operation doesn't block the loop.

---

Co-authored-by: Zohaib Hassnain [109234410+ZohaibHassan16@users.noreply.github.com](mailto:109234410+ZohaibHassan16@users.noreply.github.com)
2026-09-04 15:34:48 +05:00
Mohd Kaif be438c2201 Merge branch 'main' into docs-modules-selection-guidance 2026-09-04 16:03:33 +05:30
Mohd Kaif dc99b11d71 Merge pull request #1452 from Deep070203/docs-contributing-prose
docs(contributing): tighten prose and use developer-first guidance
2026-09-04 15:39:53 +05:30
Mohd Kaif 172b9ff718 Merge branch 'main' into docs-contributing-prose 2026-09-04 15:29:51 +05:30
KaifAhmad1 22fde95d7c docs(community-projects): fill in blank LLM provider table cells
Google Gemini, HuggingFace, DeepSeek, and Novita AI rows were left as
a stray colon with no notes. Fill them in to match the pattern of the
other rows, sourced from semantica/semantic_extract/providers.py.
2026-09-04 15:27:01 +05:30
Yuang PengandSameer Kadam b92fed2d0a feat(explorer): add Markdown editor write path (#1349)
* feat(explorer): add Markdown editing write path

* fix(explorer): address markdown editor review findings

* feat(explorer): add Markdown editor write path

---------

Co-authored-by: Sameer Kadam <sskadam6305@gmail.com>
2026-09-04 01:38:17 +05:30
AutoHarness Bot 8ede86f643 Resolved comments 2026-09-03 15:29:41 -04:00
AutoHarness Bot ace7adbbdd docs(contributing): tighten prose and use developer-first guidance 2026-09-03 14:59:10 -04:00
AutoHarness Bot 3e942cbea0 docs(modules): tighten prose, remove chained colons, and use developer-first guidance 2026-09-03 14:57:23 -04:00
Mohd Kaif 8910d2c949 docs(ontology): document quality gate threshold semantics (#1450)
* docs(ontology): document quality gate threshold semantics

The Ontology Quality Gate section (#1397) showed a thresholds={...}
example but never explained what min_coverage, max_errors,
max_warnings, or fail_on_warnings actually mean, or that
fail_on_warnings is a separate parameter rather than a thresholds
key. Add a concise defaults/semantics table, verified against
OntologyQualityGate.DEFAULT_THRESHOLDS and __init__ in quality_gate.py.

* docs(ontology): explain thresholds as prose instead of a table

A four-row table was heavier than this needed; each threshold's
meaning reads faster as two connected sentences.
2026-09-03 23:40:00 +05:30
Mohd Kaif 4c6eab632f Merge pull request #1397 from T1mn/codex/ontology-quality-gate
feat(ontology): add a CI-friendly quality gate
2026-09-03 23:20:39 +05:30
Mohd Kaif 103ab04970 Merge branch 'main' into codex/ontology-quality-gate 2026-09-03 23:05:32 +05:30
Zohaib Hassnain 88a57b39f9 docs: replace retired claude-sonnet-4-20250514 model id (#1449)
* docs: replace retired claude-sonnet-4-20250514 model id

* docs(llms): sweep retired model as qodo found
2026-09-03 22:29:50 +05:00
Zohaib HassnainandMohd Kaif f45499b5a7 fix(deps): unblock python 3.9 core install (#1445)
Co-authored-by: Mohd Kaif <98801504+KaifAhmad1@users.noreply.github.com>
2026-09-03 22:51:43 +05:30
Zohaib Hassnain c04adcd1a9 docs(semantic-extraction): fix summary output and model ID (#1448)
* docs(semantic-extraction): fix summary print and the model id

* chore: correct model id

* docs: make triplet valid split meaningful
2026-09-03 22:14:23 +05:00
Zohaib Hassnain a85cf913a5 docs(reasoning): clarify Datalog query result ordering (#1447)
* docs(reasoning): note DatalogReasoner.query() result is not guaranteed

* keep lists[dict] shape
2026-09-03 21:58:39 +05:00
Mohd Kaif 6ba433fea0 docs(index): rewrite landing page as a crisp developer welcome (#1446)
Replace the long feature-dump landing page with a lean "Welcome to
Semantica" page: a two-line problem/positioning statement (deterministic
semantic layer, no LLM required for graph construction, reasoning, or
provenance), five capability bullets, the multi-provider quickstart
snippet, and a 4-step onboarding path. Drops the redundant module
table, industry-use-case grid, and duplicate link lists in favor of
linking out to Core Concepts, guides, and the API reference. Keeps a
collapsed module-list accordion so the page still satisfies
docs_check.py's full-module-coverage check.
2026-09-03 22:26:38 +05:30
Guofang.Tang 75bcb64681 Merge branch 'main' into codex/ontology-quality-gate 2026-09-04 00:34:54 +08:00
Zohaib Hassnain f6a0e4a32e docs(pipeline): wire configured FailureHandler into the engine (#1444) 2026-09-03 21:17:02 +05:00
Mohd Kaif 111bcf997e Revamp badges and add community links in README
Updated badge styles and added community links.
2026-09-03 21:36:23 +05:30
Zohaib Hassnain 6a07ad29be docs(modules): fix code examples to match the current API (#1443)
* docs: rewrite every code example against the actual API

* docs: address Qodo
2026-09-03 21:03:54 +05:00
Mohd Kaif 064f0eccad Merge pull request #1432 from taljeon/codex/docs-langchain-prose
docs(langchain): tighten integration prose
2026-09-03 21:03:24 +05:30
pkupt af56bd865d test: assert SHACL limit messages name the env var 2026-09-03 21:16:10 +08:00
pkupt 3845fa7206 fix: name SHACL limit env vars in error messages
The three limit-exceeded messages in validate_shacl now name the env var
that controls each limit, and the SHACL validation guide documents all
four resource-limit variables with their defaults.
2026-09-03 21:16:10 +08:00
taljeon afec253451 docs(langchain): tighten integration prose 2026-09-03 21:58:36 +09:00
Wei Tao dd1e654047 fix(explorer): load registered schemas in Ontology Editor (#1278)
The Ontology Hub editor selected a registered ontology but left the canvas empty, and opening an ontology deep link landed on the Welcome workspace instead of the editor. Two independent causes: the application shell ignored `ontologyTab`/`ontologyEntity` URL state at startup, and the editor loaded registry metadata but never fetched the selected ontology's schema nodes and structural edges. The backend now exposes a bounded schema subgraph for one ontology at `GET /api/ontology/graph?uri=...`, and the editor maps that response into React Flow nodes and edges with loading, error, selection, and layout handling.

Five things came out of review on the new endpoint and the editor that consumes it.

The edge selection originally included an edge whenever either its source or its target was a core node. That let a property owned by a completely unrelated ontology leak into the requested one just because its `rdfs:domain` or `rdfs:range` happened to point at one of the requested ontology's classes. Edges are now selected only when their source is a core node, so the requested ontology can still reference outward to external vocabulary, but nothing from an unrelated ontology gets pulled in the other direction.

The backend accepts both compact and full-IRI forms for node types (`owl:Class` and `http://www.w3.org/2002/07/owl#Class` are equivalent), but the frontend classifier only recognized the compact strings, so a full-IRI class or ontology node fell through to `"external"`, wrong panel, wrongly read-only. Classification moved into `ontologyEditorModel.ts` as `classifyNodeType`, which compacts known full IRIs before matching.

An ontology imported through the fallback RDF parser, one with no `owl:Ontology` or `skos:ConceptScheme` declaration, minted a synthetic registry URI but never created a matching graph node or set `scheme_uri` on the classes and properties it imported. `_node_belongs_to_ontology` had nothing to associate those nodes with, so `core_node_ids` ended up empty and the endpoint 404'd for a registered ontology that genuinely had data. The fallback parser now records that ownership and emits a matching `owl:Ontology` node whenever it has to synthesize a URI.

Nested namespaces that were never registered as their own ontology got silently absorbed into whichever parent prefix matched, in both directions: a fragment-delimited nested name (`<stem>/child#Term`) and a path-delimited one (`<stem>/child/Term`). The first fix only handled the fragment form; prefix ownership now only extends to names minted directly in the ontology's own namespace (`<stem>#Term` or `<stem>/Term`), and any further delimiter of either kind marks a nested vocabulary that isn't absorbed until it's registered or carries an explicit owner. Once registered, the nested namespace owns its own nodes as before.

Selecting a node in the editor writes `ontologyEntity=<id>` into the URL. Switching ontologies via the dropdown cleared the in-memory selection but left that parameter pointing at the old ontology, so a reload after switching could resolve the stale ID and jump back. The dropdown now clears the parameter on change.

Regression tests cover each fix directly: inward-edge exclusion, the full-IRI classification matrix, an end-to-end fallback-import test that forces the parser path and opens the resulting ontology, and a nested-namespace ownership matrix covering both delimiter forms in both the registered and unregistered case.
2026-09-03 17:58:20 +05:00
KevinandSameer Kadam 6b8437781e fix(ontology): stop inferring framework entity fields as datatype properties (#1420)
* fix(ontology): stop inferring framework entity fields as datatype properties

* test(ontology): assert framework fields do not leak as datatype properties

* test(ontology): fix test file formatting

* test(ontology): cover unmerged graphbuilder entities

---------

Co-authored-by: Sameer Kadam <sskadam6305@gmail.com>
2026-09-03 18:24:52 +05:30
Zohaib Hassnain ba85215aea docs(graphrag): fix broken string literals and clarify max_hops (#1431)
The Banking domain example built basel_cre20_text / bcbs239_text with bare indented string continuations (no parens, no backslash), raising IndentationError. Wrapped both in parentheses like the Clinical example. Separately, the guide passed max_hops= to retrieve() and stated it overrides the constructor's expansion depth: it does not. AgentContext.retrieve(max_hops=) is only consumed by _apply_proximity_metadata (a proximity-radius filter that needs anchor_node), and expansion depth is fixed by max_expansion_hops passed into ContextRetriever. Removed max_hops from the non-anchored retrieve call, annotated the anchored ones, corrected the intro and tuning sections, and noted query_with_reasoning() does take a real per-call max_hops. Also replaced an invented node/edge count comment with the real store() return keys and qualified an ingest_file() reference.
2026-09-03 17:48:07 +05:00
T1mn 8778e6a837 fix(ontology): address follow-up quality gate findings 2026-09-03 19:57:04 +08:00
Mohd Kaif 5809418421 docs: tighten prose in concepts.md, guides/graphrag.md, reference/context.md (#1422)
Flagship pass establishing the crisp-prose style for the rest of
docs/: remove em dashes from explanatory prose (leave them in
simulated document/alert string literals, which are data, not our
voice), replace colon-as-dramatic-pause constructions, and fix two
broken relative links in reference/context.md ([Reasoning](reasoning)
and [Provenance](provenance) were missing their leading slash and
would 404 on the live site, the same class of bug fixed sitewide in
PR #1407). concepts.md's intro also picks up the new context/semantic
layer tagline. No code examples, tables, or technical content
changed.
2026-09-03 17:20:04 +05:30
Mohd Kaif 40efab6796 docs(index): cut marketing copy, remove em dashes, make crisp (#1421)
* docs(index): cut marketing copy, remove em dashes, make crisp

Replace the narrative hook and rhetorical-question opening with a
direct statement. Trim the persuasive framing on the problem list
and industry section to plain, factual bullets. Replace every em
dash with plain sentence structure or a colon, and drop the
repeated colon-as-dramatic-pause construction from the opening.
No content or links removed; only the framing and punctuation
changed.

* docs: update tagline to context/semantic layer for high-stakes domains

Replace "The Accountability and Context Layer for AI" with "The
Context and Semantic Layer for AI in High-Stakes Domains" across
docs.json (description, og:title) and index.md (frontmatter
description, opening sentence, and the Core Concepts step bullet).
Audit trail and accountability remain a downstream property, not
the headline framing.
2026-09-03 17:00:03 +05:30
Mohd Kaif 837654fc4f docs: restructure nav — drop FAQ/Changelog tabs, add API Reference tab (#1419)
Remove the standalone FAQ and Changelog top-level tabs. FAQ and
Community pages move into the Overview tab as their own groups
(still fully reachable, just relocated). Changelog was only an
external link to GitHub releases and had no pages of its own.

Split the API reference pages (reference/*) out of the Modules tab
into a new, dedicated API Reference tab, so Modules now holds only
the conceptual guides and API Reference holds every module's class
and function documentation.
2026-09-03 16:19:43 +05:30
Mohd Kaif 2daa937811 docs: simplify custom.css to a static, professional style (#1418)
Remove decorative hover animations (code block/card lift+glow, table
row highlighting, list item highlighting, animated nav underline,
button lift+glow) and the page-load fade-in transition. Keep the
color/typography branding, accessibility focus rings, and scrollbar
styling.
2026-09-03 15:59:36 +05:30
Mohd Kaif 9321b9d27e Merge pull request #1392 from pkupt/fix/1374-weaviate-delete
feat(weaviate): add delete_vectors to WeaviateStore
2026-09-03 15:53:00 +05:30
Mohd Kaif d5a7ea9f9a Merge branch 'main' into fix/1374-weaviate-delete 2026-09-03 15:47:37 +05:30
Zohaib Hassnain b872b29628 docs(concepts): rewrite code examples to match the actual API (#1417)
* docs(concepts): rewrite every code example against real API

* add Qodo review
2026-09-03 15:02:27 +05:00
Mohd Kaif 01352d5fd5 Merge branch 'main' into codex/ontology-quality-gate 2026-09-03 15:18:43 +05:30
Zohaib Hassnain bcc49f232d Merge branch 'main' into fix/1374-weaviate-delete 2026-09-03 14:41:05 +05:00
Mohd Kaif d9ed017b8c Merge branch 'main' into fix/1374-weaviate-delete 2026-09-03 15:06:24 +05:30
T1mn 6a55b8c3ee fix(ontology): address quality gate review findings 2026-09-03 10:04:46 +08:00
T1mn 471cbe7711 feat(ontology): add deterministic quality gate 2026-09-03 02:00:57 +08:00
pkupt df42a015b0 test(weaviate): cover delete_vectors and erasure integration 2026-09-02 20:45:22 +08:00
pkupt 9df54ffcd0 feat(weaviate): add delete_vectors to WeaviateStore 2026-09-02 20:02:37 +08:00
136 changed files with 13290 additions and 3194 deletions
+1 -1
View File
@@ -543,7 +543,7 @@ cuda-pathfinder==1.6.0 \
# via
# -c requirements-ci.txt
# cuda-bindings
cuda-toolkit==13.0.3.0 \
cuda-toolkit==13.0.3 \
--hash=sha256:d693caaa261214ddd7dbb60d68e71cbed884e68c2be7509778f3051da0b91c3f
# via
# -c requirements-ci.txt
+1 -1
View File
@@ -403,7 +403,7 @@ cuda-pathfinder==1.6.0 \
# via
# -c requirements-ci.txt
# cuda-bindings
cuda-toolkit==13.0.3.0 \
cuda-toolkit==13.0.3 \
--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.0 \
cuda-toolkit==13.0.3 \
--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.0 \
cuda-toolkit==13.0.3 \
--hash=sha256:d693caaa261214ddd7dbb60d68e71cbed884e68c2be7509778f3051da0b91c3f
# via
# -c requirements-ci.txt
+2
View File
@@ -168,3 +168,5 @@ jobs:
print("Explorer frontend is packaged")
PY
- name: Run Google ADK Integration Tests
run: pytest tests/integrations/google_adk/
+25 -3
View File
@@ -14,7 +14,7 @@
### Graph-Native Infrastructure for Context and Accountable AI Systems
#### *The Open Source Palantir for AI Agents*
#### *Developer-first, knowledge infrastructure for AI, alternative to expensive enterprise platforms.*
> 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.
@@ -28,7 +28,13 @@
[![GitHub Stars](https://img.shields.io/github/stars/semantica-agi/semantica?style=flat-square&color=FFD700&logo=github&logoColor=white&label=Stars)](https://github.com/semantica-agi/semantica) [![GitHub Forks](https://img.shields.io/github/forks/semantica-agi/semantica?style=flat-square&color=6E40C9&logo=github&logoColor=white&label=Forks)](https://github.com/semantica-agi/semantica/network/members) [![Contributors](https://img.shields.io/github/contributors/semantica-agi/semantica?style=flat-square&color=2EA043&logo=github&logoColor=white)](https://github.com/semantica-agi/semantica/graphs/contributors) [![PyPI](https://img.shields.io/pypi/v/semantica.svg?style=flat-square&color=0066CC&logo=pypi&logoColor=white)](https://pypi.org/project/semantica/) [![Total Downloads](https://static.pepy.tech/badge/semantica?style=flat-square)](https://pepy.tech/project/semantica) [![Python 3.8+](https://img.shields.io/badge/python-3.8+-3776AB?style=flat-square&logo=python&logoColor=white)](https://www.python.org/) [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg?style=flat-square)](https://opensource.org/licenses/MIT) [![CI](https://img.shields.io/github/actions/workflow/status/semantica-agi/semantica/ci.yml?style=flat-square&label=CI)](https://github.com/semantica-agi/semantica/actions) [![Install Matrix](https://img.shields.io/github/actions/workflow/status/semantica-agi/semantica/install-matrix.yml?style=flat-square&label=pip%20install)](https://github.com/semantica-agi/semantica/actions/workflows/install-matrix.yml) [![OpenSSF Scorecard](https://api.scorecard.dev/projects/github.com/semantica-agi/semantica/badge?style=flat-square)](https://scorecard.dev/viewer/?uri=github.com/semantica-agi/semantica) [![Ask DeepWiki](https://deepwiki.com/badge.svg)](https://deepwiki.com/semantica-agi/semantica)
[![Website](https://img.shields.io/badge/Website-getsemantica.ai-000000?style=flat-square&logo=googlechrome&logoColor=white)](https://getsemantica.ai/) [![Docs](https://img.shields.io/badge/Docs-docs.getsemantica.ai-0099FF?style=flat-square&logo=readthedocs&logoColor=white)](https://docs.getsemantica.ai/) [![Discord](https://img.shields.io/badge/Discord-Join%20Community-5865F2?style=flat-square&logo=discord&logoColor=white)](https://discord.gg/sV34vps5hH) [![Twitter/X](https://img.shields.io/badge/Follow-%40BuildSemantica-000000?style=flat-square&logo=x&logoColor=white)](https://x.com/BuildSemantica) [![YouTube](https://img.shields.io/badge/YouTube-Watch%20Demos-FF0000?style=flat-square&logo=youtube&logoColor=white)](https://www.youtube.com/watch?v=QfnNZg4-dZA) [![Changelog](https://img.shields.io/badge/Changelog-View-6E40C9?style=flat-square&logo=keepachangelog&logoColor=white)](CHANGELOG.md)
[![Website](https://img.shields.io/badge/Website-getsemantica.ai-000000?style=for-the-badge\&logo=googlechrome\&logoColor=white)](https://getsemantica.ai/)
[![Docs](https://img.shields.io/badge/Docs-docs.getsemantica.ai-0099FF?style=for-the-badge\&logo=readthedocs\&logoColor=white)](https://docs.getsemantica.ai/)
[![Community](https://img.shields.io/badge/Community-Join%20Discord-5865F2?style=for-the-badge\&logo=discord\&logoColor=white)](https://discord.gg/sV34vps5hH)
[![X](https://img.shields.io/badge/X-%40BuildSemantica-000000?style=for-the-badge\&logo=x\&logoColor=white)](https://x.com/BuildSemantica)
[![YouTube](https://img.shields.io/badge/YouTube-Watch%20Demos-FF0000?style=flat-square\&logo=youtube\&logoColor=white)](https://www.youtube.com/watch?v=QfnNZg4-dZA)
```bash
pip install semantica
@@ -1455,6 +1461,22 @@ 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.7
@@ -1463,7 +1485,7 @@ For contributor / dev-server setup: **[explorer/README.md: Local Setup Guide](ex
- **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
- **`ContextGraph` gains deterministic, human-editable Markdown round-trip persistence** alongside the existing JSON API, and Explorer can validate and apply Markdown edits to individual graph nodes and AgentMemory items supplied by the hosting application
- **`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
+9 -9
View File
@@ -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's 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 is 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.
+4 -183
View File
@@ -1,14 +1,9 @@
/* ============================================================
SEMANTICA DOCS — PREMIUM DESIGN SYSTEM
SEMANTICA DOCS — DESIGN SYSTEM
Dark-first (#080C10 bg, #10B981 emerald accent)
Minimal, static styling — no decorative motion.
============================================================ */
/* ── Keyframes ─────────────────────────────────────────────── */
@keyframes pageFadeIn {
from { opacity: 0; transform: translateY(6px); }
to { opacity: 1; transform: translateY(0); }
}
/* ── Global ─────────────────────────────────────────────────── */
html {
scroll-behavior: smooth;
@@ -29,16 +24,7 @@ html {
}
::-webkit-scrollbar-thumb:hover { background: rgba(16, 185, 129, 0.4); }
/* ── Page entrance ──────────────────────────────────────────── */
main,
article,
[class*="content-area"],
[class*="ContentArea"],
[class*="prose"] {
animation: pageFadeIn 0.35s ease both;
}
/* ── Focus rings ─────────────────────────────────────────────── */
/* ── Focus rings (accessibility — kept) ─────────────────────── */
*:focus-visible {
outline: 2px solid rgba(16, 185, 129, 0.55) !important;
outline-offset: 3px !important;
@@ -59,7 +45,7 @@ h1::after {
left: 0;
width: 44px;
height: 2px;
background: linear-gradient(90deg, #10B981 0%, transparent 100%);
background: #10B981;
border-radius: 1px;
}
@@ -71,9 +57,6 @@ article a,
[class*="prose"] a {
text-decoration-color: rgba(16, 185, 129, 0.35);
text-underline-offset: 3px;
transition:
text-decoration-color 0.15s ease,
color 0.15s ease;
}
article a:hover,
@@ -89,14 +72,6 @@ blockquote {
padding: 0.9rem 1.2rem !important;
font-style: italic;
color: rgba(255, 255, 255, 0.68) !important;
transition:
border-color 0.2s ease,
background-color 0.2s ease !important;
}
blockquote:hover {
border-left-color: rgba(16, 185, 129, 0.65) !important;
background: rgba(16, 185, 129, 0.07) !important;
}
/* ── HR / Divider ────────────────────────────────────────────── */
@@ -123,165 +98,11 @@ table thead th {
border-bottom: 1px solid rgba(16, 185, 129, 0.18) !important;
}
table tbody tr {
transition: background-color 0.15s ease;
cursor: default;
}
table tbody tr:hover {
background-color: rgba(16, 185, 129, 0.06) !important;
}
table tbody tr:hover td {
background-color: transparent !important;
}
table td,
table th {
transition: background-color 0.15s ease;
}
/* ── CODE BLOCKS ─────────────────────────────────────────────── */
pre,
[class*="codeblock"],
[class*="code-group"],
[class*="CodeBlock"],
[data-rehype-pretty-code-fragment] {
transition:
box-shadow 0.25s cubic-bezier(0.4, 0, 0.2, 1),
border-color 0.25s cubic-bezier(0.4, 0, 0.2, 1),
transform 0.25s cubic-bezier(0.4, 0, 0.2, 1) !important;
}
pre:hover,
[class*="codeblock"]:hover,
[class*="CodeBlock"]:hover,
[data-rehype-pretty-code-fragment]:hover {
transform: translateY(-1px) !important;
box-shadow:
0 0 0 1px rgba(16, 185, 129, 0.18),
0 2px 12px rgba(16, 185, 129, 0.06),
0 8px 32px rgba(0, 0, 0, 0.2) !important;
border-color: rgba(16, 185, 129, 0.2) !important;
}
/* ── CARDS ───────────────────────────────────────────────────── */
[class*="card"],
[class*="Card"],
[data-card],
.group\/card {
transition:
transform 0.22s ease,
box-shadow 0.22s ease,
border-color 0.22s ease !important;
}
[class*="card"]:hover,
[class*="Card"]:hover,
[data-card]:hover,
.group\/card:hover {
transform: translateY(-3px) !important;
box-shadow:
0 8px 28px rgba(0, 0, 0, 0.18),
0 0 0 1px rgba(16, 185, 129, 0.22) !important;
border-color: rgba(16, 185, 129, 0.28) !important;
}
/* ── CALLOUTS / ADMONITIONS ──────────────────────────────────── */
[class*="callout"],
[class*="Callout"],
[class*="admonition"] {
transition:
box-shadow 0.2s ease,
border-color 0.2s ease !important;
}
[class*="callout"]:hover,
[class*="Callout"]:hover,
[class*="admonition"]:hover {
box-shadow: 0 2px 16px rgba(16, 185, 129, 0.08) !important;
border-color: rgba(16, 185, 129, 0.35) !important;
}
/* ── STEPS ───────────────────────────────────────────────────── */
[class*="step"],
[class*="Step"] {
transition: background-color 0.15s ease !important;
}
[class*="step"]:hover,
[class*="Step"]:hover {
background-color: rgba(16, 185, 129, 0.04) !important;
}
/* ── INLINE CODE ─────────────────────────────────────────────── */
:not(pre) > code {
transition:
background-color 0.15s ease,
color 0.15s ease !important;
cursor: text;
}
:not(pre) > code:hover {
background-color: rgba(16, 185, 129, 0.16) !important;
}
/* ── NAVIGATION / SIDEBAR ────────────────────────────────────── */
nav a,
[class*="sidebar"] a,
[class*="Sidebar"] a {
transition: color 0.15s ease !important;
text-decoration: none;
position: relative;
}
nav a::after,
[class*="sidebar"] a::after,
[class*="Sidebar"] a::after {
content: "";
position: absolute;
bottom: -1px;
left: 0;
width: 0;
height: 1px;
background: #10B981;
transition: width 0.2s ease;
}
nav a:hover::after,
[class*="sidebar"] a:hover::after,
[class*="Sidebar"] a:hover::after {
width: 100%;
}
/* ── TEXT / LIST ITEMS ───────────────────────────────────────── */
ul > li,
ol > li {
border-radius: 3px;
transition: background-color 0.12s ease;
}
ul > li:hover,
ol > li:hover {
background-color: rgba(16, 185, 129, 0.04);
}
/* ── PRIMARY BUTTON / CTA ────────────────────────────────────── */
button[class*="primary"],
a[class*="primary"],
[class*="btn-primary"],
[class*="ButtonPrimary"] {
transition:
box-shadow 0.2s ease,
transform 0.2s ease !important;
}
button[class*="primary"]:hover,
a[class*="primary"]:hover,
[class*="btn-primary"]:hover,
[class*="ButtonPrimary"]:hover {
box-shadow: 0 0 22px rgba(16, 185, 129, 0.28) !important;
transform: translateY(-1px) !important;
}
/* ── HIDE THEME TOGGLE ───────────────────────────────────────── */
+19 -19
View File
@@ -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>
**Next:** [Quickstart →](/quickstart) full pipeline with visualization and export.
See the [Quickstart →](/quickstart) for a 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 available instantly.
<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.
**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>
## Still Unsure?
## Architecture Selection Guidance
<AccordionGroup>
<Accordion title="Knowledge graph vs. vector store — which do I need?" icon="scale-balanced">
<Accordion title="Knowledge graph vs. vector store selection" 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="I just want to run something quickly." icon="rocket">
<Accordion title="Fast local pipeline setup" 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="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.
<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.
```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="I need a compliance-ready pipeline — what's the minimum stack?" icon="shield-check">
<Accordion title="Minimum stack for compliance-ready pipelines" 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
View File
@@ -43,10 +43,10 @@ icon: "quote-left"
## Share Your Research
Published research using Semantica? [Let us know](https://github.com/semantica-agi/semantica/issues): we may feature your work.
If you publish research using Semantica, [let us know](https://github.com/semantica-agi/semantica/issues) so we can 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
View File
@@ -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 `0.0.0.0:8000` |
| `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-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** — 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).
- **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).
## Usage Examples
@@ -61,7 +61,7 @@ python -c "import semantica; print(semantica.__version__)"
<Tabs>
<Tab title="REST server">
```bash
# Starts FastAPI + uvicorn on 0.0.0.0:8000
# Starts FastAPI + uvicorn on 127.0.0.1:8000 (set SEMANTICA_HOST to change)
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.
+9 -9
View File
@@ -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** |: |
| **Groq** | LLaMA, Mixtral: fast inference |
| **Google Gemini** | Gemini Pro and other Gemini models |
| **Groq** | LLaMA, Mixtral (fast inference) |
| **Ollama** | Fully local, air-gapped |
| **HuggingFace** |: |
| **DeepSeek** |: |
| **Novita AI** |: |
| **HuggingFace** | Transformers-based local LLM models |
| **DeepSeek** | deepseek-chat and reasoning models |
| **Novita AI** | OpenAI-compatible gateway, DeepSeek-V3.2 default |
| **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
View File
@@ -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.
+10 -10
View File
@@ -8,16 +8,16 @@ icon: "book-open"
New here? Start with [Getting Started](/getting-started) for hands-on examples, then return here for deeper understanding.
</Info>
Semantica transforms unstructured data: documents, web pages, reports, databases: into **knowledge graphs**: structured representations that AI systems can query, reason about, and trace back to sources.
Semantica transforms unstructured data (documents, web pages, reports, databases) into **knowledge graphs**: structured representations that AI systems can query, reason about, and trace back to sources.
At its core, Semantica adds a **context and accountability layer** on top of your existing AI stack. It doesn't replace LangChain, LlamaIndex, or your LLM provider: it makes their outputs **grounded**, **traceable**, and **auditable**.
At its core, Semantica adds a context and semantic layer on top of your existing AI stack. It doesn't replace LangChain, LlamaIndex, or your LLM provider. It makes their outputs grounded, traceable, and auditable.
- **Context Layer** Knowledge graphs, GraphRAG retrieval, semantic embeddings, and temporal intelligence ground every LLM response in structured, queryable facts.
- **Accountability Layer** Provenance tracking, decision intelligence, conflict detection, and W3C PROV-O compliance make every claim in your AI stack auditable and explainable.
- **Extension Layer** `PluginRegistry` and `MethodRegistry` let you replace or augment any component: ingestors, extractors, reasoning engines, backends: without changing framework code.
- **Context Layer.** Knowledge graphs, GraphRAG retrieval, semantic embeddings, and temporal intelligence ground every LLM response in structured, queryable facts.
- **Accountability Layer.** Provenance tracking, decision intelligence, conflict detection, and W3C PROV-O compliance make every claim in your AI stack auditable and explainable.
- **Extension Layer.** `PluginRegistry` and `MethodRegistry` let you replace or augment any component (ingestors, extractors, reasoning engines, backends) without changing framework code.
<Warning>
**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: the context and data fed in, the decision produced, its provenance, the relevant relationships, the policies applied, and the full execution trail. In short, Semantica explains and audits *what the AI system did*, not the foundation model's private internal reasoning.
**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: the context and data fed in, the decision produced, its provenance, the relevant relationships, the policies applied, and the full execution trail. In short, Semantica explains and audits *what the AI system did*, not the foundation model's private internal reasoning.
</Warning>
## Knowledge Graphs
@@ -30,7 +30,7 @@ The foundation of everything in Semantica. A knowledge graph stores information
- **Edges (relationships)**: `works_for`, `located_in`, `founded_by`
- **Properties**: name, date, confidence score, source URL
This structure makes knowledge **searchable**, **connectable**, **queryable**, and: critically: **explainable**: every answer can be traced back to the facts and relationships that produced it.
This structure makes knowledge searchable, connectable, and queryable. Critically, it's explainable: every answer can be traced back to the facts and relationships that produced it.
## Entity Extraction (NER)
@@ -513,6 +513,6 @@ Semantica is designed for extension. Any component: ingestor, extractor, graph b
</Accordion>
</AccordionGroup>
- [Quickstart Tutorial](/quickstart) — Build a full pipeline with code.
- [Modules Guide](/modules) — Every module explained with examples.
- [API Reference](/reference/context) — Complete technical reference.
- [Quickstart Tutorial](/quickstart): build a full pipeline with code.
- [Modules Guide](/modules): every module explained with examples.
- [API Reference](/reference/context): complete technical reference.
+9 -9
View File
@@ -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
```
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.
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.
## 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
View File
@@ -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)** — 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*
- **[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*
## How to Run
+30 -29
View File
@@ -2,7 +2,7 @@
"$schema": "https://mintlify.com/docs.json",
"theme": "mint",
"name": "Semantica",
"description": "The Accountability and Context Layer for AI — Context Graphs · Decision Intelligence · Full Provenance",
"description": "The Context and Semantic Layer for AI in High-Stakes Domains — Context Graphs · Decision Intelligence · Full Provenance",
"colors": {
"primary": "#10B981",
"light": "#10B981",
@@ -43,7 +43,7 @@
"raiseIssue": true
},
"metadata": {
"og:title": "Semantica — Accountability & Context Layer for AI",
"og:title": "Semantica — Context & Semantic Layer for AI in High-Stakes Domains",
"og:description": "Build explainable, auditable knowledge graphs with full provenance. Open source. MIT licensed.",
"og:image": "/assets/img/semantica-logo.png",
"twitter:card": "summary_large_image",
@@ -121,6 +121,23 @@
"pages": [
"vector_stores/pgvector"
]
},
{
"group": "FAQ",
"pages": [
"faq"
]
},
{
"group": "Community",
"pages": [
"community",
"community-projects",
"contributing-guide",
"governance",
"citation",
"project-license"
]
}
]
},
@@ -167,7 +184,17 @@
"guides/policy-engine",
"guides/visualization",
"guides/distance-intelligence",
"guides/graph-analytics",
"guides/graph-analytics"
]
}
]
},
{
"tab": "API Reference",
"groups": [
{
"group": "Context & Intelligence",
"pages": [
"reference/context",
"reference/kg",
"reference/temporal",
@@ -236,32 +263,6 @@
]
}
]
},
{
"tab": "FAQ",
"groups": [
{
"group": "FAQ",
"pages": [
"faq"
]
},
{
"group": "Community",
"pages": [
"community",
"community-projects",
"contributing-guide",
"governance",
"citation",
"project-license"
]
}
]
},
{
"tab": "Changelog",
"href": "https://github.com/semantica-agi/semantica/releases"
}
]
},
+5 -5
View File
@@ -109,7 +109,7 @@ Explorer loads a graph from a JSON file on disk. You need to create that file fi
</Steps>
<Tip>
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()`.
Pipelines that already produced a saved graph can skip straight to Step 2, provided 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).
+7 -7
View File
@@ -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>
@@ -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.
+32 -32
View File
@@ -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: a 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 (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: converting dates to ISO format, canonicalizing entity names, fixing encoding issues, stripping noise. Ensures downstream extraction works on clean, consistent text.
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.
**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`: returns the most likely hypothesis given available evidence.
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.
**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: name, date, URI, confidence score, source URL.
An attribute or characteristic of an entity or relationship, such as name, date, URI, confidence score, or 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: e.g., `(Google, acquired, DeepMind)`: from raw text.
Identifying and extracting typed semantic relationships between entities (such as `(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: higher accuracy than either approach alone.
A retrieval strategy combining vector similarity search with keyword or metadata filtering, achieving 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: PageRank (link-based importance), betweenness centrality (bridge nodes), closeness centrality (average distance to all others).
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).
**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: 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, including 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, applicable to any directed graph.
An algorithm measuring node importance based on the structure of incoming relationships; originally designed for web pages, but 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: 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 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.
+10 -10
View File
@@ -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: 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.
- **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.
## 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.
+31 -7
View File
@@ -102,9 +102,8 @@ 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 = "dec_001", # UUID — auto-generated if omitted via record_decision
decision_id = None, # required arg — None/"" auto-generates a UUID
category = "threat_classification",
scenario = "Unattributed C2 cluster",
reasoning = "Infrastructure overlaps APT29 ASN",
@@ -117,9 +116,29 @@ 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.01.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.
@@ -137,7 +156,7 @@ for p in precedents:
print(" Similarity: {:.3f}".format(p.metadata.get("similarity_score", 0)))
```
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.
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.
## Building a Causal Chain
@@ -262,8 +281,13 @@ d = Decision(
)
if engine.check_compliance(d, "cti_confidence_gate"):
graph.add_decision(d)
engine.record_policy_application(d.decision_id, "cti_confidence_gate", "1.0")
# 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")
print("Decision recorded — policy compliant.")
else:
print("Decision blocked — confidence 0.62 below policy minimum 0.80.")
@@ -590,7 +614,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(d.decision_id, "lending_policy_v3", "3.0")
engine.record_policy_application(loan_id, "lending_policy_v3", "3.0")
print("Loan decision recorded — policy compliant.")
# SR 11-7 explainability report
+55 -42
View File
@@ -1,9 +1,9 @@
---
title: "GraphRAG Graph-Augmented Retrieval"
title: "GraphRAG: Graph-Augmented Retrieval"
description: "Go beyond vector search: retrieve facts, trace reasoning paths, and ground LLM responses in your knowledge graph."
---
GraphRAG combines vector similarity with knowledge graph traversal so retrieval finds structurally connected facts, not just text that sounds related. When a `ContextGraph` is attached to `AgentContext`, every retrieval call automatically blends semantic search with multi-hop graph expansion and `query_with_reasoning()` returns an auditable reasoning path alongside the LLM answer.
GraphRAG combines vector similarity with knowledge graph traversal so retrieval finds structurally connected facts, not just text that sounds related. When a `ContextGraph` is attached to `AgentContext`, every retrieval call automatically blends semantic search with multi-hop graph expansion, and `query_with_reasoning()` returns an auditable reasoning path alongside the LLM answer.
## What Is GraphRAG?
@@ -11,7 +11,7 @@ GraphRAG (Graph-Augmented Retrieval-Augmented Generation) enhances traditional R
**GraphRAG vs. traditional vector-only RAG:** Vector RAG finds documents similar to your query text. GraphRAG finds documents similar to your query AND documents connected to those through entity relationships, even if they don't mention your query terms directly.
**The role of graph traversal:** Starting from entities found in vector-similar documents, GraphRAG expands outward through relationship edges to discover related facts. This reveals connections that pure text similarity would miss like finding that a threat actor targets healthcare by following the path: Actor → Tool → Victim Organization → Industry Sector.
**The role of graph traversal:** Starting from entities found in vector-similar documents, GraphRAG expands outward through relationship edges to discover related facts. This reveals connections that pure text similarity would miss, like finding that a threat actor targets healthcare by following the path: Actor → Tool → Victim Organization → Industry Sector.
## Why Use GraphRAG?
@@ -96,7 +96,7 @@ context = AgentContext(
)
```
Now ingest your documents. `store()` with `extract_entities=True` runs the full extraction pipeline internally Named Entity Recognition (NER), relation extraction, and entity linking and populates both the vector index and the graph simultaneously:
Now ingest your documents. `store()` with `extract_entities=True` runs the full extraction pipeline internally (Named Entity Recognition, relation extraction, and entity linking) and populates both the vector index and the graph simultaneously:
```python
intel_documents = [
@@ -132,16 +132,17 @@ stats = context.store(
print("Graph built: {} nodes, {} edges".format(
stats["graph_nodes"], stats["graph_edges"]
))
# Graph built: 18 nodes, 14 edges
# Nodes: APT29, HAMMERTOSS, NATO, LifeCare, AS59796, CISA Sector 6, ...
# Edges: deployed, observed_on, classified_as, targets, operates_in, ...
```
The graph now contains a connected subgraph linking APT29 to healthcare infrastructure across four document boundaries — something that would be invisible to a pure vector search.
`store()` returns a dict with `stored_count`, `memory_ids`, `graph_nodes`, and
`graph_edges`. The extracted nodes (APT29, HAMMERTOSS, LifeCare, AS59796, …) and
edges (`deployed`, `observed_on`, `classified_as`, …) now span all four documents.
The graph now contains a connected subgraph linking APT29 to healthcare infrastructure across four document boundaries, something that would be invisible to a pure vector search.
## Retrieving the relevant subgraph
With the graph populated, a plain `retrieve()` call already does more than vector search. When `use_graph=True`, the retriever seeds the graph traversal from the top-k vector matches and expands outward by following edges, collecting connected facts within `max_hops`:
With the graph populated, a plain `retrieve()` call already does more than vector search. When `use_graph=True`, the retriever seeds the graph traversal from the top-k vector matches and expands outward by following edges. Expansion depth is set once, by `max_expansion_hops` on the `AgentContext` constructor:
```python
results = context.retrieve(
@@ -149,7 +150,6 @@ results = context.retrieve(
use_graph=True,
max_results=10,
expand_graph=True,
max_hops=3,
)
for r in results:
@@ -169,17 +169,25 @@ Notice the top results: while pure vector search might rank connected facts lowe
When you know specifically which entity you want to anchor the traversal to, pass `anchor_node`:
```python
# Anchor on APT29 explicitly proximity scores are calculated from this node
# Anchor on APT29 explicitly: proximity scores are calculated from this node
apt29_intel = context.retrieve(
"C2 infrastructure beaconing patterns",
use_graph=True,
anchor_node="APT29",
proximity_weight=0.7, # strongly favour nodes close to APT29
max_hops=3,
max_hops=3, # with an anchor, this bounds the proximity radius
max_results=8,
)
```
<Note>
`max_hops` on `retrieve()` only takes effect when `anchor_node` is set: it
bounds the proximity radius used for scoring and drops results farther than
`max_hops` from the anchor. Without an `anchor_node` it is ignored. It does
**not** change how far graph expansion reaches: that is fixed by
`max_expansion_hops` on the constructor.
</Note>
## Getting a grounded LLM answer with a reasoning path
`retrieve()` gives you the grounded context. `query_with_reasoning()` goes one step further: it passes that subgraph context to an LLM and returns the answer together with the multi-hop path the retrieval system traced through the graph. That path is your audit trail.
@@ -187,7 +195,7 @@ apt29_intel = context.retrieve(
```python
from semantica.llms import LiteLLM
llm = LiteLLM(model="anthropic/claude-sonnet-4-20250514")
llm = LiteLLM(model="anthropic/claude-sonnet-5")
result = context.query_with_reasoning(
"What are APT29's known TTPs against healthcare infrastructure, "
@@ -197,7 +205,7 @@ result = context.query_with_reasoning(
max_hops=3,
)
# The LLM answer grounded in graph-retrieved context, not training memory
# The LLM answer, grounded in graph-retrieved context, not training memory
print(result["response"])
# The multi-hop trace: APT29 → deployed → HAMMERTOSS → observed_on → LifeCare → ...
@@ -213,7 +221,7 @@ for src in result["sources"]:
print(" [{:.3f}] {}".format(src["score"], src["content"][:80]))
```
The `reasoning_path` field is what separates GraphRAG from a black-box LLM call. When an analyst asks "how do you know APT29 targeted healthcare?", you can show them the exact traversal the system made across your own documents not a claim the model generated from training data.
The `reasoning_path` field is what separates GraphRAG from a black-box LLM call. When an analyst asks "how do you know APT29 targeted healthcare?", you can show them the exact traversal the system made across your own documents, not a claim the model generated from training data.
The full return structure from `query_with_reasoning()`:
@@ -232,11 +240,11 @@ The full return structure from `query_with_reasoning()`:
<Tabs>
<Tab title="Defense CTI/Threat">
<Tab title="Defense: CTI/Threat">
Multi-INT intelligence fusion: OSINT threat feeds, NVD CVE data, and HUMINT summaries ingested into a single graph, then queried with multi-hop reasoning to trace C2 infrastructure chains and attribute campaigns to specific actors.
In classified environments the graph can be partitioned by data handling caveat each `AgentContext` operates over the subset of documents cleared for the querying user. The `reasoning_path` output doubles as a sanitisable audit trail for downgraded reporting.
In classified environments the graph can be partitioned by data handling caveat: each `AgentContext` operates over the subset of documents cleared for the querying user. The `reasoning_path` output doubles as a sanitisable audit trail for downgraded reporting.
```python
from semantica.context import AgentContext, ContextGraph
@@ -273,7 +281,7 @@ context.store(
link_entities=True,
)
llm = LiteLLM(model="anthropic/claude-sonnet-4-20250514")
llm = LiteLLM(model="anthropic/claude-sonnet-5")
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.",
@@ -300,11 +308,11 @@ proximate = context.retrieve(
</Tab>
<Tab title="Security SOC/Incident">
<Tab title="Security: SOC/Incident">
Security operations: real-time alert triage against a graph containing hosts, CVEs, user accounts, runbooks, and historical incidents. GraphRAG retrieves the relevant runbook and similar past incidents in a single call, reducing mean-time-to-respond.
The `decision_tracking=True` flag records every triage query as an auditable decision, with the full context that was provided to the LLM essential for post-incident review and SOC metrics.
The `decision_tracking=True` flag records every triage query as an auditable decision, with the full context that was provided to the LLM. That's essential for post-incident review and SOC metrics.
```python
from semantica.context import AgentContext, ContextGraph
@@ -343,7 +351,7 @@ Parent: wmiprvse.exe
Sigma match: T1053.005 Scheduled Task/Job
"""
llm = LiteLLM(model="anthropic/claude-sonnet-4-20250514")
llm = LiteLLM(model="anthropic/claude-sonnet-5")
triage = soc_context.query_with_reasoning(
"Triage this SIEM alert and identify the correct response runbook:\n{}".format(alert_text),
llm_provider=llm,
@@ -369,7 +377,7 @@ for inc in similar:
</Tab>
<Tab title="Life Science Clinical/Pharma">
<Tab title="Life Science: Clinical/Pharma">
Clinical decision support: FDA drug labels, clinical guidelines, and trial summaries ingested into a graph where drug-enzyme-metabolite-interaction chains become traversable paths. A three-hop query (drug → enzyme → metabolite → contraindication) surfaces interaction risks that no single document would make explicit.
@@ -417,7 +425,7 @@ Patient: 68F, AF, CKD stage 3b (eGFR 32). On warfarin (INR target 2.03.0).
Presenting for elective hip replacement. Concurrent: amiodarone 200mg, atorvastatin 40mg.
"""
llm = LiteLLM(model="anthropic/claude-sonnet-4-20250514")
llm = LiteLLM(model="anthropic/claude-sonnet-5")
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),
@@ -443,7 +451,7 @@ contra_chain = clinical_context.retrieve(
</Tab>
<Tab title="Banking Risk/Compliance">
<Tab title="Banking: Risk/Compliance">
Regulatory compliance: Basel III (CRE20), BCBS 239, SR 11-7, and EBA IRRBB guidelines ingested as a graph where regulation articles cross-reference each other as edges. Multi-hop queries traverse those cross-references automatically, so a question about commercial real estate RWA pulls the relevant CRE20 paragraphs and the BCBS 239 data quality requirements that govern their calculation in a single call.
@@ -466,12 +474,17 @@ compliance_context = AgentContext(
retention_days=2555, # 7-year regulatory retention
)
# In production these come from ingest_file() — shown as strings here for brevity
basel_cre20_text = "CRE20.32: For income-producing real estate where repayment depends on "
"property cash flows, RWA = exposure × risk weight, where risk weight "
"is determined by LTV bucket per Table CRE20.3..."
bcbs239_text = "Principle 3: Risk data should be accurate and have a single authoritative source. "
"Where data is aggregated across systems, reconciliation must be documented..."
# In production the text comes from a parsed file, e.g. FileIngestor().ingest_file(path).text;
# inline strings here for brevity
basel_cre20_text = (
"CRE20.32: For income-producing real estate where repayment depends on "
"property cash flows, RWA = exposure × risk weight, where risk weight "
"is determined by LTV bucket per Table CRE20.3..."
)
bcbs239_text = (
"Principle 3: Risk data should be accurate and have a single authoritative source. "
"Where data is aggregated across systems, reconciliation must be documented..."
)
compliance_context.store(
[
@@ -482,7 +495,7 @@ compliance_context.store(
extract_relationships=True,
)
llm = LiteLLM(model="anthropic/claude-sonnet-4-20250514")
llm = LiteLLM(model="anthropic/claude-sonnet-5")
answer = compliance_context.query_with_reasoning(
"Under Basel III CRE20, what are the RWA calculation requirements for "
"commercial real estate exposures with LTV > 80%? "
@@ -496,7 +509,7 @@ print(answer["response"])
print("Regulatory sources cited: {}".format(answer["num_sources"]))
print("Confidence: {:.1%}".format(answer["confidence"]))
# The reasoning path is the audit log show it to the regulator
# The reasoning path is the audit log: show it to the regulator
print("\n--- Reasoning Path (audit log) ---")
print(answer["reasoning_path"])
```
@@ -524,18 +537,18 @@ The `hybrid_alpha` parameter set in the `AgentContext` constructor establishes a
When targeting a specific `anchor_node`, you can apply `proximity_weight` in `retrieve()` to dynamically blend structural distance from the anchor into the final score:
```python
# Anchor node provided let vector semantics lead, graph proximity only slightly boosts
# Anchor node provided: let vector semantics lead, graph proximity only slightly boosts
results = context.retrieve(
query, use_graph=True, anchor_node="APT29", proximity_weight=0.2
)
# Known-entity tracing topology drives the retrieval
# Known-entity tracing: topology drives the retrieval
results = context.retrieve(
query, use_graph=True, anchor_node="APT29", proximity_weight=0.8
)
```
Each additional hop in `max_hops` exponentially increases the subgraph size. Practical defaults by domain:
Each additional expansion hop exponentially increases the subgraph size. Practical defaults by domain:
```text
General Q&A max_expansion_hops=2 (95% of useful facts within 2 hops)
@@ -544,7 +557,7 @@ Drug interactions max_expansion_hops=3 (drug → enzyme → metabolite
Regulatory cross-ref max_expansion_hops=2 (rule → article → article)
```
Set globally in the constructor; override per call with the `max_hops` argument to `retrieve()`.
Expansion depth is a constructor setting only (`max_expansion_hops`); there is no per-call override on `retrieve()`. `query_with_reasoning()` does take a per-call `max_hops` argument.
## How GraphRAG works internally
@@ -576,9 +589,9 @@ The vector search and graph traversal run independently, then their scores are f
## Related Guides
- [Semantic Extraction](/guides/semantic-extraction) build the graph from raw unstructured text
- [Agent Memory](/guides/agent-memory) store, retrieve, and persist agent memories
- [Context Graphs](/guides/context-graphs) build and traverse the knowledge graph directly
- [Reasoning](reasoning) — derive new facts and run inference rules over the graph
- [Decision Intelligence](/guides/decision-intelligence) causal chains, policy enforcement, decision tracking
- [LLM Integrations](/guides/llm-integrations) connect Groq, OpenAI, Anthropic, HuggingFace, and 100+ more
- [Semantic Extraction](/guides/semantic-extraction): build the graph from raw unstructured text
- [Agent Memory](/guides/agent-memory): store, retrieve, and persist agent memories
- [Context Graphs](/guides/context-graphs): build and traverse the knowledge graph directly
- [Reasoning](/guides/reasoning): derive new facts and run inference rules over the graph
- [Decision Intelligence](/guides/decision-intelligence): causal chains, policy enforcement, decision tracking
- [LLM Integrations](/guides/llm-integrations): connect Groq, OpenAI, Anthropic, HuggingFace, and 100+ more
+8 -8
View File
@@ -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-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.
`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.
```python
from semantica.llms import LiteLLM
# Anthropic Claude — highest accuracy for complex reasoning
llm = LiteLLM(model="anthropic/claude-sonnet-4-20250514")
llm = LiteLLM(model="anthropic/claude-sonnet-5")
# 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-3-5-sonnet-20241022-v2:0")
llm = LiteLLM(model="bedrock/anthropic.claude-sonnet-4-5-20250929-v1: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-4-20250514",
"prod": "anthropic/claude-sonnet-5",
"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-4-20250514")
deep_llm = LiteLLM(model="anthropic/claude-sonnet-5")
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-4-20250514")
deep_llm = LiteLLM(model="anthropic/claude-sonnet-5")
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-4-20250514")
report_llm = LiteLLM(model="anthropic/claude-sonnet-5")
# 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-4-20250514")
claude = LiteLLM(model="anthropic/claude-sonnet-5")
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)
+4 -4
View File
@@ -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-4-20250514")
llm = LiteLLM(model="anthropic/claude-sonnet-5")
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-4-20250514")
deep_llm = LiteLLM(model="anthropic/claude-sonnet-5")
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-4-20250514")
llm = LiteLLM(model="anthropic/claude-sonnet-5")
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-4-20250514")
llm = LiteLLM(model="anthropic/claude-sonnet-5")
# --- Risk Desk: PD/LGD/EL analysis ---
risk_desk.store(
+1 -1
View File
@@ -477,7 +477,7 @@ regs = [
]
# Use an LLM to extract the conceptual model from regulatory prose
llm_gen = LLMOntologyGenerator(provider="anthropic", model="claude-sonnet-4-20250514")
llm_gen = LLMOntologyGenerator(provider="anthropic", model="claude-sonnet-5")
ontology = llm_gen.generate_ontology_from_text(
"\n\n".join(r.text[:8000] for r in regs) # token-safe excerpt per document
)
+4 -2
View File
@@ -127,7 +127,7 @@ engine = ExecutionEngine(max_workers=4, retry_on_failure=True)
result = engine.execute_pipeline(pipeline)
print(f"Success: {result.success}")
print(f"Output: {result.output}") # {"node_count": 312, "edge_count": 847}
print(f"Output: {result.output}") # the final step's return value, e.g. {"node_count": ..., "edge_count": ...}
print(f"Duration: {result.metrics['execution_time']:.2f}s")
print(f"Steps completed: {result.metrics['steps_executed']}")
```
@@ -197,7 +197,9 @@ engine = ExecutionEngine(
max_workers = 4,
retry_on_failure = True,
)
# The engine uses handler.get_retry_policy(step.step_type) when a step fails
# ExecutionEngine builds its own FailureHandler; replace it with the configured one
engine.failure_handler = handler
# The engine now calls engine.failure_handler.get_retry_policy(step.step_type) on failure
```
`handler.classify_error()` distinguishes `ValidationError` (low severity, usually don't retry), `ProcessingError` (high severity), and timeout/connection errors (medium severity, always retry). You can inspect the classification:
+21 -16
View File
@@ -100,14 +100,15 @@ ner = NamedEntityRecognizer(
methods=["llm", "ml", "pattern"],
confidence_threshold=0.75,
provider="anthropic",
llm_model="claude-sonnet-4-6",
llm_model="claude-sonnet-5",
)
entities = ner.extract_entities(report)
for e in entities:
print("[{:>5.2f}] {:15s} {}".format(e.confidence, e.label, e.text))
# Expected output (abbreviated):
# Illustrative output — exact labels and scores depend on the method and model.
# Abbreviated:
# [ 0.94] THREAT_ACTOR GAMMA-7
# [ 0.91] THREAT_ACTOR DELTA-3
# [ 0.97] MALWARE HAMMERTOSS
@@ -262,16 +263,18 @@ from semantica.semantic_extract import TripletExtractor
tri = TripletExtractor(
method="llm",
provider="anthropic",
llm_model="claude-sonnet-4-6",
llm_model="claude-sonnet-5",
include_temporal=True, # attach time context to triplets when available
include_provenance=True, # embed source document reference in each triplet
validate=False, # return raw triplets; validate explicitly below
)
# Feed in the entities and relations you already extracted — the extractor
# uses them to constrain and validate what it produces
# uses them to constrain what it produces
triplets = tri.extract_triplets(report, entities, relations)
# Filter malformed triplets before serialisation
# (extract_triplets validates automatically unless validate=False, as above)
valid = tri.validate_triplets(triplets)
print("Valid: {}/{}".format(len(valid), len(triplets)))
@@ -320,7 +323,7 @@ def ingest_intel_report(
methods=[method, "pattern"],
confidence_threshold=0.70,
provider="anthropic",
llm_model="claude-sonnet-4-6",
llm_model="claude-sonnet-5",
)
entities = ner.extract_entities(text)
classified = ner.classify_entities(entities)
@@ -335,7 +338,7 @@ def ingest_intel_report(
relation_types=["deployed", "targets", "exploits", "operates_from", "provided_to"],
confidence_threshold=0.65,
provider="anthropic",
llm_model="claude-sonnet-4-6",
llm_model="claude-sonnet-5",
)
relations = rel.extract_relations(text, entities)
@@ -347,9 +350,10 @@ def ingest_intel_report(
tri = TripletExtractor(
method=method,
provider="anthropic",
llm_model="claude-sonnet-4-6",
llm_model="claude-sonnet-5",
include_temporal=True,
include_provenance=True,
validate=False, # keep raw triplets so the summary can report rejections
)
triplets = tri.extract_triplets(text, entities, relations)
valid = tri.validate_triplets(triplets)
@@ -377,6 +381,7 @@ def ingest_intel_report(
"coref_chains": len(chains),
"relations": len(relations),
"events": len(events),
"triplets_total": len(triplets),
"triplets_valid": len(valid),
"graph_nodes": graph_stats.get("graph_nodes", 0),
"graph_edges": graph_stats.get("graph_edges", 0),
@@ -402,7 +407,7 @@ for text, doc_id in reports:
summary["relations"],
summary["events"],
summary["triplets_valid"],
len(summary["rdf_turtle"]),
summary["triplets_total"],
))
```
@@ -421,7 +426,7 @@ ner = NamedEntityRecognizer(
methods=["llm", "pattern"],
confidence_threshold=0.75,
provider="anthropic",
llm_model="claude-sonnet-4-6",
llm_model="claude-sonnet-5",
)
entities = ner.extract_entities(fintel_text)
grouped = ner.classify_entities(entities)
@@ -438,14 +443,14 @@ rel = RelationExtractor(
relation_types=["operates_from", "deployed", "targets", "exploits"],
confidence_threshold=0.70,
provider="anthropic",
llm_model="claude-sonnet-4-6",
llm_model="claude-sonnet-5",
)
relations = rel.extract_relations(fintel_text, entities)
tri = TripletExtractor(
method="llm",
provider="anthropic",
llm_model="claude-sonnet-4-6",
llm_model="claude-sonnet-5",
include_temporal=True,
include_provenance=True,
)
@@ -544,14 +549,14 @@ rel = RelationExtractor(
relation_types=["treats", "causes_adverse_event", "has_efficacy", "evaluated_in"],
confidence_threshold=0.65,
provider="anthropic",
llm_model="claude-sonnet-4-6",
llm_model="claude-sonnet-5",
)
relations = rel.extract_relations(paper, entities)
tri = TripletExtractor(
method="llm",
provider="anthropic",
llm_model="claude-sonnet-4-6",
llm_model="claude-sonnet-5",
triplet_types=["treats", "has_efficacy", "causes_adverse_event"],
include_temporal=True,
include_provenance=True,
@@ -595,7 +600,7 @@ ner = NamedEntityRecognizer(
methods=["llm", "ml", "pattern"],
confidence_threshold=0.70,
provider="anthropic",
llm_model="claude-sonnet-4-6",
llm_model="claude-sonnet-5",
)
entities = ner.extract_entities(credit_memo)
grouped = ner.classify_entities(entities)
@@ -612,14 +617,14 @@ rel = RelationExtractor(
relation_types=["guaranteed_by", "secured_by", "classified_as", "exposed_to"],
confidence_threshold=0.65,
provider="anthropic",
llm_model="claude-sonnet-4-6",
llm_model="claude-sonnet-5",
)
relations = rel.extract_relations(credit_memo, entities)
tri = TripletExtractor(
method="llm",
provider="anthropic",
llm_model="claude-sonnet-4-6",
llm_model="claude-sonnet-5",
include_temporal=True,
include_provenance=True,
)
+16
View File
@@ -707,6 +707,22 @@ 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.
+25 -304
View File
@@ -1,109 +1,31 @@
---
title: "Semantica"
description: "The Accountability and Context Layer for AI: Context Graphs · Decision Intelligence · Full Provenance"
title: "Welcome to Semantica"
description: "The Context and Semantic Layer for AI in High-Stakes Domains: Context Graphs · Decision Intelligence · Full Provenance"
---
```bash
pip install semantica
```
Your AI agent just made a decision. Now someone needs to explain it.
Most AI agents run on embeddings, not meaning. A similarity score has no structure, no relationships, and no way to explain why a result came back.
*What did it know at the time? Which facts shaped the outcome? Where did those facts come from? Has it made the same call before: and did that go well?*
Semantica is the semantic and context layer underneath your LLM, vector store, and agent framework: deterministic infrastructure, not a model. Graph construction, reasoning, and provenance all run without an LLM in the loop. It turns fragmented enterprise data into a structured, queryable context graph and knowledge graph, governed by ontologies, taxonomies, and controlled vocabularies (OWL, SHACL, SKOS), so your data's meaning is explicit rather than approximated by an embedding.
If your stack can't answer those questions with a traceable record, you have a gap. Not a capability gap: an **accountability gap**. It's the reason AI hasn't landed at scale in healthcare, finance, legal, and government. And it's why teams building for those markets keep rebuilding the same guardrails from scratch.
Provenance and audit trails aren't a bolt-on. They fall out naturally once your data has that structure, so the same graph that powers retrieval and reasoning also gives you a straight answer when a regulator asks why.
**Semantica closes that gap.** It's the context and accountability layer that sits beneath your existing agent framework: not a replacement for LangChain or LlamaIndex, but the infrastructure that makes their outputs trustworthy.
## What you get
## The Problem Every Production AI Team Hits
Powerful agents aren't automatically trustworthy ones. Five structural blind spots make modern AI systems impossible to deploy in regulated environments:
**No memory structure** — agents store embeddings, not meaning
- No way to ask *why* a fact was recalled
- No link from a recalled fact back to its source document
- Context is a black box that resets on every run
**No decision trail** — agents act continuously but record nothing
- No history to hand to a regulator or auditor
- No way to replay or reproduce a past decision
- Debugging means re-running, not reviewing
**No provenance** — outputs can't be traced to source facts
- In healthcare, finance, and legal: this is a hard compliance blocker
- No lineage from inference back to the original document
- Impossible to demonstrate what the agent actually relied on
**No reasoning transparency** — black-box answers with no explanation
- Impossible to validate the reasoning path
- Impossible to contest a specific conclusion
- No basis for improving or correcting future behavior
**No conflict detection** — contradictory facts silently coexist in vector stores
- No detection when two sources disagree
- Outputs become inconsistent and unpredictable over time
- Silent failures compound as the knowledge base grows
<Note>
These aren't edge cases. They're why enterprise AI pilots stall: and why your compliance team keeps saying *not yet*.
</Note>
## What Semantica Adds to Your Stack
Semantica gives every agent the infrastructure it needs to be accountable. Drop it into your existing setup in minutes:
**Context Graphs** — a structured, queryable graph of everything your agent knows, decides, and reasons about
- Persistent across agent runs: no context loss between sessions
- Queryable with SPARQL and full graph algorithms
- Temporal model with `valid_from` / `valid_until` on nodes and edges
- Point-in-time snapshots of the full knowledge state
**Decision Intelligence** — every decision is a first-class object in your system
- `record_decision()` captures full lifecycle and causal chain
- Hybrid precedent search over past decisions for consistency
- `analyze_decision_impact()` shows downstream consequences
- Causal chain visualization from trigger to outcome
**Full Provenance** — every fact links to its source document and ingestion event
- W3C PROV-O compliant lineage across all modules
- Full traceability from raw input to final inference
- `recorded_at` stamping with OWL-Time export
- Audit-ready for HIPAA, SOX, GDPR, FDA 21 CFR Part 11
**Reasoning Engines** — explainable reasoning paths, not black boxes
- Forward chaining, Rete, deductive, abductive
- SPARQL query-based inference over RDF graphs
- Datalog with recursive Horn clause rules
- Every conclusion backed by a traceable derivation path
**Temporal Intelligence** — your graph knows not just *what*, but *when*
- Allen interval algebra: all 13 temporal relations
- Point-in-time queries over historical graph states
- Temporal provenance stamping on every fact
- OWL-Time export for standards-compliant archiving
**Ontology Hub** — full ontology lifecycle in the browser
- Visual editor for schema design and editing
- SHACL Studio for constraint authoring and validation
- Alignment authoring across multiple ontologies
- Health dashboard and version control built in
- **[Context graphs](/guides/context-graphs)**: a persistent, queryable graph of everything your agent knows, decides, and reasons about
- **Decision intelligence**: `record_decision()` captures the full lifecycle and causal chain of every decision
- **[Full provenance](/guides/provenance)**: every fact links back to its source, W3C PROV-O compliant and audit-ready for HIPAA, SOX, and GDPR
- **[Explainable reasoning](/guides/reasoning)**: forward chaining, Datalog, and SPARQL, each with a derivation path you can inspect
- **Temporal intelligence**: Allen interval algebra and point-in-time snapshots, so the graph knows not just *what* but *when*
<Tip>
Works alongside any LLM provider and any agent framework: add it to an existing stack without changing your architecture.
Works alongside any LLM provider and any agent framework, and ingests directly from enterprise data platforms like Databricks, SAP, Salesforce, and Snowflake. Add it to an existing stack without changing your architecture.
</Tip>
<img src="/assets/img/diagrams/architecture-overview.svg" alt="Semantica four-layer architecture: Ingestion → Processing → Intelligence → Application" style={{ width: '100%', borderRadius: '12px', margin: '24px 0' }} />
## See It In Action
One pip install. A few lines to connect your agent. Everything else becomes traceable.
```bash
pip install semantica
```
## Try it
<CodeGroup>
@@ -185,229 +107,28 @@ decision_id = context.record_decision(
</CodeGroup>
- [Full Quickstart](/quickstart) — Step-by-step pipeline walkthrough
- [Cookbook](/cookbook) — 40+ real-world Jupyter notebooks
- [Join Discord](https://discord.gg/sV34vps5hH) — Community chat and support
## Built for Where Mistakes Have Consequences
Semantica was designed for domains where every decision must be explainable and every fact must be traceable.
<Warning>
**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: the context and data fed in, the decision produced, its provenance, the relevant relationships, the policies applied, and the full execution trail. See [Core Concepts](/concepts) for the full scope note.
</Warning>
**Healthcare & Life Sciences**
- Clinical decision support with full audit trails
- Drug interaction and contraindication graphs
- Patient safety event tracking and root-cause analysis
- HIPAA-compliant provenance chains out of the box
**Finance & Risk**
- Fraud detection knowledge graphs
- Risk assessment trails built to survive an audit
- SOX, GDPR, and MiFID II compliance infrastructure
- Model decision lineage for regulatory reporting
**Legal & Compliance**
- Evidence-backed research with every cited fact provenance-linked
- Contract analysis with traceable clause extraction
- Regulatory change tracking across jurisdictions
- Full reasoning paths ready for court-admissible documentation
**Cybersecurity**
- Threat attribution graphs linking actors, TTPs, and indicators
- Incident response timelines with full event provenance
- Security audit trails across the complete kill chain
- MITRE ATT&CK-aligned knowledge graph integration
**Government & Defense**
- Policy decision trails from brief to outcome
- Classified information handling with provenance chains
- Chain-of-custody scrutiny for intelligence reporting
- Air-gapped deployment with local LLM support
**Critical Infrastructure**
- Power grid state tracking with temporal intelligence
- Transportation safety event graphs
- Emergency response coordination with decision audit trails
- Consequence modeling for high-stakes operational decisions
## Start Here
## Start here
<Steps>
<Step title="Install Semantica">
<Step title="Install">
```bash
pip install semantica
```
See [Installation](/installation) for optional extras (`[all]`, `[neo4j]`, `[pinecone]`) and environment setup.
Optional extras: `[all]`, `[neo4j]`, `[pinecone]`. See [Installation](/installation).
</Step>
<Step title="Run the Quickstart">
Build a complete knowledge graph pipeline in [5 minutes](/quickstart):
- Ingest documents from any source
- Extract entities and relationships
- Build and query the graph
- Record and trace a decision
<Step title="Build a pipeline">
Follow the [Quickstart](/quickstart) to ingest documents, extract entities, build a graph, and record a decision in 5 minutes.
</Step>
<Step title="Learn the mental model">
[Core Concepts](/concepts) covers:
- Knowledge graphs vs. vector stores: when to use each
- What GraphRAG is and how Semantica implements it
- How provenance and decision tracking work together
- The accountability layer architecture
<Step title="Learn the model">
[Core Concepts](/concepts) covers knowledge graphs vs. vector stores, GraphRAG, and how provenance and decisions fit together.
</Step>
<Step title="Go deep on any module">
Every module has a dedicated [reference page](/reference/context) with:
- Full class and method documentation
- Parameter tables with types and defaults
- Runnable code examples for each feature
<Step title="Go deep">
Every module has a [reference page](/reference/context) with full API docs and runnable examples.
</Step>
</Steps>
- [Installation](/installation) — Get Semantica installed in under a minute
- [Quickstart](/quickstart) — Build a complete knowledge graph pipeline in 5 minutes
- [Core Concepts](/concepts) — The mental model behind the API
- [API Reference](/reference/context) — Exact module, class, and method details
- [Cookbook](/cookbook) — Domain notebooks for real-world use cases
- [Changelog](https://github.com/semantica-agi/semantica/releases) — Release history
## Full Capabilities
<AccordionGroup>
<Accordion title="Context & Decision Intelligence" icon="brain">
### Context Graphs
- Structured, persistent graph of entities, relationships, and decisions
- Temporal model with `valid_from` / `valid_until` on every node and edge
- Point-in-time queries across historical graph states
- Distance Intelligence: semantic neighborhoods and N×N distance matrices
### Decision Tracking
- `record_decision()` with full lifecycle management and causal chains
- Hybrid similarity search over past decisions for consistency enforcement
- `analyze_decision_impact()` and `analyze_decision_influence()` for consequence modeling
- Ego-mode exploration for targeted neighborhood investigation
More: the [Cookbook](/cookbook) for real-world notebooks, [Discord](https://discord.gg/sV34vps5hH) for help.
<Accordion title="Full module list">
`semantica.ingest`, `semantica.parse`, `semantica.split`, `semantica.normalize`, `semantica.semantic_extract`, `semantica.kg`, `semantica.ontology`, `semantica.reasoning`, `semantica.embeddings`, `semantica.vector_store`, `semantica.graph_store`, `semantica.triplet_store`, `semantica.context`, `semantica.provenance`, `semantica.change_management`, `semantica.deduplication`, `semantica.conflicts`, `semantica.export`, `semantica.visualization`, `semantica.pipeline`, `semantica.seed`, `semantica.llms`, `semantica.mcp_server`, `semantica.explorer`, `semantica.evals`, `semantica.utils`, `semantica.core`. See the [API Reference](/reference/context) for full docs on each.
</Accordion>
<Accordion title="Knowledge Engineering" icon="diagram-project">
### Entity & Relation Extraction
- Named entity recognition: pattern, ML, or LLM methods
- Typed triplet extraction via LLM or rule-based pipelines
- Event extraction with temporal and causal linking
### Ontology & Schema
- Ontology Hub: visual editor, SHACL Studio, alignments, health dashboard
- Deduplication v2: `blocking_v2`, `hybrid_v2`, `semantic_v2`: up to 7x faster
- Datalog reasoning: recursive Horn clause rules with fixpoint semantics
- SPARQL reasoning: query-based inference over RDF graphs
</Accordion>
<Accordion title="Provenance & Auditability" icon="shield-check">
### Lineage Tracking
- W3C PROV-O lineage across all modules: every fact has a source
- `recorded_at` stamping with full OWL-Time export
- Change management with SHA-256 checksums and version control
- Full audit trails from ingestion event to final inference
### Compliance Infrastructure
- HIPAA: patient data handling with audit-ready provenance chains
- SOX / MiFID II: financial decision records with full traceability
- GDPR: data lineage for subject access and right-to-erasure workflows
- FDA 21 CFR Part 11: electronic records and signature compliance
</Accordion>
<Accordion title="Data Ingestion & Export" icon="database">
### Ingestion Formats
- Documents: PDF, DOCX, HTML, PPTX, Docling layout analysis
- Structured data: JSON, CSV, Excel, Parquet, XML
- Sources: web crawl, SQL, Snowflake, feeds, email, code repositories, MCP
### Vector Stores
- FAISS, Pinecone, Weaviate, Qdrant, Milvus, PgVector, in-memory
### Graph Stores
- Neo4j, FalkorDB, Apache AGE, Amazon Neptune
### Export Formats
- RDF: Turtle, JSON-LD, N-Triples, RDF/XML
- Tabular: Parquet, CSV, Arrow
- Graph: GraphML, GEXF, DOT, ArangoDB AQL
- Ontology: OWL, SKOS, SHACL
</Accordion>
</AccordionGroup>
## Module Reference
| Module | What it provides |
| :-------- | :----------------- |
| `semantica.context` | Context graphs, agent memory, decision tracking, causal analysis, precedent search |
| `semantica.kg` | KG construction, graph algorithms, temporal model, Allen interval algebra |
| `semantica.semantic_extract` | NER, relation extraction, event extraction, triplet generation |
| `semantica.reasoning` | Forward chaining, Rete, deductive, abductive, SPARQL, Datalog |
| `semantica.ontology` | SHACL, SKOS, alignments, diff/migration, auto-generation, OWL/RDF |
| `semantica.explorer` | FastAPI Knowledge Explorer, Ontology Hub, Distance Intelligence, SHACL Studio |
| `semantica.mcp_server` | MCP stdio server: 15 tools for Claude Desktop, VS Code, Cursor, Windsurf, Cline |
| `semantica.vector_store` | FAISS, Pinecone, Weaviate, Qdrant, Milvus, PgVector |
| `semantica.graph_store` | Neo4j, FalkorDB, Apache AGE, Amazon Neptune |
| `semantica.triplet_store` | In-memory and persistent RDF triple store with SPARQL |
| `semantica.ingest` | Files, web, feeds, databases, Snowflake, Parquet, XML, MCP |
| `semantica.parse` | Document parsing: PDF, DOCX, HTML, PPTX, Docling layout analysis |
| `semantica.split` | Text chunking: sentence, paragraph, token, semantic boundary strategies |
| `semantica.normalize` | Text normalization, entity canonicalization, whitespace and encoding cleanup |
| `semantica.embeddings` | Sentence-Transformers, FastEmbed, OpenAI, BGE, Ollama local embeddings |
| `semantica.pipeline` | Pipeline DSL, parallel workers, retry policies, failure handling |
| `semantica.export` | RDF, Parquet, ArangoDB AQL, CSV, OWL, Arrow, GraphML, GEXF, DOT |
| `semantica.visualization` | Programmatic graph rendering: force, hierarchical, circular, spring layouts |
| `semantica.deduplication` | Entity deduplication v1/v2, similarity scoring, blocking, merging |
| `semantica.conflicts` | Conflict detection and resolution across overlapping knowledge sources |
| `semantica.provenance` | W3C PROV-O lineage tracking, source attribution, audit trails |
| `semantica.change_management` | Version control with SHA-256 checksums, diff, rollback |
| `semantica.llms` | Groq, OpenAI, Anthropic, Gemini, Ollama, DeepSeek, Novita AI, LiteLLM, HuggingFace |
| `semantica.seed` | Foundation graph seeding from CSV, JSON, SQL, API, and RDF sources |
| `semantica.evals` | Evaluation harness: KG quality, extraction F1, pipeline benchmarking, regression tracking |
| `semantica.core` | Orchestration, ConfigManager, LifecycleManager, PluginRegistry, MethodRegistry |
| `semantica.utils` | Logging, validation, progress tracking, hash utilities, nested dict helpers |
## Why Semantica?
**Open Source, MIT** — No vendor lock-in. No paywalled features.
- Full source available on GitHub
- Every line auditable by your security team
- Fork, extend, and self-host with no restrictions
- No telemetry, no usage reporting
**Production Ready** — Built for teams that can't afford surprises.
- 1,000+ passing tests with full regression coverage
- `PipelineValidator` catches configuration errors at startup
- `FailureHandler` with exponential backoff and dead-letter queues
- Ongoing security hardening: fixes shipped in every release ([CHANGELOG](https://github.com/semantica-agi/semantica/blob/main/CHANGELOG.md))
**Modular by Design** — Import only what you need.
- Use `NERExtractor` without a graph store
- Use `ContextGraph` without vector storage
- Every component independently swappable and testable
- No framework lock-in: works with any agent stack
+3 -3
View File
@@ -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.
+4 -4
View File
@@ -12,13 +12,13 @@ icon: "link"
pip install "semantica[langchain]"
```
Requires `langchain-core >= 0.3`. If langchain-core is not installed, the integration still imports — every class carries the full Semantica API and degrades gracefully (`build()` returns `None`; branch on `LANGCHAIN_AVAILABLE`).
Requires `langchain-core >= 0.3`. If langchain-core is not installed, the integration still imports. Every class carries the full Semantica API and degrades gracefully (`build()` returns `None`; branch on `LANGCHAIN_AVAILABLE`).
## Components at a Glance
- **SemanticaRetriever** `BaseRetriever`: hybrid-search seeds retrieval, then graph edges are walked `hops` steps (default 2) for GraphRAG-style results.
- **SemanticaVectorStore** `VectorStore`: `add_texts` / `similarity_search` / `similarity_search_with_score` / `from_texts` over `HybridSearch`.
- **SemanticaKGTool** / **SemanticaDecisionTool** `BaseTool` subclasses: `semantica_query_graph` and `semantica_query_decisions` for LangGraph / tool-calling agents.
- **SemanticaRetriever** (`BaseRetriever`): hybrid-search seeds retrieval, then graph edges are walked `hops` steps (default 2) for GraphRAG-style results.
- **SemanticaVectorStore** (`VectorStore`): `add_texts` / `similarity_search` / `similarity_search_with_score` / `from_texts` over `HybridSearch`.
- **SemanticaKGTool** / **SemanticaDecisionTool** (`BaseTool` subclasses): `semantica_query_graph` and `semantica_query_decisions` for LangGraph / tool-calling agents.
## Component Details
+7 -7
View File
@@ -9,9 +9,9 @@ Whether you're running your first pipeline or deploying Semantica in production,
## Learning Paths
- **Beginner (12 hrs)** — New to Semantica and knowledge graphs. [Start with Installation →](/installation)
- **Intermediate (46 hrs)** — Comfortable with basics, building real applications. [Start with Modules →](/modules)
- **Advanced (8+ hrs)** — Enterprise deployments, customization, and extension. [Start with Architecture →](/architecture)
- **Beginner (12 hrs)**: new to Semantica and knowledge graphs. [Start with Installation →](/installation)
- **Intermediate (46 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 (12 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.
+177 -136
View File
@@ -5,30 +5,32 @@ icon: "puzzle-piece"
---
<Info>
Looking for a quick reference? Jump to the [Module Index](#module-index) at the bottom.
Jump to the [Module Index](#module-index) for a quick reference.
</Info>
<Tip>
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.
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
### Ingest
Loads data from files, web, databases, and streams into a unified `SourceDocument` format.
Loads data from files, web, databases, and streams. Each ingestor returns its own
result type (`FileIngestor``FileObject`, `WebIngestor``WebContent`, …);
document-oriented ones expose a `.text` payload and `.metadata`.
```python
from semantica.ingest import FileIngestor, WebIngestor, ParquetIngestor, XMLIngestor, DatabricksIngestor
@@ -37,7 +39,7 @@ from semantica.ingest import FileIngestor, WebIngestor, ParquetIngestor, XMLInge
ingestor = FileIngestor()
documents = ingestor.ingest_directory("data/")
# Web crawl
# Web page: returns a WebContent with .text, .title, .links, .metadata
web_ingestor = WebIngestor()
page = web_ingestor.ingest_url("https://example.com")
@@ -49,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")
```
@@ -57,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
@@ -67,13 +69,13 @@ Extracts structured text and layout metadata from raw documents.
```python
from semantica.parse import DocumentParser, DoclingParser
# Standard parser: all common formats
# Standard parser: all common formats. parse() takes a path, returns a dict
parser = DocumentParser()
parsed = parser.parse_document("document.pdf")
parsed = parser.parse("document.pdf") # {"full_text": ..., "metadata": ..., ...}
# Advanced parser: multi-column PDFs, merged-cell tables, OCR
parser = DoclingParser(extract_tables=True, extract_images=True, output_format="markdown")
parsed = parser.parse("data/annual_report.pdf")
# Advanced parser (pip install semantica[parse-docling]): tables, OCR, layout
parser = DoclingParser(export_format="markdown", enable_ocr=True)
parsed = parser.parse("data/annual_report.pdf") # dict with full_text, tables, pages
```
**Available parsers:** `DocumentParser`, `DoclingParser`, `CodeParser`, `CSVParser`, `DocxParser`, `EmailParser`, `ExcelParser`, `HTMLParser`, `ImageParser`, `JSONParser`, `MCPParser`, `MediaParser`, `PDFParser`, `PPTXParser`, `StructuredDataParser`, `WebParser`, `XMLParser`
@@ -85,11 +87,12 @@ Chunks text for embedding and RAG pipelines with awareness of semantic boundarie
```python
from semantica.split import TextSplitter
splitter = TextSplitter(method="semantic_transformer")
chunks = splitter.split(text, chunk_size=1000, chunk_overlap=200)
# chunk_size / chunk_overlap are constructor arguments
splitter = TextSplitter(method="semantic_transformer", chunk_size=1000, chunk_overlap=200)
chunks = splitter.split(text)
```
**Chunking strategies:** `recursive`, `semantic_transformer`, `entity_aware`, `relation_aware`, `sliding_window`, `structural`
**Chunking methods:** `recursive`, `token`, `sentence`, `paragraph`, `semantic_transformer`, `entity_aware`, `relation_aware`, `graph_based`, `ontology_aware`, `hierarchical`, `community_detection`, `centrality_based`, `llm`
### Normalize
@@ -115,17 +118,18 @@ Named entity recognition, relation extraction, and triplet generation.
```python
from semantica.semantic_extract import NERExtractor, RelationExtractor, TripletExtractor
ner = NERExtractor(method="llm", llm_provider=llm)
entities = ner.extract("Apple Inc. was founded by Steve Jobs.")
# LLM method: provider + llm_model select the backend; the API key comes from the env
ner = NERExtractor(method="llm", provider="groq", llm_model="llama-3.3-70b-versatile")
entities = ner.extract("Apple Inc. was founded by Steve Jobs.") # list[Entity]
rel = RelationExtractor(method="llm", llm_provider=llm)
relationships = rel.extract(text, entities=entities)
rel = RelationExtractor(method="llm", provider="groq", llm_model="llama-3.3-70b-versatile")
relationships = rel.extract(text, entities=entities) # list[Relation]
trip = TripletExtractor(method="llm", llm_provider=llm)
triplets = trip.extract(text)
trip = TripletExtractor(method="pattern")
triplets = trip.extract(text) # list[Triplet]
```
**Extraction methods:** `"pattern"` (no API key), `"ml"` (local model), `"llm"` (any of the 8 supported providers)
**Extraction methods:** `"pattern"` (no API key), `"ml"` (local spaCy model), `"llm"` (any of the 9 supported providers)
**Additional extractors:** `CoreferenceResolver`, `EventDetector`, `SemanticAnalyzer`, `SemanticNetworkExtractor`
@@ -137,17 +141,17 @@ Graph construction, graph algorithms, temporal model, and distance intelligence.
from semantica.kg import GraphBuilder, GraphAnalyzer, TemporalGraphQuery, SimilarityCalculator
from datetime import datetime
# Build
# Build: build() takes a {"entities": ..., "relationships": ...} dict
builder = GraphBuilder(merge_entities=True)
kg = builder.build(entities=entities, relationships=relationships)
kg = builder.build({"entities": entities, "relationships": relationships})
# Temporal graphs (v0.4.0)
query_engine = TemporalGraphQuery(enable_temporal_reasoning=True)
snapshot = query_engine.query_at_time(kg, query="", at_time=datetime(2021, 6, 15))
# Semantic similarity (v0.5.0)
calc = SimilarityCalculator()
scores = calc.calculate_similarity(entity_a, entity_b)
# Semantic similarity (v0.5.0): operates on embedding vectors
calc = SimilarityCalculator(method="cosine")
score = calc.cosine_similarity(vec_a, vec_b)
```
**Graph algorithms available:** centrality calculation, community detection, connectivity analysis, entity resolution, link prediction, path finding, similarity calculation
@@ -175,19 +179,23 @@ Derives new facts from existing knowledge using multiple inference strategies.
```python
from semantica.reasoning import Reasoner, DatalogReasoner
# Rule-based reasoning
# Forward chaining: facts and rules as predicate(args) / IF-THEN strings
engine = Reasoner()
engine.apply_transitivity("located_in")
engine.apply_symmetry("knows")
result = engine.infer()
engine.add_fact("Manager(Alice)")
engine.add_rule("IF Manager(?x) THEN HasAuthority(?x)")
results = engine.forward_chain() # list[InferenceResult] with .conclusion, .rule_used
# Datalog: recursive Horn clause rules (v0.4.0)
datalog = DatalogEngine()
datalog = DatalogReasoner()
datalog.add_fact("parent(tom, bob)")
datalog.add_fact("parent(bob, ann)")
datalog.add_rule("ancestor(X, Y) :- parent(X, Y).")
datalog.add_rule("ancestor(X, Z) :- parent(X, Y), ancestor(Y, Z).")
results = datalog.query("ancestor(alice, ?)")
datalog.derive_all()
results = datalog.query("ancestor(tom, ?Z)") # [{"Z": "bob"}, {"Z": "ann"}], order not guaranteed
```
**Engines:** forward chaining, Rete network, deductive, abductive, SPARQL, Datalog: all produce explainable inference paths
**Engines:** `Reasoner` (forward/backward chaining), `ReteEngine`, `SPARQLReasoner`, `DatalogReasoner`, `TemporalReasoningEngine`, `GraphReasoner` (LLM)
## Storage
@@ -199,9 +207,9 @@ Generates and manages vector embeddings for semantic similarity.
```python
from semantica.embeddings import EmbeddingGenerator
generator = EmbeddingGenerator(model="sentence-transformers")
embeddings = generator.generate(["text1", "text2"])
similarity = generator.similarity(embeddings[0], embeddings[1])
generator = EmbeddingGenerator()
embeddings = generator.generate_embeddings(["text1", "text2"]) # np.ndarray
similarity = generator.compare_embeddings(embeddings[0], embeddings[1])
```
**Supported models:** Sentence-Transformers, FastEmbed, OpenAI, BGE
@@ -215,12 +223,18 @@ Multi-backend vector database with hybrid search support.
```python
from semantica.vector_store import VectorStore
store = VectorStore(backend="faiss", dimension=768)
store.add_vectors(embeddings, ids)
results = store.search(query_vector, top_k=10)
store = VectorStore(backend="faiss", dimension=768)
# Raw vectors
ids = store.store_vectors(embeddings) # returns generated ids
hits = store.search_vectors(query_vector, k=10)
# Or store text and let the store embed it
store.add_documents(["Apple was founded in 1976.", "Google was founded in 1998."])
results = store.search("tech company founding dates", limit=10)
```
**Backends:** FAISS, Pinecone, Weaviate, Qdrant, Milvus, PgVector, in-memory
**Backends:** FAISS, Pinecone, Weaviate, Qdrant, Milvus, PgVector, SQLite, in-memory
**Search modes:** semantic top-k, hybrid (vector + keyword), metadata-filtered
@@ -232,8 +246,8 @@ Connects to graph databases for persistent, query-able storage.
from semantica.graph_store import GraphStore
store = GraphStore(backend="neo4j")
store.add_nodes(entities)
store.add_edges(relationships)
store.add_nodes([{"id": "acme", "type": "Organization", "properties": {"name": "Acme"}}])
store.add_edges([{"source": "alice", "target": "acme", "type": "works_for"}])
results = store.query("MATCH (n)-[r]->(m) RETURN n, r, m")
```
@@ -246,9 +260,9 @@ RDF triple-based storage with SPARQL query support.
```python
from semantica.triplet_store import TripletStore
store = TripletStore(backend="blazegraph")
store.add_triplets(subject, predicate, obj)
results = store.sparql("SELECT ?s ?p ?o WHERE { ?s ?p ?o }")
store = TripletStore(backend="oxigraph")
store.add_triplets(triplets) # list of Triplet objects (or add_triplet for one)
results = store.execute_query("SELECT ?s ?p ?o WHERE { ?s ?p ?o }")
```
**Backends:** Oxigraph (embedded), Blazegraph, Apache Jena, RDF4J
@@ -261,15 +275,18 @@ results = store.sparql("SELECT ?s ?p ?o WHERE { ?s ?p ?o }")
Detects, scores, and merges duplicate entities across sources.
```python
from semantica.deduplication import EntityResolver
from semantica.deduplication import DuplicateDetector, EntityMerger
resolver = EntityResolver()
merged = resolver.resolve(entities, strategy="semantic_v2")
detector = DuplicateDetector(similarity_threshold=0.85)
candidates = detector.detect_duplicates(entities)
merger = EntityMerger()
operations = merger.merge_duplicates(entities, strategy="keep_most_complete")
```
**v2 strategies** (`blocking_v2`, `hybrid_v2`, `semantic_v2`) are up to 7x faster than v1.
**v2 candidate-generation modes** (`blocking_v2`, `hybrid_v2`, `semantic_v2`) are up to 7x faster than v1.
**Components:** `EntityResolver`, `DuplicateDetector`, `EntityMerger`, `SimilarityCalculator`, `ClusterBuilder`
**Components:** `DuplicateDetector`, `EntityMerger`, `ClusterBuilder`, `MergeStrategyManager`
**`DuplicateDetector` options:** `max_results`, `top_k_per_entity`, `min_similarity`, `sort_by`
@@ -278,14 +295,13 @@ merged = resolver.resolve(entities, strategy="semantic_v2")
Detects and resolves fact conflicts across overlapping knowledge sources.
```python
from semantica.conflicts import ConflictDetector
from semantica.conflicts import ConflictDetector, ConflictResolver
detector = ConflictDetector()
conflicts = detector.detect_conflicts(kg)
resolved = detector.resolve(conflicts, strategy="most_recent")
conflicts = ConflictDetector().detect_conflicts(entities) # list of entity dicts
resolved = ConflictResolver().resolve_conflicts(conflicts, strategy="most_recent")
```
**Detection types:** value conflicts, type conflicts, temporal conflicts, logical conflicts
**Detection types:** value conflicts, type conflicts, relationship conflicts, temporal conflicts, logical conflicts
**Resolution strategies:** prefer most recent, prefer most reliable source, majority vote, flag for manual review
@@ -298,6 +314,7 @@ Agent context graphs, decision tracking, causal chains, and precedent search.
```python
from semantica.context import AgentContext, ContextGraph
from semantica.vector_store import VectorStore
context = AgentContext(
vector_store=VectorStore(backend="faiss", dimension=768),
@@ -328,7 +345,7 @@ W3C PROV-O compliant lineage tracking across all modules.
from semantica.provenance import ProvenanceManager
manager = ProvenanceManager()
manager.track_entity("entity_1", "document.pdf", "person")
manager.track_entity("entity_1", source="document.pdf", metadata={"type": "person"})
lineage = manager.get_lineage("entity_1")
```
@@ -364,8 +381,8 @@ RDFExporter().export(graph, file_path="graph.ttl", format="turtle")
# Analytics
ParquetExporter().export(graph, file_path="output/graph.parquet")
# ArangoDB
aql = ArangoAQLExporter().export(graph)
# ArangoDB: writes AQL INSERT statements to the given path
ArangoAQLExporter().export(graph, file_path="graph.aql")
```
**Export formats:** RDF (Turtle, JSON-LD, N-Triples, XML), Parquet, ArangoDB AQL, CSV, OWL, Arrow, LPG, YAML, distance matrices
@@ -390,16 +407,24 @@ viz.visualize_network(graph, output="html", file_path="graph.html")
Pipeline DSL with parallel workers, retry policies, and failure handling.
```python
from semantica.pipeline import Pipeline
from semantica.pipeline import PipelineBuilder, ExecutionEngine
from semantica.ingest import FileIngestor
from semantica.semantic_extract import NERExtractor
pipeline = Pipeline()
pipeline.add_step("ingest", FileIngestor())
pipeline.add_step("extract", NERExtractor())
pipeline.add_step("build", GraphBuilder())
result = pipeline.run("data/")
builder = PipelineBuilder()
# Each step type dispatches to a handler you register (or supply explicitly)
builder.register_step_handler("ingest", lambda data, **c: FileIngestor().ingest(c["source"]))
builder.register_step_handler("extract", lambda docs, **c: NERExtractor(method="pattern").extract(docs[0].text))
builder.add_step("ingest", step_type="ingest", source="data/")
builder.add_step("extract", step_type="extract")
pipeline = builder.connect_steps("ingest", "extract").build(name="docs_to_entities")
result = ExecutionEngine().execute_pipeline(pipeline)
```
**Components:** `Pipeline`, `PipelineBuilder`, `ExecutionEngine`, `FailureHandler`, `PipelineValidator`, `ParallelismManager`, `ResourceScheduler`
**Components:** `PipelineBuilder`, `Pipeline`, `ExecutionEngine`, `FailureHandler`, `PipelineValidator`, `ParallelismManager`, `ResourceScheduler`
### Explorer
@@ -428,7 +453,7 @@ llm = OpenAI(model="gpt-4o", api_key=os.getenv("OPENAI_API_KEY"))
llm = LiteLLM(model="anthropic/claude-opus-4-7", api_key=os.getenv("ANTHROPIC_API_KEY"))
```
**Supported providers:** OpenAI, Anthropic, Google Gemini, Groq, Ollama, DeepSeek, Novita AI, LiteLLM (20+ models via one interface)
**Supported providers:** OpenAI, Anthropic, Google Gemini, Groq, Ollama, DeepSeek, Novita AI, HuggingFace, plus LiteLLM (100+ models via one interface)
### MCP Server
@@ -438,51 +463,50 @@ 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 exposed
**Integrations:** Claude Desktop, VS Code, Cursor, Windsurf, Cline. 15 MCP tools are exposed.
### Seed
Bootstrap knowledge graphs from verified structured sources: fixed-point reference data, controlled vocabularies, and domain anchors.
```python
from semantica.seed import SeedManager
from semantica.seed import SeedDataManager
seed = SeedManager()
seed.populate(kg, dataset="companies", count=100)
seed = SeedDataManager()
# Load domain seeds from file or built-in datasets
seed.load_from_file("seed_data/industries.json")
seed.inject(kg) # merges seed nodes without duplicating existing entities
# Load trusted reference data from CSV / JSON / a database / an API
seed_data = seed.load_from_csv("seed_data/industries.csv", entity_type="Industry")
# Merge seed data with extraction output (seed values win on conflict by default)
combined = seed.integrate_with_extracted(
{"entities": seed_data, "relationships": []},
{"entities": extracted_entities, "relationships": extracted_relationships},
merge_strategy="seed_first",
)
```
**Use cases:** anchoring extraction with known entities, pre-populating ontology classes, deterministic test graph generation.
### Evals
Evaluation framework for measuring KG quality, extraction accuracy, and pipeline performance.
Scores decision-intelligence outputs (decision records, audit trails, reasoning
text) with a registry of deterministic and model-backed evaluators plus a small
run harness.
```python
from semantica.evals import KGEvaluator, ExtractionEvaluator, PipelineEvaluator, RegressionTracker
from semantica.evals import evaluate, list_evaluators
# KG quality
report = KGEvaluator().evaluate(kg, ontology=ontology)
print(f"Completeness: {report.completeness:.2%} Consistency: {report.consistency:.2%}")
list_evaluators()
# ['decision_scores', 'exact_match', 'keyword_check', 'length_range',
# 'levenshtein', 'llm_as_judge', 'numeric_range', 'regex_match', 'rouge',
# 'temporal_range']
# Extraction accuracy
report = ExtractionEvaluator().evaluate_ner(predictions=extracted, gold_standard=annotated)
print(f"Precision: {report.precision:.3f} Recall: {report.recall:.3f} F1: {report.f1:.3f}")
# Pipeline throughput and latency
metrics = PipelineEvaluator().benchmark(pipeline, data="data/", bench_runs=5)
print(f"Throughput: {metrics.docs_per_second:.1f} docs/sec")
# Regression tracking across runs
tracker = RegressionTracker(db_path="eval_history.db")
run_id = tracker.record_run(pipeline_version="v1.2.0", metrics=metrics)
diff = tracker.compare(run_id, baseline_run_id="run_abc123")
cases = [("apple", "aple"), ("night", "nacht")]
summary = evaluate(cases, evaluators=["levenshtein"])
print(summary.total, summary.passed, summary.pass_rate)
```
**Components:** `KGEvaluator`, `ExtractionEvaluator`, `PipelineEvaluator`, `RegressionTracker`
**Public API:** `evaluate(cases, evaluators, config=None)`, `list_evaluators()`, `get_evaluator(name)`, and the `EvalMetric` / `CaseResult` / `EvalSummary` result types. See the [Evals reference](/reference/evals).
### Core
@@ -491,20 +515,20 @@ Base classes, shared data models, and the plugin registry used across all module
```python
from semantica.core import Semantica, PluginRegistry, ConfigManager
# Top-level orchestrator
sem = Semantica(config_path="config.yaml")
# ConfigManager loads a Config; Config.get() does dotted lookups
config = ConfigManager().load_from_file("config.yaml")
batch = config.get("processing.batch_size", default=32)
# Top-level orchestrator: pass the Config object (or a dict), not a path
sem = Semantica(config=config)
sem.initialize()
# Plugin registry: register custom components
# Plugin registry: register custom components under a name
registry = PluginRegistry()
registry.register("my_ingestor", MyCustomIngestor)
# Config management
config = ConfigManager(config_path="config.yaml")
batch = config.get("processing.batch_size", default=32)
registry.register_plugin("my_ingestor", MyCustomIngestor, version="1.0.0")
```
**Components:** `Semantica`, `PluginRegistry`, `ConfigManager`, `LifecycleManager`, `HealthMonitor`, `Config`
**Components:** `Semantica`, `PluginRegistry`, `ConfigManager`, `Config`, `LifecycleManager`, `HealthStatus`, `MethodRegistry`
### Utils
@@ -532,11 +556,13 @@ from semantica.semantic_extract import NERExtractor, RelationExtractor
from semantica.kg import GraphBuilder
sources = FileIngestor().ingest("data/")
parsed = DocumentParser().parse(sources[0])
entities = NERExtractor(method="llm", llm_provider=llm).extract(parsed)
relationships = RelationExtractor(method="llm", llm_provider=llm).extract(parsed, entities=entities)
text = DocumentParser().parse(sources[0].path)["full_text"]
ner = NERExtractor(method="llm", provider="groq", llm_model="llama-3.3-70b-versatile")
rel = RelationExtractor(method="llm", provider="groq", llm_model="llama-3.3-70b-versatile")
entities = ner.extract(text)
relationships = rel.extract(text, entities=entities)
graph = GraphBuilder(merge_entities=True).build(
entities=entities, relationships=relationships
{"entities": entities, "relationships": relationships}
)
```
@@ -555,16 +581,20 @@ from semantica.vector_store import VectorStore
context = AgentContext(
vector_store=VectorStore(backend="faiss", dimension=768),
knowledge_graph=ContextGraph(advanced_analytics=True),
graph_expansion=True,
)
context.load_graph("company_kg.json")
result = context.query(
# store() extracts entities and populates the graph + vector index
context.store([{"content": "Steve Wozniak co-founded Apple with Steve Jobs."}])
# retrieve() blends vector similarity with multi-hop graph traversal
results = context.retrieve(
"What companies did Apple alumni found?",
mode="graphrag",
reasoning=True,
use_graph=True,
expand_graph=True,
)
for claim in result.claims:
print(f"{claim.text} → {claim.source_node}")
for r in results:
print(f"[{r['score']:.3f}] {r['content']} (source: {r['source']})")
```
**Best for:** question-answering systems, RAG with source attribution, research assistants
@@ -606,18 +636,22 @@ precedents = context.find_precedents("model selection", limit=5)
```python
from semantica.ingest import FileIngestor
from semantica.parse import DocumentParser
from semantica.semantic_extract import NERExtractor
from semantica.kg import GraphBuilder
from semantica.provenance import ProvenanceManager
from semantica.export import RDFExporter
sources = FileIngestor().ingest("records/")
entities = NERExtractor(method="llm", llm_provider=llm).extract(sources)
graph = GraphBuilder(merge_entities=True).build(entities=entities, relationships=[])
prov = ProvenanceManager()
lineage = prov.get_entity_lineage("entity_id")
ner = NERExtractor(method="llm", provider="groq", llm_model="llama-3.3-70b-versatile")
entities = ner.extract(DocumentParser().parse(sources[0].path)["full_text"])
graph = GraphBuilder(merge_entities=True).build({"entities": entities, "relationships": []})
RDFExporter(include_provenance=True).export(graph, file_path="audit.ttl", format="turtle")
prov = ProvenanceManager()
prov.track_entity("entity_id", source="records/filing.pdf", metadata={"extractor": "llm"})
lineage = prov.get_lineage("entity_id")
RDFExporter().export(graph, file_path="audit.ttl", format="turtle")
```
**Best for:** HIPAA, SOX, GDPR, FDA 21 CFR Part 11 deployments
@@ -632,18 +666,25 @@ RDFExporter(include_provenance=True).export(graph, file_path="audit.ttl", format
from semantica.ingest import WebIngestor
from semantica.normalize import TextNormalizer
from semantica.semantic_extract import NERExtractor, RelationExtractor
from semantica.graph_store import Neo4jStore
from semantica.graph_store import GraphStore
from semantica.kg import GraphBuilder
pages = WebIngestor(max_depth=2).ingest("https://example.com")
ingestor = WebIngestor()
normalizer = TextNormalizer()
store = Neo4jStore(uri="bolt://localhost:7687", user="neo4j", password="password")
ner = NERExtractor(method="pattern")
rel = RelationExtractor(method="pattern")
for page in pages:
# The generic GraphStore wrapper exposes the add_nodes/add_edges interface
# GraphBuilder persists through; a raw Neo4jStore does not
store = GraphStore(backend="neo4j", uri="bolt://localhost:7687", user="neo4j", password="password")
builder = GraphBuilder(merge_entities=True, graph_store=store)
for url in ["https://example.com/a", "https://example.com/b"]:
page = ingestor.ingest_url(url) # WebContent, has .text
text = normalizer.normalize_text(page.text)
entities = NERExtractor().extract(text)
relationships = RelationExtractor().extract(text, entities=entities)
store.add_nodes(entities)
store.add_edges(relationships)
entities = ner.extract(text)
relationships = rel.extract(text, entities=entities)
builder.build({"entities": entities, "relationships": relationships})
```
**Best for:** competitive intelligence, news monitoring, research aggregation
@@ -692,8 +733,8 @@ versioner.create_snapshot(kg, "2024-Q1", author="user@example.com", description=
| [vector_store](/reference/vector_store) | Vector database | `VectorStore` |
| [graph_store](/reference/graph_store) | Graph database | `GraphStore` |
| [triplet_store](/reference/triplet_store) | RDF triple store | `TripletStore` |
| [deduplication](/reference/deduplication) | Entity resolution | `EntityResolver`, `DuplicateDetector`, `ClusterBuilder`, `MergeStrategyManager` |
| [conflicts](/reference/conflicts) | Conflict resolution | `ConflictDetector` |
| [deduplication](/reference/deduplication) | Entity resolution | `DuplicateDetector`, `EntityMerger`, `ClusterBuilder`, `MergeStrategyManager` |
| [conflicts](/reference/conflicts) | Conflict resolution | `ConflictDetector`, `ConflictResolver`, `SourceTracker` |
| [context](/reference/context) | Agent context & decisions | `AgentContext`, `ContextGraph` |
| [provenance](/reference/provenance) | W3C PROV-O lineage | `ProvenanceManager` |
| [change_management](/reference/change_management) | Version control | `TemporalVersionManager` |
@@ -703,11 +744,11 @@ versioner.create_snapshot(kg, "2024-Q1", author="user@example.com", description=
| [explorer](/reference/explorer) | Knowledge Explorer UI | `semantica-explorer --graph <file>` |
| [llms](/reference/llms) | LLM providers | `Groq`, `OpenAI`, `create_provider` |
| [mcp_server](/reference/mcp_server) | MCP stdio server | `python -m semantica.mcp_server` |
| [seed](/reference/seed) | KG bootstrapping from structured sources | `SeedManager` |
| [evals](/reference/evals) | Quality evaluation | `KGEvaluator`, `ExtractionEvaluator`, `PipelineEvaluator`, `RegressionTracker` |
| [seed](/reference/seed) | KG bootstrapping from structured sources | `SeedDataManager` |
| [evals](/reference/evals) | Decision-intelligence evaluation | `evaluate`, `list_evaluators`, `EvalSummary` |
| [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.
+2 -2
View File
@@ -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.
+5 -5
View File
@@ -5,7 +5,7 @@ icon: "rocket"
---
<Info>
**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>
**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.
@@ -454,7 +454,7 @@ pip install --upgrade semantica
## Next Steps
- [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.
- [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.
+21 -21
View File
@@ -30,28 +30,28 @@ icon: "brain"
## What You Get
- **AgentContext** — Memory, decision tracking, and graph-backed retrieval behind one API
- **AgentContext**: memory, decision tracking, and graph-backed retrieval behind one API
- Conversation history and checkpoint diffing
- Persist and restore full context state to disk
- **ContextGraph** — Thread-safe in-memory knowledge graph
- **ContextGraph**: thread-safe in-memory knowledge graph
- PageRank, centrality, community detection, temporal validity
- Cross-graph navigation and link traversal
- **AgentMemory** — Embedding-backed memory with retention policy
- **AgentMemory**: embedding-backed memory with retention policy
- LRU eviction at configurable `max_memory_size`
- Per-conversation history isolation
- **DecisionRecorder** — Records decisions with causal chains and confidence scores
- **DecisionRecorder**: records decisions with causal chains and confidence scores
- Temporal validity windows (`valid_from` / `valid_until`)
- Cross-system context capture on every decision
- **PolicyEngine** — Versioned policy storage in the knowledge graph
- **PolicyEngine**: versioned policy storage in the knowledge graph
- Compliance checking against recorded decisions
- Policy exception tracking with approver audit trail
- **EntityLinker** — Maps entity text to stable URIs
- **EntityLinker**: maps entity text to stable URIs
- Creates typed links between entity IDs
- Prevents "Apple", "Apple Inc.", "AAPL" becoming separate nodes
- **ContextRetriever** — Fuses vector similarity, graph traversal, and agent memory
- **ContextRetriever**: fuses vector similarity, graph traversal, and agent memory
- Richer context than pure vector search
- Configurable `hybrid_alpha` and expansion hops
- **CausalChainAnalyzer** — Traces upstream causes and downstream effects of any decision
- **CausalChainAnalyzer**: traces upstream causes and downstream effects of any decision
- Explainability paths with relationship types
- Configurable depth and direction
@@ -273,7 +273,7 @@ icon: "brain"
</Tip>
<Tip>
**Persist your context between runs.** `VectorStore` does not auto-persist passing `index_path=` to its constructor is a no-op. Call `context.save("agent_state/")` to write memory, the vector index, and the graph to disk, and `context.load("agent_state/")` on the next process to restore them. See the "Persist & Restore" tab under [Real-World Patterns](#real-world-patterns) below.
**Persist your context between runs.** `VectorStore` does not auto-persist; passing `index_path=` to its constructor is a no-op. Call `context.save("agent_state/")` to write memory, the vector index, and the graph to disk, and `context.load("agent_state/")` on the next process to restore them. See the "Persist & Restore" tab under [Real-World Patterns](#real-world-patterns) below.
</Tip>
### Memory Methods
@@ -449,7 +449,7 @@ print("Nodes: {}, Edges: {}".format(stats["node_count"], stats["edge_count"]))
`ContextGraph` exposes a full Distance Intelligence API for exploring semantic neighborhoods and blending proximity into retrieval.
<Info>
Full Distance Intelligence reference distance matrices, API endpoints, embedding cache, Explorer UI is covered in the dedicated [Distance Intelligence](/reference/distance) page. This section documents the context-layer API.
Full Distance Intelligence reference (distance matrices, API endpoints, embedding cache, Explorer UI) is covered in the dedicated [Distance Intelligence](/reference/distance) page. This section documents the context-layer API.
</Info>
### Neighbors with Distance Metadata
@@ -480,7 +480,7 @@ for n in neighbors:
| Added field | Type | Description |
| :---------- | :---- | :----------- |
| `distance_band` | `str` | `"direct"` (1 hop) / `"near"` (2) / `"mid-range"` (34) / `"distant"` (5+) |
| `confidence_decay` | `float` | `edge_weight ^ hop_count` decays with each hop |
| `confidence_decay` | `float` | `edge_weight ^ hop_count`; decays with each hop |
| `path_to_anchor` | `List[str]` | Shortest path from anchor node to this neighbor |
| `hop_count` | `int` | BFS depth from anchor |
@@ -659,7 +659,7 @@ if not receipt.complete:
```
<Warning>
Check the receipt — the call returning is not proof the data is gone. FAISS,
Check the receipt. The call returning is not proof the data is gone. FAISS,
Milvus, and Weaviate expose no delete method, so erasure cannot be completed on
those backends today; the receipt reports `unsupported` rather than a success it
did not achieve.
@@ -687,9 +687,9 @@ At least one store is required; a store that is not supplied reports
| Status | Meaning |
| :--- | :--- |
| `erased` | Reached, data removed. On the vectors leg this means the store accepted the delete for the ids given backends offer no portable existence check, so it is not a count of embeddings that were really there |
| `erased` | Reached, data removed. On the vectors leg this means the store accepted the delete for the ids given; backends offer no portable existence check, so it is not a count of embeddings that were really there |
| `not_found` | Reached, held nothing for this entity |
| `not_configured` | No such store was bound normal, not a failure |
| `not_configured` | No such store was bound: normal, not a failure |
| `unsupported` | The store cannot delete at all; retrying will not help |
| `failed` | The store was reached and the deletion did not succeed |
@@ -721,7 +721,7 @@ receipt.to_dict()
# }
```
Erasure runs outward-in vectors, then memory, then the graph. The tombstone is
Erasure runs outward-in: vectors, then memory, then the graph. The tombstone is
the durable attestation that an erasure happened, so it is written last: a crash
mid-cascade leaves the node present and the receipt incomplete, rather than a
tombstone claiming more than actually happened. A store that raises is recorded
@@ -1087,10 +1087,10 @@ class EntityLink:
</Tab>
</Tabs>
- [Vector Store](/reference/vector_store) — Embedding storage backend for memory retrieval.
- [Knowledge Graph](/reference/kg) — Graph algorithms and analytics used inside ContextGraph.
- [Reasoning](reasoning) — Logical inference layered on top of context.
- [Provenance](provenance) — W3C PROV-O lineage for every stored fact.
- [Vector Store](/reference/vector_store): embedding storage backend for memory retrieval.
- [Knowledge Graph](/reference/kg): graph algorithms and analytics used inside ContextGraph.
- [Reasoning](/guides/reasoning): logical inference layered on top of context.
- [Provenance](/guides/provenance): W3C PROV-O lineage for every stored fact.
- [Context Module](https://github.com/semantica-agi/semantica/blob/main/cookbook/introduction/19_Context_Module.ipynb) — Memory and decision tracking · Intermediate
- [Advanced Context Engineering](https://github.com/semantica-agi/semantica/blob/main/cookbook/advanced/11_Advanced_Context_Engineering.ipynb) — Production FAISS + Neo4j setup · Advanced
- [Context Module](https://github.com/semantica-agi/semantica/blob/main/cookbook/introduction/19_Context_Module.ipynb): memory and decision tracking · Intermediate
- [Advanced Context Engineering](https://github.com/semantica-agi/semantica/blob/main/cookbook/advanced/11_Advanced_Context_Engineering.ipynb): production FAISS + Neo4j setup · Advanced
+7 -7
View File
@@ -129,7 +129,7 @@ from semantica.llms import Groq, OpenAI, LiteLLM, HuggingFaceLLM
from semantica.llms import LiteLLM
llm = LiteLLM(
model="anthropic/claude-sonnet-4-20250514",
model="anthropic/claude-sonnet-5",
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-4-20250514", api_key=os.getenv("ANTHROPIC_API_KEY"))
"smart": LiteLLM(model="anthropic/claude-sonnet-5", 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-5", api_key=os.getenv("ANTHROPIC_API_KEY"))
llm = LiteLLM(model="anthropic/claude-opus-4-7", 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-3-5-sonnet-20241022-v2:0")
llm = LiteLLM(model="bedrock/anthropic.claude-sonnet-4-5-20250929-v1: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-5", api_key=os.getenv("ANTHROPIC_API_KEY")),
"Anthropic": LiteLLM(model="anthropic/claude-opus-4-7", 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-3-5-sonnet-20241022-v2:0"),
"Bedrock": LiteLLM(model="bedrock/anthropic.claude-sonnet-4-5-20250929-v1: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-4-20250514")` | Highest reasoning capability |
| **Complex Analysis** | `LiteLLM("anthropic/claude-sonnet-5")` | Highest reasoning capability |
| **High Volume/Cost** | `LiteLLM("deepseek/deepseek-chat")` | Lowest cost per token |
### Error Handling
+30
View File
@@ -22,6 +22,7 @@ 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 |
@@ -81,9 +82,38 @@ 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:
+1 -1
View File
@@ -323,7 +323,7 @@ all_facts = datalog.derive_all()
# Query with variable pattern: variables start with uppercase or ?
results = datalog.query("ancestor(alice, ?Z)")
# → [{"Z": "bob"}, {"Z": "charlie"}, {"Z": "dave"}]
# → a list of binding dicts: [{"Z": "bob"}, {"Z": "charlie"}, {"Z": "dave"}] (order not guaranteed)
# Clear and start over
datalog.clear()
+2 -2
View File
@@ -236,7 +236,7 @@ def _() -> list[str]:
cwd=DOCS,
capture_output=True,
text=True,
timeout=300,
timeout=600,
)
# 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 300 s"]
return ["mintlify export timed out after 600 s"]
# ── Summary ───────────────────────────────────────────────────────────────────
+653
View File
@@ -33,7 +33,9 @@
"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",
@@ -43,12 +45,34 @@
"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",
@@ -340,6 +364,121 @@
"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",
@@ -1473,6 +1612,63 @@
"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",
@@ -1614,6 +1810,18 @@
"@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",
@@ -1665,6 +1873,13 @@
"@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",
@@ -2010,6 +2225,16 @@
"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",
@@ -2027,6 +2252,42 @@
"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",
@@ -2259,6 +2520,20 @@
"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",
@@ -2370,6 +2645,20 @@
"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",
@@ -2387,6 +2676,13 @@
}
}
},
"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",
@@ -2449,6 +2745,14 @@
"@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",
@@ -2466,6 +2770,19 @@
"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",
@@ -3058,6 +3375,19 @@
"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",
@@ -3068,6 +3398,47 @@
"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",
@@ -3173,6 +3544,13 @@
"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",
@@ -3186,6 +3564,46 @@
"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",
@@ -3321,6 +3739,17 @@
"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",
@@ -4283,6 +4712,13 @@
"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",
@@ -4382,6 +4818,19 @@
"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",
@@ -4505,6 +4954,30 @@
"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",
@@ -4817,6 +5290,33 @@
"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",
@@ -4924,6 +5424,13 @@
"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",
@@ -4941,6 +5448,52 @@
"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",
@@ -5364,6 +5917,67 @@
}
}
},
"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",
@@ -5390,6 +6004,45 @@
"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",
+5 -1
View File
@@ -9,7 +9,8 @@
"lint": "eslint .",
"preview": "vite preview",
"test:graph-store": "node --test tests/graphStore.multi-edge.test.mjs",
"test:graph-workspace": "node --import tsx --test tests/markdownContentViewer.test.ts tests/graphSceneState.display.test.ts tests/temporalLifecycle.test.ts tests/deterministicExplorerRendering.test.ts tests/smallGraphLayout.test.ts tests/realtimeGraphAttributes.test.ts",
"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"
},
@@ -39,7 +40,9 @@
"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",
@@ -49,6 +52,7 @@
"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",
+58 -15
View File
@@ -17,10 +17,13 @@ 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 })));
@@ -33,7 +36,6 @@ 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';
@@ -93,6 +95,18 @@ const navItems: NavItem[] = [
{ id: 'ontology-hub', label: 'Ontology Hub', hint: 'Schema governance, registry, and vocabulary management', icon: GitMerge },
];
function readInitialWorkspace(): WorkspaceId {
try {
const params = new URLSearchParams(window.location.search);
if (params.has("ontologyTab") || params.has("ontologyEntity")) {
return "ontology-hub";
}
} catch {
// Default to the welcome screen when URL state is unavailable.
}
return "welcome";
}
const shellStyles = `
:root {
--app-bg: #07111f;
@@ -1773,12 +1787,43 @@ function WelcomeScreen({
}
export default function App() {
const [activeWorkspace, setActiveWorkspace] = useState<WorkspaceId>('welcome');
const [activeWorkspace, setActiveWorkspace] = useState<WorkspaceId>(readInitialWorkspace);
const [exploreView, setExploreView] = useState<ExploreView>('graph');
const [analyzeView, setAnalyzeView] = useState<AnalyzeView>('reasoning');
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 = () => {
@@ -1811,18 +1856,15 @@ export default function App() {
return (
<WorkspaceShell
title="Explore"
subtitle={exploreView === 'graph' ? undefined : "Browse the graph and switch views without leaving the workspace."}
kicker={exploreView === 'graph' ? 'Graph Studio' : 'Vocabulary Browser'}
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'}
compact
tabs={
<>
<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>
</>
<ExploreWorkspaceTabs
activeView={exploreView}
agentMemoryAvailable={agentMemoryAvailable}
onSelect={switchExploreView}
/>
}
>
<ErrorBoundary key={`explore-${exploreView}`}>
@@ -1831,8 +1873,9 @@ export default function App() {
<GraphWorkspace
externalFocusNodeId={graphFocusRequest?.nodeId}
externalFocusToken={graphFocusRequest?.token}
onDirtyChange={setExploreDraftDirty}
/>
) : <VocabularyWorkspace />}
) : exploreView === 'memories' ? <MemoryWorkspace onDirtyChange={setExploreDraftDirty} /> : <VocabularyWorkspace />}
</Suspense>
</ErrorBoundary>
</WorkspaceShell>
@@ -1977,13 +2020,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={() => setActiveWorkspace('welcome')} style={{ cursor: 'pointer', border: '1px solid rgba(127,208,255,0.18)' }}>SKE</button>
<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>
{navItems.map(({ id, label, hint, icon: Icon }) => (
<button
key={id}
className="nav-button"
data-active={activeWorkspace === id}
onClick={() => setActiveWorkspace(id)}
onClick={() => switchWorkspace(id)}
title={hint}
>
<Icon size={20} />
+29
View File
@@ -0,0 +1,29 @@
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>
</>
);
}
+24
View File
@@ -0,0 +1,24 @@
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;
}
}
+1
View File
@@ -15,6 +15,7 @@ export type RegistryEntryOp =
| "export"
| "merge"
| "add-node"
| "update-node"
| "add-edge"
| "delete"
| "infer"
@@ -16,6 +16,7 @@ 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)" },
@@ -23,7 +24,7 @@ const OP_META: Record<
};
const ALL_OPS: (RegistryEntryOp | "all")[] = [
"all", "import", "export", "merge", "add-node", "add-edge", "infer", "delete", "vocab-import",
"all", "import", "export", "merge", "add-node", "update-node", "add-edge", "infer", "delete", "vocab-import",
];
function formatTimestamp(date: Date): string {
@@ -4,6 +4,7 @@ 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;
@@ -44,6 +45,8 @@ 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;
@@ -304,6 +307,8 @@ export function GraphInspectorPanel({
pathResult,
onDownloadProvenance,
onFocusNode,
onMarkdownApplied,
onMarkdownDirtyChange,
}: GraphInspectorPanelProps) {
if (!nodeId) {
return (
@@ -414,19 +419,18 @@ export function GraphInspectorPanel({
</div>
) : null}
{/* 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>
)}
{/* 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>
{/* Actions */}
<section style={sectionStyle}>
@@ -44,6 +44,12 @@ 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,
@@ -1241,9 +1247,10 @@ function collectPluginOverlays(
interface GraphWorkspaceProps {
externalFocusNodeId?: string;
externalFocusToken?: number;
onDirtyChange?: (dirty: boolean) => void;
}
export function GraphWorkspace({ externalFocusNodeId, externalFocusToken }: GraphWorkspaceProps = {}) {
export function GraphWorkspace({ externalFocusNodeId, externalFocusToken, onDirtyChange }: GraphWorkspaceProps = {}) {
const [selectedNodeId, setSelectedNodeId] = useState("");
const [focusedNodeId, setFocusedNodeId] = useState("");
const [lastGroupedSelectedNodeId, setLastGroupedSelectedNodeId] = useState("");
@@ -1251,6 +1258,12 @@ export function GraphWorkspace({ externalFocusNodeId, externalFocusToken }: Grap
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[]>([]);
@@ -1628,8 +1641,17 @@ export function GraphWorkspace({ externalFocusNodeId, externalFocusToken }: Grap
: 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) {
@@ -1682,6 +1704,7 @@ export function GraphWorkspace({ externalFocusNodeId, externalFocusToken }: Grap
));
setViewMode("full");
}, [
confirmDiscardMarkdownDraft,
aggregationEnabled,
collapsedNeighborhoodNodeIds,
focusedNodeId,
@@ -1692,9 +1715,11 @@ export function GraphWorkspace({ externalFocusNodeId, externalFocusToken }: Grap
lastGroupedSelectedNodeId,
resolveNodeIdForFocusedMode,
selectedNodeId,
viewMode,
]);
const focusNode = useCallback((nodeId: string) => {
if (nodeId !== selectedNodeId && !confirmDiscardMarkdownDraft()) return;
if (!nodeId) {
setSelectedNodeId("");
setSelectedEdgeId("");
@@ -1720,14 +1745,18 @@ export function GraphWorkspace({ externalFocusNodeId, externalFocusToken }: Grap
setFocusedNodeId(nextSelectedNodeId);
setIsLayoutRunning(false);
}
}, [viewMode]); // Note: ego/heatmap/distanceMode effects re-run automatically when selectedNodeId changes
}, [confirmDiscardMarkdownDraft, selectedNodeId, 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;
// 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.
@@ -1737,7 +1766,13 @@ export function GraphWorkspace({ externalFocusNodeId, externalFocusToken }: Grap
window.setTimeout(() => {
sceneRef.current?.focusNode(externalFocusNodeId);
}, 0);
}, [externalFocusNodeId, externalFocusToken, graphReady]);
}, [
confirmDiscardMarkdownDraft,
externalFocusNodeId,
externalFocusToken,
graphReady,
selectedNodeId,
]);
const handleEdgeSelect = useCallback((edgeId: string) => {
setSelectedEdgeId(edgeId);
@@ -1843,6 +1878,37 @@ export function GraphWorkspace({ externalFocusNodeId, externalFocusToken }: Grap
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`);
@@ -1873,6 +1939,25 @@ export function GraphWorkspace({ externalFocusNodeId, externalFocusToken }: Grap
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([
@@ -1899,7 +1984,7 @@ export function GraphWorkspace({ externalFocusNodeId, externalFocusToken }: Grap
return () => {
socket.close();
};
}, []);
}, [markdownRefreshGuard]);
useEffect(() => {
setCollapsedNeighborhoodNodeIds([]);
@@ -2172,28 +2257,29 @@ export function GraphWorkspace({ externalFocusNodeId, externalFocusToken }: Grap
}, [collapsedNeighborhoodNodeIds, focusedNodeId, selectedNodeId, viewMode]);
const structuralActivePath = structuralSelectedNodeId ? activePath : EMPTY_PATH;
const structuralActivePathEdgeIds = structuralSelectedNodeId ? activePathEdgeIds : EMPTY_PATH;
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 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 displayState = useMemo(
() => (
viewMode === "grouped"
@@ -3295,6 +3381,8 @@ export function GraphWorkspace({ externalFocusNodeId, externalFocusToken }: Grap
pathResult={pathResult}
onDownloadProvenance={(format) => void handleDownloadProvenance(format)}
onFocusNode={focusNode}
onMarkdownApplied={handleMarkdownApplied}
onMarkdownDirtyChange={handleMarkdownDirtyChange}
/>
</Suspense>
</div>
@@ -1,125 +1,243 @@
import { useState, useRef, useEffect, useMemo, type CSSProperties } from "react";
import {
useEffect,
useMemo,
useRef,
useState,
type CSSProperties,
} from "react";
import ReactMarkdown, { type Components } from "react-markdown";
import remarkGfm from "remark-gfm";
import { Check, Copy, Code2, Eye, ExternalLink, Image as ImageIcon } from "lucide-react";
import {
Check,
Code2,
Copy,
Eye,
ExternalLink,
Image as ImageIcon,
Loader2,
Pencil,
RefreshCw,
X,
} 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) {
// Clear the stale indicator synchronously so the new node's copy button
// never shows "Copied" from the previous selection.
setCopied(false);
}
if (copied) setCopied(false);
}
const copyTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
// Clean up any outstanding timeout on unmount.
const copyTimeoutRef = useRef<number | undefined>(undefined);
useEffect(() => {
return () => {
if (copyTimeoutRef.current) {
clearTimeout(copyTimeoutRef.current);
}
clearTimeout(copyTimeoutRef.current);
};
}, []);
const rawContent = typeof content === "string" ? content : "";
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 hasContent = rawContent.trim().length > 0;
// react-markdown runs the whole remark pipeline synchronously inside its own
// render, so without this memo every unrelated re-render of this component --
// clicking Copy, toggling Preview/Source -- re-parses the entire document.
// Measured at ~364ms per re-render for a 1000-row GFM table (issue #1118).
// Keyed on rawContent so a genuine node change still re-parses exactly once.
const renderedMarkdown = useMemo(
() => (
<ReactMarkdown remarkPlugins={REMARK_PLUGINS} components={MARKDOWN_COMPONENTS}>
{rawContent}
{previewContent}
</ReactMarkdown>
),
[rawContent],
[previewContent],
);
const handleCopy = async () => {
if (!hasContent) return;
try {
await navigator.clipboard.writeText(rawContent);
if (copyTimeoutRef.current) {
clearTimeout(copyTimeoutRef.current);
}
clearTimeout(copyTimeoutRef.current);
setCopied(true);
copyTimeoutRef.current = setTimeout(() => setCopied(false), 1500);
copyTimeoutRef.current = window.setTimeout(() => setCopied(false), 1500);
} catch {
// Clipboard write unavailable
// 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");
}
};
return (
<div className={className} style={viewerContainerStyle}>
<div style={viewerHeaderStyle}>
<div style={{ display: "flex", gap: 4 }} role="tablist">
<div style={{ display: "flex", gap: 4 }} role="tablist" aria-label="Markdown view">
<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 }} />
<Eye size={12} style={{ marginRight: 5 }} aria-hidden="true" />
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 }} />
<Code2 size={12} style={{ marginRight: 5 }} aria-hidden="true" />
Source
</button>
</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 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>
</div>
<div style={viewerBodyStyle}>
{!hasContent ? (
{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={emptyTextStyle}>No content available for this node.</div>
) : activeMode === "source" ? (
<pre style={sourcePreStyle}>
@@ -238,6 +356,8 @@ 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}`,
@@ -275,12 +395,56 @@ 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,
@@ -0,0 +1,114 @@
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";
}
@@ -0,0 +1,103 @@
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,
}),
});
}
@@ -0,0 +1,81 @@
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,
};
}
@@ -0,0 +1,177 @@
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,
};
}
+464
View File
@@ -0,0 +1,464 @@
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,
};
@@ -8,8 +8,10 @@ import {
useNodesState,
useEdgesState,
MarkerType,
Handle,
Position,
} from "@xyflow/react";
import type { Connection, Edge, Node } from "@xyflow/react";
import type { Connection, Edge, Node, ReactFlowInstance } from "@xyflow/react";
import "@xyflow/react/dist/style.css";
import {
Plus,
@@ -22,10 +24,20 @@ import {
Pencil,
Trash2,
} from "lucide-react";
import { loadOntologyEntityOwner, loadOntologyGraph } from "./api";
import type { OntologyGraphEdge, OntologyGraphNode } from "./api";
import {
classifyNodeType,
inferOntologyUri,
isEditableEntityType,
ONTOLOGY_MINIMAP_THEME,
} from "./ontologyEditorModel";
import type { EditorEntityType, RegistryEntry } from "./ontologyEditorModel";
type OntologyNodeData = {
label?: string;
type?: string;
entityType?: EditorEntityType;
};
type OntologyNode = Node<OntologyNodeData>;
@@ -34,12 +46,57 @@ type OntologyEdge = Edge<Record<string, unknown>>;
const nodeTypes = {
classNode: ({ data }: { data: OntologyNodeData }) => (
<div style={classNodeStyle}>
<Handle type="target" position={Position.Left} style={handleStyle} />
<div style={classNodeHeader}>{data.label}</div>
<div style={classNodeSub}>{data.type}</div>
<Handle type="source" position={Position.Right} style={handleStyle} />
</div>
),
};
const handleStyle: React.CSSProperties = {
width: 8,
height: 8,
border: "1px solid rgba(235, 243, 255, 0.8)",
background: "#4aa3ff",
};
const ontologyFlowThemeCss = `
.ontology-editor-flow .react-flow__controls {
overflow: hidden;
border: 1px solid rgba(127, 208, 255, 0.2);
border-radius: 9px;
background: rgba(6, 13, 26, 0.96);
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.38);
}
.ontology-editor-flow .react-flow__controls-button {
width: 30px;
height: 30px;
background: transparent;
border-bottom-color: rgba(127, 208, 255, 0.14);
color: #8fa8c6;
transition: color 140ms ease, background 140ms ease;
}
.ontology-editor-flow .react-flow__controls-button:hover {
background: rgba(74, 163, 255, 0.14);
color: #ebf3ff;
}
.ontology-editor-flow .react-flow__controls-button:focus-visible {
position: relative;
z-index: 1;
outline: 2px solid #7fd0ff;
outline-offset: -2px;
}
.ontology-editor-flow .react-flow__controls-button:disabled {
background: rgba(3, 9, 18, 0.32);
color: #40566f;
}
`;
const classNodeStyle: React.CSSProperties = {
padding: "12px 16px",
borderRadius: "8px",
@@ -79,17 +136,95 @@ interface DraftDiff {
annotation_changes: Record<string, Record<string, any>>;
}
interface RegistryEntry {
uri: string;
name: string;
function requestedEntityUri(): string {
try {
return new URLSearchParams(window.location.search).get("ontologyEntity") || "";
} catch {
return "";
}
}
function nodeLabel(node: OntologyGraphNode): string {
const explicit = String(node.content || node.properties?.["rdfs:label"] || "").trim();
if (explicit && explicit !== node.id) {
return explicit;
}
const trimmed = node.id.replace(/[/#]+$/, "");
return trimmed.split("#").pop() || trimmed.split("/").pop() || node.id;
}
function classifyEditorNode(node: OntologyGraphNode): OntologyNodeData["entityType"] {
return classifyNodeType(node.type);
}
function layoutEditorNodes(inputNodes: OntologyNode[]): OntologyNode[] {
const properties = inputNodes.filter((node) => node.data.entityType === "property");
const targets = inputNodes.filter((node) => (
node.data.entityType === "class" || node.data.entityType === "external"
));
const context = inputNodes.filter((node) => (
node.data.entityType !== "property"
&& node.data.entityType !== "class"
&& node.data.entityType !== "external"
));
const height = Math.max(360, Math.max(properties.length, targets.length) * 180);
const positions = new Map<string, { x: number; y: number }>();
properties.forEach((node, index) => {
positions.set(node.id, { x: 0, y: ((index + 1) * height) / (properties.length + 1) });
});
targets.forEach((node, index) => {
positions.set(node.id, { x: 600, y: ((index + 1) * height) / (targets.length + 1) });
});
context.forEach((node, index) => {
positions.set(node.id, { x: 300 + index * 220, y: height + 120 });
});
return inputNodes.map((node) => ({
...node,
position: positions.get(node.id) || node.position,
}));
}
function buildEditorElements(apiNodes: OntologyGraphNode[], apiEdges: OntologyGraphEdge[]) {
const sortedNodes = [...apiNodes].sort((left, right) => {
const typeDelta = left.type.localeCompare(right.type);
return typeDelta || left.id.localeCompare(right.id);
});
const nodes = layoutEditorNodes(sortedNodes.map((node) => ({
id: node.id,
type: "classNode",
position: { x: 0, y: 0 },
data: {
label: nodeLabel(node),
type: node.type,
entityType: classifyEditorNode(node),
},
})));
const edges: OntologyEdge[] = apiEdges.map((edge, index) => ({
id: edge.id || `${edge.source}:${edge.type}:${edge.target}:${index}`,
source: edge.source,
target: edge.target,
label: edge.type,
type: "default",
markerEnd: { type: MarkerType.ArrowClosed },
style: { stroke: "rgba(127, 208, 255, 0.72)", strokeWidth: 1.5 },
labelStyle: { fill: "#c8dcf5", fontSize: 11, fontWeight: 600 },
labelBgStyle: { fill: "#07111f", fillOpacity: 0.9 },
}));
return { nodes, edges };
}
export function OntologyEditor() {
const [nodes, setNodes, onNodesChange] = useNodesState<OntologyNode>([]);
const [edges, setEdges, onEdgesChange] = useEdgesState<OntologyEdge>([]);
const [selectedElement, setSelectedElement] = useState<OntologyNode | OntologyEdge | null>(null);
const hasDetailPanel = selectedElement !== null;
const [registry, setRegistry] = useState<RegistryEntry[]>([]);
const [ontologyUri, setOntologyUri] = useState<string>("");
const [flowInstance, setFlowInstance] = useState<ReactFlowInstance<OntologyNode, OntologyEdge> | null>(null);
const [isLoadingGraph, setIsLoadingGraph] = useState(false);
const [graphError, setGraphError] = useState("");
const [draftDiff, setDraftDiff] = useState<DraftDiff>({
added_classes: [],
removed_classes: [],
@@ -108,12 +243,18 @@ export function OntologyEditor() {
useEffect(() => {
let cancelled = false;
fetch("/api/ontology/registry")
.then((response) => (response.ok ? response.json() : []))
.then((entries: RegistryEntry[]) => {
const requested = requestedEntityUri();
Promise.all([
fetch("/api/ontology/registry").then((response) => (response.ok ? response.json() : [])),
requested
? loadOntologyEntityOwner(requested).catch(() => undefined)
: Promise.resolve(undefined),
])
.then(([entries, explicitOwner]: [RegistryEntry[], string | undefined]) => {
if (cancelled) return;
setRegistry(entries);
setOntologyUri((current) => current || entries[0]?.uri || "");
const inferredOntology = inferOntologyUri(entries, requested, explicitOwner);
setOntologyUri((current) => current || inferredOntology || entries[0]?.uri || "");
})
.catch((error) => {
console.error("Failed to load ontology registry:", error);
@@ -123,6 +264,47 @@ export function OntologyEditor() {
};
}, []);
useEffect(() => {
if (!ontologyUri) {
setNodes([]);
setEdges([]);
setSelectedElement(null);
return;
}
const controller = new AbortController();
setIsLoadingGraph(true);
setGraphError("");
loadOntologyGraph(ontologyUri, controller.signal)
.then((payload) => {
const elements = buildEditorElements(payload.nodes, payload.edges);
setNodes(elements.nodes);
setEdges(elements.edges);
const requested = requestedEntityUri();
setSelectedElement(elements.nodes.find((node) => node.id === requested) || null);
})
.catch((error) => {
if (controller.signal.aborted) return;
setNodes([]);
setEdges([]);
setSelectedElement(null);
setGraphError(error instanceof Error ? error.message : "Failed to load ontology graph");
})
.finally(() => {
if (!controller.signal.aborted) setIsLoadingGraph(false);
});
return () => controller.abort();
}, [ontologyUri, setEdges, setNodes]);
useEffect(() => {
if (!flowInstance || nodes.length === 0) return;
const frame = window.requestAnimationFrame(() => {
void flowInstance.fitView({ padding: 0.22, duration: 320, maxZoom: 1.25 });
});
return () => window.cancelAnimationFrame(frame);
}, [flowInstance, hasDetailPanel, nodes.length, ontologyUri]);
const onConnect = useCallback(
(params: Connection) => setEdges((eds) => addEdge({ ...params, markerEnd: { type: MarkerType.ArrowClosed } }, eds)),
[setEdges]
@@ -134,7 +316,7 @@ export function OntologyEditor() {
id: newId,
type: "classNode",
position: { x: Math.random() * 400, y: Math.random() * 300 },
data: { label: "NewClass", type: "owl:Class" },
data: { label: "NewClass", type: "owl:Class", entityType: "class" },
};
setNodes((nds) => [...nds, newNode]);
setDraftDiff((prev) => ({
@@ -170,7 +352,7 @@ export function OntologyEditor() {
id: newId,
type: "classNode",
position: { x: Math.random() * 400, y: Math.random() * 300 },
data: { label: "NewIndividual", type: "owl:NamedIndividual" },
data: { label: "NewIndividual", type: "owl:NamedIndividual", entityType: "external" },
};
setNodes((nds) => [...nds, newNode]);
}, [setNodes]);
@@ -190,13 +372,21 @@ export function OntologyEditor() {
}, []);
const autoLayout = useCallback(() => {
const layoutNodes = nodes.map((node, index) => ({
...node,
position: { x: (index % 4) * 200, y: Math.floor(index / 4) * 150 },
}));
setNodes(layoutNodes);
setNodes(layoutEditorNodes(nodes));
}, [nodes, setNodes]);
const selectNode = useCallback((node: OntologyNode) => {
setSelectedElement(node);
try {
const params = new URLSearchParams(window.location.search);
params.set("ontologyTab", "editor");
params.set("ontologyEntity", node.id);
window.history.replaceState(null, "", `?${params.toString()}`);
} catch {
// URL state is optional; the editor selection still works without it.
}
}, []);
const saveDraft = useCallback(async () => {
if (!ontologyUri) {
alert("Please select an ontology first");
@@ -247,12 +437,11 @@ export function OntologyEditor() {
...prev,
removed_properties: [...prev.removed_properties, target.id],
}));
} else {
} else if (isEditableEntityType(target.data.entityType)) {
setNodes((nds) => nds.filter((n) => n.id !== target.id));
setDraftDiff((prev) => ({
...prev,
removed_classes: [...prev.removed_classes, target.id],
}));
setDraftDiff((prev) => target.data.entityType === "property"
? { ...prev, removed_properties: [...prev.removed_properties, target.id] }
: { ...prev, removed_classes: [...prev.removed_classes, target.id] });
}
setSelectedElement(null);
}
@@ -261,16 +450,21 @@ export function OntologyEditor() {
const renameSelected = useCallback(() => {
const target = showContext?.element ?? selectedElement;
if (target && !("source" in target)) {
if (target && !("source" in target) && isEditableEntityType(target.data.entityType)) {
const newLabel = prompt("Enter new name:", String(target.data.label ?? ""));
if (newLabel) {
setNodes((nds) =>
nds.map((n) => (n.id === target.id ? { ...n, data: { ...n.data, label: newLabel } } : n))
);
setDraftDiff((prev) => ({
...prev,
modified_classes: { ...prev.modified_classes, [target.id]: { label: newLabel } },
}));
setDraftDiff((prev) => target.data.entityType === "property"
? {
...prev,
modified_properties: { ...prev.modified_properties, [target.id]: { label: newLabel } },
}
: {
...prev,
modified_classes: { ...prev.modified_classes, [target.id]: { label: newLabel } },
});
}
}
setShowContext(null);
@@ -339,11 +533,10 @@ export function OntologyEditor() {
};
const detailPanelStyle: React.CSSProperties = {
position: "absolute",
right: 0,
top: 0,
bottom: 0,
flex: "0 0 320px",
width: "320px",
minWidth: "320px",
boxSizing: "border-box",
background: "rgba(9, 19, 34, 0.95)",
borderLeft: "1px solid rgba(140, 192, 255, 0.12)",
padding: "20px",
@@ -353,11 +546,24 @@ export function OntologyEditor() {
return (
<div style={{ display: "flex", flexDirection: "column", height: "100%", background: "#07111f" }}>
<style>{ontologyFlowThemeCss}</style>
<div style={toolbarStyle}>
<select
aria-label="Active ontology"
value={ontologyUri}
onChange={(event) => setOntologyUri(event.target.value)}
onChange={(event) => {
setOntologyUri(event.target.value);
setSelectedElement(null);
try {
// Drop the previous ontology's entity from the URL, or a reload
// would resolve the stale ID and jump back to that ontology.
const params = new URLSearchParams(window.location.search);
params.delete("ontologyEntity");
window.history.replaceState(null, "", `?${params.toString()}`);
} catch {
// URL state is optional; switching ontologies still works.
}
}}
style={selectStyle}
>
<option value="">Select ontology...</option>
@@ -398,43 +604,75 @@ export function OntologyEditor() {
</button>
</div>
<div style={{ flex: 1, position: "relative" }}>
<ReactFlow
nodes={nodes}
edges={edges}
onNodesChange={onNodesChange}
onEdgesChange={onEdgesChange}
onConnect={onConnect}
onNodeClick={(_, node) => setSelectedElement(node)}
onEdgeClick={(_, edge) => setSelectedElement(edge)}
onNodeContextMenu={handleNodeContextMenu}
onEdgeContextMenu={handleEdgeContextMenu}
nodeTypes={nodeTypes}
fitView
style={{ background: "#07111f" }}
>
<Background color="#1a2d3d" gap={20} />
<Controls />
<MiniMap nodeColor="#4aa3ff" maskColor="rgba(0,0,0,0.6)" />
</ReactFlow>
<div style={{ display: "flex", flex: 1, minHeight: 0, minWidth: 0 }}>
<div style={{ flex: 1, minHeight: 0, minWidth: 0, position: "relative" }}>
<ReactFlow
className="ontology-editor-flow"
nodes={nodes}
edges={edges}
onNodesChange={onNodesChange}
onEdgesChange={onEdgesChange}
onConnect={onConnect}
onInit={setFlowInstance}
onNodeClick={(_, node) => selectNode(node)}
onEdgeClick={(_, edge) => setSelectedElement(edge)}
onNodeContextMenu={handleNodeContextMenu}
onEdgeContextMenu={handleEdgeContextMenu}
nodeTypes={nodeTypes}
fitView
style={{ background: "#07111f" }}
>
<Background color="#1a2d3d" gap={20} />
<Controls />
<MiniMap {...ONTOLOGY_MINIMAP_THEME} />
</ReactFlow>
{showContext && (
<div style={{ ...contextMenuStyle, left: showContext.x, top: showContext.y }}>
<div style={contextItemStyle} onClick={renameSelected}>
<Pencil size={14} />
Rename
{isLoadingGraph && (
<div style={canvasMessageStyle}>Loading ontology structure</div>
)}
{!isLoadingGraph && graphError && (
<div style={{ ...canvasMessageStyle, color: "#ff9a8d" }}>{graphError}</div>
)}
{!isLoadingGraph && !graphError && ontologyUri && nodes.length === 0 && (
<div style={canvasMessageStyle}>This ontology has no editable classes or properties.</div>
)}
{showContext && (
<div style={{ ...contextMenuStyle, left: showContext.x, top: showContext.y }}>
{"source" in showContext.element || isEditableEntityType(showContext.element.data.entityType) ? (
<>
{!("source" in showContext.element) && (
<div style={contextItemStyle} onClick={renameSelected}>
<Pencil size={14} />
Rename
</div>
)}
<div style={contextItemStyle} onClick={deleteSelected}>
<Trash2 size={14} />
Delete
</div>
</>
) : (
<div style={{ ...contextItemStyle, cursor: "default", color: "#8fa8c6" }}>
This term is read-only
</div>
)}
</div>
<div style={contextItemStyle} onClick={deleteSelected}>
<Trash2 size={14} />
Delete
</div>
</div>
)}
)}
</div>
{selectedElement && (
<div style={detailPanelStyle}>
<h3 style={{ margin: "0 0 16px", color: "#ebf3ff", fontSize: "16px" }}>
{"source" in selectedElement ? "Property Details" : "Class Details"}
{"source" in selectedElement
? "Relationship Details"
: selectedElement.data.entityType === "property"
? "Property Details"
: selectedElement.data.entityType === "ontology"
? "Ontology Details"
: selectedElement.data.entityType === "external"
? "External Term Details"
: "Class Details"}
</h3>
<div style={{ marginBottom: "12px" }}>
<label style={{ display: "block", color: "#8fa8c6", fontSize: "12px", marginBottom: "4px" }}>
@@ -453,7 +691,9 @@ export function OntologyEditor() {
<input
type="text"
value={String(selectedElement.data.label ?? "")}
readOnly={!isEditableEntityType(selectedElement.data.entityType)}
onChange={(e) => {
if (!isEditableEntityType(selectedElement.data.entityType)) return;
setNodes((nds) =>
nds.map((n) =>
n.id === selectedElement.id
@@ -463,10 +703,19 @@ export function OntologyEditor() {
);
setDraftDiff((prev) => ({
...prev,
modified_classes: {
...prev.modified_classes,
[selectedElement.id]: { label: e.target.value },
},
...(selectedElement.data.entityType === "property"
? {
modified_properties: {
...prev.modified_properties,
[selectedElement.id]: { label: e.target.value },
},
}
: {
modified_classes: {
...prev.modified_classes,
[selectedElement.id]: { label: e.target.value },
},
}),
}));
}}
style={{
@@ -496,3 +745,17 @@ export function OntologyEditor() {
</div>
);
}
const canvasMessageStyle: React.CSSProperties = {
position: "absolute",
left: "50%",
top: "50%",
transform: "translate(-50%, -50%)",
padding: "10px 14px",
borderRadius: "8px",
border: "1px solid rgba(127, 208, 255, 0.18)",
background: "rgba(3, 9, 18, 0.9)",
color: "#8fa8c6",
fontSize: "13px",
pointerEvents: "none",
};
@@ -9,6 +9,32 @@ import type {
ShaclValidationResponse,
} from "./types";
export type OntologyGraphNode = {
id: string;
type: string;
content?: string;
properties?: Record<string, unknown>;
};
export type OntologyGraphEdge = {
id?: string;
source: string;
target: string;
type: string;
weight?: number;
properties?: Record<string, unknown>;
};
export type OntologyGraphResponse = {
uri: string;
nodes: OntologyGraphNode[];
edges: OntologyGraphEdge[];
};
export type OntologyEntityOwner = {
source_ontology?: string;
};
async function parseResponse<T>(response: Response): Promise<T> {
if (!response.ok) {
let detail = `Request failed with status ${response.status}`;
@@ -31,6 +57,18 @@ export async function loadOntologyRegistry(): Promise<OntologyEntry[]> {
return parseResponse<OntologyEntry[]>(await fetch("/api/ontology/registry"));
}
export async function loadOntologyGraph(uri: string, signal?: AbortSignal): Promise<OntologyGraphResponse> {
return parseResponse<OntologyGraphResponse>(
await fetch(`/api/ontology/graph?uri=${encodeURIComponent(uri)}`, { signal }),
);
}
export async function loadOntologyEntityOwner(uri: string): Promise<string | undefined> {
const response = await fetch(`/api/ontology/entity/${encodeURIComponent(uri)}`);
if (!response.ok) return undefined;
return (await response.json() as OntologyEntityOwner).source_ontology;
}
export async function loadAlignments(uri?: string): Promise<OntologyAlignment[]> {
const query = uri ? `?uri=${encodeURIComponent(uri)}` : "";
return parseResponse<OntologyAlignment[]>(await fetch(`/api/ontology/alignments${query}`));
@@ -38,6 +38,7 @@ function readTabParam(): OntologyHubTab {
const params = new URLSearchParams(window.location.search);
const raw = params.get(TAB_PARAM);
if (raw && TABS.some((t) => t.id === raw)) return raw as OntologyHubTab;
if (params.get("ontologyEntity")) return "editor";
} catch {
// ignore
}
@@ -116,4 +117,3 @@ export function OntologyWorkspace({ onJumpToGraphNode }: OntologyWorkspaceProps)
</div>
);
}
@@ -0,0 +1,70 @@
export type EditorEntityType = "ontology" | "class" | "property" | "external";
export type RegistryEntry = {
uri: string;
name: string;
};
export const ONTOLOGY_MINIMAP_THEME = {
bgColor: "#0b1625",
maskColor: "rgba(7, 17, 31, 0.72)",
maskStrokeColor: "#5faeff",
maskStrokeWidth: 2,
nodeColor: "#2d7fd3",
nodeStrokeColor: "#9acbff",
nodeStrokeWidth: 1,
style: {
border: "1px solid #29435c",
borderRadius: 6,
boxShadow: "0 4px 16px rgba(0, 0, 0, 0.32)",
},
} as const;
// The backend emits node types in compact (owl:Class) or full IRI
// (http://www.w3.org/2002/07/owl#Class) form; classification must accept both.
const FULL_IRI_PREFIXES: Array<[string, string]> = [
["http://www.w3.org/2002/07/owl#", "owl:"],
["http://www.w3.org/2000/01/rdf-schema#", "rdfs:"],
["http://www.w3.org/2004/02/skos/core#", "skos:"],
];
export function compactNodeType(type: string): string {
for (const [iri, prefix] of FULL_IRI_PREFIXES) {
if (type.startsWith(iri)) {
return `${prefix}${type.slice(iri.length)}`;
}
}
return type;
}
export function classifyNodeType(rawType: string): EditorEntityType {
const type = compactNodeType(rawType);
if (type === "owl:Ontology") return "ontology";
if (type === "owl:Class" || type === "rdfs:Class") return "class";
if (type.includes("Property")) return "property";
return "external";
}
function ownsByNamespace(entityUri: string, ontologyUri: string): boolean {
const stem = ontologyUri.replace(/[/#]+$/, "");
return entityUri === ontologyUri
|| entityUri.startsWith(`${stem}#`)
|| entityUri.startsWith(`${stem}/`);
}
export function inferOntologyUri(
entries: RegistryEntry[],
entityUri: string,
explicitOwner?: string,
): string | undefined {
if (explicitOwner && entries.some((entry) => entry.uri === explicitOwner)) {
return explicitOwner;
}
return [...entries]
.filter((entry) => ownsByNamespace(entityUri, entry.uri))
.sort((left, right) => right.uri.length - left.uri.length)[0]?.uri;
}
export function isEditableEntityType(entityType?: EditorEntityType): boolean {
return entityType === "class" || entityType === "property";
}
@@ -42,6 +42,9 @@ 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") {
@@ -0,0 +1,74 @@
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,
);
});
+24 -1
View File
@@ -3,7 +3,7 @@ import assert from "node:assert/strict";
import React from "react";
import { renderToString } from "react-dom/server";
(globalThis as any).React = React;
(globalThis as typeof globalThis & { React: typeof React }).React = React;
import { MarkdownContentViewer } from "../src/workspaces/GraphWorkspace/MarkdownContentViewer.tsx";
import { isSafeUrl } from "../src/workspaces/GraphWorkspace/markdownUrlSafety.ts";
@@ -60,6 +60,29 @@ 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" }));
@@ -0,0 +1,639 @@
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)");
});
+132
View File
@@ -0,0 +1,132 @@
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);
});
+91
View File
@@ -0,0 +1,91 @@
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);
});
@@ -0,0 +1,66 @@
import assert from "node:assert/strict";
import test from "node:test";
import {
classifyNodeType,
compactNodeType,
inferOntologyUri,
isEditableEntityType,
ONTOLOGY_MINIMAP_THEME,
} from "../src/workspaces/OntologyWorkspace/ontologyEditorModel";
const registry = [
{ uri: "https://example.test/foo", name: "Foo" },
{ uri: "https://example.test/foo/nested", name: "Nested" },
];
test("ontology inference requires a URI delimiter and prefers the closest namespace", () => {
assert.equal(inferOntologyUri(registry, "https://example.test/foobar/Class"), undefined);
assert.equal(
inferOntologyUri(registry, "https://example.test/foo/nested#Class"),
"https://example.test/foo/nested",
);
});
test("explicit scheme ownership wins when an entity uses another namespace", () => {
assert.equal(
inferOntologyUri(registry, "https://vocabulary.test/Class", "https://example.test/foo"),
"https://example.test/foo",
);
});
test("only draft-supported class and property nodes are editable", () => {
assert.equal(isEditableEntityType("class"), true);
assert.equal(isEditableEntityType("property"), true);
assert.equal(isEditableEntityType("ontology"), false);
assert.equal(isEditableEntityType("external"), false);
});
test("the ontology minimap has an explicit dark, high-contrast theme", () => {
assert.equal(ONTOLOGY_MINIMAP_THEME.bgColor, "#0b1625");
assert.equal(ONTOLOGY_MINIMAP_THEME.maskStrokeColor, "#5faeff");
assert.equal(ONTOLOGY_MINIMAP_THEME.nodeStrokeColor, "#9acbff");
assert.match(ONTOLOGY_MINIMAP_THEME.style.border, /#29435c/);
});
test("node types classify identically in compact and full IRI form", () => {
const cases: Array<[string, string, string]> = [
["owl:Ontology", "http://www.w3.org/2002/07/owl#Ontology", "ontology"],
["owl:Class", "http://www.w3.org/2002/07/owl#Class", "class"],
["rdfs:Class", "http://www.w3.org/2000/01/rdf-schema#Class", "class"],
["owl:ObjectProperty", "http://www.w3.org/2002/07/owl#ObjectProperty", "property"],
["owl:DatatypeProperty", "http://www.w3.org/2002/07/owl#DatatypeProperty", "property"],
["owl:AnnotationProperty", "http://www.w3.org/2002/07/owl#AnnotationProperty", "property"],
];
for (const [compact, fullIri, expected] of cases) {
assert.equal(classifyNodeType(compact), expected, compact);
assert.equal(classifyNodeType(fullIri), expected, fullIri);
}
assert.equal(classifyNodeType("owl:NamedIndividual"), "external");
assert.equal(classifyNodeType("http://www.w3.org/2004/02/skos/core#Concept"), "external");
});
test("compactNodeType leaves unknown namespaces untouched", () => {
assert.equal(compactNodeType("https://example.org/custom#Thing"), "https://example.org/custom#Thing");
assert.equal(compactNodeType("owl:Class"), "owl:Class");
});
+270
View File
@@ -0,0 +1,270 @@
# 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.
+50
View File
@@ -0,0 +1,50 @@
"""
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",
]
+45
View File
@@ -0,0 +1,45 @@
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
+305
View File
@@ -0,0 +1,305 @@
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",
]
+659
View File
@@ -0,0 +1,659 @@
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",
]
+646
View File
@@ -0,0 +1,646 @@
"""
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",
]
-5
View File
@@ -1,5 +0,0 @@
"""Entry point: python -m mcp.server"""
from mcp.server import main
if __name__ == "__main__":
main()
+41 -10
View File
@@ -47,7 +47,11 @@ dependencies = [
"numpy>=2.0.2",
"pandas>=1.3.0",
"scipy>=1.13.1",
"scikit-learn>=1.7.2",
# 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'",
"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,
@@ -66,24 +70,49 @@ dependencies = [
"seaborn>=0.13.2",
"plotly>=6.8.0",
"ipywidgets>=8.0.0",
"requests>=2.34.2",
# 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'",
"GitPython>=3.1.58",
"chardet>=7.4.3",
# 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'",
"protobuf>=5.29.1,<8.0",
"grpcio>=1.81.1",
# 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'",
"beautifulsoup4>=4.15.0",
"lxml>=6.1.1",
"python-docx>=1.2.0",
"openpyxl>=3.1.5",
"pillow>=12.2.0",
# 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'",
"librosa>=0.9.0",
"opencv-python>=4.13.0.92",
"faiss-cpu>=1.7.0",
"fastembed>=0.2.0",
"onnxruntime>=1.20.1",
# 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'",
"tokenizers>=0.15.0",
"pydantic>=2.13.4",
"click>=8.4.2",
# 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'",
"rich>=12.5.0",
"tqdm>=4.68.3",
"pyyaml>=6.0",
@@ -163,7 +192,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-client>=3.0.0"]
vectorstore-pinecone = ["pinecone>=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"]
@@ -217,6 +246,7 @@ 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"]
@@ -247,6 +277,7 @@ 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",
@@ -264,7 +295,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]"
"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]"
]
# ---------------- ENTRYPOINTS ----------------
@@ -278,7 +309,7 @@ semantica-mcp = "semantica.mcp_server:main"
# ---------------- TOOLING ----------------
[tool.setuptools.packages.find]
where = ["."]
include = ["semantica*", "integrations*"]
include = ["semantica*", "integrations*", "semantica_mcp*"]
[tool.setuptools.package-data]
# Explicit patterns are more reliable than **/* across setuptools versions.
+2178 -1860
View File
File diff suppressed because it is too large Load Diff
+3 -3
View File
@@ -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", "mcp.server"]
cmd = [sys.executable, "-m", "semantica_mcp.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 mcp.tools import __all__ as tools
from semantica_mcp.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 mcp.session import MCPSession
from semantica_mcp.mcp.session import MCPSession
session = MCPSession(config=cli_ctx.config.to_dict())
result = session.call_tool(tool_name, **tool_args)
except ImportError as exc:
+115
View File
@@ -65,9 +65,11 @@ 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
@@ -78,6 +80,12 @@ 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):
@@ -158,6 +166,17 @@ 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.
@@ -197,6 +216,7 @@ 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")
@@ -249,6 +269,7 @@ 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.
@@ -298,6 +319,7 @@ class AgentMemory:
self.logger.info(f"Loaded agent memory from {path}")
@_with_memory_lock
def store(
self,
content: str,
@@ -574,6 +596,7 @@ class AgentMemory:
"relationships": memory_item.relationships,
}
@_with_memory_lock
def delete_memory(self, memory_id: str, *, skip_vector: bool = False) -> bool:
"""
Delete memory item.
@@ -625,6 +648,7 @@ 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.
@@ -1136,6 +1160,7 @@ class AgentMemory:
"""
return self.get_memory(memory_id)
@_with_memory_lock
def update(
self,
memory_id: str,
@@ -1416,6 +1441,13 @@ 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]]:
@@ -1631,6 +1663,88 @@ 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,
@@ -1679,6 +1793,7 @@ 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:
+181 -14
View File
@@ -131,6 +131,12 @@ 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):
@@ -1167,6 +1173,157 @@ 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:
@@ -4947,32 +5104,42 @@ 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 so that
``_decisions`` and the derived indexes stay consistent without
requiring a full rebuild of all decisions.
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.
"""
node = self.nodes.get(node_id)
if node is None:
return
if (getattr(node, "node_type", None) or "").lower() != "decision":
return
if not hasattr(self, "_decisions"):
# Indexes don't exist yet — a full rebuild is safer.
self._rebuild_decision_indexes()
return
# Remove stale index entries for this decision ID.
old = self._decisions.get(node_id)
# Remove stale index entries before deciding whether the current node
# still belongs in the decision indexes.
old = self._decisions.pop(node_id, None)
if old:
old_cat = old.get("category", "")
if old_cat and node_id in self._decision_index.get(old_cat, set()):
if old_cat:
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
]
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
# Rebuild the entry for this node and re-insert index entries.
meta: Dict[str, Any] = {}
+25
View File
@@ -0,0 +1,25 @@
"""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}"
+30 -74
View File
@@ -8,16 +8,19 @@ from contextlib import asynccontextmanager
from pathlib import Path
from typing import Optional
from fastapi import Depends, FastAPI, HTTPException, Request, WebSocket, WebSocketDisconnect
from fastapi import Depends, FastAPI, HTTPException, Request
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, is_valid_api_key, require_auth
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 .session import GraphSession
from .ws import ConnectionManager
from .ws import ConnectionManager, install_graph_updates_websocket
def _read_int_env(name: str, default: int) -> int:
@@ -53,37 +56,23 @@ 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:
@@ -95,6 +84,7 @@ 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):
@@ -117,7 +107,9 @@ def create_app(
app.state.event_loop = asyncio.get_running_loop()
app.state.ws_manager = ConnectionManager()
app.state.session = active_session
_install_mutation_bridge(app, active_session)
app.state.agent_memory = agent_memory
app.state.markdown_resources = markdown_resources
install_mutation_bridge(app, active_session)
yield
app = FastAPI(
@@ -139,7 +131,7 @@ def create_app(
CORSMiddleware,
allow_origins=settings["allowed_origins"],
allow_credentials=_allow_credentials,
allow_methods=["GET", "POST", "DELETE", "OPTIONS"],
allow_methods=["GET", "POST", "PUT", "DELETE", "OPTIONS"],
allow_headers=["Content-Type", "Authorization", "X-API-Key"],
max_age=600,
)
@@ -170,6 +162,8 @@ 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
@@ -183,54 +177,15 @@ 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)
_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)
install_graph_updates_websocket(app, settings["allowed_origins"])
@app.get("/", include_in_schema=False)
async def root():
@@ -269,6 +224,7 @@ def create_app(
"name": "Semantica Knowledge Explorer",
"version": __version__,
"status": "active",
"capabilities": explorer_capabilities(agent_memory),
}
static_dir = Path(__file__).resolve().parent.parent / "static"
+24
View File
@@ -14,6 +14,8 @@ 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)
@@ -80,3 +82,25 @@ 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
+283
View File
@@ -0,0 +1,283 @@
"""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,
)
+18 -6
View File
@@ -87,6 +87,13 @@ 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)
@@ -121,15 +128,20 @@ 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),
):
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)
return await _get_node_or_404(node_id, session)
@router.get("/node/{node_id}/neighbors", response_model=list[NeighborResponse])
@@ -441,7 +453,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:
@@ -451,7 +463,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:
+115
View File
@@ -0,0 +1,115 @@
"""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)
+41
View File
@@ -0,0 +1,41 @@
"""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,
)
+166 -9
View File
@@ -234,6 +234,12 @@ class EntityDetailResponse(BaseModel):
properties: Dict[str, Any] = Field(default_factory=dict)
class OntologyGraphResponse(BaseModel):
uri: str
nodes: List[Dict[str, Any]] = Field(default_factory=list)
edges: List[Dict[str, Any]] = Field(default_factory=list)
class SKOSScheme(BaseModel):
uri: str
title: str
@@ -664,6 +670,7 @@ def _convert_ontology_to_graph(ontology_dict: Dict[str, Any]) -> Tuple[List[Dict
"rdfs:label": cls.get("label", cls.get("name", "")),
"rdfs:comment": cls.get("description", ""),
"uri": cls_uri,
"scheme_uri": ontology_uri,
},
}
nodes.append(node)
@@ -680,14 +687,21 @@ def _convert_ontology_to_graph(ontology_dict: Dict[str, Any]) -> Tuple[List[Dict
# Add property nodes and edges
for prop in ontology_dict.get("properties", []):
prop_uri = prop.get("uri", f"temp:prop:{uuid.uuid4().hex[:12]}")
property_type = {
"object": "owl:ObjectProperty",
"data": "owl:DatatypeProperty",
"datatype": "owl:DatatypeProperty",
"annotation": "owl:AnnotationProperty",
}.get(str(prop.get("type", "object")).lower(), "owl:ObjectProperty")
node = {
"id": prop_uri,
"type": f"owl:{prop.get('type', 'Object').title()}Property",
"type": property_type,
"content": prop.get("name", prop.get("label", "")),
"properties": {
"rdfs:label": prop.get("label", prop.get("name", "")),
"rdfs:comment": prop.get("description", ""),
"uri": prop_uri,
"scheme_uri": ontology_uri,
},
}
nodes.append(node)
@@ -748,14 +762,38 @@ def _node_source_ontology(node: Dict[str, Any]) -> Optional[str]:
)
def _node_belongs_to_ontology(node: Dict[str, Any], ontology_uri: str) -> bool:
def _node_belongs_to_ontology(
node: Dict[str, Any],
ontology_uri: str,
known_ontology_uris: Optional[set[str]] = None,
) -> bool:
nid = node.get("id", "")
if nid == ontology_uri:
return True
if _node_source_ontology(node) == ontology_uri:
return True
owner = _node_source_ontology(node)
if owner:
return owner == ontology_uri
if known_ontology_uris:
namespace_owners = [
candidate
for candidate in known_ontology_uris
if nid == candidate
or nid.startswith(
(candidate.rstrip("#/") + "#", candidate.rstrip("#/") + "/")
)
]
if namespace_owners and max(namespace_owners, key=len) != ontology_uri:
return False
stem = ontology_uri.rstrip("#/")
return nid.startswith((stem + "#", stem + "/"))
if not nid.startswith((stem + "#", stem + "/")):
return False
# Prefix ownership only extends to names minted directly in the
# ontology's namespace (<stem>#Term or <stem>/Term). Any further
# delimiter marks a nested vocabulary (<stem>/child#Term,
# <stem>/child/Term), which must not be absorbed into the parent
# until it is registered or carries an explicit owner.
local_name = nid[len(stem) + 1 :]
return "#" not in local_name and "/" not in local_name
def _is_ontology_entity(node: Dict[str, Any]) -> bool:
@@ -1221,7 +1259,8 @@ def _parse_rdf_sync(content: bytes, fmt: str) -> tuple:
metadata.setdefault("description", str(obj))
break
if "uri" not in metadata:
synthetic_uri = "uri" not in metadata
if synthetic_uri:
metadata["uri"] = f"urn:semantica:onto:{uuid.uuid4().hex[:8]}"
metadata.setdefault("name", metadata["uri"].rsplit("/", 1)[-1].rsplit("#", 1)[-1] or "Unnamed")
metadata["triple_count"] = len(g)
@@ -1268,6 +1307,20 @@ def _parse_rdf_sync(content: bytes, fmt: str) -> tuple:
"weight": 1.0,
})
if synthetic_uri:
# No owl:Ontology / skos:ConceptScheme declaration exists, so the
# synthetic registry URI shares no namespace with any node. Ownership
# must be recorded explicitly, and the editor needs a matching graph
# node, or the registered ontology resolves to an empty core and 404s.
for node in nodes:
node["properties"].setdefault("scheme_uri", metadata["uri"])
nodes.append({
"id": metadata["uri"],
"type": "owl:Ontology",
"content": metadata["name"],
"properties": {"rdfs:label": metadata["name"], "uri": metadata["uri"]},
})
return nodes, edges, metadata
@@ -1768,6 +1821,107 @@ async def search_entities(
return results
@router.get("/graph", response_model=OntologyGraphResponse)
async def get_ontology_graph(
request: Request,
uri: str = Query(..., min_length=1),
session: GraphSession = Depends(get_session),
):
"""Return the editable schema subgraph for one registered ontology."""
registry = _get_registry(request)
ontology_nodes: List[Dict[str, Any]] = []
for node_type in _ONTOLOGY_TYPES:
nodes, _ = await asyncio.to_thread(
session.get_nodes, node_type=node_type, skip=0, limit=2**63 - 1
)
ontology_nodes.extend(nodes)
known_ontology_uris = set(registry) | {
str(node.get("id", "")) for node in ontology_nodes if node.get("id")
}
if uri not in known_ontology_uris:
raise HTTPException(status_code=404, detail="Ontology not found in registry.")
schema_types = _CLASS_TYPES | _PROPERTY_TYPES | _CONCEPT_TYPES | _ONTOLOGY_TYPES
candidates_by_id: Dict[str, Dict[str, Any]] = {}
for node_type in schema_types:
nodes, _ = await asyncio.to_thread(
session.get_nodes, node_type=node_type, skip=0, limit=2**63 - 1
)
candidates_by_id.update(
(str(node.get("id", "")), node) for node in nodes if node.get("id")
)
core_node_ids = {
str(node.get("id", ""))
for node in candidates_by_id.values()
if _node_belongs_to_ontology(node, uri, known_ontology_uris)
}
if not core_node_ids:
raise HTTPException(status_code=404, detail="Ontology graph not found.")
structure_edge_types = {
"rdf:type",
"rdfs:subClassOf",
"rdfs:domain",
"rdfs:range",
"owl:disjointWith",
"owl:equivalentClass",
"owl:equivalentProperty",
"owl:inverseOf",
"skos:broader",
"skos:narrower",
"skos:related",
}
selected_edges: List[Dict[str, Any]] = []
for edge_type in structure_edge_types:
edges, _ = await asyncio.to_thread(
session.get_edges,
edge_type=edge_type,
skip=0,
limit=2**63 - 1,
)
# Keep only edges whose source is a core node: the requested ontology
# may reference outward (e.g. rdfs:range to an external vocabulary),
# but an unrelated ontology's property pointing at a core class must
# not leak inward.
selected_edges.extend(
edge for edge in edges
if str(edge.get("source", "")) in core_node_ids
)
if (
len(core_node_ids) > _MAX_ANALYSIS_NODES
or len(selected_edges) > _MAX_ANALYSIS_NODES
):
raise HTTPException(
status_code=413,
detail=(
"Ontology editor graph exceeds the maximum size "
f"({_MAX_ANALYSIS_NODES} nodes or edges)."
),
)
selected_node_ids = set(core_node_ids)
for edge in selected_edges:
selected_node_ids.add(str(edge.get("source", "")))
selected_node_ids.add(str(edge.get("target", "")))
selected_nodes = [candidates_by_id[node_id] for node_id in core_node_ids]
for node_id in selected_node_ids - core_node_ids:
external = await asyncio.to_thread(session.get_node, node_id)
if external is not None:
selected_nodes.append(external)
selected_nodes.sort(key=lambda node: str(node.get("id", "")))
selected_edges.sort(
key=lambda edge: (
str(edge.get("source", "")),
str(edge.get("type", "")),
str(edge.get("target", "")),
str(edge.get("id", "")),
)
)
return OntologyGraphResponse(uri=uri, nodes=selected_nodes, edges=selected_edges)
@router.get("/entity/{entity_uri:path}", response_model=EntityDetailResponse)
async def get_entity_detail(
entity_uri: str,
@@ -2723,7 +2877,8 @@ 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"exceeds maximum allowed size ({_MAX_SHACL_TURTLE_BYTES} bytes); "
f"set SEMANTICA_MAX_SHACL_TURTLE_BYTES to raise the limit."
),
violations=[],
)
@@ -2740,7 +2895,8 @@ async def validate_shacl(
status="error",
message=(
f"SHACL graph triple count ({len(g)}) "
f"exceeds maximum allowed limit ({_MAX_SHACL_TRIPLES})."
f"exceeds maximum allowed limit ({_MAX_SHACL_TRIPLES}); "
f"set SEMANTICA_MAX_SHACL_TRIPLES to raise the limit."
),
violations=[],
)
@@ -2791,7 +2947,8 @@ async def validate_shacl(
conforms=False,
status="error",
message=(
f"SHACL validation timed out after {_MAX_SHACL_TIMEOUT_SECONDS} seconds."
f"SHACL validation timed out after {_MAX_SHACL_TIMEOUT_SECONDS} seconds; "
f"set SEMANTICA_MAX_SHACL_TIMEOUT to raise the timeout."
),
violations=[],
)
+45
View File
@@ -0,0 +1,45 @@
"""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
+36
View File
@@ -428,3 +428,39 @@ 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
+47 -4
View File
@@ -9,9 +9,53 @@ import asyncio
import json
import threading
from datetime import datetime, timezone
from typing import Any, Dict, Set
from typing import Any, Dict, List, Sequence, Set
from fastapi import WebSocket
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)
class ConnectionManager:
@@ -63,8 +107,7 @@ 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)
+4 -1
View File
@@ -301,7 +301,10 @@ class GraphValidator:
code="ORPHAN_NODES",
message=f"Found {len(isolates)} orphan nodes (no relationships).",
severity=ValidationSeverity.WARNING,
details={"count": len(isolates), "ids": isolates[:10]} # Limit output
details={
"count": len(isolates),
"ids": sorted(isolates, key=str)[:10],
} # Limit output deterministically
))
except Exception as e:
+1 -1
View File
@@ -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-4-20250514")
>>> llm = LiteLLM(model="anthropic/claude-sonnet-5")
>>> response = llm.generate("Hello, world!")
>>>
>>> # Anthropic provider
+3 -3
View File
@@ -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-4-20250514", "groq/llama-3.1-8b-instant")
Model format: "provider/model-name" (e.g., "openai/gpt-4o", "anthropic/claude-sonnet-5", "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-4-20250514")
>>> llm = LiteLLM(model="anthropic/claude-sonnet-5")
>>> 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-4-20250514",
Examples: "openai/gpt-4o", "anthropic/claude-sonnet-5",
"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.)
+12
View File
@@ -162,6 +162,13 @@ 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
@@ -194,6 +201,11 @@ __all__ = [
"SHACLValidationReport",
"SHACLViolation",
"run_shacl_validation",
"OntologyQualityGate",
"OntologyQualityReport",
"QualityIssue",
"QualitySeverity",
"ontology_quality_check",
# OWL/RDF generation
"OWLGenerator",
# Requirements and competency questions
+13
View File
@@ -9,6 +9,7 @@ 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
@@ -25,6 +26,9 @@ 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")
@@ -593,6 +597,15 @@ 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)
+24 -1
View File
@@ -37,6 +37,29 @@ from .naming_conventions import NamingConventions
from .relationship_utils import build_entity_aliases, resolve_relationship_endpoint_type
# Top-level entity keys that describe structure or provenance rather than
# business attributes. GraphBuilder and EntityMerger attach these to entity
# dicts (relationships list, nested properties/metadata maps, merge history),
# so they must not be inferred as datatype properties. Each key mirrors what
# the framework actually writes to a merged entity top level
# (see MergeStrategyManager._merge_entities merged_entity dict and GraphBuilder).
_CONTROL_FIELDS = frozenset(
{
"id",
"type",
"entity_type",
"text",
"label",
"confidence",
"properties",
"relationships",
"metadata",
"merged_from",
"merge_strategy",
}
)
class PropertyGenerator:
"""
Property generation engine for ontologies.
@@ -347,7 +370,7 @@ class PropertyGenerator:
for entity in entities:
for key, value in entity.items():
if key in ["id", "type", "entity_type", "text", "label", "confidence"]:
if key in _CONTROL_FIELDS:
continue
# Infer type
+855
View File
@@ -0,0 +1,855 @@
"""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,
)
+26 -5
View File
@@ -5,6 +5,7 @@ This module provides the REST API server for the Semantica framework
using FastAPI and uvicorn.
"""
import asyncio
import logging
import os
import uvicorn
@@ -24,8 +25,10 @@ from .utils.logging import setup_logging
try:
from .context.context_graph import ContextGraph
from .explorer.session import GraphSession
from .explorer.ws import ConnectionManager
from .explorer.ws import ConnectionManager, install_graph_updates_websocket
from .explorer.dependencies import anonymous_access_allowed, get_expected_api_key, require_auth
from .explorer.markdown_resources import MarkdownResourceRegistry
from .explorer.runtime import explorer_capabilities, install_mutation_bridge
EXPLORER_AVAILABLE = True
except ImportError:
EXPLORER_AVAILABLE = False
@@ -57,16 +60,24 @@ async def lifespan(app: FastAPI):
graph = ContextGraph()
app.state.session = GraphSession(graph)
app.state.ws_manager = ConnectionManager()
app.state.event_loop = asyncio.get_running_loop()
app.state.agent_memory = None
app.state.markdown_resources = MarkdownResourceRegistry(graph)
install_mutation_bridge(app, app.state.session)
logging.info("Database Session and WebSockets attached to app state.")
except Exception as e:
logging.error(f"Failed to initialize GraphSession: {e}")
app.state.session = None
app.state.ws_manager = None
app.state.agent_memory = None
app.state.markdown_resources = None
else:
app.state.session = None
app.state.ws_manager = None
app.state.agent_memory = None
app.state.markdown_resources = None
yield
yield
logging.info("Shutting down Semantica API...")
if getattr(app.state, "session", None) and hasattr(app.state.session.graph, "close"):
@@ -91,11 +102,14 @@ app.add_middleware(
CORSMiddleware,
allow_origins=_cors_origins,
allow_credentials=True,
allow_methods=["GET", "POST", "DELETE", "OPTIONS"],
allow_methods=["GET", "POST", "PUT", "DELETE", "OPTIONS"],
allow_headers=["Content-Type", "Authorization", "X-API-Key"],
max_age=600,
)
if EXPLORER_AVAILABLE:
install_graph_updates_websocket(app, _cors_origins)
# --- Security response headers -------------------------------------
class _SecurityHeadersMiddleware(BaseHTTPMiddleware):
@@ -138,7 +152,10 @@ async def root():
return {
"name": "Semantica API",
"version": __version__,
"status": "active"
"status": "active",
"capabilities": explorer_capabilities(
getattr(app.state, "agent_memory", None)
),
}
@app.get("/health")
@@ -165,6 +182,8 @@ if EXPLORER_AVAILABLE:
enrich,
export_import,
graph,
markdown,
memories,
ontology,
temporal,
vocabulary,
@@ -179,6 +198,8 @@ if EXPLORER_AVAILABLE:
app.include_router(enrich.router, dependencies=_auth)
app.include_router(export_import.router, dependencies=_auth)
app.include_router(graph.router, dependencies=_auth)
app.include_router(markdown.router, dependencies=_auth)
app.include_router(memories.router, dependencies=_auth)
app.include_router(ontology.router, dependencies=_auth)
app.include_router(temporal.router, dependencies=_auth)
app.include_router(vocabulary.router, dependencies=_auth)
@@ -260,4 +281,4 @@ def main():
uvicorn.run(app, host=host, port=8000)
if __name__ == "__main__":
main()
main()
+2 -2
View File
@@ -374,7 +374,7 @@ class PineconeStore:
# Check Pinecone availability
if not PINECONE_AVAILABLE:
self.logger.warning(
"Pinecone not available. Install with: pip install pinecone-client"
"Pinecone not available. Install with: pip install pinecone>=3.0.0 or pip install semantica[vectorstore-pinecone]"
)
def connect(self, **kwargs) -> bool:
@@ -389,7 +389,7 @@ class PineconeStore:
"""
if not PINECONE_AVAILABLE:
raise ProcessingError(
"Pinecone is not available. Install it with: pip install pinecone-client"
"Pinecone is not available. Install it with: pip install pinecone>=3.0.0 or pip install semantica[vectorstore-pinecone]"
)
api_key = kwargs.get("api_key") or self.api_key
+32
View File
@@ -416,6 +416,38 @@ class WeaviateStore:
)
raise ProcessingError(f"Failed to add objects: {str(e)}")
def delete_vectors(self, vector_ids: List[str], **options) -> Dict[str, Any]:
"""Delete vectors (objects) from the collection by their ids.
Args:
vector_ids: Object uuids to delete
**options: Additional options (ignored, kept for API parity)
Returns:
A dict with the number of successfully deleted objects
(``delete_count``).
"""
if self.collection is None or not WEAVIATE_AVAILABLE:
raise ProcessingError("Collection not initialized or Weaviate unavailable")
if not vector_ids:
return {"delete_count": 0}
deleted = 0
try:
data = self.collection.data
for vector_id in vector_ids:
if not vector_id:
continue
# delete_by_id returns False (not an error) for a uuid that is
# not present, and True when an object was deleted. Count only
# actual deletes so delete_count never over-reports.
if data.delete_by_id(vector_id):
deleted += 1
return {"delete_count": deleted}
except Exception as e:
raise ProcessingError(f"Failed to delete vectors: {str(e)}")
def get_vector(self, vector_id: str) -> Optional[np.ndarray]:
"""Get vector by ID."""
if self.collection is None or not WEAVIATE_AVAILABLE:

Some files were not shown because too many files have changed in this diff Show More