mirror of
https://github.com/semantica-agi/semantica.git
synced 2026-09-13 04:04:09 +00:00
Compare commits
58
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1928f80104 | ||
|
|
e883facedb | ||
|
|
b4a0bc4063 | ||
|
|
cbd33fba67 | ||
|
|
9d93a80840 | ||
|
|
a9f1b29292 | ||
|
|
0338f90bca | ||
|
|
def18cd552 | ||
|
|
6b55cf1101 | ||
|
|
bd41a5a24f | ||
|
|
fa8002e554 | ||
|
|
d4127131ba | ||
|
|
42bbe51382 | ||
|
|
4b11660f00 | ||
|
|
44c0c7a76d | ||
|
|
1c6296a5ab | ||
|
|
2383d228c7 | ||
|
|
5a0d6ea431 | ||
|
|
384c69293a | ||
|
|
5606767a29 | ||
|
|
7dbad0b167 | ||
|
|
b6538740e9 | ||
|
|
0b31d3a370 | ||
|
|
c449209168 | ||
|
|
7be1582786 | ||
|
|
1ff890abdd | ||
|
|
0646601219 | ||
|
|
ad0fbcf235 | ||
|
|
a23f1edb9a | ||
|
|
a0de5c2cdd | ||
|
|
3a69721abf | ||
|
|
86ffa05dd4 | ||
|
|
07c74cc544 | ||
|
|
9c38fd49e5 | ||
|
|
0babf0d787 | ||
|
|
82fd1b8d88 | ||
|
|
97840da51b | ||
|
|
b8011eb44a | ||
|
|
6ad2e8b615 | ||
|
|
1773d8b5ab | ||
|
|
28f4fc7bc4 | ||
|
|
008b9e0b64 | ||
|
|
1ec8de5d25 | ||
|
|
9cb85d1f66 | ||
|
|
fea4ab8098 | ||
|
|
7e8c47ba73 | ||
|
|
5a5706a7c9 | ||
|
|
ec55385b78 | ||
|
|
847aa86b34 | ||
|
|
8460ede201 | ||
|
|
34fe19601c | ||
|
|
2b866c8638 | ||
|
|
30fc6447fd | ||
|
|
c25b88c07e | ||
|
|
14d25cabcd | ||
|
|
c2bde11f3c | ||
|
|
058527bf4a | ||
|
|
0bbd674d89 |
+256
-2694
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
+27
-18
@@ -93,27 +93,14 @@ jobs:
|
||||
npm run test:graph-workspace
|
||||
npm run test:plugin-registry
|
||||
npm run test:deterministic-e2e
|
||||
npm run test:graph-legend-e2e
|
||||
- name: Build Explorer frontend
|
||||
working-directory: explorer
|
||||
run: npm run build
|
||||
- name: Install Explorer backend test dependencies
|
||||
- name: Install core package and base dependencies
|
||||
run: |
|
||||
# Run the deterministic backend path before the all-extras CI
|
||||
# environment is installed. The Explorer extra supplies the
|
||||
# production API dependencies without importing optional vector
|
||||
# providers such as Pinecone during test collection.
|
||||
#
|
||||
# --no-deps + a separate hash-pinned install (rather than the old
|
||||
# `pip install -e ".[explorer]" pytest==9.1.1`) so every fetched
|
||||
# package is hash-verified (Scorecard Pinned-Dependencies); the
|
||||
# local editable install itself has nothing to hash.
|
||||
# .github/requirements/explorer-extra-py311.txt is
|
||||
# `uv pip compile pyproject.toml --extra explorer --python-version 3.11 --constraint requirements-ci.txt --generate-hashes`
|
||||
# - regenerate it the same way if pyproject.toml's base/explorer
|
||||
# deps change. Resolved specifically for this job's python 3.11
|
||||
# (see the Dockerfile's explorer-extra-py313.txt for why this
|
||||
# can't be shared with python 3.13: audioread needs extra
|
||||
# standard-aifc/standard-sunau hashes only on 3.13+).
|
||||
# Verify that core semantica installs cleanly with only its base dependencies
|
||||
# (no optional extras) and that core imports and lazy missing-dependency hints work.
|
||||
#
|
||||
# --no-deps only skips *runtime* dependency resolution - `-e .`
|
||||
# still does a PEP 517 build, which by default creates an isolated
|
||||
@@ -124,8 +111,30 @@ jobs:
|
||||
# copies instead of fetching its own.
|
||||
pip install -r .github/requirements/pep517-build.txt --require-hashes
|
||||
pip install --no-deps --no-build-isolation -e .
|
||||
pip install -r .github/requirements/explorer-extra-py311.txt --require-hashes
|
||||
pip install -r .github/requirements/base-deps.txt --require-hashes
|
||||
pip install -r .github/requirements/pytest-tool.txt --require-hashes
|
||||
- name: Verify core-only package importability and slim behavior
|
||||
run: |
|
||||
python -c "
|
||||
import semantica
|
||||
print('semantica', semantica.__version__, 'core installed and importable')
|
||||
"
|
||||
pytest -q tests/test_issue_1513_slim_core.py
|
||||
- name: Install Explorer backend test dependencies
|
||||
run: |
|
||||
# Run the deterministic backend path before the all-extras CI
|
||||
# environment is installed. The Explorer extra supplies the
|
||||
# production API dependencies without importing optional vector
|
||||
# providers such as Pinecone during test collection.
|
||||
#
|
||||
# .github/requirements/explorer-extra-py311.txt is
|
||||
# `uv pip compile pyproject.toml --extra explorer --python-version 3.11 --constraint requirements-ci.txt --generate-hashes`
|
||||
# - regenerate it the same way if pyproject.toml's base/explorer
|
||||
# deps change. Resolved specifically for this job's python 3.11
|
||||
# (see the Dockerfile's explorer-extra-py313.txt for why this
|
||||
# can't be shared with python 3.13: audioread needs extra
|
||||
# standard-aifc/standard-sunau hashes only on 3.13+).
|
||||
pip install -r .github/requirements/explorer-extra-py311.txt --require-hashes
|
||||
- name: Test deterministic Explorer backend path
|
||||
run: |
|
||||
pytest -q tests/explorer/test_explorer_deterministic_rendering_e2e.py
|
||||
|
||||
@@ -154,13 +154,21 @@ jobs:
|
||||
fi
|
||||
|
||||
# Vulnerability IDs reviewed and accepted as non-actionable for this
|
||||
# project. Empty for now: pip-audit's OSV-backed database doesn't
|
||||
# currently carry either of the findings Safety used to flag here
|
||||
# (cuda-toolkit CVE-2025-33228, torchvision CVE-2026-65918), so
|
||||
# there's nothing to exclude. Left in place so a future finding can
|
||||
# be added the same way without restructuring this step - see git
|
||||
# history on this file for the reasoning behind past entries.
|
||||
IGNORED_VULN_IDS=""
|
||||
# project:
|
||||
# - GHSA-4j2p-28q2-5m79 (aka CVE-2026-69112): accelerate<=1.14.0
|
||||
# (transitive via docling-slim). Path traversal in sharded checkpoint
|
||||
# index loading (load_checkpoint_in_model). 1.14.0 is the latest
|
||||
# available PyPI release; no upstream patch exists yet. Semantica does
|
||||
# not load arbitrary user checkpoints. Re-evaluate once accelerate
|
||||
# releases a fixed version.
|
||||
# NOTE: pip-audit's OSV-backed report may surface either identifier as
|
||||
# the primary `id` (with the other listed under `aliases`) depending on
|
||||
# which alias the backing database picks as canonical, so both need to
|
||||
# be listed here and the matching below checks aliases too - see
|
||||
# https://github.com/semantica-agi/semantica/actions/runs/34296586683
|
||||
# where this ignore list had only the GHSA id but the report's `id`
|
||||
# was the CVE, so the gate still failed.
|
||||
IGNORED_VULN_IDS="GHSA-4j2p-28q2-5m79,CVE-2026-69112"
|
||||
|
||||
# Exported so the "Comment PR with Security Results" step below can
|
||||
# apply the same exclusion list to the raw report - it reads
|
||||
@@ -176,9 +184,16 @@ jobs:
|
||||
# no vulns field at all (see the skip_reason handling above) -
|
||||
# without the fallback, iterating `null[]` raises inside jq and
|
||||
# this whole computation silently evaluates to empty.
|
||||
#
|
||||
# Matching checks `.id` AND `.aliases` (pip-audit includes aliases by
|
||||
# default for JSON output): the OSV-backed report can surface either
|
||||
# the GHSA or the CVE identifier as the canonical `id` for the same
|
||||
# advisory, with the other one demoted to an alias, so matching on
|
||||
# `.id` alone is not reliable.
|
||||
VULNS=$(jq --arg ignored "$IGNORED_VULN_IDS" '
|
||||
($ignored | split(",") | map(select(length > 0))) as $ignore_list
|
||||
| [.dependencies[] | (.vulns // [])[] | select(.id as $id | ($ignore_list | index($id)) | not)]
|
||||
| [.dependencies[] | (.vulns // [])[]
|
||||
| select(([.id] + (.aliases // [])) as $ids | ($ids - $ignore_list | length) == ($ids | length))]
|
||||
| length
|
||||
' pip-audit-report.json 2>/dev/null)
|
||||
|
||||
@@ -199,7 +214,8 @@ jobs:
|
||||
jq --arg ignored "$IGNORED_VULN_IDS" -r '
|
||||
($ignored | split(",") | map(select(length > 0))) as $ignore_list
|
||||
| .dependencies[] as $dependency
|
||||
| ($dependency.vulns // [])[] | select(.id as $id | ($ignore_list | index($id)) | not)
|
||||
| ($dependency.vulns // [])[]
|
||||
| select(([.id] + (.aliases // [])) as $ids | ($ids - $ignore_list | length) == ($ids | length))
|
||||
| "- \($dependency.name)==\($dependency.version): \(.id)"
|
||||
' pip-audit-report.json || true
|
||||
exit 1
|
||||
@@ -345,9 +361,16 @@ jobs:
|
||||
return null;
|
||||
}
|
||||
|
||||
// A vuln's canonical `id` and its `aliases` (e.g. GHSA vs. CVE
|
||||
// for the same advisory) are checked together - mirrors the
|
||||
// shell gate above, which needs the same fallback because
|
||||
// pip-audit's OSV-backed report doesn't consistently pick the
|
||||
// same identifier as canonical across advisories.
|
||||
return data.dependencies.flatMap((dependency) =>
|
||||
(dependency.vulns || [])
|
||||
.filter((vulnerability) => !ignoredVulnIds.includes(vulnerability.id))
|
||||
.filter((vulnerability) =>
|
||||
![vulnerability.id, ...(vulnerability.aliases || [])].some((id) => ignoredVulnIds.includes(id))
|
||||
)
|
||||
.map(
|
||||
(vulnerability) => `- \`${dependency.name}==${dependency.version}\`: ${vulnerability.id}` +
|
||||
(vulnerability.fix_versions?.length ? ` (fixed by ${vulnerability.fix_versions.join(', ')})` : '')
|
||||
|
||||
BIN
Binary file not shown.
@@ -9,6 +9,41 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Added
|
||||
|
||||
- **Schema-guided extraction validation** (#1510) by @Besokus
|
||||
- New `SchemaValidator` (`semantica.semantic_extract`, lazy export): a deterministic sibling of `ExtractionValidator` that checks extraction output for *conformance to a domain ontology* — an axis orthogonal to `ExtractionValidator`'s confidence checks. It mirrors the same interface (`validate_entities()` / `validate_relations()` returning `ValidationResult`, batch-aware), so the two compose back-to-back
|
||||
- Entity labels must be concepts in the schema; relation predicates must be in the schema and satisfy their `domain` / `range`. Violations are reported in `ValidationResult.errors` with counts in `metrics` and `score` = conformance ratio; `filter_by_schema()` / `filter_relations_by_schema()` return the conforming subset (mirroring `filter_by_confidence`). No LLM required
|
||||
- New `ExtractionSchema` (`semantica.semantic_extract`, lazy export): a lightweight, read-only view over a domain ontology (allowed concepts + predicates with optional `domain` / `range`). Reuses the project's existing OWL ontology representation rather than a parallel type — build one from a `generate_ontology`-style dict (`ExtractionSchema.from_ontology`) or an OWL/Turtle file/string (`ExtractionSchema.from_owl`, via the existing `rdflib` dependency). An empty `domain`/`range` means unconstrained, matching OWL
|
||||
- Implements the deterministic core of ontology-based information extraction (OBIE; Wimalasuriya & Dou, 2010). No new runtime dependencies
|
||||
- New `tests/semantic_extract/test_schema_validator.py`
|
||||
|
||||
## [0.7.0] - 2026-09-07
|
||||
|
||||
### Changed
|
||||
|
||||
- **Slim core dependencies: moved ~22 heavy packages to optional extras** (#1513)
|
||||
- Core dependencies in `pyproject.toml` are now reduced to exactly 22 direct packages: `numpy`, `pandas`, `scipy`, `scikit-learn`, `rdflib`, `networkx`, `requests`, `chardet`, `protobuf`, `grpcio`, `pillow`, `pydantic`, `click`, `rich`, `tqdm`, `pyyaml`, `toml`, `python-dotenv`, `loguru`, `structlog`, `httpx`, and `pyarrow`.
|
||||
- Heavy ML/NLP, visualization, document parsing, and ingestion packages moved into granular optional extras:
|
||||
- `models-huggingface`: `torch`, `transformers`
|
||||
- `embeddings-local`: `sentence-transformers`, `fastembed`, `onnxruntime`, `tokenizers`
|
||||
- `nlp-spacy`: `spacy`
|
||||
- `viz`: expanded to include `matplotlib`, `seaborn`, `plotly`, `ipywidgets`, `umap-learn`, alongside `pyvis`, `graphviz`, and `d3blocks`
|
||||
- `media`: `librosa`, `opencv-python`
|
||||
- `vectorstore-faiss`: `faiss-cpu` (also included in `vectorstore-all`)
|
||||
- `documents`: `python-docx`, `openpyxl`, `lxml`, `beautifulsoup4`
|
||||
- `ingest-git`: `GitPython`
|
||||
- `graph-embeddings`: `gensim` (also included in `graph-all`)
|
||||
- Full bundled behavior preserved via `pip install "semantica[all]"`, which includes all optional extras. Pinning `semantica<0.7.0` remains a permanent escape hatch for legacy workflows.
|
||||
- Safe lazy construction across parsers and visualizers:
|
||||
- `DOCXParser`, `ExcelParser`, `HTMLParser`, and `XMLParser` remain constructible without error on `__init__()`. They fail only upon calling `.parse()` with actionable error messages directing users to install `semantica[documents]`.
|
||||
- `XMLParser` automatically falls back to standard library `xml.etree` (`_parse_with_etree`) when `lxml` is not installed, preserving XML parsing capabilities without extra dependencies.
|
||||
- `EmbeddingVisualizer` and `OntologyVisualizer` safely guard `matplotlib` and optional reduction packages, advising `pip install 'semantica[viz]'`.
|
||||
- `RepoIngestor` guards `GitPython` with a clear error pointing to `semantica[ingest-git]`.
|
||||
- `PublicAPIIngestor` guards `lxml` and `_SAFE_XML_PARSER`.
|
||||
- Updated user-facing installation hints across CLI doctor commands, node embeddings (`NodeEmbedder`), vector stores (`FAISSStore`), and model loaders.
|
||||
- Recompiled CI lockfiles (`requirements-ci.txt`, `.github/requirements/explorer-extra-py311.txt`, `.github/requirements/explorer-extra-py313.txt`, and `.github/requirements/base-deps.txt`).
|
||||
|
||||
## [0.6.8] - 2026-09-05
|
||||
|
||||
### Added
|
||||
|
||||
+1
-1
@@ -7,7 +7,7 @@ authors:
|
||||
repository-code: "https://github.com/semantica-agi/semantica"
|
||||
url: "https://getsemantica.ai"
|
||||
license: MIT
|
||||
version: 0.6.8
|
||||
version: 0.7.0
|
||||
date-released: 2026-09-05
|
||||
keywords:
|
||||
- knowledge-graph
|
||||
|
||||
+12
-1
@@ -20,7 +20,18 @@ RUN mkdir -p /app/semantica && npm run build
|
||||
# .github/dependabot.yml opens a PR bumping the digest pin above. Also: this
|
||||
# image only serves plain HTTP via uvicorn and never opens a QUIC listener,
|
||||
# so the bug isn't reachable here regardless.
|
||||
FROM python:3.13-slim@sha256:7ce4b6dfe35e55397b7cda544f8a13f191b7ae28dc5aad71fe664dbc9bc2623f AS runtime
|
||||
#
|
||||
# Pinned to 3.13, NOT 3.14: #1290 bumped this to python:3.14-slim and broke
|
||||
# the build outright (Container Security Scan, every run since) - gensim
|
||||
# (a base, non-extras-gated dependency) ships no cp314 wheel on PyPI yet, so
|
||||
# pip falls back to building it from source, which needs a C compiler this
|
||||
# slim image doesn't carry ("error: [Errno 2] No such file or directory:
|
||||
# 'gcc'"). Revisit the 3.14 bump once gensim (and anything else pulled in
|
||||
# transitively) publishes cp314 wheels - check with
|
||||
# `pip index versions gensim` / the project's PyPI files page, not just
|
||||
# whether `uv pip compile` resolves (resolution only reads sdist metadata,
|
||||
# it doesn't attempt the build that fails here).
|
||||
FROM python:3.14-slim@sha256:cad9a2c871761c413caa6fdd6441c783451e740a48aaeba60ae62a8b53525ef6 AS runtime
|
||||
|
||||
ENV PYTHONDONTWRITEBYTECODE=1 \
|
||||
PYTHONUNBUFFERED=1 \
|
||||
|
||||
@@ -1406,6 +1406,9 @@ semantica-mcp
|
||||
| `get_graph_analytics` | Centrality, communities |
|
||||
| `export_graph` | Export to RDF/JSON/Parquet |
|
||||
| `get_graph_summary` | Graph statistics |
|
||||
| `query_graph` | Fetch a node, walk neighbours, keyword search |
|
||||
| `update_node` | Merge properties onto a node |
|
||||
| `delete_node` | Archive (soft-delete) a node |
|
||||
|
||||
### REST API
|
||||
|
||||
@@ -1477,6 +1480,14 @@ 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.7.0
|
||||
|
||||
**Slim core dependencies: lightweight base install with granular optional extras** — `pip install semantica` now installs only 22 essential core dependencies, moving heavy packages into dedicated optional extras:
|
||||
- **Dramatically lighter and faster installation**: Core installation no longer pulls heavy machine learning or visualization packages by default.
|
||||
- **Granular extras**: Install only what your workload requires (`documents`, `embeddings-local`, `models-huggingface`, `nlp-spacy`, `viz`, `media`, `vectorstore-faiss`, `graph-embeddings`, `ingest-git`).
|
||||
- **Full backward compatibility**: `pip install "semantica[all]"` preserves the full bundled suite, while `semantica<0.7.0` remains a permanent escape hatch.
|
||||
- **Lazy parser construction & graceful fallbacks**: Document parsers can be constructed without extras and only raise actionable error hints upon calling `.parse()`; `XMLParser` automatically falls back to Python's standard library `xml.etree`.
|
||||
|
||||
---
|
||||
|
||||
## What's New in v0.6.8
|
||||
@@ -1515,32 +1526,42 @@ Semantica is designed for environments where AI outputs must be explainable, aud
|
||||
## Installation
|
||||
|
||||
```bash
|
||||
pip install semantica # core
|
||||
pip install semantica[all] # everything
|
||||
pip install semantica # lightweight core (22 essential dependencies)
|
||||
pip install "semantica[all]" # full bundled behavior with all extras
|
||||
```
|
||||
|
||||
> **Note for upgrades from <0.7.0**: In Semantica 0.7.0+, heavy machine learning, NLP, visualization, and document dependencies were moved into optional extras to make core installation significantly lighter and faster. If you want the previous bundled installation, install with `pip install "semantica[all]"` or pin `semantica<0.7.0`.
|
||||
|
||||
```bash
|
||||
pip install semantica[agno] # Agno multi-agent integration
|
||||
pip install semantica[crewai] # CrewAI integration
|
||||
pip install semantica[langchain] # LangChain / LangGraph integration
|
||||
pip install semantica[llm-litellm] # OpenAI, Anthropic, Gemini, Mistral, Llama, Groq, Cohere, Bedrock, Ollama, DeepSeek, and more
|
||||
pip install semantica[graph-neo4j] # Neo4j graph store (LPG)
|
||||
pip install semantica[graph-falkordb] # FalkorDB graph store (LPG)
|
||||
pip install semantica[graph-apache-age] # Apache AGE graph store (LPG)
|
||||
pip install semantica[graph-amazon-neptune] # AWS Neptune graph store (LPG)
|
||||
pip install semantica[tripletstore-oxigraph] # Embedded in-memory/on-disk RDF store
|
||||
# Granular Extras
|
||||
pip install "semantica[documents]" # Document parsing (docx, openpyxl, lxml, beautifulsoup4)
|
||||
pip install "semantica[embeddings-local]" # Local embeddings (sentence-transformers, fastembed, onnxruntime)
|
||||
pip install "semantica[models-huggingface]" # HuggingFace models (transformers, torch)
|
||||
pip install "semantica[nlp-spacy]" # spaCy NLP pipelines (spacy)
|
||||
pip install "semantica[viz]" # Visualization (matplotlib, seaborn, plotly, pyvis, graphviz)
|
||||
pip install "semantica[media]" # Audio & computer vision (librosa, opencv-python)
|
||||
pip install "semantica[graph-embeddings]" # Knowledge graph embeddings (gensim / Node2Vec)
|
||||
pip install "semantica[ingest-git]" # Git repository ingestor (GitPython)
|
||||
pip install "semantica[vectorstore-faiss]" # FAISS vector store
|
||||
pip install "semantica[vectorstore-all]" # All vector stores (Qdrant, Pinecone, Weaviate, FAISS, PgVector, SQLite)
|
||||
pip install "semantica[agno]" # Agno multi-agent integration
|
||||
pip install "semantica[crewai]" # CrewAI integration
|
||||
pip install "semantica[langchain]" # LangChain / LangGraph integration
|
||||
pip install "semantica[llm-all]" # All LLM provider clients
|
||||
pip install "semantica[graph-neo4j]" # Neo4j graph store (LPG)
|
||||
pip install "semantica[graph-falkordb]" # FalkorDB graph store (LPG)
|
||||
pip install "semantica[graph-apache-age]" # Apache AGE graph store (LPG)
|
||||
pip install "semantica[graph-amazon-neptune]" # AWS Neptune graph store (LPG)
|
||||
pip install "semantica[tripletstore-oxigraph]" # Embedded in-memory/on-disk RDF store
|
||||
# RDF triple stores (Blazegraph, Apache Jena, Eclipse RDF4J) need no extra:
|
||||
# semantica.triplet_store talks SPARQL over HTTP using the core `requests` dependency
|
||||
pip install semantica[vectorstore-qdrant] # Qdrant vector store
|
||||
pip install semantica[vectorstore-pinecone] # Pinecone vector store
|
||||
pip install semantica[db-snowflake] # Snowflake
|
||||
pip install semantica[db-databricks] # Databricks (SDK + SQL connector)
|
||||
pip install semantica[ingest-sap] # SAP OData
|
||||
pip install semantica[ingest-parquet] # Parquet / PyArrow
|
||||
pip install semantica[ingest-arrow] # Apache Arrow, Feather, IPC
|
||||
pip install semantica[viz] # HTML interactive visualization
|
||||
pip install semantica[watch] # Directory file watcher
|
||||
pip install semantica[explorer] # Knowledge Explorer dashboard
|
||||
pip install "semantica[db-snowflake]" # Snowflake
|
||||
pip install "semantica[db-databricks]" # Databricks (SDK + SQL connector)
|
||||
pip install "semantica[ingest-sap]" # SAP OData
|
||||
pip install "semantica[ingest-parquet]" # Parquet / PyArrow
|
||||
pip install "semantica[ingest-arrow]" # Apache Arrow, Feather, IPC
|
||||
pip install "semantica[watch]" # Directory file watcher
|
||||
pip install "semantica[explorer]" # Knowledge Explorer dashboard
|
||||
```
|
||||
|
||||
For production deployments, use Docker or Kubernetes rather than a local `pip install`. Set `SEMANTICA_API_KEY`, configure a persistent LPG graph store (Neo4j / FalkorDB / Apache AGE / AWS Neptune) and/or RDF triple store (Blazegraph / Apache Jena / Eclipse RDF4J), and point the vector store at a hosted backend (Qdrant / Pinecone). See [ARCHITECTURE.md](ARCHITECTURE.md) for the full deployment topology.
|
||||
|
||||
@@ -8,7 +8,7 @@ icon: "plug"
|
||||
|
||||
MCP stands for the Model Context Protocol. It is an open standard that allows external AI assistants (like Claude Desktop, Cursor, or Windsurf) to securely access local tools and data sources.
|
||||
|
||||
The Semantica MCP server exposes your knowledge graph as 12 callable tools. By connecting it, any compatible AI client can traverse the graph live, record decisions, run analytics, and export results during a conversation — without you having to write custom tool wrappers.
|
||||
The Semantica MCP server exposes your knowledge graph as 15 callable tools. By connecting it, any compatible AI client can traverse the graph live, record decisions, run analytics, and export results during a conversation — without you having to write custom tool wrappers.
|
||||
|
||||
<Info>
|
||||
The Semantica MCP server exposes 15 tools and 3 read-only resources. All tools accept and return JSON. No configuration beyond an optional environment variable for graph persistence is required.
|
||||
@@ -40,7 +40,7 @@ Connecting your AI client follows a standard progression:
|
||||
1. **Install**: Install Semantica in your Python environment.
|
||||
2. **Configure Client**: Add the `semantica-mcp` command and absolute graph paths to your AI client's JSON configuration.
|
||||
3. **Start Client**: Launch Claude Desktop or Windsurf, which automatically spawns the MCP server.
|
||||
4. **Tool Calls**: Prompt the AI in natural language. The AI autonomously chains the 12 available tools.
|
||||
4. **Tool Calls**: Prompt the AI in natural language. The AI autonomously chains the 15 available tools.
|
||||
5. **Graph Updates**: The AI directly modifies your local graph, adding entities, edges, and decisions.
|
||||
|
||||
---
|
||||
|
||||
+43
-21
@@ -64,7 +64,7 @@ Whether you're running your first pipeline or deploying Semantica in production,
|
||||
[Temporal Graphs notebook](https://github.com/semantica-agi/semantica/blob/main/cookbook/advanced/10_Temporal_Knowledge_Graphs.ipynb): `valid_from`/`valid_until`, Allen interval algebra, point-in-time queries.
|
||||
</Step>
|
||||
<Step title="Ontology-driven knowledge bases">
|
||||
[Ontology notebook](https://github.com/semantica-agi/semantica/blob/main/cookbook/introduction/14_Ontology.ipynb): auto-generation, SHACL validation, Ontology Hub (v0.5.0).
|
||||
[Ontology notebook](https://github.com/semantica-agi/semantica/blob/main/cookbook/introduction/14_Ontology.ipynb): auto-generation, SHACL validation, Ontology Hub.
|
||||
</Step>
|
||||
<Step title="Advanced visualization">
|
||||
[Complete Visualization Suite notebook](https://github.com/semantica-agi/semantica/blob/main/cookbook/advanced/03_Complete_Visualization_Suite.ipynb): UMAP, t-SNE, community layouts, embedding projections.
|
||||
@@ -86,10 +86,10 @@ All settings can be overridden with environment variables: no code changes neede
|
||||
| OpenAI API Key | `OPENAI_API_KEY` | `None` |
|
||||
| Groq API Key | `GROQ_API_KEY` | `None` |
|
||||
| Anthropic API Key | `ANTHROPIC_API_KEY` | `None` |
|
||||
| Embedding Provider | `SEMANTICA_EMBEDDING_PROVIDER` | `"openai"` |
|
||||
| Graph Backend | `SEMANTICA_GRAPH_BACKEND` | `"networkx"` |
|
||||
| Log Level | `SEMANTICA_LOG_LEVEL` | `"INFO"` |
|
||||
| Log Format | `SEMANTICA_LOG_FORMAT` | `"text"` |
|
||||
| Graph Store Backend | `GRAPH_STORE_DEFAULT_BACKEND` | `"neo4j"` |
|
||||
| Vector Store Backend | `VECTOR_STORE_DEFAULT_BACKEND` | `"faiss"` |
|
||||
| Server Host | `SEMANTICA_HOST` | `"127.0.0.1"` |
|
||||
| Server API Key | `SEMANTICA_API_KEY` | `None` |
|
||||
|
||||
|
||||
## Troubleshooting
|
||||
@@ -146,10 +146,15 @@ Also reduce batch sizes and enable streaming ingestion for large corpora.
|
||||
Enable parallel execution and GPU acceleration:
|
||||
|
||||
```python
|
||||
from semantica.pipeline import Pipeline
|
||||
from semantica.pipeline import ParallelismManager, Task
|
||||
|
||||
pipeline = Pipeline(workers=8, batch_size=32)
|
||||
pipeline.run(sources)
|
||||
# Run pipeline tasks concurrently across worker threads
|
||||
manager = ParallelismManager(max_workers=8)
|
||||
tasks = [
|
||||
Task("task_1", lambda: "process part 1"),
|
||||
Task("task_2", lambda: "process part 2"),
|
||||
]
|
||||
results = manager.execute_parallel(tasks)
|
||||
```
|
||||
|
||||
```bash
|
||||
@@ -160,19 +165,19 @@ pip install "semantica[gpu]" # CUDA-backed embeddings
|
||||
|
||||
<Accordion title="Windows [all] installation fails" icon="windows">
|
||||
|
||||
Fixed in **v0.5.0**. Upgrade:
|
||||
Upgrade to the latest release:
|
||||
|
||||
```bash
|
||||
pip install --upgrade semantica
|
||||
```
|
||||
|
||||
Or install extras individually: `pip install "semantica[core]"`, then add `[llm-openai]`, `[gpu]`, etc. as needed.
|
||||
Or install extras individually: `pip install semantica`, then add `[llm-openai]`, `[gpu]`, etc. as needed.
|
||||
|
||||
</Accordion>
|
||||
|
||||
<Accordion title="cp1252 encoding crash on Windows" icon="windows">
|
||||
|
||||
Fixed in **v0.5.0**. For earlier versions, set the encoding environment variable:
|
||||
Set the encoding environment variable:
|
||||
|
||||
```bash
|
||||
set PYTHONIOENCODING=utf-8
|
||||
@@ -202,27 +207,44 @@ Use NetworkX for local development and prototyping. Switch to a persistent backe
|
||||
|
||||
<Accordion title="Batch processing for large corpora" icon="layer-group">
|
||||
|
||||
Process documents in batches rather than one at a time. Configure `chunk_size` based on available RAM: a good starting point is 1,000 documents per batch on a 16 GB machine.
|
||||
Process documents in batches rather than one at a time. Split large texts into chunks and extract entities in batches:
|
||||
|
||||
```python
|
||||
from semantica.pipeline import Pipeline
|
||||
from semantica.split import TextSplitter
|
||||
from semantica.semantic_extract import NERExtractor
|
||||
|
||||
pipeline = Pipeline(workers=8, batch_size=32)
|
||||
pipeline.run(sources)
|
||||
document_text = "Acme Corp announced record revenue in Seattle. CEO Jane Doe presented results."
|
||||
splitter = TextSplitter(chunk_size=1000, chunk_overlap=100)
|
||||
chunks = splitter.split(document_text)
|
||||
|
||||
extractor = NERExtractor()
|
||||
batch_entities = extractor.extract_entities_batch([c.text for c in chunks])
|
||||
```
|
||||
|
||||
</Accordion>
|
||||
|
||||
<Accordion title="Deduplication v2: up to 7× faster" icon="bolt">
|
||||
|
||||
If deduplication is a bottleneck, switch from v1 strategies to the v2 engine:
|
||||
If deduplication is a bottleneck, use candidate blocking to reduce O(n²) comparisons before similarity scoring:
|
||||
|
||||
```python
|
||||
resolver = EntityResolver()
|
||||
merged = resolver.resolve(entities, strategy="semantic_v2") # up to 7x faster
|
||||
from semantica.deduplication import DuplicateDetector, EntityMerger
|
||||
|
||||
entities = [
|
||||
{"id": "1", "name": "Acme Corp", "type": "Company"},
|
||||
{"id": "2", "name": "Acme Corporation", "type": "Company"},
|
||||
{"id": "3", "name": "Globex", "type": "Company"},
|
||||
]
|
||||
|
||||
# Fast candidate blocking for large entity sets
|
||||
detector = DuplicateDetector(similarity_threshold=0.8)
|
||||
duplicates = detector.detect_duplicates(entities, candidate_strategy="blocking_v2")
|
||||
|
||||
merger = EntityMerger()
|
||||
merged = merger.merge_duplicates(entities, strategy="keep_most_complete")
|
||||
```
|
||||
|
||||
The `blocking_v2`, `hybrid_v2`, and `semantic_v2` strategies reduce O(n²) comparisons via candidate blocking before similarity scoring.
|
||||
The `blocking_v2` and `hybrid_v2` candidate strategies filter candidate pairs before calculating fine-grained similarity.
|
||||
|
||||
</Accordion>
|
||||
|
||||
@@ -233,8 +255,8 @@ The `blocking_v2`, `hybrid_v2`, and `semantic_v2` strategies reduce O(n²) compa
|
||||
|
||||
- **API keys**: store in environment variables or a secrets manager; never commit them to version control; rotate on a schedule
|
||||
- **Sensitive data**: use local embedding models (Ollama, HuggingFace) for PII or classified content; avoid sending sensitive data to external APIs without data handling agreements
|
||||
- **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
|
||||
- **Graph exports**: encrypt sensitive exports at rest; use SSRF-safe `base_url` validation when configuring custom LLM gateways
|
||||
- **XML ingestion**: always use `XMLIngestor`, 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.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
---
|
||||
title: "Embeddings Module"
|
||||
description: "Text and graph embedding generation: FastEmbed, Sentence-Transformers, OpenAI, BGE: with pooling strategies and provider-agnostic API."
|
||||
description: "Text and graph embedding generation (FastEmbed, Sentence-Transformers, OpenAI, BGE) with pooling strategies and a provider-agnostic API."
|
||||
icon: "vector-square"
|
||||
---
|
||||
|
||||
@@ -41,12 +41,12 @@ Semantica uses embeddings for:
|
||||
|
||||
## What You Get
|
||||
|
||||
- **EmbeddingGenerator** — Main entry point: provider-agnostic, handles batching automatically across all backends.
|
||||
- **TextEmbedder** — Text-specific with automatic batching and progress tracking. Default method is FastEmbed.
|
||||
- **GraphEmbeddingManager** — Node and edge embeddings for graph databases: Neo4j, NetworkX, FalkorDB.
|
||||
- **VectorEmbeddingManager** — Prepare, normalize, and format embeddings for FAISS, Weaviate, Qdrant, and Milvus.
|
||||
- **Provider Stores** — `OpenAIStore`, `BGEStore`, `FastEmbedStore`, and `ProviderStoreFactory`.
|
||||
- **Pooling Strategies** — Mean, Max, CLS, Attention, and Hierarchical: control token-to-vector aggregation.
|
||||
- **EmbeddingGenerator**: provider-agnostic main entry point that handles batching automatically across all backends.
|
||||
- **TextEmbedder**: text-specific embedder with automatic batching and progress tracking. Default method is FastEmbed.
|
||||
- **GraphEmbeddingManager**: node and edge embeddings for graph databases (Neo4j, NetworkX, FalkorDB).
|
||||
- **VectorEmbeddingManager**: prepare, normalize, and format embeddings for FAISS, Weaviate, Qdrant, and Milvus.
|
||||
- **Provider Stores**: `OpenAIStore`, `BGEStore`, `FastEmbedStore`, and `ProviderStoreFactory`.
|
||||
- **Pooling Strategies**: Mean, Max, CLS, Attention, and Hierarchical control token-to-vector aggregation.
|
||||
|
||||
## Provider Setup
|
||||
|
||||
@@ -71,7 +71,7 @@ Semantica uses embeddings for:
|
||||
</Check>
|
||||
|
||||
<Warning>
|
||||
**FastEmbed ignores the `device` parameter.** FastEmbed uses ONNX Runtime and manages its own execution providers: passing `device="cuda"` has no effect. Switch to `method="sentence_transformers"` if you need explicit GPU control.
|
||||
**FastEmbed ignores the `device` parameter.** FastEmbed uses ONNX Runtime and manages its own execution providers; passing `device="cuda"` has no effect. Switch to `method="sentence_transformers"` if you need explicit GPU control.
|
||||
</Warning>
|
||||
</Tab>
|
||||
<Tab title="Sentence-Transformers">
|
||||
@@ -153,7 +153,7 @@ providers = check_available_providers()
|
||||
|
||||
## Getting Started
|
||||
|
||||
`EmbeddingGenerator` is the fastest path to embeddings: the default method is FastEmbed (ONNX, no GPU needed):
|
||||
`EmbeddingGenerator` is the fastest path to embeddings. The default method is FastEmbed (ONNX, no GPU needed):
|
||||
|
||||
```python
|
||||
from semantica.embeddings import EmbeddingGenerator
|
||||
@@ -173,7 +173,7 @@ print(f"Similarity: {score:.3f}")
|
||||
```
|
||||
|
||||
<Tip>
|
||||
**Always use the same model for indexing and querying.** Vectors from different models are not comparable: they live in different vector spaces. Switching models requires re-embedding your entire corpus.
|
||||
**Always use the same model for indexing and querying.** Vectors from different models are not comparable; they live in different vector spaces. Switching models requires re-embedding your entire corpus.
|
||||
</Tip>
|
||||
|
||||
To switch provider after construction:
|
||||
@@ -258,7 +258,7 @@ generator.set_text_model("sentence_transformers", "BAAI/bge-large-en-v1.5")
|
||||
similarity = generator.compare_embeddings(embeddings[0], embeddings[1])
|
||||
```
|
||||
|
||||
**Best for:** CPU-only production, lowest latency without GPU. Default: works out of the box.
|
||||
**Best for:** CPU-only production and lowest latency without GPU. The default works out of the box.
|
||||
</Tab>
|
||||
<Tab title="Sentence-Transformers">
|
||||
```python
|
||||
@@ -390,7 +390,7 @@ store = ProviderStoreFactory.create(provider="bge", model_name="BAAI/bge-large-e
|
||||
|
||||
## Pooling Strategies
|
||||
|
||||
Pooling aggregates a set of embeddings into a single vector: useful when you have multiple chunk embeddings to combine:
|
||||
Pooling aggregates a set of embeddings into a single vector. Useful when you have multiple chunk embeddings to combine:
|
||||
|
||||
<Tabs>
|
||||
<Tab title="MeanPooling (default)">
|
||||
@@ -401,7 +401,7 @@ Pooling aggregates a set of embeddings into a single vector: useful when you hav
|
||||
pooled = pooler.pool(token_embeddings) # shape: (hidden_dim,)
|
||||
```
|
||||
|
||||
**Best for:** retrieval, semantic search, and clustering: averages all contributions.
|
||||
**Best for:** retrieval, semantic search, and clustering. Averages all contributions.
|
||||
</Tab>
|
||||
<Tab title="MaxPooling">
|
||||
```python
|
||||
@@ -411,7 +411,7 @@ Pooling aggregates a set of embeddings into a single vector: useful when you hav
|
||||
pooled = pooler.pool(token_embeddings)
|
||||
```
|
||||
|
||||
**Best for:** capturing the presence of any feature: takes the max activation per dimension.
|
||||
**Best for:** capturing the presence of any feature. Takes the max activation per dimension.
|
||||
</Tab>
|
||||
<Tab title="CLSPooling">
|
||||
```python
|
||||
@@ -432,7 +432,7 @@ Pooling aggregates a set of embeddings into a single vector: useful when you hav
|
||||
pooled = pooler.pool(token_embeddings, chunk_size=10)
|
||||
```
|
||||
|
||||
**Best for:** long documents: chunk-level mean pooling, then global mean pooling across chunks.
|
||||
**Best for:** long documents (chunk-level mean pooling, then global mean pooling across chunks).
|
||||
</Tab>
|
||||
<Tab title="Strategy Comparison">
|
||||
|
||||
@@ -619,7 +619,7 @@ providers = check_available_providers()
|
||||
# → {"sentence_transformers": True, "fastembed": True, "openai": False}
|
||||
```
|
||||
|
||||
- [Vector Store](/reference/vector_store) — Store and search the generated embeddings.
|
||||
- [Split](/reference/split) — Chunk text before embedding for better retrieval quality.
|
||||
- [KG Module](/reference/kg) — Distance Intelligence uses graph embeddings for semantic neighbourhoods.
|
||||
- [Deduplication](deduplication) — Semantic deduplication uses embedding distance for entity resolution.
|
||||
- [Vector Store](/reference/vector_store): store and search the generated embeddings.
|
||||
- [Split](/reference/split): chunk text before embedding for better retrieval quality.
|
||||
- [KG Module](/reference/kg): Distance Intelligence uses graph embeddings for semantic neighbourhoods.
|
||||
- [Deduplication](/reference/deduplication): semantic deduplication uses embedding distance for entity resolution.
|
||||
|
||||
@@ -191,6 +191,41 @@ trip = TripletExtractor(method=["llm", "pattern"])
|
||||
entities = ner.extract(text)
|
||||
```
|
||||
|
||||
### NER Merge Strategies
|
||||
|
||||
`NERExtractor` uses `merge_strategy="fallback"` by default, so a method list remains an ordered fallback chain. To run several methods together, choose one of the explicit strategies below:
|
||||
|
||||
| Strategy | Behavior |
|
||||
| :--- | :--- |
|
||||
| `fallback` | Return the first non-empty method result. |
|
||||
| `union` | Keep candidates from any method. Same-label boundary variants are aligned, while distinct labels remain available. |
|
||||
| `consensus` | Require cross-method support for an offset-aligned candidate. `min_votes` defaults to `2`. |
|
||||
|
||||
```python
|
||||
from semantica.semantic_extract import NERExtractor
|
||||
|
||||
ner = NERExtractor(
|
||||
method=["spacy", "huggingface"],
|
||||
merge_strategy="consensus",
|
||||
min_votes=2,
|
||||
min_agreement=0.75, # optional support-ratio requirement
|
||||
method_weights={"spacy": 0.8, "huggingface": 1.0},
|
||||
)
|
||||
entities = ner.extract(text)
|
||||
|
||||
for entity in entities:
|
||||
print(entity.metadata["supporting_methods"])
|
||||
print(entity.metadata["vote_count"], entity.metadata["agreement"])
|
||||
```
|
||||
|
||||
Consensus counts support against the configured eligible methods, not only methods that emitted a candidate. An empty or failed eligible method is therefore a non-supporting vote. Use `eligible_methods=[...]` to restrict the consensus denominator when the configured methods have different coverage, or use `merge_strategy="union"` for complementary rule extractors. `method_weights` only break an otherwise eligible exact-span cross-label tie; they never turn one method into multiple votes.
|
||||
|
||||
Each merged entity includes `supporting_methods`, `vote_count`, `eligible_method_count`, `agreement`, and per-method `method_scores` in its metadata. Consensus treats compatible label aliases such as `PER`/`PERSON` and `ORGANIZATION`/`ORG` as the same vote. It resolves a cross-label conflict only when the final spans are identical, using method weight, vote count, confidence, and a stable label order; nested entities at different spans remain available. `ml` and `spacy` are one backend for both voting and weights, so their weights are interchangeable (conflicting values are rejected). Boundary candidates are matched one-to-one only when their span IoU is at least 0.5 with every existing vote in that candidate; equal-confidence variants prefer the longer span. If a provider omits offsets, Semantica resolves its entity text against whole-word document matches before merging. This keeps repeated mentions with the same text distinct and prevents one broad span from acting as a vote for multiple mentions.
|
||||
|
||||
`ensemble_voting=True` is deprecated and maps to `merge_strategy="union"` during migration. Use `merge_strategy="consensus"` when method agreement is required.
|
||||
|
||||
Unlike `fallback`, `union` and `consensus` never inject a pattern-derived entity after the configured methods return no candidates. An empty result is therefore meaningful in those strategies.
|
||||
|
||||
|
||||
## Quick Start
|
||||
|
||||
|
||||
@@ -160,7 +160,7 @@ No installation or API key required. FAISS requires `pip install faiss-cpu`.
|
||||
<Tab title="Pinecone">
|
||||
|
||||
```bash
|
||||
pip install "semantica[pinecone]"
|
||||
pip install "semantica[vectorstore-pinecone]"
|
||||
```
|
||||
|
||||
```python
|
||||
@@ -178,7 +178,7 @@ store = VectorStore(
|
||||
<Tab title="Weaviate">
|
||||
|
||||
```bash
|
||||
pip install "semantica[weaviate]"
|
||||
pip install "semantica[vectorstore-weaviate]"
|
||||
```
|
||||
|
||||
```python
|
||||
@@ -194,7 +194,7 @@ store = VectorStore(
|
||||
<Tab title="Qdrant">
|
||||
|
||||
```bash
|
||||
pip install "semantica[qdrant]"
|
||||
pip install "semantica[vectorstore-qdrant]"
|
||||
```
|
||||
|
||||
```python
|
||||
@@ -210,7 +210,7 @@ store = VectorStore(
|
||||
<Tab title="PgVector">
|
||||
|
||||
```bash
|
||||
pip install "semantica[pgvector]"
|
||||
pip install "semantica[vectorstore-pgvector]"
|
||||
```
|
||||
|
||||
```python
|
||||
|
||||
+6
-3
@@ -246,9 +246,12 @@ def _() -> list[str]:
|
||||
combined = (result.stdout or "") + (result.stderr or "")
|
||||
|
||||
if result.returncode != 0:
|
||||
# On Windows, npm cleanup raises EPERM on temp dirs — not a real
|
||||
# export failure. Treat as a skip rather than a hard failure.
|
||||
if sys.platform == "win32" and "EPERM" in combined and \
|
||||
# On Windows, npm post-command cleanup can fail with EPERM/EBUSY on
|
||||
# temp dirs — not a real export failure. Treat as a skip rather
|
||||
# than a hard failure, unless a real Mintlify error signature is
|
||||
# present.
|
||||
if sys.platform == "win32" and \
|
||||
("EPERM" in combined or "EBUSY" in combined) and \
|
||||
"could not be generated" not in combined:
|
||||
return [] # Windows temp-cleanup noise; real CI runs on Linux
|
||||
|
||||
|
||||
@@ -9,9 +9,9 @@
|
||||
"lint": "eslint .",
|
||||
"preview": "vite preview",
|
||||
"test:graph-store": "node --test tests/graphStore.multi-edge.test.mjs",
|
||||
"test:graph-workspace": "node --import tsx --test tests/markdownContentViewer.test.ts tests/markdownEditorInteraction.test.tsx tests/markdownEditorState.test.ts tests/nodeMarkdownSync.test.ts tests/graphSceneState.display.test.ts tests/temporalLifecycle.test.ts tests/deterministicExplorerRendering.test.ts tests/explorerCapabilities.test.tsx tests/smallGraphLayout.test.ts tests/realtimeGraphAttributes.test.ts",
|
||||
"test:graph-workspace": "node --import tsx --test tests/markdownContentViewer.test.ts tests/graphSceneState.display.test.ts tests/temporalLifecycle.test.ts tests/deterministicExplorerRendering.test.ts tests/smallGraphLayout.test.ts tests/realtimeGraphAttributes.test.ts tests/ontologyEditorModel.test.ts",
|
||||
"test: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/temporalScrubberBounds.test.ts tests/deterministicExplorerRendering.test.ts tests/explorerCapabilities.test.tsx tests/smallGraphLayout.test.ts tests/realtimeGraphAttributes.test.ts tests/ontologyEditorModel.test.ts tests/graphColorLegend.test.ts tests/ontologyUrlState.test.ts",
|
||||
"test:deterministic-e2e": "node --import tsx --test tests/deterministicExplorerRendering.e2e.ts",
|
||||
"test:graph-legend-e2e": "node --import tsx --test tests/graphColorLegend.e2e.ts",
|
||||
"test:plugin-registry": "node --import tsx --test tests/pluginRegistry.temporal.test.mjs"
|
||||
},
|
||||
"dependencies": {
|
||||
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
import { ErrorBoundary } from './ErrorBoundary';
|
||||
import { ExploreWorkspaceTabs, type ExploreView } from './ExploreWorkspaceTabs';
|
||||
import { fetchAgentMemoryAvailability } from './explorerCapabilities';
|
||||
import { hasOntologyUrlState } from './workspaces/OntologyWorkspace/ontologyUrlState';
|
||||
|
||||
const DecisionWorkspace = lazy(() => import('./workspaces/DecisionWorkspace/DecisionWorkspace').then((module) => ({ default: module.DecisionWorkspace })));
|
||||
const DiffMergeWorkspace = lazy(() => import('./workspaces/DiffMergeWorkspace/DiffMergeWorkspace').then((module) => ({ default: module.DiffMergeWorkspace })));
|
||||
@@ -96,15 +97,7 @@ const navItems: NavItem[] = [
|
||||
];
|
||||
|
||||
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";
|
||||
return hasOntologyUrlState() ? 'ontology-hub' : 'welcome';
|
||||
}
|
||||
|
||||
const shellStyles = `
|
||||
|
||||
@@ -26,6 +26,8 @@ export interface NodeAttributes {
|
||||
size: number;
|
||||
color: string;
|
||||
baseColor?: string;
|
||||
/** Original semantic color when a display clone bakes interaction styling into baseColor. */
|
||||
semanticBaseColor?: string;
|
||||
mutedColor?: string;
|
||||
glowColor?: string;
|
||||
baseSize?: number;
|
||||
|
||||
@@ -346,7 +346,7 @@ export function GraphInspectorPanel({
|
||||
<div style={{ color: GRAPH_THEME.ui.text.strong, fontWeight: 600, marginBottom: 6 }}>Selected item is not directly inspectable in the current graph.</div>
|
||||
<div style={{ color: GRAPH_THEME.ui.text.body, fontSize: 13, lineHeight: 1.6 }}>
|
||||
{canActivateFocused
|
||||
? "Activate Focused mode to resolve this grouped selection to its canonical node."
|
||||
? "Use Focus to resolve this grouped selection to its canonical node."
|
||||
: (focusedUnavailableReason ?? "Focused mode is unavailable for the current selection.")}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -28,7 +28,7 @@ import { useLoadGraph, useReloadGraph } from "./useLoadGraph";
|
||||
import { GraphLoadingOverlay } from "./GraphLoadingOverlay";
|
||||
import { createGraphLoadProgress, getGraphLoadTitle } from "./graphLoading";
|
||||
import { GRAPH_THEME, withAlpha } from "./graphTheme";
|
||||
import type { GraphEntityShapeVariant } from "./graphTheme";
|
||||
import { buildGraphColorLegend, type GraphColorLegendItem } from "./graphColorLegend";
|
||||
import { buildHeatmapRenderSnapshot, buildStructuralDistanceSnapshot, checkGroupedViewAvailability, getDistanceBandColor, resolveDisplayGraph, resolveDisplayStateSnapshot, resolveGroupedDisplayNodeId, resolveGroupedDisplayStateSnapshot, summarizeDistanceBuckets } from "./graphSceneState";
|
||||
import {
|
||||
type GraphPlugin,
|
||||
@@ -169,14 +169,7 @@ const loadNeighborhoodPanelPlugin = () => import("./plugins/neighborhoodPanelPlu
|
||||
const loadTemporalOverlayPlugin = () => import("./plugins/temporalOverlayPlugin").then((module) => module.temporalOverlayPlugin);
|
||||
const EMPTY_PATH: string[] = [];
|
||||
const COMPACT_TOOLBAR_CLUSTER_IDS = new Set(["camera", "utility"]);
|
||||
const ENTITY_VISUAL_KEY: Array<{ shape: GraphEntityShapeVariant; label: string }> = [
|
||||
{ shape: "biomolecule", label: "Biomolecule" },
|
||||
{ shape: "condition", label: "Condition" },
|
||||
{ shape: "compound", label: "Compound" },
|
||||
{ shape: "process", label: "Process" },
|
||||
{ shape: "community", label: "Community" },
|
||||
{ shape: "entity", label: "Other" },
|
||||
];
|
||||
|
||||
const DEBUG_GRAPH_WORKSPACE = import.meta.env.DEV;
|
||||
|
||||
function debugGraphWorkspace(message: string, payload?: Record<string, unknown>) {
|
||||
@@ -459,15 +452,21 @@ function SearchCommandBar({
|
||||
);
|
||||
}
|
||||
|
||||
function EntityVisualKey() {
|
||||
function SemanticColorLegend({ items }: { items: GraphColorLegendItem[] }) {
|
||||
if (!items.length) return null;
|
||||
return (
|
||||
<div className="explore-entity-key" aria-label="Node visual key">
|
||||
{ENTITY_VISUAL_KEY.map((item) => (
|
||||
<div key={item.shape} className="explore-entity-key-item">
|
||||
<span className="explore-entity-key-mark" data-shape={item.shape} />
|
||||
<span>{item.label}</span>
|
||||
</div>
|
||||
))}
|
||||
<div className="explore-color-legend" role="group" aria-label="Node colors">
|
||||
<span className="explore-color-legend-label" title="Base semantic colors; selection, zoom, and distance effects can change node appearance.">
|
||||
Node colors
|
||||
</span>
|
||||
<ul className="explore-color-legend-items">
|
||||
{items.map((item) => (
|
||||
<li key={item.id} className="explore-color-legend-item" title={`${item.group}: ${item.count.toLocaleString()} nodes`}>
|
||||
<span className="explore-color-legend-mark" style={{ backgroundColor: item.color }} aria-hidden="true" />
|
||||
<span className="explore-color-legend-name">{item.group}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -906,55 +905,46 @@ const HUD_CSS = `
|
||||
.explore-tool-button[data-compact="true"] .explore-tool-button-label {
|
||||
display: none;
|
||||
}
|
||||
.explore-entity-key {
|
||||
.explore-color-legend {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
align-items: baseline;
|
||||
gap: 12px;
|
||||
padding: 2px 1px 0;
|
||||
color: ${GRAPH_THEME.ui.text.subtle};
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.02em;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
}
|
||||
.explore-entity-key-item {
|
||||
.explore-color-legend-label {
|
||||
flex-shrink: 0;
|
||||
color: ${GRAPH_THEME.ui.text.muted};
|
||||
}
|
||||
.explore-color-legend-items {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px 14px;
|
||||
min-width: 0;
|
||||
max-height: 76px;
|
||||
overflow-y: auto;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
}
|
||||
.explore-color-legend-item {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
white-space: nowrap;
|
||||
min-width: 0;
|
||||
max-width: 100%;
|
||||
}
|
||||
.explore-entity-key-mark {
|
||||
width: 13px;
|
||||
height: 13px;
|
||||
display: inline-block;
|
||||
border: 1px solid rgba(194, 214, 218, 0.42);
|
||||
background: rgba(73, 154, 150, 0.58);
|
||||
box-shadow: inset 0 1px 0 rgba(255,255,255,0.08);
|
||||
.explore-color-legend-name {
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.explore-entity-key-mark[data-shape="entity"] {
|
||||
border-radius: 999px;
|
||||
}
|
||||
.explore-entity-key-mark[data-shape="biomolecule"] {
|
||||
clip-path: polygon(50% 7%, 86% 28%, 86% 72%, 50% 93%, 14% 72%, 14% 28%);
|
||||
}
|
||||
.explore-entity-key-mark[data-shape="condition"] {
|
||||
border-radius: 5px;
|
||||
transform: rotate(45deg) scale(0.88);
|
||||
}
|
||||
.explore-entity-key-mark[data-shape="compound"] {
|
||||
width: 20px;
|
||||
border-radius: 999px;
|
||||
}
|
||||
.explore-entity-key-mark[data-shape="process"] {
|
||||
border-radius: 4px;
|
||||
clip-path: polygon(0 0, 86% 0, 100% 16%, 100% 100%, 0 100%);
|
||||
}
|
||||
.explore-entity-key-mark[data-shape="community"] {
|
||||
width: 15px;
|
||||
height: 15px;
|
||||
border-radius: 999px;
|
||||
background: rgba(96, 190, 180, 0.16);
|
||||
border-color: rgba(229, 213, 175, 0.54);
|
||||
.explore-color-legend-mark {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
flex-shrink: 0;
|
||||
border-radius: 50%;
|
||||
box-shadow: inset 0 0 0 1px rgba(255,255,255,0.16);
|
||||
}
|
||||
.explore-search-results {
|
||||
display: flex;
|
||||
@@ -2280,6 +2270,11 @@ export function GraphWorkspace({ externalFocusNodeId, externalFocusToken, onDirt
|
||||
structuralSelectedNodeId,
|
||||
viewMode,
|
||||
]);
|
||||
const colorLegendItems = useMemo(() => {
|
||||
// Store mutations can preserve graph identity while changing its attributes.
|
||||
void graphVersion;
|
||||
return buildGraphColorLegend(displayResult.graph);
|
||||
}, [displayResult.graph, graphVersion]);
|
||||
const displayState = useMemo(
|
||||
() => (
|
||||
viewMode === "grouped"
|
||||
@@ -2796,7 +2791,7 @@ export function GraphWorkspace({ externalFocusNodeId, externalFocusToken, onDirt
|
||||
},
|
||||
{
|
||||
id: "view-focused",
|
||||
label: "Focused",
|
||||
label: "Focus",
|
||||
title: canActivateFocusedMode
|
||||
? "Inspect the selected node in a focused local graph"
|
||||
: (focusedSelectionResolution.reason ?? "Focused mode is unavailable for the current selection"),
|
||||
@@ -3122,7 +3117,7 @@ export function GraphWorkspace({ externalFocusNodeId, externalFocusToken, onDirt
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<EntityVisualKey />
|
||||
{!showDistanceStatus ? <SemanticColorLegend items={colorLegendItems} /> : null}
|
||||
</div>
|
||||
|
||||
{egoModeEnabled && (
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
import {
|
||||
useEffect,
|
||||
useId,
|
||||
useLayoutEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
type CSSProperties,
|
||||
type KeyboardEvent,
|
||||
} from "react";
|
||||
import ReactMarkdown, { type Components } from "react-markdown";
|
||||
import remarkGfm from "remark-gfm";
|
||||
@@ -34,6 +37,26 @@ export interface MarkdownContentViewerProps {
|
||||
defaultMode?: "preview" | "source";
|
||||
}
|
||||
|
||||
// Exported for unit-testing the roving-tabindex navigation logic without a DOM.
|
||||
// Given the ordered list of tab modes and the currently focused mode, returns
|
||||
// the mode that should receive focus for a given keyboard key. Returns null if
|
||||
// the key is not a navigation key so callers can handle the default case.
|
||||
// eslint-disable-next-line react-refresh/only-export-components
|
||||
export function resolveTabNavigation(
|
||||
current: "preview" | "source",
|
||||
key: string,
|
||||
): "preview" | "source" | null {
|
||||
const order = ["preview", "source"] as const;
|
||||
const idx = order.indexOf(current);
|
||||
switch (key) {
|
||||
case "ArrowRight": return order[(idx + 1) % order.length];
|
||||
case "ArrowLeft": return order[(idx - 1 + order.length) % order.length];
|
||||
case "Home": return order[0];
|
||||
case "End": return order[order.length - 1];
|
||||
default: return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function MarkdownContentViewer({
|
||||
content,
|
||||
resource,
|
||||
@@ -70,6 +93,64 @@ export function MarkdownContentViewer({
|
||||
if (copied) setCopied(false);
|
||||
}
|
||||
|
||||
// useId (not hardcoded strings) so the ids stay unique if more than one viewer
|
||||
// is ever mounted at once — the same pattern GraphWorkspace uses for its search
|
||||
// combobox. Hardcoded ids would collide silently in that case.
|
||||
const baseId = useId();
|
||||
const previewTabId = `${baseId}-tab-preview`;
|
||||
const sourceTabId = `${baseId}-tab-source`;
|
||||
const panelId = `${baseId}-panel`;
|
||||
|
||||
// Roving tabindex: the tablist is one Tab stop and arrows move focus within it.
|
||||
// Focus is tracked separately from selection because activation is manual (see
|
||||
// handleTabKeyDown), so a tab can hold focus without being the selected one.
|
||||
//
|
||||
// focusedMode is stored in a ref rather than state so that moving focus with
|
||||
// arrow keys does NOT trigger a React re-render. A re-render here is expensive:
|
||||
// react-markdown@10 has no internal memoisation and calls processor.parse() +
|
||||
// processor.runSync() unconditionally on every render — measured at 385ms for a
|
||||
// 1000-row GFM table and 1.4s at 2000 rows (#1118). Using a ref means arrow-key
|
||||
// navigation is free of Markdown re-parses while still keeping the DOM tabIndex
|
||||
// attributes correct via direct mutation (the same pattern used by WAI-ARIA APG
|
||||
// keyboard examples for roving tabindex).
|
||||
//
|
||||
// The JSX tabIndex props use activeMode (not the ref) to satisfy the
|
||||
// react-hooks/refs lint rule that bars ref reads during render. JSX provides the
|
||||
// correct value on initial render and after selectMode() calls (which always keep
|
||||
// focusedModeRef.current === activeMode at React render boundaries). A
|
||||
// useLayoutEffect (see below) corrects any JSX overwrite that occurs when focus
|
||||
// and selection temporarily differ during arrow navigation.
|
||||
const focusedModeRef = useRef<"preview" | "source">(defaultMode);
|
||||
const previewTabRef = useRef<HTMLButtonElement>(null);
|
||||
const sourceTabRef = useRef<HTMLButtonElement>(null);
|
||||
|
||||
const focusTab = (mode: "preview" | "source") => {
|
||||
focusedModeRef.current = mode;
|
||||
// Imperatively update tabIndex on both buttons so the roving tabindex
|
||||
// DOM state is correct without scheduling a React re-render.
|
||||
if (previewTabRef.current) previewTabRef.current.tabIndex = mode === "preview" ? 0 : -1;
|
||||
if (sourceTabRef.current) sourceTabRef.current.tabIndex = mode === "source" ? 0 : -1;
|
||||
(mode === "preview" ? previewTabRef : sourceTabRef).current?.focus();
|
||||
};
|
||||
|
||||
const selectMode = (mode: "preview" | "source") => {
|
||||
// Keep the ref in sync before setActiveMode so the upcoming re-render reads
|
||||
// the correct focusedModeRef.current when evaluating JSX tabIndex props.
|
||||
focusedModeRef.current = mode;
|
||||
setActiveMode(mode);
|
||||
};
|
||||
|
||||
// Manual activation (APG permits it, and here it is required): arrows move
|
||||
// focus only, Enter/Space activates via the native button click. Automatic
|
||||
// activation would re-run the full markdown parse on every arrow keypress —
|
||||
// measured at 385ms for a 1000-row GFM table and 1.4s at 2000 rows (#1118).
|
||||
const handleTabKeyDown = (event: KeyboardEvent<HTMLButtonElement>) => {
|
||||
const next = resolveTabNavigation(focusedModeRef.current, event.key);
|
||||
if (next === null) return;
|
||||
event.preventDefault();
|
||||
focusTab(next);
|
||||
};
|
||||
|
||||
const copyTimeoutRef = useRef<number | undefined>(undefined);
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
@@ -77,9 +158,44 @@ export function MarkdownContentViewer({
|
||||
};
|
||||
}, []);
|
||||
|
||||
// When the viewed resource/node changes, reset focusedModeRef to match the
|
||||
// incoming defaultMode. The render-phase setActiveMode(defaultMode) above
|
||||
// resets React state, but refs are not state and must be updated separately.
|
||||
// useLayoutEffect fires synchronously before paint so the ref is correct before
|
||||
// the no-deps tabIndex-correction effect (declared next) reads it.
|
||||
// Using resourceKey as the dep means this runs exactly once per resource change,
|
||||
// immediately after the render that detected the change.
|
||||
useLayoutEffect(() => {
|
||||
focusedModeRef.current = defaultMode;
|
||||
}, [resourceKey, defaultMode]);
|
||||
|
||||
// After every render, restore the DOM tabIndex to match focusedModeRef.current.
|
||||
// This is necessary because the JSX tabIndex props derive from activeMode, which
|
||||
// is correct for initial render and for renders triggered by selectMode(). However,
|
||||
// when focus and selection differ (i.e. after arrow-key navigation, before Enter/Space),
|
||||
// any unrelated re-render (copy-button click, parent update, etc.) will reconcile JSX
|
||||
// tabIndex={activeMode === X} and overwrite the imperative tabIndex values set by
|
||||
// focusTab(). useLayoutEffect fires synchronously after React's DOM mutations, before
|
||||
// paint, so it corrects any such overwrite before the user sees it. It does not
|
||||
// schedule another render — the two property writes are pure DOM mutations.
|
||||
// No deps array: intentional. The correction must run after every render, not just mount.
|
||||
// SSR-safe: useLayoutEffect is silently skipped on the server; the JSX tabIndex from
|
||||
// activeMode provides the correct initial value (focusedModeRef.current === activeMode
|
||||
// at mount). Strict Mode: runs twice on remount — both runs write the same values,
|
||||
// no state mutation, no render triggered.
|
||||
useLayoutEffect(() => {
|
||||
if (previewTabRef.current) {
|
||||
previewTabRef.current.tabIndex = focusedModeRef.current === "preview" ? 0 : -1;
|
||||
}
|
||||
if (sourceTabRef.current) {
|
||||
sourceTabRef.current.tabIndex = focusedModeRef.current === "source" ? 0 : -1;
|
||||
}
|
||||
});
|
||||
|
||||
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/);
|
||||
@@ -87,7 +203,9 @@ export function MarkdownContentViewer({
|
||||
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;
|
||||
|
||||
const renderedMarkdown = useMemo(
|
||||
() => (
|
||||
<ReactMarkdown remarkPlugins={REMARK_PLUGINS} components={MARKDOWN_COMPONENTS}>
|
||||
@@ -96,6 +214,7 @@ export function MarkdownContentViewer({
|
||||
),
|
||||
[previewContent],
|
||||
);
|
||||
|
||||
const handleCopy = async () => {
|
||||
if (!hasContent) return;
|
||||
try {
|
||||
@@ -110,33 +229,37 @@ export function MarkdownContentViewer({
|
||||
|
||||
const handleEdit = async () => {
|
||||
modeBeforeEditRef.current = activeMode;
|
||||
setActiveMode("source");
|
||||
selectMode("source");
|
||||
if (!await editor.beginEdit()) {
|
||||
setActiveMode(modeBeforeEditRef.current);
|
||||
selectMode(modeBeforeEditRef.current);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCancel = () => {
|
||||
editor.discard();
|
||||
setActiveMode(modeBeforeEditRef.current);
|
||||
selectMode(modeBeforeEditRef.current);
|
||||
};
|
||||
|
||||
const handleApply = async () => {
|
||||
if (await editor.save()) {
|
||||
setActiveMode("preview");
|
||||
selectMode("preview");
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={className} style={viewerContainerStyle}>
|
||||
<div style={viewerHeaderStyle}>
|
||||
<div style={{ display: "flex", gap: 4 }} role="tablist" aria-label="Markdown view">
|
||||
<div style={{ display: "flex", gap: 4 }} role="tablist" aria-label="Content view mode">
|
||||
<button
|
||||
type="button"
|
||||
role="tab"
|
||||
id={previewTabId}
|
||||
ref={previewTabRef}
|
||||
aria-selected={activeMode === "preview"}
|
||||
aria-controls="markdown-viewer-panel"
|
||||
onClick={() => setActiveMode("preview")}
|
||||
aria-controls={panelId}
|
||||
tabIndex={activeMode === "preview" ? 0 : -1}
|
||||
onClick={() => selectMode("preview")}
|
||||
onKeyDown={handleTabKeyDown}
|
||||
style={{ ...tabBtnStyle, ...(activeMode === "preview" ? activeTabBtnStyle : {}) }}
|
||||
>
|
||||
<Eye size={12} style={{ marginRight: 5 }} aria-hidden="true" />
|
||||
@@ -145,9 +268,13 @@ export function MarkdownContentViewer({
|
||||
<button
|
||||
type="button"
|
||||
role="tab"
|
||||
id={sourceTabId}
|
||||
ref={sourceTabRef}
|
||||
aria-selected={activeMode === "source"}
|
||||
aria-controls="markdown-viewer-panel"
|
||||
onClick={() => setActiveMode("source")}
|
||||
aria-controls={panelId}
|
||||
tabIndex={activeMode === "source" ? 0 : -1}
|
||||
onClick={() => selectMode("source")}
|
||||
onKeyDown={handleTabKeyDown}
|
||||
style={{ ...tabBtnStyle, ...(activeMode === "source" ? activeTabBtnStyle : {}) }}
|
||||
>
|
||||
<Code2 size={12} style={{ marginRight: 5 }} aria-hidden="true" />
|
||||
@@ -220,10 +347,20 @@ export function MarkdownContentViewer({
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{/* Both tabs point aria-controls at this one panel: only the active view is
|
||||
ever rendered inside (edit/preview/source/empty), so per-tab panel ids
|
||||
would leave the inactive tab referencing an element not in the DOM.
|
||||
Wrapping all branches — including empty state and editing textarea —
|
||||
keeps every aria-controls reference resolvable at all times.
|
||||
tabIndex=0 because the panel is a scroll container (viewerBodyStyle caps
|
||||
its height), so keyboard users need to be able to focus and scroll it.
|
||||
aria-busy signals to assistive tech that the content is loading/saving. */}
|
||||
<div
|
||||
id="markdown-viewer-panel"
|
||||
role="tabpanel"
|
||||
id={panelId}
|
||||
aria-labelledby={activeMode === "preview" ? previewTabId : sourceTabId}
|
||||
aria-busy={saving || loading}
|
||||
tabIndex={0}
|
||||
style={viewerBodyStyle}
|
||||
>
|
||||
{activeMode === "source" && editing ? (
|
||||
|
||||
@@ -4,6 +4,7 @@ import { Timeline } from "vis-timeline";
|
||||
import type { TimelineOptions } from "vis-timeline";
|
||||
import "vis-timeline/styles/vis-timeline-graph2d.css";
|
||||
import { GRAPH_THEME } from "./graphTheme";
|
||||
import { DEFAULT_MIN_DATE, resolvePlayStepMs, resolveScrubberBounds } from "./temporalScrubberBounds";
|
||||
|
||||
export interface TimelinePanelProps {
|
||||
onTimeChange: (time: Date) => void;
|
||||
@@ -11,11 +12,9 @@ export interface TimelinePanelProps {
|
||||
maxDate?: string;
|
||||
}
|
||||
|
||||
const DEFAULT_MIN_DATE = new Date("1970-01-01T00:00:00Z");
|
||||
const DEFAULT_MAX_DATE = new Date("2030-01-01T00:00:00Z");
|
||||
const PLAYHEAD_ID = "playhead";
|
||||
const PLAY_INTERVAL_MS = 500;
|
||||
const PLAY_STEP_MONTHS = 6;
|
||||
const ONE_DAY_MS = 1000 * 60 * 60 * 24;
|
||||
|
||||
const VIS_OVERRIDE_CSS = `
|
||||
.sem-timeline-wrap .vis-timeline { border: none !important; background: transparent !important; overflow: visible !important; }
|
||||
@@ -54,12 +53,6 @@ const VIS_OVERRIDE_CSS = `
|
||||
.sem-timeline-wrap .vis-panel.vis-left { display: none !important; }
|
||||
`;
|
||||
|
||||
function safeDate(value: string | undefined, fallback: Date): Date {
|
||||
if (!value) return fallback;
|
||||
const parsed = new Date(value);
|
||||
return Number.isNaN(parsed.getTime()) ? fallback : parsed;
|
||||
}
|
||||
|
||||
function formatPlayheadLabel(value: Date): string {
|
||||
return `${value.getFullYear()}/${String(value.getMonth() + 1).padStart(2, "0")}`;
|
||||
}
|
||||
@@ -72,9 +65,13 @@ export function TimelinePanel({ onTimeChange, minDate, maxDate }: TimelinePanelP
|
||||
const [isPlaying, setIsPlaying] = useState(false);
|
||||
const [displayDate, setDisplayDate] = useState(formatPlayheadLabel(DEFAULT_MIN_DATE));
|
||||
|
||||
const minBound = useMemo(() => safeDate(minDate, DEFAULT_MIN_DATE), [minDate]);
|
||||
const maxBound = useMemo(() => safeDate(maxDate, DEFAULT_MAX_DATE), [maxDate]);
|
||||
const defaultTime = useMemo(() => new Date(Math.round((minBound.getTime() + maxBound.getTime()) / 2)), [maxBound, minBound]);
|
||||
// Captured once per mount so re-renders keep the same reference and do not
|
||||
// retrigger the timeline effect below.
|
||||
const now = useMemo(() => new Date(), []);
|
||||
const { minBound, maxBound, defaultTime } = useMemo(
|
||||
() => resolveScrubberBounds({ minDate, maxDate, now }),
|
||||
[maxDate, minDate, now],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!containerRef.current) return;
|
||||
@@ -91,12 +88,10 @@ export function TimelinePanel({ onTimeChange, minDate, maxDate }: TimelinePanelP
|
||||
showCurrentTime: false,
|
||||
zoomable: true,
|
||||
moveable: true,
|
||||
zoomMin: 1000 * 60 * 60 * 24 * 365,
|
||||
zoomMin: ONE_DAY_MS,
|
||||
zoomMax: 1000 * 60 * 60 * 24 * 365 * 80,
|
||||
showMajorLabels: true,
|
||||
showMinorLabels: true,
|
||||
timeAxis: { scale: "year", step: 5 },
|
||||
format: { minorLabels: { year: "YYYY" }, majorLabels: { year: "YYYY" } },
|
||||
orientation: { axis: "bottom" },
|
||||
margin: { item: 0, axis: 0 },
|
||||
selectable: false,
|
||||
@@ -134,8 +129,7 @@ export function TimelinePanel({ onTimeChange, minDate, maxDate }: TimelinePanelP
|
||||
playIntervalRef.current = setInterval(() => {
|
||||
const timeline = timelineRef.current;
|
||||
if (!timeline) return;
|
||||
const next = new Date(playheadRef.current);
|
||||
next.setMonth(next.getMonth() + PLAY_STEP_MONTHS);
|
||||
const next = new Date(playheadRef.current.getTime() + resolvePlayStepMs(minBound, maxBound));
|
||||
if (next >= maxBound) {
|
||||
next.setTime(minBound.getTime());
|
||||
}
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
import type Graph from "graphology";
|
||||
|
||||
import type { NodeAttributes } from "../../store/graphStore";
|
||||
import { GRAPH_THEME, type GraphTheme } from "./graphTheme";
|
||||
|
||||
export type GraphColorLegendItem = {
|
||||
id: string;
|
||||
group: string;
|
||||
color: string;
|
||||
count: number;
|
||||
};
|
||||
|
||||
/** Normal semantic color, before selection, distance, or zoom styling. */
|
||||
export function getSemanticNodeColor(
|
||||
attrs: Pick<NodeAttributes, "baseColor" | "color">,
|
||||
theme: GraphTheme = GRAPH_THEME,
|
||||
) {
|
||||
return String(attrs.baseColor || attrs.color || theme.palette.semantic[0]);
|
||||
}
|
||||
|
||||
/** Use the displayed graph so synthetic groups and filtered views keep their colors. */
|
||||
export function buildGraphColorLegend(graph: Graph, theme: GraphTheme = GRAPH_THEME): GraphColorLegendItem[] {
|
||||
const entries = new Map<string, GraphColorLegendItem>();
|
||||
graph.forEachNode((_id, attrs) => {
|
||||
if (attrs.hidden) return;
|
||||
const group = String(attrs.semanticGroup || attrs.nodeType || "entity");
|
||||
// Focused clones bake interaction colors into baseColor; keep those out of the semantic key.
|
||||
const color = String(attrs.semanticBaseColor || getSemanticNodeColor(attrs as NodeAttributes, theme));
|
||||
// A synthetic display node can share a semantic label with a different color.
|
||||
const id = JSON.stringify([group, color]);
|
||||
const current = entries.get(id);
|
||||
if (current) current.count += 1;
|
||||
else entries.set(id, { id, group, color, count: 1 });
|
||||
});
|
||||
return [...entries.values()].sort((a, b) => a.group.localeCompare(b.group) || a.color.localeCompare(b.color));
|
||||
}
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
withAlpha,
|
||||
zoomTierAtLeast,
|
||||
} from "./graphTheme";
|
||||
import { getSemanticNodeColor } from "./graphColorLegend";
|
||||
import { classifyEntityShape } from "./graphEntityShape";
|
||||
import { computeGraphAnalyticsBase } from "./graphAnalytics";
|
||||
import type {
|
||||
@@ -1013,9 +1014,8 @@ function resolveNodeColor(
|
||||
state: GraphNodeVisualState,
|
||||
attrs: NodeAttributes,
|
||||
cameraRatio: number,
|
||||
fallbackColor?: string,
|
||||
) {
|
||||
const semanticColor = String(attrs.baseColor || fallbackColor || theme.palette.semantic[0]);
|
||||
const semanticColor = getSemanticNodeColor(attrs, theme);
|
||||
const isCommunityGroup = Boolean(attrs.isCommunityGroup);
|
||||
const entityShapeConfig = theme.nodes.entityShapes[resolveEntityShape(attrs)];
|
||||
const overviewTint = state === "neighbor"
|
||||
@@ -1062,9 +1062,8 @@ function resolveNodeShellColor(
|
||||
state: GraphNodeVisualState,
|
||||
attrs: NodeAttributes,
|
||||
cameraRatio: number,
|
||||
fallbackColor?: string,
|
||||
) {
|
||||
const semanticColor = String(attrs.baseColor || fallbackColor || theme.palette.semantic[0]);
|
||||
const semanticColor = getSemanticNodeColor(attrs, theme);
|
||||
const isCommunityGroup = Boolean(attrs.isCommunityGroup);
|
||||
const entityShapeConfig = theme.nodes.entityShapes[resolveEntityShape(attrs)];
|
||||
const presenceBoost = getOverviewPresenceBoost(cameraRatio);
|
||||
@@ -1633,8 +1632,8 @@ export function resolveNodeElementStyle(
|
||||
const isCommunityGroup = Boolean(attrs.isCommunityGroup);
|
||||
const baseSize = Number(attrs.baseSize || attrs.size || 4);
|
||||
const labelPriority = Number(attrs.labelPriority ?? 0);
|
||||
const color = resolveNodeColor(theme, zoomTier, state, attrs, cameraRatio, attrs.color);
|
||||
const shellColor = resolveNodeShellColor(theme, zoomTier, state, attrs, cameraRatio, attrs.color);
|
||||
const color = resolveNodeColor(theme, zoomTier, state, attrs, cameraRatio);
|
||||
const shellColor = resolveNodeShellColor(theme, zoomTier, state, attrs, cameraRatio);
|
||||
const sizeMultiplier = (state === "default" ? tierConfig.nodeScale : stateConfig.sizeMultiplier)
|
||||
* variantConfig.sizeMultiplier
|
||||
* (isCommunityGroup ? theme.grouped.style.nodeSizeScale : 1);
|
||||
@@ -2570,6 +2569,7 @@ export function createFocusedGraph(
|
||||
color: selectedState.color,
|
||||
size: Math.max(selectedState.size, 22),
|
||||
baseColor: selectedState.color,
|
||||
semanticBaseColor: getSemanticNodeColor(selectedAttrs),
|
||||
baseSize: Math.max(selectedState.size, 22),
|
||||
label: selectedState.label,
|
||||
});
|
||||
@@ -2609,6 +2609,7 @@ export function createFocusedGraph(
|
||||
color: style.color,
|
||||
size: Math.max(style.size, 8.5),
|
||||
baseColor: style.color,
|
||||
semanticBaseColor: getSemanticNodeColor(baseAttrs),
|
||||
baseSize: Math.max(style.size, 8.5),
|
||||
label: style.label,
|
||||
});
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { CSSProperties } from "react";
|
||||
|
||||
import { buildGraphColorLegend } from "../graphColorLegend";
|
||||
import type {
|
||||
GraphAnalyticsSnapshot,
|
||||
GraphDiagnosticsSnapshot,
|
||||
@@ -104,19 +105,7 @@ function renderAvailabilityText(availability: GraphEffectAvailability) {
|
||||
}
|
||||
|
||||
function collectFallbackLegendItems(context: Parameters<NonNullable<GraphPlugin["renderPanel"]>>[0]) {
|
||||
const groups = new Map<string, { count: number; color: string }>();
|
||||
context.graph.forEachNode((_nodeId, attrs) => {
|
||||
const semanticGroup = String(attrs.semanticGroup || attrs.nodeType || "entity");
|
||||
const color = String(attrs.baseColor || context.theme.palette.semantic[0]);
|
||||
const current = groups.get(semanticGroup);
|
||||
groups.set(semanticGroup, {
|
||||
count: (current?.count ?? 0) + 1,
|
||||
color,
|
||||
});
|
||||
});
|
||||
|
||||
return [...groups.entries()]
|
||||
.map(([group, data]) => ({ group, ...data }))
|
||||
return buildGraphColorLegend(context.graph, context.theme)
|
||||
.sort((left, right) => right.count - left.count)
|
||||
.slice(0, context.theme.effects.legend.maxGroups);
|
||||
}
|
||||
@@ -255,7 +244,7 @@ function renderRegionsAndSignals(
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 8 }}>
|
||||
<div style={subsectionTitleStyle}>Fallback semantic legend</div>
|
||||
{fallbackLegendItems.map((item) => (
|
||||
<div key={item.group} style={legendRowStyle}>
|
||||
<div key={item.id} style={legendRowStyle}>
|
||||
<span
|
||||
style={{
|
||||
...legendSwatchStyle,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { CSSProperties } from "react";
|
||||
|
||||
import { buildGraphColorLegend } from "../graphColorLegend";
|
||||
import type { GraphPlugin } from "./types";
|
||||
|
||||
const LEGEND_PANEL_ID = "legend-panel";
|
||||
@@ -25,19 +26,7 @@ export const legendPlugin: GraphPlugin = {
|
||||
return null;
|
||||
}
|
||||
|
||||
const groups = new Map<string, { count: number; color: string }>();
|
||||
context.graph.forEachNode((_nodeId, attrs) => {
|
||||
const semanticGroup = String(attrs.semanticGroup || attrs.nodeType || "entity");
|
||||
const color = String(attrs.baseColor || context.theme.palette.semantic[0]);
|
||||
const current = groups.get(semanticGroup);
|
||||
groups.set(semanticGroup, {
|
||||
count: (current?.count ?? 0) + 1,
|
||||
color,
|
||||
});
|
||||
});
|
||||
|
||||
const items = [...groups.entries()]
|
||||
.map(([group, data]) => ({ group, ...data }))
|
||||
const items = buildGraphColorLegend(context.graph, context.theme)
|
||||
.sort((left, right) => right.count - left.count)
|
||||
.slice(0, MAX_GROUPS);
|
||||
|
||||
@@ -55,7 +44,7 @@ export const legendPlugin: GraphPlugin = {
|
||||
{items.length ? (
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 8 }}>
|
||||
{items.map((item) => (
|
||||
<div key={item.group} style={legendRowStyle}>
|
||||
<div key={item.id} style={legendRowStyle}>
|
||||
<span
|
||||
style={{
|
||||
...swatchStyle,
|
||||
|
||||
@@ -91,7 +91,7 @@ export const temporalOverlayPlugin: GraphPlugin = {
|
||||
<div style={detailRowStyle}>
|
||||
<span style={detailLabelStyle}>Bounds</span>
|
||||
<span style={detailValueStyle}>
|
||||
{(temporal?.minDate ?? "1970")} → {(temporal?.maxDate ?? "2030")}
|
||||
{(temporal?.minDate ?? "1970")} → {(temporal?.maxDate ?? "now")}
|
||||
</span>
|
||||
</div>
|
||||
<div style={detailRowStyle}>
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
export interface ScrubberBoundsInput {
|
||||
minDate?: string;
|
||||
maxDate?: string;
|
||||
now: Date;
|
||||
}
|
||||
|
||||
export interface ScrubberBounds {
|
||||
minBound: Date;
|
||||
maxBound: Date;
|
||||
defaultTime: Date;
|
||||
}
|
||||
|
||||
export const DEFAULT_MIN_DATE = new Date("1970-01-01T00:00:00Z");
|
||||
const ONE_DAY_MS = 1000 * 60 * 60 * 24;
|
||||
const PLAY_FRAMES = 60;
|
||||
|
||||
function parseBound(value: string | undefined, fallback: Date): Date {
|
||||
if (!value) return fallback;
|
||||
const parsed = new Date(value);
|
||||
return Number.isNaN(parsed.getTime()) ? fallback : parsed;
|
||||
}
|
||||
|
||||
function clamp(value: Date, minBound: Date, maxBound: Date): Date {
|
||||
if (value < minBound) return minBound;
|
||||
if (value > maxBound) return maxBound;
|
||||
return value;
|
||||
}
|
||||
|
||||
/**
|
||||
* `/api/temporal/bounds` reports `max: null` for graphs whose nodes carry
|
||||
* `valid_from` instants and no `valid_until`, which is the common case rather
|
||||
* than malformed data. Such a graph is known up to the present and no further,
|
||||
* so `now` is the honest upper bound and the honest starting playhead.
|
||||
*/
|
||||
export function resolveScrubberBounds({ minDate, maxDate, now }: ScrubberBoundsInput): ScrubberBounds {
|
||||
const minBound = parseBound(minDate, DEFAULT_MIN_DATE);
|
||||
const maxBound = parseBound(maxDate, now);
|
||||
const orderedMax = maxBound > minBound ? maxBound : minBound;
|
||||
return { minBound, maxBound: orderedMax, defaultTime: clamp(now, minBound, orderedMax) };
|
||||
}
|
||||
|
||||
/** Keeps a play-through at ~PLAY_FRAMES steps whatever the span, with a one-day floor. */
|
||||
export function resolvePlayStepMs(minBound: Date, maxBound: Date): number {
|
||||
const span = maxBound.getTime() - minBound.getTime();
|
||||
return Math.max(ONE_DAY_MS, Math.round(span / PLAY_FRAMES));
|
||||
}
|
||||
@@ -28,11 +28,12 @@ import { loadOntologyEntityOwner, loadOntologyGraph } from "./api";
|
||||
import type { OntologyGraphEdge, OntologyGraphNode } from "./api";
|
||||
import {
|
||||
classifyNodeType,
|
||||
inferOntologyUri,
|
||||
isEditableEntityType,
|
||||
ONTOLOGY_MINIMAP_THEME,
|
||||
resolveEditorOntology,
|
||||
} from "./ontologyEditorModel";
|
||||
import type { EditorEntityType, RegistryEntry } from "./ontologyEditorModel";
|
||||
import { clearEntitySelection, readOntologyUrlState, writeEntitySelection } from "./ontologyUrlState";
|
||||
|
||||
type OntologyNodeData = {
|
||||
label?: string;
|
||||
@@ -137,11 +138,7 @@ interface DraftDiff {
|
||||
}
|
||||
|
||||
function requestedEntityUri(): string {
|
||||
try {
|
||||
return new URLSearchParams(window.location.search).get("ontologyEntity") || "";
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
return readOntologyUrlState().entityUri || "";
|
||||
}
|
||||
|
||||
function nodeLabel(node: OntologyGraphNode): string {
|
||||
@@ -225,6 +222,7 @@ export function OntologyEditor() {
|
||||
const [flowInstance, setFlowInstance] = useState<ReactFlowInstance<OntologyNode, OntologyEdge> | null>(null);
|
||||
const [isLoadingGraph, setIsLoadingGraph] = useState(false);
|
||||
const [graphError, setGraphError] = useState("");
|
||||
const [unownedEntity, setUnownedEntity] = useState("");
|
||||
const [draftDiff, setDraftDiff] = useState<DraftDiff>({
|
||||
added_classes: [],
|
||||
removed_classes: [],
|
||||
@@ -250,11 +248,21 @@ export function OntologyEditor() {
|
||||
? loadOntologyEntityOwner(requested).catch(() => undefined)
|
||||
: Promise.resolve(undefined),
|
||||
])
|
||||
.then(([entries, explicitOwner]: [RegistryEntry[], string | undefined]) => {
|
||||
.then(([entries, ownerVerdict]: [RegistryEntry[], string | null | undefined]) => {
|
||||
if (cancelled) return;
|
||||
setRegistry(entries);
|
||||
const inferredOntology = inferOntologyUri(entries, requested, explicitOwner);
|
||||
setOntologyUri((current) => current || inferredOntology || entries[0]?.uri || "");
|
||||
const resolution = resolveEditorOntology(entries, requested, ownerVerdict);
|
||||
// The registry default is the right landing place for "no entity asked
|
||||
// for", but not for "the backend says nothing owns the entity that was
|
||||
// asked for" — that would open an arbitrary ontology whose graph
|
||||
// excludes the entity, and report nothing about why.
|
||||
if (resolution.status === "unowned") {
|
||||
setUnownedEntity(resolution.entityUri);
|
||||
return;
|
||||
}
|
||||
setUnownedEntity("");
|
||||
const resolvedOntology = resolution.status === "resolved" ? resolution.uri : undefined;
|
||||
setOntologyUri((current) => current || resolvedOntology || entries[0]?.uri || "");
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error("Failed to load ontology registry:", error);
|
||||
@@ -377,14 +385,7 @@ export function OntologyEditor() {
|
||||
|
||||
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.
|
||||
}
|
||||
writeEntitySelection(node.id);
|
||||
}, []);
|
||||
|
||||
const saveDraft = useCallback(async () => {
|
||||
@@ -554,15 +555,8 @@ export function OntologyEditor() {
|
||||
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.
|
||||
}
|
||||
setUnownedEntity("");
|
||||
clearEntitySelection();
|
||||
}}
|
||||
style={selectStyle}
|
||||
>
|
||||
@@ -633,7 +627,12 @@ export function OntologyEditor() {
|
||||
{!isLoadingGraph && graphError && (
|
||||
<div style={{ ...canvasMessageStyle, color: "#ff9a8d" }}>{graphError}</div>
|
||||
)}
|
||||
{!isLoadingGraph && !graphError && ontologyUri && nodes.length === 0 && (
|
||||
{!isLoadingGraph && !graphError && unownedEntity && (
|
||||
<div style={{ ...canvasMessageStyle, color: "#f2b66d" }}>
|
||||
No registered ontology owns {unownedEntity}. Pick an ontology above to start editing.
|
||||
</div>
|
||||
)}
|
||||
{!isLoadingGraph && !graphError && !unownedEntity && ontologyUri && nodes.length === 0 && (
|
||||
<div style={canvasMessageStyle}>This ontology has no editable classes or properties.</div>
|
||||
)}
|
||||
|
||||
|
||||
@@ -32,7 +32,11 @@ export type OntologyGraphResponse = {
|
||||
};
|
||||
|
||||
export type OntologyEntityOwner = {
|
||||
source_ontology?: string;
|
||||
// Optional on purpose, unlike OntologyGraphNode.entity_type. There, a missing
|
||||
// field degrades to a read-only node — benign. Here it would be read as an
|
||||
// authoritative "nothing owns this entity", which now suppresses selection
|
||||
// outright, so presence has to be checked rather than assumed.
|
||||
owning_ontology?: string | null;
|
||||
};
|
||||
|
||||
async function parseResponse<T>(response: Response): Promise<T> {
|
||||
@@ -63,10 +67,21 @@ export async function loadOntologyGraph(uri: string, signal?: AbortSignal): Prom
|
||||
);
|
||||
}
|
||||
|
||||
export async function loadOntologyEntityOwner(uri: string): Promise<string | undefined> {
|
||||
// Three-state verdict: a string names the owner, null is the backend's
|
||||
// authoritative "no known ontology owns this entity", and undefined means the
|
||||
// request failed so there is no verdict to act on.
|
||||
export type OntologyOwnerVerdict = string | null | undefined;
|
||||
|
||||
export async function loadOntologyEntityOwner(uri: string): Promise<OntologyOwnerVerdict> {
|
||||
const response = await fetch(`/api/ontology/entity/${encodeURIComponent(uri)}`);
|
||||
if (!response.ok) return undefined;
|
||||
return (await response.json() as OntologyEntityOwner).source_ontology;
|
||||
const owner = await response.json() as OntologyEntityOwner | null;
|
||||
// Only a field that is actually there carries the verdict. Coercing an absent
|
||||
// field to null would assert the strongest available claim — "nothing owns
|
||||
// this" — on the weakest possible evidence, and that claim now stops the
|
||||
// editor selecting an ontology at all.
|
||||
const verdict = owner?.owning_ontology;
|
||||
return verdict === undefined ? undefined : verdict;
|
||||
}
|
||||
|
||||
export async function loadAlignments(uri?: string): Promise<OntologyAlignment[]> {
|
||||
|
||||
@@ -13,6 +13,7 @@ import { OntologyManager } from "./OntologyManager";
|
||||
import { OntologyEditor } from "./OntologyEditor";
|
||||
import { ShaclStudio } from "./ShaclStudio";
|
||||
import { VersionsTab } from "./VersionsTab";
|
||||
import { readOntologyUrlState, writeEntitySelection, writeTab } from "./ontologyUrlState";
|
||||
|
||||
export type OntologyHubTab =
|
||||
| "registry"
|
||||
@@ -22,8 +23,6 @@ export type OntologyHubTab =
|
||||
| "health"
|
||||
| "shacl";
|
||||
|
||||
const TAB_PARAM = "ontologyTab";
|
||||
|
||||
const TABS: { id: OntologyHubTab; label: string; icon: typeof GitMerge }[] = [
|
||||
{ id: "registry", label: "Registry", icon: BookMarked },
|
||||
{ id: "editor", label: "Editor", icon: Sliders },
|
||||
@@ -33,37 +32,23 @@ const TABS: { id: OntologyHubTab; label: string; icon: typeof GitMerge }[] = [
|
||||
{ id: "shacl", label: "SHACL", icon: Shield },
|
||||
];
|
||||
|
||||
function readTabParam(): OntologyHubTab {
|
||||
try {
|
||||
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
|
||||
}
|
||||
function readInitialTab(): OntologyHubTab {
|
||||
const { tab, entityUri } = readOntologyUrlState();
|
||||
const requested = TABS.find((candidate) => candidate.id === tab);
|
||||
if (requested) return requested.id;
|
||||
if (entityUri) return "editor";
|
||||
return "registry";
|
||||
}
|
||||
|
||||
function writeTabParam(tab: OntologyHubTab) {
|
||||
try {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
params.set(TAB_PARAM, tab);
|
||||
window.history.replaceState(null, "", `?${params.toString()}`);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
interface OntologyWorkspaceProps {
|
||||
onJumpToGraphNode?: (nodeId: string) => void;
|
||||
}
|
||||
|
||||
export function OntologyWorkspace({ onJumpToGraphNode }: OntologyWorkspaceProps) {
|
||||
const [activeTab, setActiveTab] = useState<OntologyHubTab>(readTabParam);
|
||||
const [activeTab, setActiveTab] = useState<OntologyHubTab>(readInitialTab);
|
||||
|
||||
useEffect(() => {
|
||||
writeTabParam(activeTab);
|
||||
writeTab(activeTab);
|
||||
}, [activeTab]);
|
||||
|
||||
const handleTabChange = useCallback((tab: OntologyHubTab) => {
|
||||
@@ -71,10 +56,7 @@ export function OntologyWorkspace({ onJumpToGraphNode }: OntologyWorkspaceProps)
|
||||
}, []);
|
||||
|
||||
const handleFixInEditor = useCallback((entityUri: string) => {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
params.set(TAB_PARAM, "editor");
|
||||
params.set("ontologyEntity", entityUri);
|
||||
window.history.replaceState(null, "", `?${params.toString()}`);
|
||||
writeEntitySelection(entityUri);
|
||||
setActiveTab("editor");
|
||||
}, []);
|
||||
|
||||
|
||||
@@ -45,6 +45,10 @@ export function classifyNodeType(rawType: string): EditorEntityType {
|
||||
return "external";
|
||||
}
|
||||
|
||||
// Last-resort guess, reached only when the backend gave no verdict: it has no
|
||||
// notion of nested vocabularies, so it can name a parent that does not contain
|
||||
// the entity. Authority is owning_ontology from /api/ontology/entity
|
||||
// (_resolve_owning_ontology in semantica/explorer/routes/ontology.py).
|
||||
function ownsByNamespace(entityUri: string, ontologyUri: string): boolean {
|
||||
const stem = ontologyUri.replace(/[/#]+$/, "");
|
||||
return entityUri === ontologyUri
|
||||
@@ -52,12 +56,50 @@ function ownsByNamespace(entityUri: string, ontologyUri: string): boolean {
|
||||
|| entityUri.startsWith(`${stem}/`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Three outcomes, deliberately not collapsed into `string | undefined`.
|
||||
*
|
||||
* `unowned` and `unresolved` both yield "no ontology to open", but they must
|
||||
* not be treated alike: a caller that writes `resolve(...) || entries[0]` turns
|
||||
* the backend's authoritative "nothing owns this entity" into "open an
|
||||
* arbitrary ontology", which reintroduces the parent-selection bug this whole
|
||||
* verdict exists to prevent. The union makes that collapse a type error.
|
||||
*/
|
||||
export type EditorOntologyResolution =
|
||||
| { status: "resolved"; uri: string }
|
||||
| { status: "unowned"; entityUri: string }
|
||||
| { status: "unresolved" };
|
||||
|
||||
// Picks the registered ontology to open for a deep-linked entity. A null
|
||||
// verdict is the backend's authoritative "nothing owns this entity": the
|
||||
// namespace guess must stay suppressed, or an unregistered nested namespace
|
||||
// would select its registered parent again. Only an unavailable verdict
|
||||
// (undefined) may fall back to inference.
|
||||
export function resolveEditorOntology(
|
||||
entries: RegistryEntry[],
|
||||
entityUri: string,
|
||||
ownerVerdict: string | null | undefined,
|
||||
): EditorOntologyResolution {
|
||||
if (ownerVerdict === null) {
|
||||
return { status: "unowned", entityUri };
|
||||
}
|
||||
const uri = inferOntologyUri(entries, entityUri, ownerVerdict);
|
||||
return uri === undefined ? { status: "unresolved" } : { status: "resolved", uri };
|
||||
}
|
||||
|
||||
// Picks the registered ontology to open for an entity: the backend-resolved
|
||||
// explicitOwner wins outright, the namespace guess is only the fallback.
|
||||
export function inferOntologyUri(
|
||||
entries: RegistryEntry[],
|
||||
entityUri: string,
|
||||
explicitOwner?: string,
|
||||
): string | undefined {
|
||||
if (explicitOwner && entries.some((entry) => entry.uri === explicitOwner)) {
|
||||
// Trusted even when the registry does not list it. Falling through to the
|
||||
// namespace guess here would answer a question nobody asked — the backend
|
||||
// named this entity's owner, and silently opening a *different* ontology is
|
||||
// worse than opening one the registry has not been told about yet, which
|
||||
// surfaces as an explicit error from /api/ontology/graph.
|
||||
if (explicitOwner) {
|
||||
return explicitOwner;
|
||||
}
|
||||
return [...entries]
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
// Sole owner of the Ontology Hub deep-link query parameters: the names below must not be
|
||||
// spelled out anywhere else, so that the protocol can change in one place.
|
||||
const TAB_PARAM = "ontologyTab";
|
||||
const ENTITY_PARAM = "ontologyEntity";
|
||||
const EDITOR_TAB = "editor";
|
||||
|
||||
export interface OntologyUrlState {
|
||||
/** Raw parameter value; the set of legal tab ids belongs to the workspace, not this module. */
|
||||
tab?: string;
|
||||
entityUri?: string;
|
||||
}
|
||||
|
||||
/** `undefined` means the parameter is absent; an empty string means it is present but blank. */
|
||||
export function parseOntologyUrlState(search: string): OntologyUrlState {
|
||||
const params = new URLSearchParams(search);
|
||||
return {
|
||||
tab: params.get(TAB_PARAM) ?? undefined,
|
||||
entityUri: params.get(ENTITY_PARAM) ?? undefined,
|
||||
};
|
||||
}
|
||||
|
||||
export function applyTab(search: string, tab: string): string {
|
||||
const params = new URLSearchParams(search);
|
||||
params.set(TAB_PARAM, tab);
|
||||
return `?${params.toString()}`;
|
||||
}
|
||||
|
||||
// A selected entity is only addressable from the editor, so the tab moves with it.
|
||||
export function applyEntitySelection(search: string, entityUri: string): string {
|
||||
const params = new URLSearchParams(search);
|
||||
params.set(TAB_PARAM, EDITOR_TAB);
|
||||
params.set(ENTITY_PARAM, entityUri);
|
||||
return `?${params.toString()}`;
|
||||
}
|
||||
|
||||
// Pairs with applyEntitySelection: an entity URI is resolved back to its owning ontology on
|
||||
// load, so leaving a stale one behind when the active ontology changes reopens the old ontology.
|
||||
export function removeEntitySelection(search: string): string {
|
||||
const params = new URLSearchParams(search);
|
||||
params.delete(ENTITY_PARAM);
|
||||
return `?${params.toString()}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Deliberately dual-role, and the argument is what selects the role: given a
|
||||
* `search` string this is pure and total, delegating straight to
|
||||
* `parseOntologyUrlState`; called with no argument it reads live `window`
|
||||
* state and yields empty state if the URL is unreadable. Callers in render or
|
||||
* effect paths use the no-argument form; tests and any caller that already
|
||||
* holds a search string pass it, which is the only form that is testable.
|
||||
*/
|
||||
export function readOntologyUrlState(search?: string): OntologyUrlState {
|
||||
if (search !== undefined) {
|
||||
return parseOntologyUrlState(search);
|
||||
}
|
||||
try {
|
||||
return parseOntologyUrlState(window.location.search);
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
/** True when the URL addresses the Ontology Hub at all, even with blank parameter values. */
|
||||
export function hasOntologyUrlState(search?: string): boolean {
|
||||
const { tab, entityUri } = readOntologyUrlState(search);
|
||||
return tab !== undefined || entityUri !== undefined;
|
||||
}
|
||||
|
||||
// The transform returns a query string only, so the fragment has to be carried
|
||||
// across explicitly: replaceState with a bare "?..." drops it. This is the one
|
||||
// place that knows how the URL is written, so it is the only place that can.
|
||||
function updateSearch(transform: (search: string) => string): void {
|
||||
try {
|
||||
window.history.replaceState(
|
||||
null,
|
||||
"",
|
||||
`${transform(window.location.search)}${window.location.hash}`,
|
||||
);
|
||||
} catch {
|
||||
// Deep-link state is a convenience; every caller stays correct without it.
|
||||
}
|
||||
}
|
||||
|
||||
export function writeTab(tab: string): void {
|
||||
updateSearch((search) => applyTab(search, tab));
|
||||
}
|
||||
|
||||
export function writeEntitySelection(entityUri: string): void {
|
||||
updateSearch((search) => applyEntitySelection(search, entityUri));
|
||||
}
|
||||
|
||||
export function clearEntitySelection(): void {
|
||||
updateSearch(removeEntitySelection);
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { spawn } from "node:child_process";
|
||||
import { existsSync } from "node:fs";
|
||||
import { setTimeout as delay } from "node:timers/promises";
|
||||
import test from "node:test";
|
||||
import { chromium, type Page } from "playwright";
|
||||
|
||||
const BASE_URL = "http://127.0.0.1:4175";
|
||||
const initialNodes = [
|
||||
{ id: "alice", type: "Person", content: "Alice", properties: {} },
|
||||
{ id: "bob", type: "Person", content: "Bob", properties: {} },
|
||||
{ id: "acme", type: "Organization", content: "Acme", properties: {} },
|
||||
{ id: "london", type: "Location", content: "London", properties: {} },
|
||||
{ id: "research", type: "Project", content: "Research", properties: {} },
|
||||
{ id: "report", type: "Document", content: "Report", properties: {} },
|
||||
];
|
||||
const edges = [
|
||||
["alice", "acme", "WORKS_AT"], ["bob", "acme", "WORKS_AT"],
|
||||
["acme", "london", "LOCATED_IN"], ["alice", "research", "LEADS"],
|
||||
["bob", "report", "AUTHORED"], ["report", "research", "DESCRIBES"],
|
||||
].map(([source, target, type], i) => ({
|
||||
id: `edge_${i}`, familyId: `edge_${i}`, source, target, type, weight: 1, properties: {},
|
||||
}));
|
||||
|
||||
async function assertLegendMatchesGraph(page: Page, nodeIds?: string[]) {
|
||||
const result = await page.evaluate(async (includedNodeIds) => {
|
||||
const storePath = "/src/store/graphStore.ts";
|
||||
const { graph } = await import(storePath);
|
||||
const colors: Record<string, string> = {};
|
||||
graph.forEachNode((id: string, attrs: { semanticGroup: string; baseColor: string }) => {
|
||||
if (includedNodeIds && !includedNodeIds.includes(id)) return;
|
||||
const hex = attrs.baseColor.replace("#", "");
|
||||
colors[attrs.semanticGroup] = `rgb(${[0, 2, 4].map((i) => parseInt(hex.slice(i, i + 2), 16)).join(", ")})`;
|
||||
});
|
||||
const items = [...document.querySelectorAll(".explore-color-legend-item")].map((item) => ({
|
||||
group: item.querySelector(".explore-color-legend-name")?.textContent,
|
||||
color: getComputedStyle(item.querySelector(".explore-color-legend-mark")!).backgroundColor,
|
||||
}));
|
||||
return { colors, items };
|
||||
}, nodeIds);
|
||||
assert.equal(result.items.length, Object.keys(result.colors).length);
|
||||
for (const item of result.items) {
|
||||
assert.equal(item.color, result.colors[item.group!], `Swatch for ${item.group} must match the loaded canvas color`);
|
||||
}
|
||||
}
|
||||
|
||||
test("visible legend follows loaded data, reloads, focused views, and distance mode", async (t) => {
|
||||
const server = spawn("npm", ["run", "dev", "--", "--host", "127.0.0.1", "--port", "4175", "--strictPort"], { stdio: "ignore" });
|
||||
t.after(() => { server.kill(); });
|
||||
let ready = false;
|
||||
for (let i = 0; i < 100; i += 1) {
|
||||
try { if ((await fetch(BASE_URL)).ok) { ready = true; break; } } catch { /* Starting Vite. */ }
|
||||
await delay(100);
|
||||
}
|
||||
assert.ok(ready, "Vite must start");
|
||||
const browser = await chromium.launch({
|
||||
headless: true,
|
||||
executablePath:
|
||||
process.env.CHROMIUM_PATH || (existsSync("/usr/bin/chromium") ? "/usr/bin/chromium" : undefined),
|
||||
});
|
||||
t.after(() => browser.close());
|
||||
const page = await browser.newPage({ viewport: { width: 1440, height: 1000 } });
|
||||
page.setDefaultTimeout(10_000);
|
||||
const errors: string[] = [];
|
||||
page.on("pageerror", (error) => errors.push(error.message));
|
||||
page.on("console", (message) => { if (message.type() === "error") errors.push(message.text()); });
|
||||
let nodes = initialNodes;
|
||||
await page.routeWebSocket("**/ws/graph-updates", () => {});
|
||||
await page.route("**/api/**", async (route) => {
|
||||
const path = new URL(route.request().url()).pathname;
|
||||
let json: unknown = {};
|
||||
if (path === "/api/info") json = { capabilities: { agent_memory: false } };
|
||||
if (path === "/api/graph/stats") json = { node_count: nodes.length, edge_count: edges.length };
|
||||
if (path === "/api/graph/nodes") json = { nodes, total: nodes.length, next_cursor: null };
|
||||
if (path === "/api/graph/edges") json = { edges, total: edges.length, next_cursor: null };
|
||||
if (path === "/api/temporal/bounds") json = { min: null, max: null };
|
||||
if (path === "/api/temporal/snapshot") json = { active_node_ids: nodes.map((n) => n.id), active_node_count: nodes.length };
|
||||
if (path === "/api/graph/search") json = { results: [{ node: nodes[0], score: 1 }] };
|
||||
await route.fulfill({ json });
|
||||
});
|
||||
await page.goto(BASE_URL);
|
||||
await page.getByRole("button", { name: "Open Semantica Explorer" }).click();
|
||||
const legend = page.getByRole("group", { name: "Node colors" });
|
||||
await legend.waitFor();
|
||||
await page.locator("canvas").first().waitFor({ state: "visible" });
|
||||
await assertLegendMatchesGraph(page);
|
||||
assert.equal(await legend.getByText("Person", { exact: true }).count(), 1);
|
||||
assert.equal(await legend.getByText("Biomolecule", { exact: true }).count(), 0);
|
||||
|
||||
nodes = initialNodes.map((node) => ({ ...node, type: node.type === "Person" ? "Researcher" : node.type }));
|
||||
await page.getByRole("button", { name: "Reload graph data" }).click();
|
||||
await legend.getByText("Researcher", { exact: true }).waitFor();
|
||||
assert.equal(await legend.getByText("Person", { exact: true }).count(), 0);
|
||||
await assertLegendMatchesGraph(page);
|
||||
|
||||
await page.getByPlaceholder("Search command, node, or concept").fill("Alice");
|
||||
await page.getByRole("option").filter({ hasText: "Alice" }).click();
|
||||
const heatmap = page.getByRole("button", { name: "Heatmap", exact: true });
|
||||
await heatmap.click();
|
||||
await legend.waitFor({ state: "hidden" });
|
||||
await heatmap.click();
|
||||
await legend.waitFor();
|
||||
await assertLegendMatchesGraph(page);
|
||||
const focusButton = page.getByRole("button", { name: "Focus", exact: true });
|
||||
assert.equal(await focusButton.isDisabled(), false, "Focus is enabled once a node is selected");
|
||||
await focusButton.click();
|
||||
await legend.getByText("Document", { exact: true }).waitFor({ state: "hidden" });
|
||||
await assertLegendMatchesGraph(page, ["alice", "acme", "research"]);
|
||||
assert.equal(await legend.getByText("Researcher", { exact: true }).count(), 1);
|
||||
await page.getByRole("button", { name: "Full Graph", exact: true }).click();
|
||||
await legend.getByText("Document", { exact: true }).waitFor();
|
||||
await assertLegendMatchesGraph(page);
|
||||
assert.deepEqual(errors, []);
|
||||
});
|
||||
@@ -0,0 +1,103 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import Graph from "graphology";
|
||||
|
||||
import { clearGraph, graph as sourceGraph, type NodeAttributes } from "../src/store/graphStore.ts";
|
||||
import { buildGraphColorLegend } from "../src/workspaces/GraphWorkspace/graphColorLegend.ts";
|
||||
import { resolveDisplayGraph, resolveNodeElementStyle } from "../src/workspaces/GraphWorkspace/graphSceneState.ts";
|
||||
import { GRAPH_THEME, withAlpha } from "../src/workspaces/GraphWorkspace/graphTheme.ts";
|
||||
|
||||
function attributes(overrides: Partial<NodeAttributes> = {}): NodeAttributes {
|
||||
return {
|
||||
label: "Example", content: "Example", x: 0, y: 0, size: 8,
|
||||
nodeType: "Person", semanticGroup: "Person", color: "#123456",
|
||||
properties: {}, ...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
test("legend matches normal canvas colors, including color and theme fallbacks", () => {
|
||||
const graph = new Graph();
|
||||
const samples = [
|
||||
attributes({ baseColor: "#abcdef" }),
|
||||
attributes({ semanticGroup: "Organization" }),
|
||||
attributes({ semanticGroup: "Location", color: "" }),
|
||||
];
|
||||
samples.forEach((attrs, i) => graph.addNode(String(i), attrs));
|
||||
const items = buildGraphColorLegend(graph);
|
||||
for (const attrs of samples) {
|
||||
const item = items.find((entry) => entry.group === attrs.semanticGroup)!;
|
||||
const style = resolveNodeElementStyle(GRAPH_THEME, "inspection", "default", attrs, attrs.label);
|
||||
assert.equal(style.color, withAlpha(item.color, GRAPH_THEME.nodes.entityShapes.entity.fillAlpha));
|
||||
}
|
||||
assert.equal(items.find((item) => item.group === "Person")?.color, "#abcdef");
|
||||
assert.equal(items.find((item) => item.group === "Organization")?.color, "#123456");
|
||||
assert.equal(items.find((item) => item.group === "Location")?.color, GRAPH_THEME.palette.semantic[0]);
|
||||
});
|
||||
|
||||
test("semantic groups, not shape categories, determine labels and distinct entries", () => {
|
||||
const graph = new Graph();
|
||||
graph.addNode("one", attributes({ semanticGroup: "Research", entityShape: "compound" }));
|
||||
graph.addNode("two", attributes({ semanticGroup: "Research", entityShape: "entity" }));
|
||||
graph.addNode("synthetic", attributes({ semanticGroup: "Research", baseColor: "#654321", isCommunityGroup: true }));
|
||||
graph.addNode("hidden", { ...attributes(), hidden: true });
|
||||
assert.deepEqual(buildGraphColorLegend(graph).map(({ group, color, count }) => ({ group, color, count })), [
|
||||
{ group: "Research", color: "#123456", count: 2 },
|
||||
{ group: "Research", color: "#654321", count: 1 },
|
||||
]);
|
||||
assert.equal(new Set(buildGraphColorLegend(graph).map((item) => item.id)).size, 2);
|
||||
});
|
||||
|
||||
test("legend rebuilds after in-place changes and uses only the supplied display graph", () => {
|
||||
const graph = new Graph();
|
||||
graph.addNode("one", attributes());
|
||||
graph.addNode("two", attributes({ semanticGroup: "Location" }));
|
||||
const first = buildGraphColorLegend(graph);
|
||||
graph.mergeNodeAttributes("one", { semanticGroup: "Project", baseColor: "#fedcba" });
|
||||
graph.dropNode("two");
|
||||
assert.equal(first.length, 2);
|
||||
assert.deepEqual(buildGraphColorLegend(graph).map(({ group, color }) => ({ group, color })), [
|
||||
{ group: "Project", color: "#fedcba" },
|
||||
]);
|
||||
graph.clear();
|
||||
assert.deepEqual(buildGraphColorLegend(graph), []);
|
||||
});
|
||||
|
||||
test("fallback labels and ordering are stable and no groups are silently dropped", () => {
|
||||
const graph = new Graph();
|
||||
graph.addNode("fallback", attributes({ semanticGroup: undefined, nodeType: "" }));
|
||||
for (let i = 11; i >= 0; i -= 1) graph.addNode(String(i), attributes({ semanticGroup: undefined, nodeType: `Type ${i}` }));
|
||||
const items = buildGraphColorLegend(graph);
|
||||
assert.equal(items.length, 13);
|
||||
assert.ok(items.some((item) => item.group === "entity"));
|
||||
const reverse = new Graph();
|
||||
graph.nodes().reverse().forEach((id) => reverse.addNode(id, graph.getNodeAttributes(id)));
|
||||
assert.deepEqual(items, buildGraphColorLegend(reverse));
|
||||
});
|
||||
|
||||
|
||||
test("focused legend keeps semantic colors for selected, path, and neighbor clones", (t) => {
|
||||
clearGraph();
|
||||
t.after(clearGraph);
|
||||
for (const id of ["selected", "path", "neighbor", "outside"]) {
|
||||
sourceGraph.addNode(id, attributes({ label: id, baseColor: "#abcdef" }));
|
||||
}
|
||||
sourceGraph.addDirectedEdgeWithKey("path-edge", "selected", "path", { weight: 1 });
|
||||
sourceGraph.addDirectedEdgeWithKey("neighbor-edge", "selected", "neighbor", { weight: 1 });
|
||||
const focused = resolveDisplayGraph("selected", ["selected", "path"], ["path-edge"], "focused").graph;
|
||||
|
||||
// Verify the fixture exercises baked interaction colors, not ordinary clones.
|
||||
assert.equal(focused.getNodeAttribute("selected", "baseColor"), GRAPH_THEME.palette.accent.selected);
|
||||
assert.equal(focused.getNodeAttribute("path", "baseColor"), GRAPH_THEME.palette.accent.path);
|
||||
assert.notEqual(focused.getNodeAttribute("neighbor", "baseColor"), "#abcdef");
|
||||
assert.ok(!focused.hasNode("outside"));
|
||||
assert.deepEqual(buildGraphColorLegend(focused).map(({ group, color, count }) => ({ group, color, count })), [
|
||||
{ group: "Person", color: "#abcdef", count: 3 },
|
||||
]);
|
||||
assert.equal(sourceGraph.getNodeAttribute("selected", "baseColor"), "#abcdef");
|
||||
|
||||
// Changing focus retains the semantic swatch while following the displayed subset.
|
||||
const nextFocus = resolveDisplayGraph("neighbor", [], [], "focused").graph;
|
||||
assert.deepEqual(buildGraphColorLegend(nextFocus).map(({ color, count }) => ({ color, count })), [
|
||||
{ color: "#abcdef", count: 2 },
|
||||
]);
|
||||
});
|
||||
@@ -286,3 +286,335 @@ test("copy button always starts in un-copied state on initial render", () => {
|
||||
assert.equal(html.includes("Copy"), true, "Copy button must be present on initial render");
|
||||
assert.equal(html.includes("Copied"), false, "Copied indicator must NOT be present on initial render");
|
||||
});
|
||||
|
||||
// ─── #1117: complete ARIA tab/tabpanel relationship ─────────────────────────
|
||||
// The tabs previously exposed role/aria-selected but never connected to the
|
||||
// panel, so assistive tech could not tell which content the tabs controlled.
|
||||
// These assertions read the rendered HTML, matching the aria-label precedent
|
||||
// used by the GFM footnote tests above.
|
||||
|
||||
/** Pull an attribute value out of the element carrying a given marker attribute. */
|
||||
function attrOf(html: string, elementMarker: string, attr: string): string | null {
|
||||
const idx = html.indexOf(elementMarker);
|
||||
if (idx === -1) return null;
|
||||
const tagStart = html.lastIndexOf("<", idx);
|
||||
const tag = html.slice(tagStart, html.indexOf(">", idx) + 1);
|
||||
const m = tag.match(new RegExp(`${attr}="([^"]*)"`));
|
||||
return m ? m[1] : null;
|
||||
}
|
||||
|
||||
test("each tab is wired to the panel and the panel back to the active tab", () => {
|
||||
const html = renderToString(React.createElement(MarkdownContentViewer, {
|
||||
content: "# Node\n\nBody text.",
|
||||
defaultMode: "preview",
|
||||
}));
|
||||
|
||||
const panelId = attrOf(html, 'role="tabpanel"', "id");
|
||||
assert.ok(panelId, "panel must carry an id");
|
||||
|
||||
// Both tabs must reference the panel that actually exists in the DOM.
|
||||
const controls = [...html.matchAll(/aria-controls="([^"]*)"/g)].map((m) => m[1]);
|
||||
assert.equal(controls.length, 2, "both tabs must declare aria-controls");
|
||||
for (const c of controls) {
|
||||
assert.equal(c, panelId, "aria-controls must resolve to the rendered panel");
|
||||
}
|
||||
|
||||
// The panel must be labelled by the *selected* tab.
|
||||
const labelledBy = attrOf(html, 'role="tabpanel"', "aria-labelledby");
|
||||
const selectedTabId = attrOf(html, 'aria-selected="true"', "id");
|
||||
assert.ok(selectedTabId, "selected tab must carry an id");
|
||||
assert.equal(labelledBy, selectedTabId, "panel must be labelled by the selected tab");
|
||||
});
|
||||
|
||||
test("panel labelling follows the active tab in source mode", () => {
|
||||
const html = renderToString(React.createElement(MarkdownContentViewer, {
|
||||
content: "# Node\n\nBody text.",
|
||||
defaultMode: "source",
|
||||
}));
|
||||
const labelledBy = attrOf(html, 'role="tabpanel"', "aria-labelledby");
|
||||
const selectedTabId = attrOf(html, 'aria-selected="true"', "id");
|
||||
// Assert both are present before comparing — otherwise null === null would
|
||||
// make this pass against a component with no tab wiring at all.
|
||||
assert.ok(labelledBy, "panel must declare aria-labelledby");
|
||||
assert.ok(selectedTabId, "selected tab must carry an id");
|
||||
assert.equal(labelledBy, selectedTabId);
|
||||
assert.equal(selectedTabId.endsWith("-tab-source"), true, "source tab must be the selected one");
|
||||
});
|
||||
|
||||
// The empty state is a third render branch. If the panel only existed on the two
|
||||
// content branches, aria-controls would dangle for empty nodes.
|
||||
test("tabpanel is still rendered, and aria-controls still resolves, when empty", () => {
|
||||
const html = renderToString(React.createElement(MarkdownContentViewer, { content: "" }));
|
||||
assert.equal(html.includes("No content available for this node."), true);
|
||||
const panelId = attrOf(html, 'role="tabpanel"', "id");
|
||||
assert.ok(panelId, "empty state must still render the tabpanel");
|
||||
const controls = [...html.matchAll(/aria-controls="([^"]*)"/g)].map((m) => m[1]);
|
||||
assert.equal(controls.length, 2);
|
||||
assert.deepEqual([...new Set(controls)], [panelId], "aria-controls must not dangle on the empty state");
|
||||
});
|
||||
|
||||
test("tablist is a single tab stop via roving tabindex", () => {
|
||||
const html = renderToString(React.createElement(MarkdownContentViewer, {
|
||||
content: "# Node",
|
||||
defaultMode: "preview",
|
||||
}));
|
||||
const tabIndexes = [...html.matchAll(/role="tab"[^>]*/g)].map((m) => m[0].match(/tabindex="(-?\d+)"/)?.[1]);
|
||||
assert.equal(tabIndexes.filter((t) => t === "0").length, 1, "exactly one tab may be reachable via Tab");
|
||||
assert.equal(tabIndexes.filter((t) => t === "-1").length, 1, "the other tab must be removed from tab order");
|
||||
});
|
||||
|
||||
test("ids are unique per instance so two mounted viewers cannot collide", () => {
|
||||
const one = renderToString(React.createElement(MarkdownContentViewer, { content: "# A" }));
|
||||
const two = renderToString(React.createElement(
|
||||
"div",
|
||||
null,
|
||||
React.createElement(MarkdownContentViewer, { content: "# A" }),
|
||||
React.createElement(MarkdownContentViewer, { content: "# B" }),
|
||||
));
|
||||
assert.ok(attrOf(one, 'role="tabpanel"', "id"));
|
||||
const panelIds = [...two.matchAll(/role="tabpanel" id="([^"]*)"/g)].map((m) => m[1]);
|
||||
assert.equal(panelIds.length, 2, "both viewers must render a panel");
|
||||
assert.notEqual(panelIds[0], panelIds[1], "panel ids must differ between instances");
|
||||
});
|
||||
|
||||
import { resolveTabNavigation } from "../src/workspaces/GraphWorkspace/MarkdownContentViewer.tsx";
|
||||
|
||||
// ─── #1117 / Qodo: roving-tabindex navigation logic ─────────────────────────
|
||||
// resolveTabNavigation is the pure function that drives handleTabKeyDown.
|
||||
// Testing it directly gives us coverage of the navigation contract without
|
||||
// needing a live DOM or synthetic keyboard events.
|
||||
|
||||
// ── ArrowRight moves focus forward, wraps at end ────────────────────────────
|
||||
test("ArrowRight from preview moves focus to source without wrapping", () => {
|
||||
assert.equal(resolveTabNavigation("preview", "ArrowRight"), "source");
|
||||
});
|
||||
|
||||
test("ArrowRight from source wraps back to preview", () => {
|
||||
// With only two tabs the rightmost tab wraps to the first.
|
||||
assert.equal(resolveTabNavigation("source", "ArrowRight"), "preview");
|
||||
});
|
||||
|
||||
// ── ArrowLeft moves focus backward, wraps at start ──────────────────────────
|
||||
test("ArrowLeft from source moves focus to preview without wrapping", () => {
|
||||
assert.equal(resolveTabNavigation("source", "ArrowLeft"), "preview");
|
||||
});
|
||||
|
||||
test("ArrowLeft from preview wraps back to source", () => {
|
||||
// The leftmost tab wraps to the last.
|
||||
assert.equal(resolveTabNavigation("preview", "ArrowLeft"), "source");
|
||||
});
|
||||
|
||||
// ── Home and End always resolve to the boundary tabs ────────────────────────
|
||||
test("Home always moves focus to the first tab (preview)", () => {
|
||||
assert.equal(resolveTabNavigation("preview", "Home"), "preview", "Home on first tab stays at first");
|
||||
assert.equal(resolveTabNavigation("source", "Home"), "preview", "Home on last tab jumps to first");
|
||||
});
|
||||
|
||||
test("End always moves focus to the last tab (source)", () => {
|
||||
assert.equal(resolveTabNavigation("source", "End"), "source", "End on last tab stays at last");
|
||||
assert.equal(resolveTabNavigation("preview", "End"), "source", "End on first tab jumps to last");
|
||||
});
|
||||
|
||||
// ── Non-navigation keys return null so the handler can bail out ─────────────
|
||||
test("non-navigation keys return null so keydown handler does not move focus", () => {
|
||||
for (const key of ["Enter", "Space", " ", "Tab", "Escape", "a", "F1"]) {
|
||||
assert.equal(
|
||||
resolveTabNavigation("preview", key),
|
||||
null,
|
||||
`key "${key}" must return null`,
|
||||
);
|
||||
assert.equal(
|
||||
resolveTabNavigation("source", key),
|
||||
null,
|
||||
`key "${key}" on source must return null`,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
// ── Arrow navigation does NOT change activeMode (manual activation) ──────────
|
||||
// resolveTabNavigation only returns the target for focus movement. The caller
|
||||
// (focusTab) imperatively updates tabIndex and moves DOM focus without calling
|
||||
// setActiveMode. We verify the contract: resolveTabNavigation never returns a
|
||||
// value that could be interpreted as "activate" — it just returns a tab identity.
|
||||
// The absence of a setActiveMode call in focusTab is what enforces manual
|
||||
// activation; these tests confirm the logic layer does not accidentally activate.
|
||||
test("resolveTabNavigation return value is purely a focus target, never an activation signal", () => {
|
||||
// A real activation calls setActiveMode. resolveTabNavigation just computes
|
||||
// the next focused tab. If the caller only updates focusedModeRef + DOM tabIndex,
|
||||
// activeMode remains unchanged. This test asserts the function's return contract.
|
||||
const result = resolveTabNavigation("preview", "ArrowRight");
|
||||
assert.equal(typeof result, "string", "returns a string tab name when key is a navigation key");
|
||||
assert.notEqual(result, null, "non-null means 'move focus here'");
|
||||
// The returned value is a valid tab mode, not a command to switch content.
|
||||
assert.ok(result === "preview" || result === "source");
|
||||
});
|
||||
|
||||
// ── Roving tabindex initial state for defaultMode='source' ──────────────────
|
||||
// The existing 'tablist is a single tab stop' test only checks defaultMode='preview'.
|
||||
// When the component starts in source mode the source tab must start at tabIndex 0.
|
||||
test("roving tabindex initial state is correct when defaultMode is source", () => {
|
||||
const html = renderToString(React.createElement(MarkdownContentViewer, {
|
||||
content: "# Node",
|
||||
defaultMode: "source",
|
||||
}));
|
||||
const tabIndexes = [...html.matchAll(/role="tab"[^>]*/g)].map(
|
||||
(m) => m[0].match(/tabindex="(-?\d+)"/)?.[1],
|
||||
);
|
||||
// There are exactly two tabs; one must be 0, the other -1.
|
||||
assert.equal(tabIndexes.filter((t) => t === "0").length, 1, "exactly one tab is reachable via Tab");
|
||||
assert.equal(tabIndexes.filter((t) => t === "-1").length, 1, "the other tab is removed from tab order");
|
||||
|
||||
// The source tab specifically must hold tabIndex 0 (it is the focused/active one).
|
||||
// We identify the source tab by its id suffix and verify its tabindex.
|
||||
const sourceTabMatch = [...html.matchAll(/role="tab"[^>]*/g)].find((m) =>
|
||||
m[0].includes("-tab-source"),
|
||||
);
|
||||
assert.ok(sourceTabMatch, "source tab must be present in rendered HTML");
|
||||
assert.equal(
|
||||
sourceTabMatch[0].match(/tabindex="(-?\d+)"/)?.[1],
|
||||
"0",
|
||||
"source tab must have tabIndex 0 when defaultMode is source",
|
||||
);
|
||||
});
|
||||
|
||||
// ── aria-labelledby correctness for each defaultMode ────────────────────────
|
||||
// These tests verify the static wiring; the existing tests cover preview and
|
||||
// source modes, so these act as a consolidated regression check that both
|
||||
// directions of the panel labelling contract hold after the refactor.
|
||||
test("panel aria-labelledby matches the selected tab in preview mode after refactor", () => {
|
||||
const html = renderToString(React.createElement(MarkdownContentViewer, {
|
||||
content: "# Refactor check",
|
||||
defaultMode: "preview",
|
||||
}));
|
||||
const labelledBy = attrOf(html, 'role="tabpanel"', "aria-labelledby");
|
||||
const selectedTabId = attrOf(html, 'aria-selected="true"', "id");
|
||||
assert.ok(labelledBy, "panel must carry aria-labelledby after refactor");
|
||||
assert.ok(selectedTabId, "a tab must be aria-selected=true after refactor");
|
||||
assert.equal(labelledBy, selectedTabId, "panel must be labelled by the selected tab");
|
||||
assert.ok(selectedTabId.endsWith("-tab-preview"), "preview tab must be selected");
|
||||
});
|
||||
|
||||
test("panel aria-labelledby matches the selected tab in source mode after refactor", () => {
|
||||
const html = renderToString(React.createElement(MarkdownContentViewer, {
|
||||
content: "# Refactor check",
|
||||
defaultMode: "source",
|
||||
}));
|
||||
const labelledBy = attrOf(html, 'role="tabpanel"', "aria-labelledby");
|
||||
const selectedTabId = attrOf(html, 'aria-selected="true"', "id");
|
||||
assert.ok(labelledBy, "panel must carry aria-labelledby after refactor");
|
||||
assert.ok(selectedTabId, "a tab must be aria-selected=true after refactor");
|
||||
assert.equal(labelledBy, selectedTabId, "panel must be labelled by the selected tab");
|
||||
assert.ok(selectedTabId.endsWith("-tab-source"), "source tab must be selected");
|
||||
});
|
||||
|
||||
// ── Qodo performance regression: focusedModeRef is a ref, not state ──────────
|
||||
// The confirmed bug was: setFocusedMode (useState setter) caused a re-render
|
||||
// on every arrow keypress, which triggered react-markdown's full parse+runSync
|
||||
// cycle even though activeMode did not change.
|
||||
//
|
||||
// The fix uses useRef instead of useState for the focused-mode tracking. Refs
|
||||
// do not schedule re-renders when mutated. We cannot directly count React
|
||||
// renders inside renderToString (it runs synchronously, once). What we CAN
|
||||
// verify is the structural invariant that makes the fix work:
|
||||
//
|
||||
// 1. The component renders identically for the same props on successive
|
||||
// renderToString calls (no hidden state that would differ if focusedMode
|
||||
// were state vs ref — both start at defaultMode on fresh mount).
|
||||
// 2. The tabIndex JSX prop reads from focusedModeRef.current which equals
|
||||
// defaultMode on initial render. This is the same output the old code
|
||||
// produced, so no regression in SSR output.
|
||||
//
|
||||
// Full verification of "arrow key press does NOT trigger ReactMarkdown.parse()"
|
||||
// requires a live DOM + render-count instrumentation. That test belongs in an
|
||||
// interactive framework (Playwright component test or jsdom + Testing Library)
|
||||
// which is not installed in this project. The structural guarantee provided by
|
||||
// the ref-based implementation is documented here for that future test to pin.
|
||||
test("successive renderToString calls produce identical tabIndex output (ref parity with state)", () => {
|
||||
const props = { content: "# Perf node\n\n" + "row. ".repeat(200), defaultMode: "preview" as const };
|
||||
const first = renderToString(React.createElement(MarkdownContentViewer, props));
|
||||
const second = renderToString(React.createElement(MarkdownContentViewer, props));
|
||||
// Both renders start with a fresh ref initialised to defaultMode, so output
|
||||
// must be byte-for-byte identical (modulo React's useId counter which advances
|
||||
// per call — we compare structure, not the specific id values).
|
||||
const extractTabIndexes = (html: string) =>
|
||||
[...html.matchAll(/role="tab"[^>]*/g)].map((m) => m[0].match(/tabindex="(-?\d+)"/)?.[1]);
|
||||
assert.deepEqual(
|
||||
extractTabIndexes(first),
|
||||
extractTabIndexes(second),
|
||||
"tabIndex values must be the same on every fresh mount with the same defaultMode",
|
||||
);
|
||||
// Verify content is actually rendered (not an empty-state shortcut).
|
||||
assert.ok(first.includes("Perf node"), "markdown content must be rendered");
|
||||
});
|
||||
|
||||
// ── Regression guard: tabIndex-reset-on-re-render (useLayoutEffect fix) ──────
|
||||
//
|
||||
// The adversarial review identified a concrete bug: after ArrowRight moves focus
|
||||
// to Source while Preview remains selected (activeMode='preview'), any subsequent
|
||||
// React re-render applied JSX tabIndex={activeMode === X} and overwrote the
|
||||
// imperative tabIndex values set by focusTab(), reverting focus tracking to the
|
||||
// selection state.
|
||||
//
|
||||
// Fix: useLayoutEffect(() => { ... }) with no deps array, which runs after every
|
||||
// React render and restores focusedModeRef.current to the DOM before paint.
|
||||
//
|
||||
// WHY THIS CANNOT BE TESTED WITH renderToString:
|
||||
// The fix is a client-side DOM mutation applied by useLayoutEffect. On the
|
||||
// server, useLayoutEffect is silently skipped (React design: effects do not run
|
||||
// during SSR). renderToString produces only the initial HTML, which correctly
|
||||
// reflects activeMode === focusedModeRef.current at mount time. It cannot
|
||||
// simulate: (a) a keydown event that calls focusTab(), (b) a subsequent
|
||||
// state-update re-render, or (c) the useLayoutEffect correction after that
|
||||
// render. The full sequence requires a live DOM with React hydrated and event
|
||||
// dispatch — either jsdom + React Testing Library, or Playwright component
|
||||
// tests. Neither is installed in this project.
|
||||
//
|
||||
// WHAT WE CAN VERIFY (SSR-compatible proxies):
|
||||
// 1. The fix is mechanical: useLayoutEffect reads focusedModeRef.current and
|
||||
// writes it unconditionally to the DOM. The only way it fails is if:
|
||||
// (a) focusedModeRef.current is wrong — covered by the navigation logic tests.
|
||||
// (b) useLayoutEffect is not called — impossible if it is in the component body
|
||||
// unconditionally.
|
||||
// (c) The ref assignment in focusTab() is skipped — covered by the imperative
|
||||
// DOM update tests (focusTab sets the ref before calling .focus()).
|
||||
// 2. We verify the structural guarantee: on initial render focusedModeRef.current
|
||||
// equals defaultMode, so JSX and useLayoutEffect agree, and no visible change
|
||||
// occurs. This is the only SSR-observable aspect of the fix.
|
||||
//
|
||||
// TRACKING: Add a jsdom/Playwright test for the full sequence as a follow-up.
|
||||
// The specific scenario to pin:
|
||||
// Preview selected → focusTab('source') → re-render (e.g. setCopied) →
|
||||
// useLayoutEffect runs → sourceTab.tabIndex === 0 AND previewTab.tabIndex === -1.
|
||||
|
||||
test("tabIndex regression (SSR proxy): initial focusedModeRef matches defaultMode so JSX and useLayoutEffect agree on mount", () => {
|
||||
// On initial mount focusedModeRef.current = defaultMode and activeMode = defaultMode,
|
||||
// so both the JSX tabIndex expression and the useLayoutEffect correction write
|
||||
// identical values. There is no visible disagreement at first render.
|
||||
// This confirms the static foundation the fix relies on.
|
||||
for (const mode of ["preview", "source"] as const) {
|
||||
const html = renderToString(React.createElement(MarkdownContentViewer, {
|
||||
content: "# Node",
|
||||
defaultMode: mode,
|
||||
}));
|
||||
const tabs = [...html.matchAll(/role="tab"[^>]*/g)];
|
||||
assert.equal(tabs.length, 2, `${mode}: both tab buttons must be present`);
|
||||
|
||||
const focusedTab = tabs.find((m) => m[0].includes(`-tab-${mode}`));
|
||||
const otherTab = tabs.find((m) => !m[0].includes(`-tab-${mode}`));
|
||||
assert.ok(focusedTab, `${mode}: the ${mode} tab must be present`);
|
||||
assert.ok(otherTab, `${mode}: the other tab must be present`);
|
||||
|
||||
// The tab matching defaultMode must have tabIndex=0 (focused/active at mount).
|
||||
assert.equal(
|
||||
focusedTab[0].match(/tabindex="(-?\d+)"/)?.[1],
|
||||
"0",
|
||||
`${mode}: ${mode} tab must start as the single Tab stop`,
|
||||
);
|
||||
// The other tab must have tabIndex=-1 (removed from tab order at mount).
|
||||
assert.equal(
|
||||
otherTab[0].match(/tabindex="(-?\d+)"/)?.[1],
|
||||
"-1",
|
||||
`${mode}: the other tab must be removed from tab order at mount`,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
compactNodeType,
|
||||
inferOntologyUri,
|
||||
isEditableEntityType,
|
||||
resolveEditorOntology,
|
||||
ONTOLOGY_MINIMAP_THEME,
|
||||
} from "../src/workspaces/OntologyWorkspace/ontologyEditorModel";
|
||||
|
||||
@@ -29,6 +30,20 @@ test("explicit scheme ownership wins when an entity uses another namespace", ()
|
||||
);
|
||||
});
|
||||
|
||||
test("an explicit owner missing from the registry is used, not quietly replaced", () => {
|
||||
// The entity sits under a registered namespace, so the guess has an answer
|
||||
// ready; the backend naming a different, unregistered owner must still win,
|
||||
// or the editor opens an ontology nobody said owned this entity.
|
||||
assert.equal(
|
||||
inferOntologyUri(
|
||||
registry,
|
||||
"https://example.test/foo#Class",
|
||||
"https://unregistered.test/vocab",
|
||||
),
|
||||
"https://unregistered.test/vocab",
|
||||
);
|
||||
});
|
||||
|
||||
test("only draft-supported class and property nodes are editable", () => {
|
||||
assert.equal(isEditableEntityType("class"), true);
|
||||
assert.equal(isEditableEntityType("property"), true);
|
||||
@@ -64,3 +79,36 @@ 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");
|
||||
});
|
||||
|
||||
test("an authoritative no-owner verdict suppresses the namespace guess", () => {
|
||||
// Without suppression the prefix guess would pick the registered parent
|
||||
// for an unregistered nested entity — the deep link must not do that.
|
||||
const nested = "https://example.test/foo/unregistered#Term";
|
||||
assert.deepEqual(resolveEditorOntology(registry, nested, null), {
|
||||
status: "unowned",
|
||||
entityUri: nested,
|
||||
});
|
||||
// An unavailable verdict may still fall back to inference
|
||||
assert.deepEqual(
|
||||
resolveEditorOntology(registry, "https://example.test/foo#Class", undefined),
|
||||
{ status: "resolved", uri: "https://example.test/foo" },
|
||||
);
|
||||
// A named owner wins outright
|
||||
assert.deepEqual(
|
||||
resolveEditorOntology(registry, nested, "https://example.test/foo/nested"),
|
||||
{ status: "resolved", uri: "https://example.test/foo/nested" },
|
||||
);
|
||||
});
|
||||
|
||||
test("unowned is distinguishable from unresolved, so neither collapses to a default", () => {
|
||||
// Both mean "no ontology to open", and the editor treats them oppositely:
|
||||
// unresolved may land on the registry default, unowned must not. A caller
|
||||
// writing `resolve(...) || entries[0]` reintroduced exactly the parent
|
||||
// selection this verdict exists to prevent, so the difference is typed.
|
||||
const unowned = resolveEditorOntology(registry, "https://example.test/foo/x#T", null);
|
||||
const unresolved = resolveEditorOntology(registry, "https://elsewhere.test/T", undefined);
|
||||
|
||||
assert.equal(unowned.status, "unowned");
|
||||
assert.equal(unresolved.status, "unresolved");
|
||||
assert.notEqual(unowned.status, unresolved.status);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import {
|
||||
applyEntitySelection,
|
||||
applyTab,
|
||||
clearEntitySelection,
|
||||
hasOntologyUrlState,
|
||||
parseOntologyUrlState,
|
||||
readOntologyUrlState,
|
||||
removeEntitySelection,
|
||||
writeEntitySelection,
|
||||
writeTab,
|
||||
} from "../src/workspaces/OntologyWorkspace/ontologyUrlState";
|
||||
|
||||
function withStubbedLocation(search: string, hash: string, body: () => void): string[] {
|
||||
const written: string[] = [];
|
||||
const original = (globalThis as { window?: unknown }).window;
|
||||
(globalThis as { window?: unknown }).window = {
|
||||
location: { search, hash },
|
||||
history: { replaceState: (_s: unknown, _t: string, url: string) => written.push(url) },
|
||||
};
|
||||
try {
|
||||
body();
|
||||
} finally {
|
||||
(globalThis as { window?: unknown }).window = original;
|
||||
}
|
||||
return written;
|
||||
}
|
||||
|
||||
test("selecting an entity round-trips and pins the editor tab", () => {
|
||||
const search = applyEntitySelection("", "https://example.test/foo#Bar");
|
||||
assert.deepEqual(parseOntologyUrlState(search), {
|
||||
tab: "editor",
|
||||
entityUri: "https://example.test/foo#Bar",
|
||||
});
|
||||
});
|
||||
|
||||
test("clearing the selection drops only the entity and keeps unrelated params", () => {
|
||||
const search = applyEntitySelection("?view=graph&depth=2", "https://example.test/foo#Bar");
|
||||
const cleared = parseOntologyUrlState(removeEntitySelection(search));
|
||||
|
||||
assert.equal(cleared.entityUri, undefined);
|
||||
assert.equal(cleared.tab, "editor");
|
||||
assert.equal(new URLSearchParams(removeEntitySelection(search)).get("depth"), "2");
|
||||
});
|
||||
|
||||
test("writing a tab leaves an existing entity selection alone", () => {
|
||||
const search = applyTab(applyEntitySelection("", "urn:x"), "health");
|
||||
assert.deepEqual(parseOntologyUrlState(search), { tab: "health", entityUri: "urn:x" });
|
||||
});
|
||||
|
||||
test("absent params read as undefined, blank params as empty strings", () => {
|
||||
assert.deepEqual(parseOntologyUrlState(""), { tab: undefined, entityUri: undefined });
|
||||
assert.deepEqual(parseOntologyUrlState("?other=1"), { tab: undefined, entityUri: undefined });
|
||||
assert.deepEqual(parseOntologyUrlState("?ontologyTab=&ontologyEntity="), {
|
||||
tab: "",
|
||||
entityUri: "",
|
||||
});
|
||||
});
|
||||
|
||||
test("a present but blank param still counts as ontology deep-link state", () => {
|
||||
assert.equal(hasOntologyUrlState("?ontologyEntity="), true);
|
||||
assert.equal(hasOntologyUrlState("?ontologyTab="), true);
|
||||
assert.equal(hasOntologyUrlState("?view=graph"), false);
|
||||
assert.equal(hasOntologyUrlState(""), false);
|
||||
});
|
||||
|
||||
test("malformed search strings degrade to plain values instead of throwing", () => {
|
||||
assert.deepEqual(parseOntologyUrlState("???"), { tab: undefined, entityUri: undefined });
|
||||
assert.deepEqual(parseOntologyUrlState("ontologyEntity=urn%3Ax&&=&"), {
|
||||
tab: undefined,
|
||||
entityUri: "urn:x",
|
||||
});
|
||||
});
|
||||
|
||||
test("entity URIs survive characters that need escaping", () => {
|
||||
const entityUri = "https://example.test/vocab#Has Part/&?=";
|
||||
const search = applyEntitySelection("?keep=1", entityUri);
|
||||
assert.equal(parseOntologyUrlState(search).entityUri, entityUri);
|
||||
});
|
||||
|
||||
test("every writer preserves the URL fragment", () => {
|
||||
const written = withStubbedLocation("?view=graph", "#section-3", () => {
|
||||
writeTab("health");
|
||||
writeEntitySelection("urn:x");
|
||||
clearEntitySelection();
|
||||
});
|
||||
|
||||
assert.deepEqual(written, [
|
||||
"?view=graph&ontologyTab=health#section-3",
|
||||
"?view=graph&ontologyTab=editor&ontologyEntity=urn%3Ax#section-3",
|
||||
"?view=graph#section-3",
|
||||
]);
|
||||
});
|
||||
|
||||
test("readOntologyUrlState with no argument reads live URL state", () => {
|
||||
withStubbedLocation("?ontologyTab=editor&ontologyEntity=urn%3Ax", "", () => {
|
||||
assert.deepEqual(readOntologyUrlState(), { tab: "editor", entityUri: "urn:x" });
|
||||
assert.equal(hasOntologyUrlState(), true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,113 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import {
|
||||
DEFAULT_MIN_DATE,
|
||||
resolvePlayStepMs,
|
||||
resolveScrubberBounds,
|
||||
} from "../src/workspaces/GraphWorkspace/temporalScrubberBounds.ts";
|
||||
|
||||
const NOW = new Date("2026-09-09T10:30:00Z");
|
||||
|
||||
// ── resolveScrubberBounds ────────────────────────────────────────────────────
|
||||
|
||||
test("scrubber bounds: open max ends the window at now, not at a future year", () => {
|
||||
const { minBound, maxBound } = resolveScrubberBounds({ minDate: "2026-01-15T00:00:00Z", now: NOW });
|
||||
|
||||
assert.equal(minBound.toISOString(), "2026-01-15T00:00:00.000Z");
|
||||
assert.equal(
|
||||
maxBound.getTime(),
|
||||
NOW.getTime(),
|
||||
"a graph carrying only valid_from instants is known up to the present and no further",
|
||||
);
|
||||
});
|
||||
|
||||
test("scrubber bounds: playhead starts at now so the first snapshot describes the present", () => {
|
||||
const { defaultTime } = resolveScrubberBounds({ minDate: "2026-01-15T00:00:00Z", now: NOW });
|
||||
|
||||
assert.equal(defaultTime.getTime(), NOW.getTime());
|
||||
});
|
||||
|
||||
test("scrubber bounds: playhead is not the midpoint of the range", () => {
|
||||
const { minBound, maxBound, defaultTime } = resolveScrubberBounds({
|
||||
minDate: "2020-01-01T00:00:00Z",
|
||||
maxDate: "2030-01-01T00:00:00Z",
|
||||
now: NOW,
|
||||
});
|
||||
const midpoint = Math.round((minBound.getTime() + maxBound.getTime()) / 2);
|
||||
|
||||
assert.notEqual(defaultTime.getTime(), midpoint, "the midpoint was the source of the future start time");
|
||||
assert.equal(defaultTime.getTime(), NOW.getTime());
|
||||
});
|
||||
|
||||
test("scrubber bounds: reported max is honoured when the data supplies one", () => {
|
||||
const { maxBound } = resolveScrubberBounds({
|
||||
minDate: "2020-01-01T00:00:00Z",
|
||||
maxDate: "2030-06-01T00:00:00Z",
|
||||
now: NOW,
|
||||
});
|
||||
|
||||
assert.equal(maxBound.toISOString(), "2030-06-01T00:00:00.000Z");
|
||||
});
|
||||
|
||||
test("scrubber bounds: playhead clamps into a range that ends before now", () => {
|
||||
const { maxBound, defaultTime } = resolveScrubberBounds({
|
||||
minDate: "2019-01-01T00:00:00Z",
|
||||
maxDate: "2020-01-01T00:00:00Z",
|
||||
now: NOW,
|
||||
});
|
||||
|
||||
assert.equal(defaultTime.getTime(), maxBound.getTime());
|
||||
});
|
||||
|
||||
test("scrubber bounds: playhead clamps into a range that starts after now", () => {
|
||||
const { minBound, defaultTime } = resolveScrubberBounds({
|
||||
minDate: "2030-01-01T00:00:00Z",
|
||||
maxDate: "2031-01-01T00:00:00Z",
|
||||
now: NOW,
|
||||
});
|
||||
|
||||
assert.equal(defaultTime.getTime(), minBound.getTime());
|
||||
});
|
||||
|
||||
test("scrubber bounds: min ahead of an open max keeps the window ordered", () => {
|
||||
const { minBound, maxBound } = resolveScrubberBounds({ minDate: "2031-01-01T00:00:00Z", now: NOW });
|
||||
|
||||
assert.ok(maxBound >= minBound, "vis-timeline requires min <= max");
|
||||
});
|
||||
|
||||
test("scrubber bounds: malformed and missing dates fall back without producing Invalid Date", () => {
|
||||
const { minBound, maxBound } = resolveScrubberBounds({ minDate: "not-a-date", maxDate: "also-bad", now: NOW });
|
||||
|
||||
assert.equal(minBound.getTime(), DEFAULT_MIN_DATE.getTime());
|
||||
assert.equal(maxBound.getTime(), NOW.getTime());
|
||||
});
|
||||
|
||||
// ── resolvePlayStepMs ────────────────────────────────────────────────────────
|
||||
|
||||
test("play step: a one-year span advances in ~60 frames, not 2", () => {
|
||||
const minBound = new Date("2026-01-01T00:00:00Z");
|
||||
const maxBound = new Date("2027-01-01T00:00:00Z");
|
||||
const span = maxBound.getTime() - minBound.getTime();
|
||||
|
||||
const frames = span / resolvePlayStepMs(minBound, maxBound);
|
||||
|
||||
assert.ok(frames > 50 && frames < 70, `expected ~60 frames, got ${frames}`);
|
||||
});
|
||||
|
||||
test("play step: a decade-long span also advances in ~60 frames", () => {
|
||||
const minBound = new Date("2016-01-01T00:00:00Z");
|
||||
const maxBound = new Date("2026-01-01T00:00:00Z");
|
||||
const span = maxBound.getTime() - minBound.getTime();
|
||||
|
||||
const frames = span / resolvePlayStepMs(minBound, maxBound);
|
||||
|
||||
assert.ok(frames > 50 && frames < 70, `expected ~60 frames, got ${frames}`);
|
||||
});
|
||||
|
||||
test("play step: a span of hours still advances by at least a day", () => {
|
||||
const minBound = new Date("2026-09-09T00:00:00Z");
|
||||
const maxBound = new Date("2026-09-09T06:00:00Z");
|
||||
|
||||
assert.equal(resolvePlayStepMs(minBound, maxBound), 1000 * 60 * 60 * 24);
|
||||
});
|
||||
@@ -21,6 +21,7 @@ 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:
|
||||
@@ -33,7 +34,7 @@ try:
|
||||
except ImportError:
|
||||
from google.adk.sessions.base_session_service import GetSessionConfig
|
||||
ADK_AVAILABLE = True
|
||||
except (ImportError, ModuleNotFoundError):
|
||||
except (ImportError, OSError):
|
||||
ADK_AVAILABLE = False
|
||||
BaseSessionService = object
|
||||
Session = Any
|
||||
@@ -162,10 +163,10 @@ class SemanticaSessionService(BaseSessionService):
|
||||
return {}
|
||||
|
||||
def _find_session_node(
|
||||
self,
|
||||
app_name: str,
|
||||
user_id: str,
|
||||
session_id: str,
|
||||
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)
|
||||
@@ -182,18 +183,18 @@ class SemanticaSessionService(BaseSessionService):
|
||||
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)
|
||||
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,
|
||||
self,
|
||||
node_id: str,
|
||||
) -> Optional[Any]:
|
||||
"""Find a ContextGraph node by graph node ID."""
|
||||
for node in self.graph.find_nodes() or []:
|
||||
@@ -212,13 +213,13 @@ class SemanticaSessionService(BaseSessionService):
|
||||
data = SemanticaSessionService._safe_dict(event)
|
||||
|
||||
for field in (
|
||||
"id",
|
||||
"invocation_id",
|
||||
"author",
|
||||
"timestamp",
|
||||
"partial",
|
||||
"turn_complete",
|
||||
"branch",
|
||||
"id",
|
||||
"invocation_id",
|
||||
"author",
|
||||
"timestamp",
|
||||
"partial",
|
||||
"turn_complete",
|
||||
"branch",
|
||||
):
|
||||
if field not in data and hasattr(event, field):
|
||||
value = getattr(event, field)
|
||||
@@ -231,10 +232,10 @@ class SemanticaSessionService(BaseSessionService):
|
||||
return data
|
||||
|
||||
def _event_nodes(
|
||||
self,
|
||||
app_name: str,
|
||||
user_id: str,
|
||||
session_id: str,
|
||||
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)
|
||||
@@ -275,8 +276,8 @@ class SemanticaSessionService(BaseSessionService):
|
||||
return str(timestamp)
|
||||
|
||||
def _event_from_node(
|
||||
self,
|
||||
node: Any,
|
||||
self,
|
||||
node: Any,
|
||||
) -> Any:
|
||||
"""
|
||||
Reconstruct an ADK Event from its stored metadata.
|
||||
@@ -289,7 +290,7 @@ class SemanticaSessionService(BaseSessionService):
|
||||
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:"):]
|
||||
event_id = graph_node_id[len("adk-event:") :]
|
||||
|
||||
if event_id:
|
||||
properties["id"] = event_id
|
||||
@@ -310,11 +311,11 @@ class SemanticaSessionService(BaseSessionService):
|
||||
|
||||
@staticmethod
|
||||
def _session_kwargs(
|
||||
app_name: str,
|
||||
user_id: str,
|
||||
session_id: str,
|
||||
state: Optional[dict],
|
||||
events: Optional[List[Any]],
|
||||
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 {
|
||||
@@ -326,8 +327,8 @@ class SemanticaSessionService(BaseSessionService):
|
||||
}
|
||||
|
||||
def _session_from_node(
|
||||
self,
|
||||
node: Any,
|
||||
self,
|
||||
node: Any,
|
||||
) -> Session:
|
||||
"""Reconstruct an ADK Session from a ContextGraph node."""
|
||||
properties = self._node_properties(node)
|
||||
@@ -347,14 +348,14 @@ class SemanticaSessionService(BaseSessionService):
|
||||
# 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(":")
|
||||
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:"):]
|
||||
session_id = graph_node_id[len("adk-session:") :]
|
||||
else:
|
||||
session_id = graph_node_id
|
||||
|
||||
@@ -383,12 +384,12 @@ class SemanticaSessionService(BaseSessionService):
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
async def create_session(
|
||||
self,
|
||||
*,
|
||||
app_name: str,
|
||||
user_id: str,
|
||||
state: Optional[dict[str, Any]] = None,
|
||||
session_id: Optional[str] = None,
|
||||
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(
|
||||
@@ -396,11 +397,11 @@ class SemanticaSessionService(BaseSessionService):
|
||||
)
|
||||
|
||||
def _create_session_sync(
|
||||
self,
|
||||
app_name: str,
|
||||
user_id: str,
|
||||
state: Optional[dict[str, Any]],
|
||||
session_id: Optional[str],
|
||||
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())
|
||||
@@ -430,12 +431,12 @@ class SemanticaSessionService(BaseSessionService):
|
||||
)
|
||||
|
||||
async def get_session(
|
||||
self,
|
||||
*,
|
||||
app_name: str,
|
||||
user_id: str,
|
||||
session_id: str,
|
||||
config: Optional[GetSessionConfig] = None,
|
||||
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(
|
||||
@@ -443,11 +444,11 @@ class SemanticaSessionService(BaseSessionService):
|
||||
)
|
||||
|
||||
def _get_session_sync(
|
||||
self,
|
||||
app_name: str,
|
||||
user_id: str,
|
||||
session_id: str,
|
||||
config: Optional[GetSessionConfig],
|
||||
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)
|
||||
@@ -468,7 +469,7 @@ class SemanticaSessionService(BaseSessionService):
|
||||
# trims the already-built Session object.
|
||||
if config:
|
||||
if config.num_recent_events:
|
||||
session.events = session.events[-config.num_recent_events:]
|
||||
session.events = session.events[-config.num_recent_events :]
|
||||
if config.after_timestamp:
|
||||
i = len(session.events) - 1
|
||||
while i >= 0:
|
||||
@@ -476,14 +477,14 @@ class SemanticaSessionService(BaseSessionService):
|
||||
break
|
||||
i -= 1
|
||||
if i >= 0:
|
||||
session.events = session.events[i + 1:]
|
||||
session.events = session.events[i + 1 :]
|
||||
|
||||
return session
|
||||
|
||||
async def append_event(
|
||||
self,
|
||||
session: Session,
|
||||
event: 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
|
||||
@@ -510,8 +511,13 @@ class SemanticaSessionService(BaseSessionService):
|
||||
|
||||
# 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.")
|
||||
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
|
||||
@@ -553,11 +559,11 @@ class SemanticaSessionService(BaseSessionService):
|
||||
)
|
||||
|
||||
async def delete_session(
|
||||
self,
|
||||
*,
|
||||
app_name: str,
|
||||
user_id: str,
|
||||
session_id: str,
|
||||
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(
|
||||
@@ -565,10 +571,10 @@ class SemanticaSessionService(BaseSessionService):
|
||||
)
|
||||
|
||||
def _delete_session_sync(
|
||||
self,
|
||||
app_name: str,
|
||||
user_id: str,
|
||||
session_id: str,
|
||||
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)
|
||||
@@ -590,9 +596,9 @@ class SemanticaSessionService(BaseSessionService):
|
||||
continue
|
||||
|
||||
if (
|
||||
edge.get("source") == session_node_id
|
||||
and edge.get("type") == "HAS_EVENT"
|
||||
and edge.get("target")
|
||||
edge.get("source") == session_node_id
|
||||
and edge.get("type") == "HAS_EVENT"
|
||||
and edge.get("target")
|
||||
):
|
||||
event_node_ids.append(str(edge["target"]))
|
||||
|
||||
@@ -602,18 +608,18 @@ class SemanticaSessionService(BaseSessionService):
|
||||
self.graph.purge_node(session_node_id)
|
||||
|
||||
async def list_sessions(
|
||||
self,
|
||||
*,
|
||||
app_name: str,
|
||||
user_id: Optional[str] = None,
|
||||
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],
|
||||
self,
|
||||
app_name: str,
|
||||
user_id: Optional[str],
|
||||
) -> ListSessionsResponse:
|
||||
with self._lock:
|
||||
sessions: List[Session] = []
|
||||
@@ -643,4 +649,4 @@ class SemanticaSessionService(BaseSessionService):
|
||||
__all__ = [
|
||||
"ADK_AVAILABLE",
|
||||
"SemanticaSessionService",
|
||||
]
|
||||
]
|
||||
|
||||
@@ -39,7 +39,7 @@ python -m semantica.mcp_server
|
||||
openclaw gateway restart
|
||||
```
|
||||
|
||||
All **12 Semantica tools** are now available to any OpenClaw agent:
|
||||
All **15 Semantica tools** are now available to any OpenClaw agent:
|
||||
|
||||
| Tool | What it does |
|
||||
|---|---|
|
||||
@@ -55,6 +55,9 @@ All **12 Semantica tools** are now available to any OpenClaw agent:
|
||||
| `get_graph_analytics` | Centrality, communities, topology stats |
|
||||
| `export_graph` | Export graph (JSON, RDF, GraphML, …) |
|
||||
| `get_graph_summary` | High-level graph overview |
|
||||
| `query_graph` | Fetch a node, walk neighbours, keyword search |
|
||||
| `update_node` | Merge properties onto a node |
|
||||
| `delete_node` | Archive (soft-delete) a node |
|
||||
|
||||
**3 resources** are also exposed: `semantica://graph/summary`, `semantica://decisions/list`, `semantica://schema/info`.
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ First-class integration between the Semantica semantic intelligence stack and
|
||||
`OpenClaw <https://openclaw.ai>`_ — the open-source personal AI agent platform.
|
||||
|
||||
OpenClaw connects to external tools via MCP (Model Context Protocol). This
|
||||
integration exposes the full Semantica MCP surface (12 tools, 3 resources) to
|
||||
integration exposes the full Semantica MCP surface (15 tools, 3 resources) to
|
||||
any OpenClaw agent and also ships a lightweight ``OpenClawKGTool`` that can be
|
||||
dropped directly into an OpenClaw SOUL.md tool-list as a native tool.
|
||||
|
||||
@@ -37,7 +37,7 @@ restart the OpenClaw Gateway::
|
||||
|
||||
openclaw gateway restart
|
||||
|
||||
All 12 Semantica tools are then available as native OpenClaw agent tools.
|
||||
All 15 Semantica tools are then available as native OpenClaw agent tools.
|
||||
|
||||
Compatibility
|
||||
-------------
|
||||
|
||||
@@ -6,7 +6,7 @@ Two integration paths:
|
||||
|
||||
1. **MCP (recommended)** — ``OpenClawMCPConfig`` emits the ``mcporter.json``
|
||||
snippet that wires Semantica's MCP server into the OpenClaw Gateway.
|
||||
All 12 Semantica MCP tools become native OpenClaw agent tools with no
|
||||
All 15 Semantica MCP tools become native OpenClaw agent tools with no
|
||||
extra code.
|
||||
|
||||
2. **REST** — ``OpenClawKGTool`` is a plain Python class that calls the
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
# OSV-Scanner ignore config (also consumed by OpenSSF Scorecard's
|
||||
# "Vulnerabilities" check, which reports advisories found in this repo's
|
||||
# dependency manifests via https://osv.dev).
|
||||
#
|
||||
# See https://github.com/google/osv-scanner#ignore-vulnerabilities-by-id for
|
||||
# the file format.
|
||||
|
||||
[[IgnoredVulns]]
|
||||
id = "GHSA-4j2p-28q2-5m79"
|
||||
reason = """
|
||||
accelerate<=1.14.0 (transitive dependency via docling-slim, pinned in
|
||||
requirements-ci.txt) has an open path traversal / DoS advisory (also tracked
|
||||
as CVE-2026-69112) in load_checkpoint_in_model / load_checkpoint_and_dispatch,
|
||||
which fail to sanitize weight_map entries from sharded checkpoint indexes.
|
||||
1.14.0 is the latest release on PyPI; no patched version exists yet.
|
||||
Semantica does not call either function or load arbitrary/untrusted sharded
|
||||
checkpoints, so the vulnerable code path is not reachable. Re-evaluate once
|
||||
accelerate ships a fix - see .github/workflows/security-scan.yml for the
|
||||
matching pip-audit exclusion.
|
||||
"""
|
||||
@@ -1,38 +1,80 @@
|
||||
---
|
||||
name: change
|
||||
description: Track and inspect graph changes, diffs, temporal updates, and the impact of new data on Semantica knowledge graphs.
|
||||
description: Inspect graph changes over time and ontology version diffs in Semantica. Uses ContextGraph.state_at for point-in-time graph state and change_management.VersionManager for ontology versioning.
|
||||
---
|
||||
|
||||
# /semantica:change
|
||||
|
||||
Inspect changes over time and evaluate updates. Usage: `/semantica:change <task> [args]`
|
||||
Track what changed. Usage: `/semantica:change <task> [args]`
|
||||
|
||||
`$ARGUMENTS` = task + optional node, time window, or filter.
|
||||
> Two distinct mechanisms cover this, and they are **not** interchangeable:
|
||||
>
|
||||
> | Question | Tool |
|
||||
> | --- | --- |
|
||||
> | "What did the *graph* look like on date X?" | `ContextGraph.state_at()` |
|
||||
> | "What changed between *ontology* versions?" | `change_management.VersionManager` |
|
||||
|
||||
---
|
||||
|
||||
## `diff [--from <ts>] [--to <ts>] [--node <id>]`
|
||||
|
||||
Compute graph diffs between two snapshots.
|
||||
## `graph-at <timestamp>` — point-in-time graph state
|
||||
|
||||
```python
|
||||
from semantica.provenance.change_tracker import ChangeTracker
|
||||
import os
|
||||
from semantica.context import ContextGraph
|
||||
|
||||
tracker = ChangeTracker()
|
||||
diff = tracker.compute_diff(from_ts=from_ts, to_ts=to_ts, node_id=node_id)
|
||||
graph = ContextGraph()
|
||||
graph.load_from_file(os.path.expanduser("~/.semantica/kg.json")) # load_from_file does not expand ~
|
||||
|
||||
snapshot = graph.state_at("2026-06-01") # str | int | float | datetime
|
||||
```
|
||||
|
||||
Output: added/removed nodes and edges, attribute changes, and impact summary.
|
||||
Diff two moments by comparing node IDs — `state_at()["nodes"]` is a list of
|
||||
dicts (unhashable), so compare the `id` fields, not the dicts themselves:
|
||||
|
||||
```python
|
||||
before = graph.state_at("2026-06-01")
|
||||
after = graph.state_at("2026-09-01")
|
||||
before_ids = {n["id"] for n in before["nodes"]}
|
||||
after_ids = {n["id"] for n in after["nodes"]}
|
||||
added = after_ids - before_ids
|
||||
```
|
||||
|
||||
For richer temporal work (scrubbing, evolution, temporal patterns) use
|
||||
`/semantica:temporal`, which wraps the same layer.
|
||||
|
||||
---
|
||||
|
||||
## `history <node_id> [--limit N]`
|
||||
## `node-history <node_id>` — who touched this node
|
||||
|
||||
Show the change history for a node or relationship.
|
||||
Node-level history is provenance, not change management:
|
||||
|
||||
```python
|
||||
history = tracker.get_node_history(node_id=node_id, limit=limit)
|
||||
import os
|
||||
from semantica.provenance import ProvenanceManager
|
||||
|
||||
db_path = os.path.expanduser("~/.semantica/prov.db") # storage_path is passed to
|
||||
os.makedirs(os.path.dirname(db_path), exist_ok=True) # sqlite3.connect() unexpanded
|
||||
pm = ProvenanceManager(storage_path=db_path)
|
||||
history = pm.revision_history(node_id)
|
||||
log = pm.audit_log(since="2026-01-01")
|
||||
```
|
||||
|
||||
Return: revisions, timestamps, authors, and summary comments.
|
||||
---
|
||||
|
||||
## `versions` / `diff <v1> <v2>` — ontology versioning
|
||||
|
||||
```python
|
||||
from semantica.change_management import VersionManager
|
||||
|
||||
vm = VersionManager()
|
||||
vm.create_version("1.1.0", ontology)
|
||||
vm.list_versions()
|
||||
vm.get_latest_version()
|
||||
|
||||
delta = vm.compare_versions("1.0.0", "1.1.0")
|
||||
delta = vm.diff_ontologies(base_ontology, target_ontology)
|
||||
migrated = vm.migrate_ontology("1.0.0", "1.1.0", ontology)
|
||||
```
|
||||
|
||||
`TemporalVersionManager` and `OntologyVersionManager` are also exported for
|
||||
time-scoped and ontology-specific variants.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
---
|
||||
name: decision
|
||||
description: Full decision lifecycle in Semantica — record, query, find precedents (hybrid/advanced), analyze influence, explain, insights dashboard, list, and record exceptions. Uses AgentContext, ContextGraph, DecisionQuery, CausalChainAnalyzer, DecisionRecorder.
|
||||
description: Full decision lifecycle in Semantica — record, query, find precedents (hybrid/advanced), analyze influence, explain, insights dashboard, list, and record exceptions. Uses AgentContext, ContextGraph, DecisionQuery, CausalChainAnalyzer, DecisionRecorder.
|
||||
---
|
||||
|
||||
# /semantica:decision
|
||||
@@ -114,7 +114,7 @@ Output: Influence score + influenced decisions table + predicted new relationshi
|
||||
|
||||
## `explain <decision_id>`
|
||||
|
||||
Full explainability trace — reasoning steps, causal antecedents, policy compliance.
|
||||
Full explainability trace — reasoning steps, causal antecedents, policy compliance.
|
||||
|
||||
```python
|
||||
from semantica.context import AgentContext, ContextGraph
|
||||
|
||||
@@ -21,8 +21,8 @@ Run the full extraction pipeline. Usage: `/semantica:extract [file_path | "inlin
|
||||
**2. Clear the result cache** to prevent cross-invocation pollution:
|
||||
|
||||
```python
|
||||
from semantica.semantic_extract.cache import _result_cache
|
||||
_result_cache.clear()
|
||||
from semantica.semantic_extract.cache import extraction_cache
|
||||
extraction_cache.clear()
|
||||
```
|
||||
|
||||
**3. Run the full pipeline:**
|
||||
|
||||
@@ -1,37 +1,89 @@
|
||||
---
|
||||
name: ontology
|
||||
description: Manage ontology schemas, concepts, relationships, and alignments for Semantica knowledge graphs.
|
||||
description: Manage ontology schemas, concepts, alignments, and SHACL/OWL validation for Semantica knowledge graphs. Uses OntologyEngine and OntologyValidator.
|
||||
---
|
||||
|
||||
# /semantica:ontology
|
||||
|
||||
Manage ontology definitions and validation. Usage: `/semantica:ontology <task> [args]`
|
||||
|
||||
`$ARGUMENTS` = task + optional ontology item or schema file.
|
||||
> Entry points: `OntologyEngine` (authoring, export, alignments) and
|
||||
> `OntologyValidator` (consistency checking).
|
||||
|
||||
---
|
||||
|
||||
## `describe <concept>`
|
||||
## `concepts <scheme_uri>`
|
||||
|
||||
Show ontology concept details.
|
||||
List SKOS concepts in a vocabulary scheme.
|
||||
|
||||
```python
|
||||
from semantica.ontology import OntologyManager
|
||||
from semantica.ontology import OntologyEngine
|
||||
from semantica.triplet_store import TripletStore
|
||||
|
||||
manager = OntologyManager()
|
||||
concept = manager.get_concept(concept_name)
|
||||
store = TripletStore(backend="oxigraph") # needs semantica[tripletstore-oxigraph]
|
||||
engine = OntologyEngine(store=store) # list_concepts/list_vocabularies need a
|
||||
# configured store — raises ProcessingError without one
|
||||
concepts = engine.list_concepts(scheme_uri)
|
||||
vocabs = engine.list_vocabularies()
|
||||
```
|
||||
|
||||
Output: properties, relationships, inherited types, and examples.
|
||||
|
||||
---
|
||||
|
||||
## `validate [--schema <file>]`
|
||||
## `validate <ontology>`
|
||||
|
||||
Validate the graph or schema against the ontology.
|
||||
Check an ontology for consistency and satisfiability.
|
||||
|
||||
```python
|
||||
result = manager.validate_graph(graph=graph, schema_file=schema_file)
|
||||
from semantica.ontology import OntologyValidator
|
||||
|
||||
validator = OntologyValidator(check_consistency=True, check_satisfiability=True)
|
||||
result = validator.validate(ontology) # dict or path to an ontology file
|
||||
# result.valid, result.errors, result.warnings
|
||||
```
|
||||
|
||||
Return: validation status, errors, and correction suggestions.
|
||||
For SHACL shape validation of instance data use `SHACLGenerator` / `SHACLValidationReport`:
|
||||
|
||||
```python
|
||||
from semantica.ontology import SHACLGenerator
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## `build <text|data>`
|
||||
|
||||
Generate an ontology from unstructured text or structured records.
|
||||
|
||||
```python
|
||||
engine = OntologyEngine()
|
||||
onto = engine.from_text(text) # LLM-assisted (needs an llm-* extra + API key)
|
||||
onto = engine.from_data(records) # deterministic, from structured data
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## `export <ontology> <path> [--format turtle]`
|
||||
|
||||
```python
|
||||
engine.export_owl(onto, path, format="turtle")
|
||||
engine.export_shacl(onto, path, format="turtle")
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## `align <source_uri> <target_uri> <predicate>`
|
||||
|
||||
```python
|
||||
engine.create_alignment(source_uri, target_uri, predicate)
|
||||
engine.get_alignments(entity_uri)
|
||||
engine.list_alignments()
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## `evaluate <ontology>`
|
||||
|
||||
Quality-gate an ontology (`OntologyEvaluator` / `OntologyQualityReport` under the hood).
|
||||
|
||||
```python
|
||||
report = engine.evaluate(onto)
|
||||
```
|
||||
|
||||
@@ -1,37 +1,66 @@
|
||||
---
|
||||
name: policy
|
||||
description: Define and enforce policies, access controls, and compliance rules over Semantica knowledge graphs.
|
||||
description: Define and enforce decision policies, compliance rules, and exceptions over Semantica graphs. Uses ContextGraph.check_decision_rules/enforce_decision_policy and context.PolicyEngine.
|
||||
---
|
||||
|
||||
# /semantica:policy
|
||||
|
||||
Apply policy rules and checks. Usage: `/semantica:policy <task> [args]`
|
||||
Policy governance over recorded decisions. Usage: `/semantica:policy <task> [args]`
|
||||
|
||||
`$ARGUMENTS` = task + optional policy name, rule set, or target entity.
|
||||
> `PolicyEngine` lives in `semantica.context`. For most cases the two policy
|
||||
> methods on `ContextGraph` itself are enough.
|
||||
|
||||
---
|
||||
|
||||
## `check [--rule <name>] [--target <id>]`
|
||||
## `check <decision>` — the simple path
|
||||
|
||||
Run policy checks against the graph.
|
||||
No policy store needed; rules default to a built-in policy set.
|
||||
|
||||
```python
|
||||
from semantica.policy import PolicyEngine
|
||||
from semantica.context import ContextGraph
|
||||
|
||||
engine = PolicyEngine()
|
||||
result = engine.check(rule_name=rule_name, target=target)
|
||||
graph = ContextGraph()
|
||||
result = graph.check_decision_rules({
|
||||
"category": "vendor_selection",
|
||||
"outcome": "approved",
|
||||
"confidence": 0.93,
|
||||
"decision_maker": "gyro",
|
||||
})
|
||||
# {'compliant': bool, 'violations': [...], 'warnings': [...], 'policy_rules': {...}}
|
||||
```
|
||||
|
||||
Output: compliance status, failing rules, and remediation guidance.
|
||||
Default rules: `min_confidence=0.7`, `required_outcomes=['approved','rejected','flagged']`,
|
||||
`required_metadata=['decision_maker']`, `max_reasoning_length=1000`. Override by
|
||||
passing your own `rules=` dict.
|
||||
|
||||
## `enforce <decision> [--rules <dict>]`
|
||||
|
||||
```python
|
||||
verdict = graph.enforce_decision_policy(decision_data, policy_rules=None)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## `list`
|
||||
## Managed policies — the full path
|
||||
|
||||
List available policy rules and categories.
|
||||
`PolicyEngine` requires a graph store and versioned `Policy` objects.
|
||||
|
||||
```python
|
||||
rules = engine.list_rules()
|
||||
from semantica.context import PolicyEngine
|
||||
from semantica.context.decision_models import Policy
|
||||
|
||||
engine = PolicyEngine(graph_store)
|
||||
|
||||
policy_id = engine.add_policy(Policy(...))
|
||||
policies = engine.get_applicable_policies(category="vendor_selection", entities=[...])
|
||||
ok = engine.check_compliance(decision, policy_id)
|
||||
history = engine.get_policy_history(policy_id)
|
||||
|
||||
engine.update_policy(policy_id, rules={...}, change_reason="tightened threshold")
|
||||
engine.record_exception(decision_id, policy_id, reason="...", approver="...")
|
||||
impact = engine.analyze_policy_impact(policy_id, proposed_rules={...})
|
||||
affected = engine.get_affected_decisions(policy_id, from_version, to_version)
|
||||
```
|
||||
|
||||
Return: rule name, description, severity, and category.
|
||||
Note `check_compliance` takes a `Decision` object, not a dict — fetch it from the
|
||||
graph rather than constructing one by hand.
|
||||
|
||||
@@ -1,37 +1,70 @@
|
||||
---
|
||||
name: provenance
|
||||
description: Trace data lineage, source attribution, audit trails, and provenance assertions in Semantica graphs.
|
||||
description: Trace data lineage, source attribution, audit trails, and W3C PROV-O export in Semantica graphs. Uses ProvenanceManager.
|
||||
---
|
||||
|
||||
# /semantica:provenance
|
||||
|
||||
Inspect provenance metadata. Usage: `/semantica:provenance <task> [args]`
|
||||
|
||||
`$ARGUMENTS` = task + optional node, edge, or time range.
|
||||
Lineage and audit trails. Usage: `/semantica:provenance <task> [args]`
|
||||
|
||||
---
|
||||
|
||||
## `trace <node_id> [--depth N]`
|
||||
|
||||
Trace the provenance of a node or fact.
|
||||
## `lineage <entity_id> [--depth N]`
|
||||
|
||||
```python
|
||||
from semantica.provenance import ProvenanceTracer
|
||||
import os
|
||||
from semantica.provenance import ProvenanceManager
|
||||
|
||||
tracer = ProvenanceTracer()
|
||||
trace = tracer.trace_node(node_id=node_id, depth=depth)
|
||||
db_path = os.path.expanduser("~/.semantica/prov.db") # storage_path is passed to
|
||||
os.makedirs(os.path.dirname(db_path), exist_ok=True) # sqlite3.connect() unexpanded
|
||||
pm = ProvenanceManager(storage_path=db_path) # SQLite, or omit for in-memory
|
||||
chain = pm.lineage(entity_id, depth=3)
|
||||
full = pm.get_lineage(entity_id) # complete ancestry
|
||||
down = pm.get_descendants(entity_id) # what this entity influenced
|
||||
```
|
||||
|
||||
Output: source chain, authors, timestamps, and validation status.
|
||||
|
||||
---
|
||||
|
||||
## `audit [--since <ts>] [--actor <id>]`
|
||||
|
||||
View audit logs for graph changes.
|
||||
## `sources <entity_id>`
|
||||
|
||||
```python
|
||||
audit_log = tracer.get_audit_log(since=since, actor=actor)
|
||||
srcs = pm.get_all_sources(entity_id) # every source that contributed
|
||||
prov = pm.get_provenance(entity_id) # the raw PROV entry
|
||||
hist = pm.revision_history(entity_id)
|
||||
```
|
||||
|
||||
Return: change events, actor, affected objects, and action details.
|
||||
---
|
||||
|
||||
## `audit [--since <iso-date>] [--format table|json]`
|
||||
|
||||
```python
|
||||
log = pm.audit_log(since="2026-01-01", format="table")
|
||||
between = pm.query_recorded_between(start, end)
|
||||
stats = pm.get_statistics()
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## `export [--format turtle|json-ld|xml]`
|
||||
|
||||
W3C PROV-O export — this is the regulator-facing artifact.
|
||||
|
||||
```python
|
||||
rdf = pm.export_prov(format="turtle", base_uri="https://example.org/prov/")
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## `invalidate <entity_id> <agent_id> [--reason ...]`
|
||||
|
||||
Mark an entity superseded without deleting history.
|
||||
|
||||
```python
|
||||
pm.invalidate(entity_id, agent_id, reason="source retracted")
|
||||
```
|
||||
|
||||
## `check [--strict]`
|
||||
|
||||
```python
|
||||
report = pm.check(strict=False) # integrity check over the provenance store
|
||||
```
|
||||
|
||||
@@ -1,49 +1,84 @@
|
||||
---
|
||||
name: query
|
||||
description: Query the Semantica knowledge graph using SPARQL, Cypher, keyword search, and structured graph query patterns.
|
||||
description: Query Semantica knowledge graphs — in-memory ContextGraph search, SPARQL over RDF triple stores, and Cypher over LPG backends.
|
||||
---
|
||||
|
||||
# /semantica:query
|
||||
|
||||
Run graph queries and search. Usage: `/semantica:query <mode> [args]`
|
||||
Query the graph. Usage: `/semantica:query <task> [args]`
|
||||
|
||||
`$ARGUMENTS` = query mode + query string or filter.
|
||||
> Which API you want depends on where the graph lives.
|
||||
|
||||
---
|
||||
|
||||
## `sparql <query>`
|
||||
## `search "<keywords>"` — the in-memory ContextGraph
|
||||
|
||||
Execute a SPARQL query against the graph.
|
||||
This is the one that works with no external server.
|
||||
|
||||
```python
|
||||
from semantica.query import QueryEngine
|
||||
import os
|
||||
from semantica.context import ContextGraph
|
||||
|
||||
engine = QueryEngine()
|
||||
results = engine.query_sparql(query)
|
||||
graph = ContextGraph()
|
||||
graph.load_from_file(os.path.expanduser("~/.semantica/kg.json")) # load_from_file does not expand ~
|
||||
|
||||
results = graph.query("vendor selection", skip=0, limit=20)
|
||||
```
|
||||
|
||||
Return: query bindings as a Markdown table.
|
||||
Related lookups on the same object:
|
||||
|
||||
```python
|
||||
graph.find_nodes(...) graph.find_node(...)
|
||||
graph.find_related_nodes(...) graph.get_neighbors(node_id)
|
||||
graph.find_similar_nodes(...) graph.get_nodes_by_label(label)
|
||||
```
|
||||
|
||||
Decision-specific queries belong to `/semantica:decision`.
|
||||
|
||||
---
|
||||
|
||||
## `cypher <query>`
|
||||
|
||||
Execute a Cypher-like query.
|
||||
## `sparql "<query>"` — RDF triple stores
|
||||
|
||||
```python
|
||||
results = engine.query_cypher(query)
|
||||
from semantica.triplet_store import TripletStore
|
||||
|
||||
store = TripletStore(backend="oxigraph") # embedded; needs semantica[tripletstore-oxigraph]
|
||||
# or backend="blazegraph" | "jena" | "rdf4j" with endpoint="http://..."
|
||||
result = store.execute_query(sparql)
|
||||
```
|
||||
|
||||
Output: node/relationship results and path summaries.
|
||||
For query planning, optimisation, and caching over a backend:
|
||||
|
||||
```python
|
||||
from semantica.triplet_store import QueryEngine, OxigraphStore
|
||||
|
||||
# QueryEngine needs an object exposing execute_sparql() — the raw backend,
|
||||
# not the TripletStore wrapper above (which only exposes execute_query()).
|
||||
backend = OxigraphStore()
|
||||
|
||||
qe = QueryEngine()
|
||||
plan = qe.plan_query(sparql)
|
||||
tuned = qe.optimize_query(sparql)
|
||||
result = qe.execute_query(sparql, store_backend=backend)
|
||||
stats = qe.get_query_statistics()
|
||||
```
|
||||
|
||||
Blazegraph / Jena / RDF4J need **no** extra — `semantica.triplet_store` speaks
|
||||
SPARQL over HTTP using the core `requests` dependency.
|
||||
|
||||
---
|
||||
|
||||
## `search <keywords> [--filter <type>]`
|
||||
|
||||
Search graph entities by keyword.
|
||||
## `cypher "<query>"` — labeled property graphs
|
||||
|
||||
```python
|
||||
results = engine.search(keywords=keywords, filter_type=filter_type)
|
||||
from semantica.graph_store import Neo4jStore # needs semantica[graph-neo4j]
|
||||
|
||||
store = Neo4jStore(uri=..., user=..., password=...)
|
||||
result = store.execute_query(query, parameters={...})
|
||||
```
|
||||
|
||||
Return: ranked matches with entity types and relevance scores.
|
||||
Also available: `FalkorDBStore`, `ApacheAgeStore`, `AmazonNeptuneStore`,
|
||||
and `GraphManager` / `GraphStore` for backend-agnostic access.
|
||||
|
||||
**Not installed in this environment** — add the backend extra first, e.g.
|
||||
`pip install "semantica[graph-neo4j]"`.
|
||||
|
||||
@@ -119,9 +119,9 @@ from semantica.semantic_extract import (
|
||||
NamedEntityRecognizer,
|
||||
RelationExtractor,
|
||||
)
|
||||
from semantica.semantic_extract.cache import _result_cache
|
||||
from semantica.semantic_extract.cache import extraction_cache
|
||||
|
||||
_result_cache.clear() # prevent cross-invocation cache pollution
|
||||
extraction_cache.clear() # prevent cross-invocation cache pollution
|
||||
|
||||
text = open(file_path).read()
|
||||
|
||||
|
||||
+88
-52
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "semantica"
|
||||
version = "0.6.8"
|
||||
version = "0.7.0"
|
||||
description = "Graph-Native Infrastructure for Context and Accountable AI Systems: context graphs, decision intelligence, full provenance tracking, and explainable reasoning engines — every AI decision traceable, every output auditable."
|
||||
readme = "README.md"
|
||||
license = { text = "MIT" }
|
||||
@@ -12,7 +12,11 @@ license = { text = "MIT" }
|
||||
authors = [{ name = "Semantica", email = "kaif@getsemantica.ai" }]
|
||||
maintainers = [{ name = "Semantica", email = "kaif@getsemantica.ai" }]
|
||||
|
||||
requires-python = ">=3.8"
|
||||
# 3.8 is already unsatisfiable in practice (numpy>=2.0.2 requires >=3.9) and is
|
||||
# not exercised by the Install Matrix (3.9-3.12). The floor is 3.9.2 rather
|
||||
# than 3.9.0 because cryptography (db-snowflake) excludes 3.9.0/3.9.1 from
|
||||
# every release's requires-python, so those patch levels can never resolve.
|
||||
requires-python = ">=3.9.2"
|
||||
|
||||
classifiers = [
|
||||
"Development Status :: 5 - Production/Stable",
|
||||
@@ -22,7 +26,6 @@ classifiers = [
|
||||
"License :: OSI Approved :: MIT License",
|
||||
"Operating System :: OS Independent",
|
||||
"Programming Language :: Python :: 3",
|
||||
"Programming Language :: Python :: 3.8",
|
||||
"Programming Language :: Python :: 3.9",
|
||||
"Programming Language :: Python :: 3.10",
|
||||
"Programming Language :: Python :: 3.11",
|
||||
@@ -52,30 +55,13 @@ dependencies = [
|
||||
# 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,
|
||||
# which forces a source build that fails outright on 3.9 (see Install
|
||||
# Matrix run history). Capping both keeps 3.9 on the last wheel-compatible
|
||||
# pair; 3.10+ is left unconstrained to always get the latest spacy/thinc.
|
||||
"spacy>=3.4.0,<3.8.8; python_version < '3.10'",
|
||||
"spacy>=3.4.0; python_version >= '3.10'",
|
||||
"thinc<8.3.5; python_version < '3.10'",
|
||||
"transformers>=4.20.0",
|
||||
"torch>=1.13.1",
|
||||
"sentence-transformers>=2.2.0",
|
||||
"rdflib>=6.2.0",
|
||||
"networkx>=2.8.0",
|
||||
"matplotlib>=3.9.4",
|
||||
"seaborn>=0.13.2",
|
||||
"plotly>=6.8.0",
|
||||
"ipywidgets>=8.0.0",
|
||||
# requests dropped Python 3.9 support at 2.33.0 (requires_python >=3.10),
|
||||
# so an unqualified >=2.34.2 floor is unsatisfiable on 3.9. Cap 3.9 to the
|
||||
# last 3.9-compatible release; 3.10+ is left unconstrained.
|
||||
"requests>=2.32.5,<2.33.0; python_version < '3.10'",
|
||||
"requests>=2.34.2; python_version >= '3.10'",
|
||||
"GitPython>=3.1.58",
|
||||
# chardet dropped Python 3.9 support at 6.0.0 (requires_python >=3.10), so
|
||||
# an unqualified >=7.4.3 floor is unsatisfiable on 3.9. Cap 3.9 to the last
|
||||
# 3.9-compatible release; 3.10+ is left unconstrained.
|
||||
@@ -87,26 +73,11 @@ dependencies = [
|
||||
# 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 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 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 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
|
||||
@@ -120,7 +91,6 @@ dependencies = [
|
||||
"python-dotenv>=1.2.1",
|
||||
"loguru>=0.7.3",
|
||||
"structlog>=22.1.0",
|
||||
"gensim>=4.4.0",
|
||||
"httpx<0.29.0",
|
||||
"pyarrow>=14.0.0"
|
||||
]
|
||||
@@ -144,7 +114,10 @@ llm-anthropic = ["anthropic>=0.122.0"]
|
||||
llm-ollama = ["ollama>=0.1.0"]
|
||||
llm-deepseek = ["openai>=1.0.0"]
|
||||
llm-novita = ["openai>=1.0.0"]
|
||||
llm-litellm = ["litellm>=1.83.9"]
|
||||
# litellm>=1.83.10 requires Python>=3.10, and the last 3.9-compatible release
|
||||
# (1.83.9) pins python-dotenv==1.0.1, which conflicts with our >=1.2.1 core
|
||||
# floor — so no litellm satisfies 3.9 at all. Gate it to 3.10+.
|
||||
llm-litellm = ["litellm>=1.83.9; python_version >= '3.10'"]
|
||||
llm-instructor = ["instructor>=1.15.3"]
|
||||
|
||||
llm-all = [
|
||||
@@ -152,19 +125,49 @@ llm-all = [
|
||||
]
|
||||
|
||||
# ---- Document Parsing ----
|
||||
parse-docling = ["docling>=2.107.0"]
|
||||
documents = [
|
||||
"python-docx>=1.2.0",
|
||||
"openpyxl>=3.1.5",
|
||||
"lxml>=6.1.1",
|
||||
"beautifulsoup4>=4.15.0"
|
||||
]
|
||||
# every docling release requires Python>=3.10 (no 3.9-compatible version
|
||||
# exists to cap to), so gate it like google-adk below rather than split it.
|
||||
parse-docling = ["docling>=2.107.0; python_version >= '3.10'"]
|
||||
# pdfplumber powers the default PDFParser; not pulled in by any other extra.
|
||||
parse-pdf = ["pdfplumber>=0.10.0"]
|
||||
|
||||
# ---- SHACL Validation ----
|
||||
shacl = ["pyshacl>=0.25.0"]
|
||||
|
||||
# ---- Database Connectors ----
|
||||
db-snowflake = ["snowflake-connector-python>=4.6.0", "cryptography>=49.0.0"]
|
||||
# snowflake-connector-python dropped Python 3.9 support at 4.6.0
|
||||
# (requires_python >=3.10), so an unqualified >=4.6.0 floor is unsatisfiable
|
||||
# on 3.9. Cap 3.9 below it; 3.10+ keeps the newer floor.
|
||||
db-snowflake = [
|
||||
"snowflake-connector-python>=4.6.0; python_version >= '3.10'",
|
||||
"snowflake-connector-python>=3.13.0,<4.6.0; python_version < '3.10'",
|
||||
"cryptography>=49.0.0"
|
||||
]
|
||||
db-databricks = ["databricks-sdk>=0.60.0", "databricks-sql-connector>=4.0.0"]
|
||||
db-arrow = ["pyarrow>=24.0.0"]
|
||||
# pyarrow dropped Python 3.9 support at 24.0.0 (requires_python >=3.10), so an
|
||||
# unqualified >=24.0.0 floor is unsatisfiable on 3.9. Cap 3.9 below the last
|
||||
# 3.9-compatible release line; 3.10+ is left unconstrained.
|
||||
db-arrow = [
|
||||
"pyarrow>=24.0.0; python_version >= '3.10'",
|
||||
"pyarrow>=14.0.0,<24.0.0; python_version < '3.10'"
|
||||
]
|
||||
db-salesforce = ["simple-salesforce>=1.12.0"]
|
||||
ingest-parquet = ["pyarrow>=24.0.0"]
|
||||
ingest-arrow = ["pyarrow>=24.0.0"]
|
||||
ingest-parquet = [
|
||||
"pyarrow>=24.0.0; python_version >= '3.10'",
|
||||
"pyarrow>=14.0.0,<24.0.0; python_version < '3.10'"
|
||||
]
|
||||
ingest-arrow = [
|
||||
"pyarrow>=24.0.0; python_version >= '3.10'",
|
||||
"pyarrow>=14.0.0,<24.0.0; python_version < '3.10'"
|
||||
]
|
||||
ingest-sap = ["requests>=2.28.0"]
|
||||
ingest-git = ["GitPython>=3.1.58"]
|
||||
|
||||
db-all = [
|
||||
"semantica[db-snowflake,db-databricks,db-salesforce,db-arrow]"
|
||||
@@ -175,22 +178,35 @@ models-huggingface = [
|
||||
"transformers>=4.20.0",
|
||||
"torch>=1.13.1"
|
||||
]
|
||||
embeddings-local = [
|
||||
"sentence-transformers>=2.2.0",
|
||||
"fastembed>=0.2.0",
|
||||
"onnxruntime>=1.19.2,<1.20.0; python_version < '3.10'",
|
||||
"onnxruntime>=1.20.1; python_version >= '3.10'",
|
||||
"tokenizers>=0.15.0"
|
||||
]
|
||||
nlp-spacy = [
|
||||
"spacy>=3.4.0,<3.8.8; python_version < '3.10'",
|
||||
"spacy>=3.4.0; python_version >= '3.10'"
|
||||
]
|
||||
|
||||
# ---- Graph Backends ----
|
||||
graph-neo4j = ["neo4j>=5.0.0"]
|
||||
graph-falkordb = ["falkordb>=1.0.0", "redis>=4.3.0"]
|
||||
graph-amazon-neptune = ["boto3>=1.24.0", "neo4j>=5.0.0"]
|
||||
graph-apache-age = ["psycopg2-binary>=2.9.0"]
|
||||
graph-embeddings = ["gensim>=4.4.0"]
|
||||
|
||||
graph-all = [
|
||||
"semantica[graph-neo4j,graph-falkordb,graph-amazon-neptune,graph-apache-age]"
|
||||
"semantica[graph-neo4j,graph-falkordb,graph-amazon-neptune,graph-apache-age,graph-embeddings]"
|
||||
]
|
||||
|
||||
# ---- Triplet Store Backends ----
|
||||
tripletstore-oxigraph = ["pyoxigraph>=0.5.0"]
|
||||
|
||||
# ---- Vector Store Backends ----
|
||||
vectorstore-qdrant = ["qdrant-client>=1.0.0"]
|
||||
vectorstore-faiss = ["faiss-cpu>=1.7.0"]
|
||||
vectorstore-qdrant = ["qdrant-client>=1.10.0"]
|
||||
vectorstore-weaviate = ["weaviate-client>=4.0.0"]
|
||||
vectorstore-pinecone = ["pinecone>=3.0.0"]
|
||||
vectorstore-milvus = ["pymilvus>=2.0.0"]
|
||||
@@ -198,7 +214,7 @@ vectorstore-pgvector = ["psycopg[binary,pool]>=3.0.0", "pgvector>=0.2.0"]
|
||||
vectorstore-sqlite = ["sqlite-vec>=0.1.1"]
|
||||
|
||||
vectorstore-all = [
|
||||
"semantica[vectorstore-qdrant,vectorstore-weaviate,vectorstore-pinecone,vectorstore-milvus,vectorstore-pgvector,vectorstore-sqlite]"
|
||||
"semantica[vectorstore-qdrant,vectorstore-weaviate,vectorstore-pinecone,vectorstore-milvus,vectorstore-pgvector,vectorstore-sqlite,vectorstore-faiss]"
|
||||
]
|
||||
|
||||
# ---- Infra / Queues / Workers ----
|
||||
@@ -230,7 +246,18 @@ monitoring = [
|
||||
viz = [
|
||||
"pyvis>=0.3.0",
|
||||
"graphviz>=0.21",
|
||||
"d3blocks>=1.0.0"
|
||||
"d3blocks>=1.0.0",
|
||||
"matplotlib>=3.9.4",
|
||||
"seaborn>=0.13.2",
|
||||
"plotly>=6.8.0",
|
||||
"ipywidgets>=8.0.0",
|
||||
"umap-learn>=0.5.12"
|
||||
]
|
||||
|
||||
# ---- Media ----
|
||||
media = [
|
||||
"librosa>=0.9.0",
|
||||
"opencv-python>=4.13.0.92"
|
||||
]
|
||||
|
||||
# ---- GPU ----
|
||||
@@ -244,7 +271,9 @@ agno = ["agno>=1.0.0"]
|
||||
# crewai core provides BaseTool and BaseKnowledgeSource; crewai-tools is not
|
||||
# needed (it pulls vulnerable transitive deps like chromadb) and would only
|
||||
# duplicate the prebuilt tooling users can install separately.
|
||||
crewai = ["crewai>=0.80.0"]
|
||||
# No un-yanked crewai release supports Python 3.9 (all require >=3.10), so
|
||||
# gate the extra to 3.10+.
|
||||
crewai = ["crewai>=0.80.0; python_version >= '3.10'"]
|
||||
langchain = ["langchain-core>=0.3.0"]
|
||||
google-adk = ["google-adk>=1.27.0; python_version >= '3.10'"]
|
||||
|
||||
@@ -269,15 +298,23 @@ dev = [
|
||||
"isort>=6.1.0",
|
||||
"flake8>=4.0.0",
|
||||
"mypy>=0.971",
|
||||
"pre-commit>=4.6.0",
|
||||
# pre-commit dropped Python 3.9 support at 4.6.0 (requires_python >=3.10).
|
||||
# Cap 3.9 below it; 3.10+ keeps the >=4.6.0 floor.
|
||||
"pre-commit>=4.0.0,<4.6.0; python_version < '3.10'",
|
||||
"pre-commit>=4.6.0; python_version >= '3.10'",
|
||||
"jupyter>=1.0.0",
|
||||
"ipykernel>=6.15.0"
|
||||
]
|
||||
|
||||
# Explorer Dashboard
|
||||
# fastapi dropped Python 3.9 support at 0.129.0 (requires_python >=3.10), and
|
||||
# every fastapi below that caps starlette<0.53.0 — so the 3.10+ starlette
|
||||
# floor is unsatisfiable on 3.9. Cap both on 3.9; 3.10+ keeps the newer floors.
|
||||
explorer = [
|
||||
"fastapi>=0.109.2",
|
||||
"starlette>=0.53.0",
|
||||
"fastapi>=0.109.2,<0.129.0; python_version < '3.10'",
|
||||
"fastapi>=0.109.2; python_version >= '3.10'",
|
||||
"starlette>=0.36.3,<0.53.0; python_version < '3.10'",
|
||||
"starlette>=0.53.0; python_version >= '3.10'",
|
||||
"uvicorn[standard]>=0.22.0",
|
||||
"websockets>=15.0.1",
|
||||
"python-multipart>=0.0.7",
|
||||
@@ -294,8 +331,7 @@ explorer-lite = [
|
||||
# (CVE-2026-45829) with no fixed release — including it here would fail the CI
|
||||
# dependency-audit/security gates. Install it explicitly via ``semantica[crewai]``.
|
||||
all = [
|
||||
"semantica[dev,viz,infra,cloud,monitoring,watch,llm-all,models-huggingface,split-all,graph-all,tripletstore-oxigraph,vectorstore-all,parse-docling,ingest-parquet,ingest-arrow,shacl,explorer]",
|
||||
"semantica[dev,viz,infra,cloud,monitoring,watch,llm-all,models-huggingface,split-all,graph-all,tripletstore-oxigraph,vectorstore-all,parse-docling,ingest-parquet,ingest-arrow,shacl,agno,langchain,google-adk]"
|
||||
"semantica[dev,viz,media,infra,cloud,monitoring,watch,llm-all,models-huggingface,embeddings-local,nlp-spacy,documents,ingest-git,graph-embeddings,split-all,graph-all,tripletstore-oxigraph,vectorstore-all,parse-docling,parse-pdf,ingest-parquet,ingest-arrow,shacl,explorer,agno,langchain,google-adk]"
|
||||
]
|
||||
|
||||
# ---------------- ENTRYPOINTS ----------------
|
||||
|
||||
+26
-11
@@ -181,6 +181,7 @@ anyio==4.14.2 \
|
||||
# jupyter-server
|
||||
# langsmith
|
||||
# openai
|
||||
# pinecone
|
||||
# starlette
|
||||
# watchfiles
|
||||
argon2-cffi==25.1.0 \
|
||||
@@ -786,7 +787,9 @@ charset-normalizer==3.5.1 \
|
||||
--hash=sha256:fd0350afdc3aabd5576f60ea109228bd5538139713c7b094c5cd27c73a98bc6f \
|
||||
--hash=sha256:fd0a274c0e5f9a21565cd9d3dd749b61f96b7aa1e20a93aa1ba4029518f2e5c0 \
|
||||
--hash=sha256:fdb8a068947befafba9952162645dc2fecaeb400e64584829ed5e9b2fbe21a7f
|
||||
# via requests
|
||||
# via
|
||||
# pdfminer-six
|
||||
# requests
|
||||
click==8.5.0 \
|
||||
--hash=sha256:255bc9599cf7748b4b1a446ccc735421bd08a2ae529a8b88597d3de5664ee360 \
|
||||
--hash=sha256:ba0d2089de75ea0310e2dde03160e6ca10009947fb95a182f9b54021bb272e34
|
||||
@@ -1097,6 +1100,7 @@ cryptography==50.0.1 \
|
||||
# azure-storage-blob
|
||||
# google-auth
|
||||
# joserfc
|
||||
# pdfminer-six
|
||||
cuda-bindings==13.3.1 \
|
||||
--hash=sha256:04436a9364059c84b8f9636f359eccda1cf814341f5b670c71d80d2f79dbc708 \
|
||||
--hash=sha256:120fcc53d57903df529c3486962c56528cba5b7d6c57c99537320ed9922c8b86 \
|
||||
@@ -4298,6 +4302,14 @@ patsy==1.0.3 \
|
||||
--hash=sha256:79ebf4c93ff4d296e58a9d5be2b2ee31bd49d737cf11d70ffbd8a44b2de42e65 \
|
||||
--hash=sha256:d3dbebe8fd5f46e29912d030b63c6268647b59bf788a99e2af28a30234cf357c
|
||||
# via statsmodels
|
||||
pdfminer-six==20260107 \
|
||||
--hash=sha256:366585ba97e80dffa8f00cebe303d2f381884d8637af4ce422f1df3ef38111a9 \
|
||||
--hash=sha256:96bfd431e3577a55a0efd25676968ca4ce8fd5b53f14565f85716ff363889602
|
||||
# via pdfplumber
|
||||
pdfplumber==0.11.10 \
|
||||
--hash=sha256:7741ea81bf165b474b153e6789d10d18e06b6ddcf3ec84289c3ef2fed6802580 \
|
||||
--hash=sha256:b95b2d28c66efb0a794a83b88c6c6aea5987532a445d20a1cbcfa657022e6e57
|
||||
# via semantica (pyproject.toml)
|
||||
pexpect==4.9.0 \
|
||||
--hash=sha256:7236d1e080e4936be2dc3e326cec0af72acf9212a7e1d060210e70a47e253523 \
|
||||
--hash=sha256:ee7d41123f3c9911050ea2c2dac107568dc43b2d3b0c7557a33212c398ead30f
|
||||
@@ -4406,18 +4418,19 @@ pillow==12.3.0 \
|
||||
# docling-slim
|
||||
# fastembed
|
||||
# matplotlib
|
||||
# pdfplumber
|
||||
# python-pptx
|
||||
# rapidocr
|
||||
# torchvision
|
||||
pinecone==9.1.0 \
|
||||
--hash=sha256:461632bb07919da32b943100b8a047c74be53a6aa15c8b7679bff7a0f834c939 \
|
||||
--hash=sha256:6c3a6dfa577dc11aed3197e1b221e65522603e9e1f6bd27a1b504a0909b3559f \
|
||||
--hash=sha256:d3871bd3f39cb430ae8470158dc9c5dcffbac5ae31d144d9a7c3b351ac51755f \
|
||||
--hash=sha256:d53fe6f4978ab0642eb2d3a0ee3b2576ccfeebaa11e0690b18e67dac4e057047 \
|
||||
--hash=sha256:e930ba819f5b7e20aac688d04c840a8b6fbc6d12630d71303bb2130881a9d169 \
|
||||
--hash=sha256:fc71ec431108de2df1a1978d3a24ac16f74ba3d8f3265c3760f969386e8742b8 \
|
||||
--hash=sha256:fe6aeaf6515e9021984755ebc162f643c79d98056059aab2e765962a7538818c \
|
||||
--hash=sha256:ffae8fb7cbb4056b920586629f15b08107350be4802a5637d10b31e2ad841f9c
|
||||
pinecone==10.0.0 \
|
||||
--hash=sha256:0994270c514b16c72ec94dd6c29ff2708b81d30ff8467e19de192a28a7c86b7e \
|
||||
--hash=sha256:0e05956a3201b1fbb54a1861277df919318a4941797f2d87fd558ac5ec232151 \
|
||||
--hash=sha256:2f4e3200ee3562d195802b363487dd7fe6039a8c13630fc25fa3e8726c7a8654 \
|
||||
--hash=sha256:3ab0c843b4fb04fbac22f1b8455e389063208a50f6a95d7a90627939968198de \
|
||||
--hash=sha256:6066bbe9a7ae1d667cde08d262deb6fbea6feb35deb9177dd47141b55bbd9833 \
|
||||
--hash=sha256:94d4c64779f3213a5cc538d3bd10a873da192cb9b0039db56690e556ba00b55c \
|
||||
--hash=sha256:995c06e905940b10bb2aefe653225340b5a3f56f3efb373fe07f4e57b5043705 \
|
||||
--hash=sha256:d482ed27a805cbd4aca2660da212008dd6e41d87d255279f405ff13b725970e2
|
||||
# via semantica (pyproject.toml)
|
||||
platformdirs==4.11.7 \
|
||||
--hash=sha256:4f41487eeeeeb07f3a6625e61d9bc0ae6809f92d3386dbd74392fbb76108104d \
|
||||
@@ -5293,7 +5306,9 @@ pypdfium2==5.13.0 \
|
||||
--hash=sha256:d96929bde3bd64c771ab3558ca1ffd7704cc4d872ab92cd9f8f8b8a20f7f36b8 \
|
||||
--hash=sha256:d96beb7f379e6c76d874ca93fcd182ac3168dd499056407070f9927fb1061b8e \
|
||||
--hash=sha256:da5c7b74eebf40b5c1fbe1de01aa1edc8827a79fb1efd999616bc20dcaf77ba4
|
||||
# via docling-slim
|
||||
# via
|
||||
# docling-slim
|
||||
# pdfplumber
|
||||
pypickle==2.0.2 \
|
||||
--hash=sha256:d3307127314465fe3dc8f0162e11777d5e8284f3a29dc48b0f770d364a85d998 \
|
||||
--hash=sha256:d577e39cf501c7c80b1387f6d7dc885cf4efeba65f213df41226d1f24881b1e8
|
||||
|
||||
@@ -10,7 +10,7 @@ Main exports:
|
||||
- Config: Configuration management
|
||||
"""
|
||||
|
||||
__version__ = "0.6.8"
|
||||
__version__ = "0.7.0"
|
||||
__author__ = "Semantica Contributors"
|
||||
__license__ = "MIT"
|
||||
|
||||
|
||||
+138
-35
@@ -95,7 +95,26 @@ _ERROR_HINTS: Dict[type, str] = {
|
||||
}
|
||||
|
||||
|
||||
def _json_error_mode() -> bool:
|
||||
"""True when this invocation promised machine-readable stdout.
|
||||
|
||||
Covers both the global ``--json`` flag (stored on the CLI context) and a
|
||||
subcommand's local ``--json`` flag (uniformly named ``local_json``).
|
||||
"""
|
||||
ctx = click.get_current_context(silent=True)
|
||||
if ctx is None:
|
||||
return False
|
||||
if ctx.params.get("local_json"):
|
||||
return True
|
||||
return isinstance(ctx.obj, CLIContext) and ctx.obj.json_output
|
||||
|
||||
|
||||
def _show_error_card(title: str, detail: str, hint: Optional[str] = None) -> None:
|
||||
if _json_error_mode():
|
||||
# --json promises machine-readable stdout with errors on stderr, so
|
||||
# emit a structured error line there instead of a Rich panel.
|
||||
click.echo(json.dumps({"error": detail, "type": title}), err=True)
|
||||
return
|
||||
body = f"[bold]{title}[/bold]\n[{_DIM}]{detail}[/{_DIM}]"
|
||||
if hint:
|
||||
body += f"\n\n[{_KEY}]→[/{_KEY}] [{_DIM}]{hint}[/{_DIM}]"
|
||||
@@ -105,7 +124,7 @@ def _show_error_card(title: str, detail: str, hint: Optional[str] = None) -> Non
|
||||
|
||||
|
||||
def _run_with_error_handling(action: Callable[[], None]) -> None:
|
||||
"""Run a CLI action with Rich error cards on failure."""
|
||||
"""Run a CLI action with error cards (or JSON-mode stderr errors) on failure."""
|
||||
try:
|
||||
action()
|
||||
except click.ClickException as exc:
|
||||
@@ -446,7 +465,7 @@ def _show_startup(cli_ctx: CLIContext) -> None:
|
||||
return
|
||||
cfg = cli_ctx.config.to_dict()
|
||||
graph_store = (
|
||||
cli_ctx.store_backend or cfg.get("graph_db", {}).get("backend", "memory")
|
||||
cli_ctx.store_backend or cfg.get("graph_db", {}).get("backend", "neo4j")
|
||||
)
|
||||
vector_store = (
|
||||
cli_ctx.vector_store_backend
|
||||
@@ -832,9 +851,7 @@ def doctor(cli_ctx: CLIContext, local_json: bool, deep_embeddings: bool) -> None
|
||||
# Graph store reachability
|
||||
def _graph() -> str:
|
||||
cfg = cli_ctx.config.to_dict()
|
||||
backend = cli_ctx.store_backend or cfg.get("graph_db", {}).get("backend", "memory")
|
||||
if backend == "memory":
|
||||
return "memory (always available)"
|
||||
backend = cli_ctx.store_backend or cfg.get("graph_db", {}).get("backend", "neo4j")
|
||||
gs = _get_graph_store(cli_ctx)
|
||||
gs.ping() if hasattr(gs, "ping") else gs.connect()
|
||||
return f"{backend} reachable"
|
||||
@@ -861,10 +878,18 @@ def doctor(cli_ctx: CLIContext, local_json: bool, deep_embeddings: bool) -> None
|
||||
def _embedding_backend(method: str) -> str:
|
||||
if method == "sentence_transformers":
|
||||
import sentence_transformers # noqa: F401
|
||||
note = f"importable ({importlib.metadata.version('sentence-transformers')})"
|
||||
try:
|
||||
ver = importlib.metadata.version("sentence-transformers")
|
||||
except Exception:
|
||||
ver = getattr(sentence_transformers, "__version__", "installed")
|
||||
note = f"importable ({ver})"
|
||||
else:
|
||||
import fastembed # noqa: F401
|
||||
note = f"importable ({importlib.metadata.version('fastembed')})"
|
||||
try:
|
||||
ver = importlib.metadata.version("fastembed")
|
||||
except Exception:
|
||||
ver = getattr(fastembed, "__version__", "installed")
|
||||
note = f"importable ({ver})"
|
||||
if not deep:
|
||||
return note
|
||||
try:
|
||||
@@ -885,12 +910,12 @@ def doctor(cli_ctx: CLIContext, local_json: bool, deep_embeddings: bool) -> None
|
||||
checks.append(_check(
|
||||
"Embeddings (sentence-transformers)",
|
||||
lambda: _embedding_backend("sentence_transformers"),
|
||||
hint="pip install sentence-transformers",
|
||||
hint="pip install 'semantica[embeddings-local]'",
|
||||
))
|
||||
checks.append(_check(
|
||||
"Embeddings (fastembed)",
|
||||
lambda: _embedding_backend("fastembed"),
|
||||
hint="pip install fastembed",
|
||||
hint="pip install 'semantica[embeddings-local]'",
|
||||
))
|
||||
|
||||
# LLM provider keys
|
||||
@@ -917,11 +942,11 @@ def doctor(cli_ctx: CLIContext, local_json: bool, deep_embeddings: bool) -> None
|
||||
for lbl, st, note, hint in checks])
|
||||
return
|
||||
|
||||
tbl = Table(box=_TABLE_BOX, show_edge=False, padding=(0, 2))
|
||||
tbl.add_column("Check", style=_KEY, no_wrap=True, min_width=16)
|
||||
tbl.add_column("Status", no_wrap=True, min_width=6)
|
||||
tbl.add_column("Note", style=_DIM)
|
||||
tbl.add_column("Hint", style=_DIM)
|
||||
tbl = Table(box=_TABLE_BOX, show_edge=False, padding=(0, 2), expand=True)
|
||||
tbl.add_column("Check", style=_KEY, no_wrap=True, min_width=34)
|
||||
tbl.add_column("Status", no_wrap=True, min_width=4)
|
||||
tbl.add_column("Note", style=_DIM, min_width=15, ratio=2, overflow="fold")
|
||||
tbl.add_column("Hint", style=_DIM, min_width=20, ratio=3, overflow="fold")
|
||||
|
||||
icons = {"ok": f"[{_SUCCESS}] ✓[/{_SUCCESS}]",
|
||||
"warn": f"[{_WARN_STY}] ⚠[/{_WARN_STY}]",
|
||||
@@ -1155,6 +1180,57 @@ def _get_graph_store(cli_ctx: CLIContext) -> Any:
|
||||
return GraphStore(backend=backend, **graph_db)
|
||||
|
||||
|
||||
def _load_rule_definitions(path: str) -> List[str]:
|
||||
"""Load reasoning rule definitions from a YAML or plain-text rules file.
|
||||
|
||||
YAML files may hold a list of rule strings or a mapping with a ``rules``
|
||||
list; anything else (e.g. Datalog) is read as one rule per non-comment
|
||||
line. The strings are handed to ``Reasoner.add_rule()`` untouched.
|
||||
"""
|
||||
text = Path(path).read_text(encoding="utf-8")
|
||||
try:
|
||||
data = yaml.safe_load(text)
|
||||
except yaml.YAMLError:
|
||||
data = None
|
||||
if isinstance(data, dict):
|
||||
rules_value = data.get("rules")
|
||||
if rules_value is None and "rules" not in data:
|
||||
raise click.ClickException(
|
||||
f"Rules file '{path}' is a YAML mapping but has no 'rules' key. "
|
||||
"Expected either a YAML list or a mapping with a 'rules' list."
|
||||
)
|
||||
data = rules_value
|
||||
if isinstance(data, list):
|
||||
return [str(item) for item in data]
|
||||
return [line.strip() for line in text.splitlines()
|
||||
if line.strip() and not line.lstrip().startswith("#")]
|
||||
|
||||
|
||||
def _graph_store_facts(cli_ctx: CLIContext) -> List[str]:
|
||||
"""Read the configured graph store into Reasoner fact strings.
|
||||
|
||||
Follows the same conventions ``Reasoner.add_fact()`` applies to
|
||||
KG-style dicts: nodes become ``Label(name)`` and relationships become
|
||||
``TYPE(source, target)``, with internal node ids resolved to names.
|
||||
"""
|
||||
gs = _get_graph_store(cli_ctx)
|
||||
nodes = gs.get_nodes(limit=sys.maxsize)
|
||||
relationships = gs.get_relationships(limit=sys.maxsize)
|
||||
names: Dict[Any, Any] = {}
|
||||
facts: List[str] = []
|
||||
for node in nodes:
|
||||
props = node.get("properties") or {}
|
||||
name = props.get("name") or props.get("id") or node.get("id")
|
||||
names[node.get("id")] = name
|
||||
for label in node.get("labels") or ["Entity"]:
|
||||
facts.append(f"{label}({name})")
|
||||
for rel in relationships:
|
||||
source = names.get(rel.get("start_node_id"), rel.get("start_node_id"))
|
||||
target = names.get(rel.get("end_node_id"), rel.get("end_node_id"))
|
||||
facts.append(f"{rel.get('type', 'RELATED_TO')}({source}, {target})")
|
||||
return facts
|
||||
|
||||
|
||||
# ─── Output helpers ──────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@@ -2212,17 +2288,43 @@ def reason_run(cli_ctx: CLIContext, engine: str, rules: Optional[str],
|
||||
cli_ctx = _require_ctx(cli_ctx)
|
||||
|
||||
def _action() -> None:
|
||||
# Only the forward-chaining production-rule engines run through
|
||||
# Reasoner.infer_facts(); the other engines take different inputs
|
||||
# (SPARQL/Datalog queries, observations, premises) and are not wired
|
||||
# to this command yet. Fail honestly instead of silently
|
||||
# forward-chaining under another engine's name.
|
||||
if engine not in ("rete", "forward-chain"):
|
||||
hint = (" Use 'semantica reason query' for SPARQL/Datalog queries."
|
||||
if engine in ("sparql", "datalog") else "")
|
||||
raise click.ClickException(
|
||||
f"Engine '{engine}' is not wired to 'reason run' yet; "
|
||||
f"supported engines: rete, forward-chain.{hint}")
|
||||
try:
|
||||
from .reasoning import Reasoner
|
||||
# Reasoner has no run() method (#1354); dispatch to its real
|
||||
# API: facts from the configured graph store + rules from the
|
||||
# optional --rules file into infer_facts().
|
||||
r = Reasoner(engine=engine, config=cli_ctx.config.to_dict())
|
||||
rule_defs = _load_rule_definitions(rules) if rules else None
|
||||
facts = _graph_store_facts(cli_ctx)
|
||||
|
||||
def _infer() -> Dict[str, Any]:
|
||||
inferred = r.infer_facts(facts, rule_defs)
|
||||
return {
|
||||
"engine": engine,
|
||||
"facts": len(facts),
|
||||
"inferred_count": len(inferred),
|
||||
"inferred_facts": inferred,
|
||||
}
|
||||
|
||||
if cli_ctx.quiet or cli_ctx.json_output:
|
||||
result = r.run(rules_file=rules)
|
||||
result = _infer()
|
||||
else:
|
||||
with console.status(
|
||||
f"[{_DIM}]Running {engine} reasoning engine…[/{_DIM}]",
|
||||
spinner="dots",
|
||||
):
|
||||
result = r.run(rules_file=rules)
|
||||
result = _infer()
|
||||
except ImportError as exc:
|
||||
raise click.ClickException(f"Reasoning module not available: {exc}") from exc
|
||||
if _is_json(cli_ctx, local_json):
|
||||
@@ -3713,14 +3815,17 @@ def store_connect(cli_ctx: CLIContext, backend: str, uri: Optional[str], local_j
|
||||
|
||||
def _action() -> None:
|
||||
try:
|
||||
from .graph_store import get_graph_store_method
|
||||
store_cls = get_graph_store_method(backend)
|
||||
# get_graph_store_method(task, method_name) is the method
|
||||
# registry, not a backend factory (#1354); build the store
|
||||
# through GraphStore, which resolves the backend by name.
|
||||
from .graph_store import GraphStore
|
||||
cfg = dict(cli_ctx.config.to_dict().get("graph_db", {}))
|
||||
cfg.pop("backend", None)
|
||||
if uri:
|
||||
cfg["uri"] = uri
|
||||
# Attempt instantiation as the minimal connectivity probe; backends
|
||||
# that require a live connection will fail here if unreachable.
|
||||
store_instance = store_cls(config=cfg)
|
||||
# Instantiation only wires the backend; the probe below performs
|
||||
# the live connectivity check and raises if unreachable.
|
||||
store_instance = GraphStore(backend=backend, **cfg)
|
||||
for probe in ("health_check", "ping", "connect"):
|
||||
fn = getattr(store_instance, probe, None)
|
||||
if callable(fn):
|
||||
@@ -4665,15 +4770,10 @@ def mcp_list_tools(cli_ctx: CLIContext, local_json: bool) -> None:
|
||||
cli_ctx = _require_ctx(cli_ctx)
|
||||
|
||||
def _action() -> None:
|
||||
try:
|
||||
from semantica_mcp.mcp.tools import __all__ as tools
|
||||
except ImportError:
|
||||
tools = [
|
||||
"extract_entities", "extract_relations", "build_graph",
|
||||
"query_graph", "get_graph_analytics", "run_reasoning",
|
||||
"record_decision", "get_decisions", "export_graph",
|
||||
"validate_shacl", "get_provenance", "embed_and_search",
|
||||
]
|
||||
# Same catalog the server exposes via tools/list, so `list-tools`
|
||||
# and `mcp start` can't drift (issue #1355).
|
||||
from semantica_mcp.mcp.tools import TOOL_DEFINITIONS
|
||||
tools = [t["name"] for t in TOOL_DEFINITIONS]
|
||||
if _is_json(cli_ctx, local_json):
|
||||
_jecho({"tools": list(tools)})
|
||||
else:
|
||||
@@ -4706,12 +4806,15 @@ def mcp_call(cli_ctx: CLIContext, tool_name: str, args: str, local_json: bool) -
|
||||
tool_args = json.loads(args)
|
||||
except json.JSONDecodeError as exc:
|
||||
raise click.ClickException(f"Invalid JSON in --args: {exc}") from exc
|
||||
if not isinstance(tool_args, dict):
|
||||
raise click.ClickException("--args must be a JSON object")
|
||||
# Dispatch through the same server `mcp start` spawns; its session
|
||||
# module never defined MCPSession (issue #1355).
|
||||
from semantica_mcp.mcp.server import UnknownToolError, call_tool
|
||||
try:
|
||||
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:
|
||||
raise click.ClickException(f"MCP module not available: {exc}") from exc
|
||||
result = call_tool(tool_name, tool_args)
|
||||
except UnknownToolError as exc:
|
||||
raise click.ClickException(str(exc)) from exc
|
||||
if _is_json(cli_ctx, local_json):
|
||||
_jecho(result if isinstance(result, (dict, list)) else {"result": str(result)})
|
||||
else:
|
||||
|
||||
@@ -4791,14 +4791,18 @@ class ContextGraph:
|
||||
|
||||
# Find potential causes (decisions that influenced this one) via
|
||||
# shared entities/timestamps - additive heuristic, skipping anything
|
||||
# already covered by an explicit relationship above.
|
||||
potential_causes = []
|
||||
# already covered by an explicit relationship above. Deduplicate by
|
||||
# decision id (dict preserves insertion order): a decision sharing
|
||||
# several entities with the current one is one potential cause,
|
||||
# not one per shared entity, otherwise the trace reports the same
|
||||
# "influences" chain once per overlapping entity.
|
||||
potential_causes = {}
|
||||
for entity in current_decision["entities"]:
|
||||
for other_decision_id in self._entity_index.get(entity, set()):
|
||||
if other_decision_id != current_id and other_decision_id not in explicit_cause_ids:
|
||||
other_decision = self._decisions[other_decision_id]
|
||||
if other_decision["timestamp"] < current_decision["timestamp"]:
|
||||
potential_causes.append(other_decision_id)
|
||||
potential_causes[other_decision_id] = None
|
||||
|
||||
for cause_id in potential_causes:
|
||||
cause_dec = self._decisions.get(cause_id, {})
|
||||
|
||||
@@ -14,11 +14,15 @@ not. It *composes* the existing public APIs; nothing in ``context_graph.py`` or
|
||||
``agent_memory.py`` changes, and ``ContextGraph`` keeps its graph-scope
|
||||
contract.
|
||||
|
||||
The property that matters is honest partial reporting. Three vector backends
|
||||
(FAISS, Milvus, Weaviate) expose no delete at all, so erasure is genuinely not
|
||||
completable on them today. The receipt says ``unsupported`` for those rather
|
||||
than reporting a success it did not achieve -- a receipt that reads
|
||||
"graph: erased, memory: 14 erased, vectors: unsupported on faiss" is
|
||||
The property that matters is honest partial reporting. FAISS Flat indices now
|
||||
expose ``delete_vectors`` backed by native ``remove_ids``, so erasure is
|
||||
completable on them. FAISS IVF indices explicitly reject deletion because
|
||||
their internal labels are not compacted after ``remove_ids``, which would
|
||||
desynchronize search results from the ``vector_ids`` mapping. HNSW does not
|
||||
implement ``remove_ids`` at all. Both IVF and HNSW report ``unsupported``.
|
||||
Milvus and Weaviate are also fully supported. The receipt says ``unsupported``
|
||||
rather than reporting a success it did not achieve -- a receipt that reads
|
||||
"graph: erased, memory: 14 erased, vectors: unsupported on faiss/hnsw" is
|
||||
actionable; a bare ``True`` is a compliance liability.
|
||||
|
||||
Example:
|
||||
@@ -29,9 +33,9 @@ Example:
|
||||
... "customer-4471", reason="GDPR Art. 17 request #882"
|
||||
... )
|
||||
>>> receipt.complete
|
||||
False
|
||||
True
|
||||
>>> receipt.stores["vectors"]["status"]
|
||||
'unsupported'
|
||||
'not_configured'
|
||||
"""
|
||||
|
||||
import copy
|
||||
@@ -377,8 +381,9 @@ class ErasureCoordinator:
|
||||
|
||||
method_name, target = _vector_delete_capability(self.vector_store)
|
||||
if method_name is None:
|
||||
# FAISS, Milvus and Weaviate expose no delete at all; FAISS in
|
||||
# particular cannot remove from a flat index without a rebuild.
|
||||
# FAISS HNSW does not implement remove_ids and FAISS IVF
|
||||
# does not compact labels after remove_ids. Only Flat indices
|
||||
# currently support deletion via this code path.
|
||||
self.logger.warning(
|
||||
"Vector backend %r exposes no delete; %d vector id(s) for %r "
|
||||
"were not erased",
|
||||
|
||||
@@ -212,7 +212,7 @@ class FastEmbedStore(ProviderStore):
|
||||
self.logger.info(f"Loaded FastEmbed model: {self.model_name}")
|
||||
except (ImportError, OSError):
|
||||
self.logger.warning(
|
||||
"fastembed not available. Install with: pip install fastembed"
|
||||
"fastembed not available. Install with: pip install 'semantica[embeddings-local]'"
|
||||
)
|
||||
except Exception as e:
|
||||
self.logger.warning(f"Failed to load FastEmbed model: {e}")
|
||||
|
||||
@@ -35,6 +35,7 @@ try:
|
||||
|
||||
SENTENCE_TRANSFORMERS_AVAILABLE = True
|
||||
except (ImportError, OSError):
|
||||
SentenceTransformer = None
|
||||
SENTENCE_TRANSFORMERS_AVAILABLE = False
|
||||
|
||||
try:
|
||||
@@ -42,6 +43,7 @@ try:
|
||||
|
||||
FASTEMBED_AVAILABLE = True
|
||||
except (ImportError, OSError):
|
||||
TextEmbedding = None
|
||||
FASTEMBED_AVAILABLE = False
|
||||
|
||||
|
||||
@@ -156,7 +158,7 @@ class TextEmbedder:
|
||||
else:
|
||||
self.logger.warning(
|
||||
"fastembed not available. "
|
||||
"Install with: pip install fastembed. "
|
||||
"Install with: pip install 'semantica[embeddings-local]'. "
|
||||
"Using fallback embedding method."
|
||||
)
|
||||
else:
|
||||
@@ -178,7 +180,7 @@ class TextEmbedder:
|
||||
else:
|
||||
self.logger.warning(
|
||||
"sentence-transformers not available. "
|
||||
"Install with: pip install sentence-transformers. "
|
||||
"Install with: pip install 'semantica[embeddings-local]'. "
|
||||
"Using fallback embedding method."
|
||||
)
|
||||
|
||||
|
||||
@@ -52,6 +52,23 @@ async def list_decisions(
|
||||
return [_node_to_decision(node) for node in nodes[skip : skip + limit]]
|
||||
|
||||
|
||||
@router.get("/causal-distance", response_model=CausalDistanceReport)
|
||||
async def causal_distance(
|
||||
source: str = Query(..., description="Source node/decision ID"),
|
||||
target: str = Query(..., description="Target node/decision ID"),
|
||||
session: GraphSession = Depends(get_session),
|
||||
):
|
||||
"""FR-8 — Compute causal distance between two decisions via causal-edge-only traversal."""
|
||||
from ...context.causal_analyzer import CausalChainAnalyzer
|
||||
|
||||
analyzer = CausalChainAnalyzer(session.graph)
|
||||
report = await asyncio.to_thread(analyzer.interpret_causal_distance, source, target)
|
||||
return CausalDistanceReport(**report)
|
||||
|
||||
|
||||
# NOTE: static routes (e.g. /causal-distance above) must stay above this
|
||||
# dynamic route — Starlette matches in definition order, otherwise the static
|
||||
# path is captured as decision_id (see issue #1531).
|
||||
@router.get("/{decision_id}", response_model=DecisionResponse)
|
||||
async def get_decision(
|
||||
decision_id: str,
|
||||
@@ -125,20 +142,6 @@ async def get_precedents(
|
||||
return [_node_to_decision(decision) for _, decision in scored[:limit]]
|
||||
|
||||
|
||||
@router.get("/causal-distance", response_model=CausalDistanceReport)
|
||||
async def causal_distance(
|
||||
source: str = Query(..., description="Source node/decision ID"),
|
||||
target: str = Query(..., description="Target node/decision ID"),
|
||||
session: GraphSession = Depends(get_session),
|
||||
):
|
||||
"""FR-8 — Compute causal distance between two decisions via causal-edge-only traversal."""
|
||||
from ...context.causal_analyzer import CausalChainAnalyzer
|
||||
|
||||
analyzer = CausalChainAnalyzer(session.graph)
|
||||
report = await asyncio.to_thread(analyzer.interpret_causal_distance, source, target)
|
||||
return CausalDistanceReport(**report)
|
||||
|
||||
|
||||
@router.get("/{decision_id}/compliance", response_model=ComplianceResponse)
|
||||
async def check_compliance(
|
||||
decision_id: str,
|
||||
|
||||
@@ -35,6 +35,10 @@ router = APIRouter(prefix="/api/ontology", tags=["ontology"])
|
||||
_MAX_FETCH_BYTES = 20 * 1024 * 1024 # 20 MB
|
||||
_MAX_ANALYSIS_NODES = 5_000 # cap for health/suggest/shacl node scans to avoid OOM
|
||||
_MAX_ENTITIES_PER_SIDE = 500 # per-ontology cap for the O(n²) pairwise suggestion loop
|
||||
_GRAPH_TOO_LARGE_DETAIL = (
|
||||
"Ontology editor graph exceeds the maximum size "
|
||||
f"({_MAX_ANALYSIS_NODES} nodes or edges)."
|
||||
)
|
||||
|
||||
|
||||
class GraphTruncationError(Exception):
|
||||
@@ -72,6 +76,20 @@ _ONTOLOGY_TYPES = frozenset({
|
||||
}) | _SCHEME_TYPES
|
||||
|
||||
_SEARCHABLE_TYPES = _CLASS_TYPES | _PROPERTY_TYPES | _INDIVIDUAL_TYPES | _CONCEPT_TYPES | _SCHEME_TYPES
|
||||
_SCHEMA_NODE_TYPES = _CLASS_TYPES | _PROPERTY_TYPES | _CONCEPT_TYPES | _ONTOLOGY_TYPES
|
||||
_STRUCTURE_EDGE_TYPES = frozenset({
|
||||
"rdf:type",
|
||||
"rdfs:subClassOf",
|
||||
"rdfs:domain",
|
||||
"rdfs:range",
|
||||
"owl:disjointWith",
|
||||
"owl:equivalentClass",
|
||||
"owl:equivalentProperty",
|
||||
"owl:inverseOf",
|
||||
"skos:broader",
|
||||
"skos:narrower",
|
||||
"skos:related",
|
||||
})
|
||||
|
||||
_URI_PREFIX_MAP = {
|
||||
"http://www.w3.org/2002/07/owl#": "owl:",
|
||||
@@ -226,6 +244,7 @@ class EntityDetailResponse(BaseModel):
|
||||
entity_type: str
|
||||
definition: Optional[str] = None
|
||||
source_ontology: Optional[str] = None
|
||||
owning_ontology: Optional[str] = None
|
||||
superclasses: List[str] = Field(default_factory=list)
|
||||
subclasses: List[str] = Field(default_factory=list)
|
||||
domain: List[str] = Field(default_factory=list)
|
||||
@@ -796,6 +815,55 @@ def _node_belongs_to_ontology(
|
||||
return "#" not in local_name and "/" not in local_name
|
||||
|
||||
|
||||
def _resolve_owning_ontology(
|
||||
node: Dict[str, Any],
|
||||
known_ontology_uris: set[str],
|
||||
) -> Optional[str]:
|
||||
"""Return the one known ontology that owns this node, or None if none does.
|
||||
|
||||
The most specific (longest) match wins, so a nested vocabulary claims its
|
||||
own terms instead of the parent absorbing them.
|
||||
|
||||
Kept agreeing with _node_belongs_to_ontology by construction — the same
|
||||
three rules in the same order — but in one pass over the candidates rather
|
||||
than one pass per candidate, each of which rescanned the whole set to find
|
||||
the longest namespace. That made resolution quadratic in the number of
|
||||
registered ontologies.
|
||||
"""
|
||||
nid = str(node.get("id", ""))
|
||||
if not nid:
|
||||
return None
|
||||
|
||||
# An ontology node owns itself, ahead of any scheme_uri it may carry.
|
||||
if nid in known_ontology_uris:
|
||||
return nid
|
||||
|
||||
# An explicit owner is authoritative even when it is not registered:
|
||||
# naming a different ontology by namespace guess would be worse than
|
||||
# reporting the one the node itself points at.
|
||||
explicit_owner = _node_source_ontology(node)
|
||||
if explicit_owner:
|
||||
return explicit_owner
|
||||
|
||||
longest_namespace: Optional[str] = None
|
||||
for candidate in known_ontology_uris:
|
||||
stem = candidate.rstrip("#/")
|
||||
if not nid.startswith((stem + "#", stem + "/")):
|
||||
continue
|
||||
if longest_namespace is None or len(candidate) > len(longest_namespace):
|
||||
longest_namespace = candidate
|
||||
if longest_namespace is None:
|
||||
return None
|
||||
|
||||
# Prefix ownership only extends to names minted directly in the namespace.
|
||||
# A further delimiter marks a nested vocabulary, which stays unowned until
|
||||
# it is registered or carries an explicit owner.
|
||||
local_name = nid[len(longest_namespace.rstrip("#/")) + 1 :]
|
||||
if "#" in local_name or "/" in local_name:
|
||||
return None
|
||||
return longest_namespace
|
||||
|
||||
|
||||
def _is_ontology_entity(node: Dict[str, Any]) -> bool:
|
||||
return _classify_node_type(node.get("type", "")) in {"class", "property", "concept", "scheme"}
|
||||
|
||||
@@ -1821,6 +1889,64 @@ async def search_entities(
|
||||
return results
|
||||
|
||||
|
||||
def _known_ontology_uris(
|
||||
session: GraphSession, registry: Dict[str, OntologyEntry]
|
||||
) -> set[str]:
|
||||
known = set(registry)
|
||||
for node_type in _ONTOLOGY_TYPES:
|
||||
for node in session.iter_nodes(node_type=node_type):
|
||||
node_id = str(node.get("id", ""))
|
||||
if node_id:
|
||||
known.add(node_id)
|
||||
return known
|
||||
|
||||
|
||||
def _collect_core_nodes(
|
||||
session: GraphSession, uri: str, known_ontology_uris: set[str]
|
||||
) -> Dict[str, Dict[str, Any]]:
|
||||
"""Stream schema nodes, keeping only the ones this ontology owns.
|
||||
|
||||
Filtering as each node arrives makes _MAX_ANALYSIS_NODES bound the work and
|
||||
not merely the response: foreign nodes are discarded instead of materialized,
|
||||
and the scan stops once the owned ones pass the cap. The ownership filter has
|
||||
to stay ahead of that check — thousands of *other* ontologies' nodes must
|
||||
never make this one too large to open. Requesting pages instead would bound
|
||||
nothing: paginate_nodes normalizes the whole matching set on every call.
|
||||
"""
|
||||
core_nodes_by_id: Dict[str, Dict[str, Any]] = {}
|
||||
for node_type in _SCHEMA_NODE_TYPES:
|
||||
for node in session.iter_nodes(node_type=node_type):
|
||||
node_id = str(node.get("id", ""))
|
||||
if not node_id or not _node_belongs_to_ontology(
|
||||
node, uri, known_ontology_uris
|
||||
):
|
||||
continue
|
||||
core_nodes_by_id[node_id] = node
|
||||
if len(core_nodes_by_id) > _MAX_ANALYSIS_NODES:
|
||||
raise GraphTruncationError(_GRAPH_TOO_LARGE_DETAIL)
|
||||
return core_nodes_by_id
|
||||
|
||||
|
||||
def _select_structure_edges(
|
||||
session: GraphSession, core_node_ids: set[str]
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""Stream structural edges, keeping only those leaving 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: List[Dict[str, Any]] = []
|
||||
for edge_type in _STRUCTURE_EDGE_TYPES:
|
||||
for edge in session.iter_edges(edge_type=edge_type):
|
||||
if str(edge.get("source", "")) not in core_node_ids:
|
||||
continue
|
||||
selected_edges.append(edge)
|
||||
if len(selected_edges) > _MAX_ANALYSIS_NODES:
|
||||
raise GraphTruncationError(_GRAPH_TOO_LARGE_DETAIL)
|
||||
return selected_edges
|
||||
|
||||
|
||||
@router.get("/graph", response_model=OntologyGraphResponse)
|
||||
async def get_ontology_graph(
|
||||
request: Request,
|
||||
@@ -1828,88 +1954,39 @@ async def get_ontology_graph(
|
||||
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")
|
||||
}
|
||||
known_ontology_uris = await asyncio.to_thread(
|
||||
_known_ontology_uris, session, _get_registry(request)
|
||||
)
|
||||
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
|
||||
try:
|
||||
core_nodes_by_id = await asyncio.to_thread(
|
||||
_collect_core_nodes, session, uri, known_ontology_uris
|
||||
)
|
||||
candidates_by_id.update(
|
||||
(str(node.get("id", "")), node) for node in nodes if node.get("id")
|
||||
if not core_nodes_by_id:
|
||||
raise HTTPException(status_code=404, detail="Ontology graph not found.")
|
||||
core_node_ids = set(core_nodes_by_id)
|
||||
selected_edges = await asyncio.to_thread(
|
||||
_select_structure_edges, session, core_node_ids
|
||||
)
|
||||
except GraphTruncationError as exc:
|
||||
raise HTTPException(status_code=413, detail=str(exc)) from exc
|
||||
|
||||
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.")
|
||||
# Invariant: the helpers raise the moment their accumulation passes
|
||||
# _MAX_ANALYSIS_NODES, so core_nodes_by_id and selected_edges are both
|
||||
# within the cap here; a post-filter re-check would be unreachable.
|
||||
external_node_ids = {
|
||||
node_id
|
||||
for edge in selected_edges
|
||||
for node_id in (str(edge.get("source", "")), str(edge.get("target", "")))
|
||||
} - core_node_ids
|
||||
external_nodes = await asyncio.gather(
|
||||
*(asyncio.to_thread(session.get_node, node_id) for node_id in external_node_ids)
|
||||
)
|
||||
|
||||
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 = list(core_nodes_by_id.values())
|
||||
selected_nodes.extend(node for node in external_nodes if node is not None)
|
||||
selected_nodes.sort(key=lambda node: str(node.get("id", "")))
|
||||
selected_edges.sort(
|
||||
key=lambda edge: (
|
||||
@@ -1925,6 +2002,7 @@ async def get_ontology_graph(
|
||||
@router.get("/entity/{entity_uri:path}", response_model=EntityDetailResponse)
|
||||
async def get_entity_detail(
|
||||
entity_uri: str,
|
||||
request: Request,
|
||||
session: GraphSession = Depends(get_session),
|
||||
):
|
||||
node = await asyncio.to_thread(session.get_node, entity_uri)
|
||||
@@ -1946,12 +2024,21 @@ async def get_entity_detail(
|
||||
|
||||
all_nodes, _ = await asyncio.to_thread(session.get_nodes, skip=0, limit=999_999)
|
||||
instance_count = sum(1 for n in all_nodes if n.get("type") == entity_uri)
|
||||
# Ownership must use the same candidate set as /graph, so it goes through the
|
||||
# same helper rather than being derived from all_nodes above: that scan is
|
||||
# capped at 999,999, and on a larger graph a truncated set would silently
|
||||
# drop ontologies and make the two endpoints disagree about who owns a node.
|
||||
# The helper iterates only the ontology node types, so it is not a full scan.
|
||||
known_ontology_uris = await asyncio.to_thread(
|
||||
_known_ontology_uris, session, _get_registry(request)
|
||||
)
|
||||
|
||||
return EntityDetailResponse(
|
||||
uri=entity_uri, label=label,
|
||||
type=ntype, entity_type=_classify_node_type(ntype),
|
||||
definition=definition,
|
||||
source_ontology=props.get("scheme_uri"),
|
||||
owning_ontology=_resolve_owning_ontology(node, known_ontology_uris),
|
||||
superclasses=superclasses, subclasses=subclasses,
|
||||
domain=domain, range=range_,
|
||||
instance_count=instance_count, properties=props,
|
||||
|
||||
@@ -9,7 +9,7 @@ import threading
|
||||
import time
|
||||
import uuid
|
||||
from datetime import UTC, datetime
|
||||
from typing import Any, Dict, Iterable, List, Optional
|
||||
from typing import Any, Dict, Iterable, Iterator, List, Optional
|
||||
|
||||
from ..context.context_graph import ContextGraph, _resolve_edge_identity
|
||||
from .search_index import GraphSearchIndex
|
||||
@@ -375,6 +375,52 @@ class GraphSession:
|
||||
)
|
||||
return page, total
|
||||
|
||||
def iter_nodes(self, node_type: Optional[str] = None) -> Iterator[Dict[str, Any]]:
|
||||
"""Yield matching nodes one at a time, in the same order as ``paginate_nodes``.
|
||||
|
||||
``paginate_nodes`` normalizes and holds the entire matching set before it
|
||||
slices out a page, so a caller that filters the result down itself cannot
|
||||
bound its cost by asking for smaller pages — it would re-pay that full
|
||||
cost per page. Streaming lets such a caller retain only what it selects
|
||||
and stop scanning as soon as it has enough.
|
||||
|
||||
Only the id list is snapshotted under the lock; nodes are read one at a
|
||||
time, so a concurrent mutation can be observed mid-iteration and ids that
|
||||
disappear are skipped. ``paginate_nodes`` is the atomic alternative.
|
||||
"""
|
||||
with self._lock:
|
||||
source_ids = (
|
||||
self.graph.node_type_index.get(node_type, set())
|
||||
if node_type
|
||||
else self.graph.nodes.keys()
|
||||
)
|
||||
node_ids = sorted(
|
||||
(node_id for node_id in source_ids if node_id is not None),
|
||||
key=lambda value: str(value),
|
||||
)
|
||||
for node_id in node_ids:
|
||||
with self._lock:
|
||||
raw = self.graph.find_node(node_id)
|
||||
if raw is None:
|
||||
continue
|
||||
yield self.normalize_node(raw)
|
||||
|
||||
def iter_edges(self, edge_type: Optional[str] = None) -> Iterator[Dict[str, Any]]:
|
||||
"""Yield matching edges one at a time, in raw graph order.
|
||||
|
||||
Same rationale as ``iter_nodes``. Edge normalization derives an identity
|
||||
hash per edge, which ``paginate_edges`` pays for every matching edge (and
|
||||
then sorts) before paging; a filtering caller only needs it for the edges
|
||||
it keeps. Callers that need a stable order sort the subset they select.
|
||||
"""
|
||||
with self._lock:
|
||||
raw_edges = self.graph.find_edges(edge_type=edge_type)
|
||||
for edge in raw_edges:
|
||||
normalized = self.normalize_edge(edge)
|
||||
if not normalized["source"] or not normalized["target"]:
|
||||
continue
|
||||
yield normalized
|
||||
|
||||
def get_raw_counts(self) -> tuple[int, int]:
|
||||
"""O(1) node/edge counts from the raw collections, with no per-item
|
||||
normalization.
|
||||
|
||||
@@ -329,12 +329,27 @@ class GraphExporter:
|
||||
lines.append(' http://graphml.graphdrawing.org/xmlns/1.0/graphml.xsd">')
|
||||
lines.append("")
|
||||
|
||||
# Define attribute keys
|
||||
# Define attribute keys.
|
||||
# GraphML requires every key id referenced by a <data> element to be
|
||||
# declared here with a matching <key> element.
|
||||
#
|
||||
# for="all" — key is valid on both nodes and edges
|
||||
# for="node" — key is valid on nodes only
|
||||
# for="edge" — key is valid on edges only
|
||||
#
|
||||
# label : used on <node> (human-readable label) and <edge> (type).
|
||||
# Declared for="all" so both uses are schema-valid.
|
||||
# type : node entity type; only written on nodes.
|
||||
# confidence: written on both nodes and edges when include_attributes
|
||||
# is True; declared for="all" so edge confidence is valid.
|
||||
lines.append(
|
||||
' <key id="label" for="all" attr.name="label" attr.type="string"/>'
|
||||
)
|
||||
lines.append(
|
||||
' <key id="type" for="node" attr.name="type" attr.type="string"/>'
|
||||
)
|
||||
lines.append(
|
||||
' <key id="confidence" for="node" attr.name="confidence" attr.type="double"/>'
|
||||
' <key id="confidence" for="all" attr.name="confidence" attr.type="double"/>'
|
||||
)
|
||||
lines.append("")
|
||||
|
||||
@@ -342,23 +357,31 @@ class GraphExporter:
|
||||
lines.append(' <graph id="G" edgedefault="directed">')
|
||||
lines.append("")
|
||||
|
||||
# xml.sax.saxutils helpers:
|
||||
# escape(v) – escapes & < > in text node content
|
||||
# quoteattr(v) – escapes & < > " ' and wraps in the
|
||||
# appropriate quote character for use as an
|
||||
# XML attribute value (including the quotes)
|
||||
from xml.sax.saxutils import escape, quoteattr
|
||||
|
||||
# Export nodes
|
||||
nodes = graph_data.get("nodes", [])
|
||||
for node in nodes:
|
||||
node_id = node.get("id", "")
|
||||
label = node.get("label", "")
|
||||
node_type = node.get("type", "")
|
||||
node_id = str(node.get("id") or "")
|
||||
label = str(node.get("label") or "")
|
||||
node_type = str(node.get("type") or "")
|
||||
|
||||
lines.append(f' <node id="{node_id}">')
|
||||
lines.append(f' <data key="label">{label}</data>')
|
||||
lines.append(f' <data key="type">{node_type}</data>')
|
||||
# quoteattr produces the surrounding quotes; do NOT add extra "…"
|
||||
lines.append(f" <node id={quoteattr(node_id)}>")
|
||||
lines.append(f" <data key=\"label\">{escape(label)}</data>")
|
||||
lines.append(f" <data key=\"type\">{escape(node_type)}</data>")
|
||||
|
||||
# Add attributes if requested
|
||||
if self.include_attributes and "attributes" in node:
|
||||
attrs = node["attributes"]
|
||||
if "confidence" in attrs:
|
||||
lines.append(
|
||||
f' <data key="confidence">{attrs["confidence"]}</data>'
|
||||
f" <data key=\"confidence\">{escape(str(attrs['confidence']))}</data>"
|
||||
)
|
||||
|
||||
lines.append(" </node>")
|
||||
@@ -368,19 +391,19 @@ class GraphExporter:
|
||||
# Export edges
|
||||
edges = graph_data.get("edges", [])
|
||||
for edge in edges:
|
||||
source = edge.get("source", "")
|
||||
target = edge.get("target", "")
|
||||
edge_type = edge.get("type", "")
|
||||
source = str(edge.get("source") or "")
|
||||
target = str(edge.get("target") or "")
|
||||
edge_type = str(edge.get("type") or "")
|
||||
|
||||
lines.append(f' <edge source="{source}" target="{target}">')
|
||||
lines.append(f' <data key="label">{edge_type}</data>')
|
||||
lines.append(f" <edge source={quoteattr(source)} target={quoteattr(target)}>")
|
||||
lines.append(f" <data key=\"label\">{escape(edge_type)}</data>")
|
||||
|
||||
# Add attributes if requested
|
||||
if self.include_attributes and "attributes" in edge:
|
||||
attrs = edge["attributes"]
|
||||
if "confidence" in attrs:
|
||||
lines.append(
|
||||
f' <data key="confidence">{attrs["confidence"]}</data>'
|
||||
f" <data key=\"confidence\">{escape(str(attrs['confidence']))}</data>"
|
||||
)
|
||||
|
||||
lines.append(" </edge>")
|
||||
|
||||
@@ -421,7 +421,7 @@ class VectorExporter:
|
||||
import numpy as np
|
||||
except (ImportError, OSError):
|
||||
raise ImportError(
|
||||
"FAISS not installed. Install with: pip install faiss-cpu or faiss-gpu"
|
||||
"FAISS not installed. Install with: pip install 'semantica[vectorstore-faiss]' (or 'semantica[gpu]' for CUDA)"
|
||||
)
|
||||
|
||||
# Extract vectors and IDs
|
||||
|
||||
@@ -133,7 +133,11 @@ import importlib
|
||||
from typing import TYPE_CHECKING, Any, Dict, Tuple
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .salesforce_ingestor import SalesforceConnector, SalesforceData, SalesforceIngestor
|
||||
from .salesforce_ingestor import (
|
||||
SalesforceConnector,
|
||||
SalesforceData,
|
||||
SalesforceIngestor,
|
||||
)
|
||||
|
||||
from .config import IngestConfig, ingest_config
|
||||
from .file_ingestor import (
|
||||
@@ -248,32 +252,42 @@ _LAZY_EXPORTS: Dict[str, Tuple[str, str]] = {
|
||||
_OPTIONAL_DEPENDENCY_MESSAGES = {
|
||||
".repo_ingestor": (
|
||||
"Repository ingestion requires optional dependency 'GitPython'. "
|
||||
"Install it before importing RepoIngestor or using ingest_repository()."
|
||||
"Install it before importing RepoIngestor or using ingest_repository(). "
|
||||
"Install it with: pip install 'semantica[ingest-git]'"
|
||||
),
|
||||
".web_ingestor": (
|
||||
"Web ingestion requires optional dependency 'beautifulsoup4'. "
|
||||
"Install it before importing WebIngestor or using ingest_web()."
|
||||
"Install it before importing WebIngestor or using ingest_web(). "
|
||||
"Install it with: pip install 'semantica[documents]'"
|
||||
),
|
||||
".feed_ingestor": (
|
||||
"Feed ingestion requires optional dependency 'beautifulsoup4'. "
|
||||
"Install it before importing FeedIngestor or using ingest_feed()."
|
||||
"Install it before importing FeedIngestor or using ingest_feed(). "
|
||||
"Install it with: pip install 'semantica[documents]'"
|
||||
),
|
||||
".email_ingestor": (
|
||||
"Email ingestion requires optional dependency 'beautifulsoup4'. "
|
||||
"Install it before importing EmailIngestor or using ingest_email()."
|
||||
"Install it before importing EmailIngestor or using ingest_email(). "
|
||||
"Install it with: pip install 'semantica[documents]'"
|
||||
),
|
||||
".xml_ingestor": (
|
||||
"XML ingestion requires optional dependency 'lxml'. "
|
||||
"Install it before importing XMLIngestor or using ingest_xml(). "
|
||||
"Install it with: pip install 'semantica[documents]'"
|
||||
),
|
||||
".parquet_ingestor": (
|
||||
"Parquet ingestion requires optional dependency 'pyarrow'. "
|
||||
"Install it before importing ParquetIngestor or using ingest_parquet()."
|
||||
"Install it before importing ParquetIngestor or using ingest_parquet(). "
|
||||
"Install it with: pip install 'semantica[ingest-parquet]'"
|
||||
),
|
||||
".arrow_ingestor": (
|
||||
"Arrow ingestion requires optional dependency 'pyarrow'. "
|
||||
"Install it before importing ArrowIngestor or using ingest_arrow()."
|
||||
"Install it before importing ArrowIngestor or using ingest_arrow(). "
|
||||
"Install it with: pip install 'semantica[ingest-arrow]'"
|
||||
),
|
||||
".salesforce_ingestor": (
|
||||
"Salesforce ingestion requires optional dependency 'simple-salesforce'. "
|
||||
"Install it with: pip install \"semantica[db-salesforce]\" "
|
||||
"or: pip install simple-salesforce>=1.12.0"
|
||||
"Install it with: pip install 'semantica[db-salesforce]'"
|
||||
),
|
||||
}
|
||||
|
||||
@@ -286,13 +300,55 @@ def __getattr__(name: str) -> Any:
|
||||
module_name, attr_name = _LAZY_EXPORTS[name]
|
||||
try:
|
||||
module = importlib.import_module(module_name, __name__)
|
||||
except ModuleNotFoundError as exc:
|
||||
except (ImportError, OSError) as exc:
|
||||
message = _OPTIONAL_DEPENDENCY_MESSAGES.get(module_name)
|
||||
missing_name = getattr(exc, "name", None)
|
||||
if message and missing_name in {"git", "bs4", "pyarrow", "simple_salesforce"}:
|
||||
if message and (
|
||||
missing_name is None
|
||||
or any(
|
||||
pkg in missing_name
|
||||
for pkg in ("git", "bs4", "pyarrow", "simple_salesforce", "lxml")
|
||||
)
|
||||
):
|
||||
raise ImportError(message) from exc
|
||||
raise
|
||||
|
||||
# Guard against backends whose modules imported cleanly with dependencies
|
||||
# set to None; ensure probe imports (e.g. try: from semantica.ingest import ...)
|
||||
# fail at import time rather than postponing failure to construction time.
|
||||
if module_name == ".repo_ingestor" and name in {"RepoIngestor"}:
|
||||
if getattr(module, "git", None) is None:
|
||||
message = _OPTIONAL_DEPENDENCY_MESSAGES.get(module_name)
|
||||
if message:
|
||||
raise ImportError(message)
|
||||
|
||||
if module_name == ".xml_ingestor" and name in {"XMLIngestor"}:
|
||||
if getattr(module, "etree", None) is None:
|
||||
message = _OPTIONAL_DEPENDENCY_MESSAGES.get(module_name)
|
||||
if message:
|
||||
raise ImportError(message)
|
||||
|
||||
if module_name == ".parquet_ingestor" and name in {"ParquetIngestor"}:
|
||||
if not getattr(module, "PARQUET_AVAILABLE", True):
|
||||
message = _OPTIONAL_DEPENDENCY_MESSAGES.get(module_name)
|
||||
if message:
|
||||
raise ImportError(message)
|
||||
|
||||
if module_name == ".arrow_ingestor" and name in {"ArrowIngestor"}:
|
||||
if not getattr(module, "ARROW_AVAILABLE", True):
|
||||
message = _OPTIONAL_DEPENDENCY_MESSAGES.get(module_name)
|
||||
if message:
|
||||
raise ImportError(message)
|
||||
|
||||
if module_name == ".salesforce_ingestor" and name in {
|
||||
"SalesforceIngestor",
|
||||
"SalesforceConnector",
|
||||
}:
|
||||
if not getattr(module, "SALESFORCE_AVAILABLE", True):
|
||||
message = _OPTIONAL_DEPENDENCY_MESSAGES.get(module_name)
|
||||
if message:
|
||||
raise ImportError(message)
|
||||
|
||||
value = getattr(module, attr_name)
|
||||
globals()[name] = value
|
||||
return value
|
||||
|
||||
+203
-132
@@ -193,6 +193,7 @@ def _is_scp_like_repo_source(source: str) -> bool:
|
||||
"""Return True for scp-like SSH remotes (``user@host:path``)."""
|
||||
return bool(_SCP_LIKE_REPO_URL_RE.match(source.strip()))
|
||||
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .api_ingestor import APIData
|
||||
from .arrow_ingestor import ArrowData
|
||||
@@ -252,7 +253,12 @@ def ingest_file(
|
||||
if custom_method and custom_method != ingest_file:
|
||||
fallback = kwargs.pop("fallback_on_custom_error", False)
|
||||
result = call_custom_method(
|
||||
logger, method, custom_method, source, fallback_on_custom_error=fallback, **kwargs
|
||||
logger,
|
||||
method,
|
||||
custom_method,
|
||||
source,
|
||||
fallback_on_custom_error=fallback,
|
||||
**kwargs,
|
||||
)
|
||||
if result is not CUSTOM_METHOD_FELL_BACK:
|
||||
return result
|
||||
@@ -318,21 +324,18 @@ def ingest_parquet(
|
||||
if custom_method and custom_method != ingest_parquet:
|
||||
fallback = kwargs.pop("fallback_on_custom_error", False)
|
||||
result = call_custom_method(
|
||||
logger, method, custom_method, source, fallback_on_custom_error=fallback, **kwargs
|
||||
logger,
|
||||
method,
|
||||
custom_method,
|
||||
source,
|
||||
fallback_on_custom_error=fallback,
|
||||
**kwargs,
|
||||
)
|
||||
if result is not CUSTOM_METHOD_FELL_BACK:
|
||||
return result
|
||||
|
||||
try:
|
||||
try:
|
||||
from .parquet_ingestor import ParquetIngestor
|
||||
except ModuleNotFoundError as exc:
|
||||
if _is_missing_dependency(exc, "pyarrow"):
|
||||
raise _missing_optional_dependency(
|
||||
"Parquet ingestion",
|
||||
"pyarrow",
|
||||
) from exc
|
||||
raise
|
||||
from .parquet_ingestor import ParquetIngestor
|
||||
|
||||
config = ingest_config.get_method_config("parquet")
|
||||
config.update(kwargs)
|
||||
@@ -397,21 +400,18 @@ def ingest_arrow(
|
||||
if custom_method and custom_method != ingest_arrow:
|
||||
fallback = kwargs.pop("fallback_on_custom_error", False)
|
||||
result = call_custom_method(
|
||||
logger, method, custom_method, source, fallback_on_custom_error=fallback, **kwargs
|
||||
logger,
|
||||
method,
|
||||
custom_method,
|
||||
source,
|
||||
fallback_on_custom_error=fallback,
|
||||
**kwargs,
|
||||
)
|
||||
if result is not CUSTOM_METHOD_FELL_BACK:
|
||||
return result
|
||||
|
||||
try:
|
||||
try:
|
||||
from .arrow_ingestor import ArrowIngestor
|
||||
except ModuleNotFoundError as exc:
|
||||
if _is_missing_dependency(exc, "pyarrow"):
|
||||
raise _missing_optional_dependency(
|
||||
"Arrow ingestion",
|
||||
"pyarrow",
|
||||
) from exc
|
||||
raise
|
||||
from .arrow_ingestor import ArrowIngestor
|
||||
|
||||
config = ingest_config.get_method_config("arrow")
|
||||
config.update(kwargs)
|
||||
@@ -481,7 +481,12 @@ def ingest_xml(
|
||||
if custom_method and custom_method != ingest_xml:
|
||||
fallback = kwargs.pop("fallback_on_custom_error", False)
|
||||
result = call_custom_method(
|
||||
logger, method, custom_method, source, fallback_on_custom_error=fallback, **kwargs
|
||||
logger,
|
||||
method,
|
||||
custom_method,
|
||||
source,
|
||||
fallback_on_custom_error=fallback,
|
||||
**kwargs,
|
||||
)
|
||||
if result is not CUSTOM_METHOD_FELL_BACK:
|
||||
return result
|
||||
@@ -491,7 +496,10 @@ def ingest_xml(
|
||||
|
||||
config = ingest_config.get_method_config("xml")
|
||||
config.update(kwargs)
|
||||
ingestor = XMLIngestor(**config)
|
||||
try:
|
||||
ingestor = XMLIngestor(**config)
|
||||
except ImportError as exc:
|
||||
raise _missing_optional_dependency("XML ingestion", "lxml") from exc
|
||||
|
||||
def _run_single(
|
||||
path: Union[str, Path],
|
||||
@@ -511,6 +519,8 @@ def ingest_xml(
|
||||
|
||||
return _run_single(source_path)
|
||||
|
||||
except ConfigurationError:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to ingest XML: {e}")
|
||||
raise
|
||||
@@ -545,7 +555,12 @@ def ingest_web(
|
||||
if custom_method and custom_method != ingest_web:
|
||||
fallback = kwargs.pop("fallback_on_custom_error", False)
|
||||
result = call_custom_method(
|
||||
logger, method, custom_method, source, fallback_on_custom_error=fallback, **kwargs
|
||||
logger,
|
||||
method,
|
||||
custom_method,
|
||||
source,
|
||||
fallback_on_custom_error=fallback,
|
||||
**kwargs,
|
||||
)
|
||||
if result is not CUSTOM_METHOD_FELL_BACK:
|
||||
return result
|
||||
@@ -635,7 +650,12 @@ def ingest_public_api(
|
||||
if custom_method and custom_method != ingest_public_api:
|
||||
fallback = kwargs.pop("fallback_on_custom_error", False)
|
||||
result = call_custom_method(
|
||||
logger, method, custom_method, source, fallback_on_custom_error=fallback, **kwargs
|
||||
logger,
|
||||
method,
|
||||
custom_method,
|
||||
source,
|
||||
fallback_on_custom_error=fallback,
|
||||
**kwargs,
|
||||
)
|
||||
if result is not CUSTOM_METHOD_FELL_BACK:
|
||||
return result
|
||||
@@ -722,7 +742,12 @@ def ingest_feed(
|
||||
if custom_method and custom_method != ingest_feed:
|
||||
fallback = kwargs.pop("fallback_on_custom_error", False)
|
||||
result = call_custom_method(
|
||||
logger, method, custom_method, source, fallback_on_custom_error=fallback, **kwargs
|
||||
logger,
|
||||
method,
|
||||
custom_method,
|
||||
source,
|
||||
fallback_on_custom_error=fallback,
|
||||
**kwargs,
|
||||
)
|
||||
if result is not CUSTOM_METHOD_FELL_BACK:
|
||||
return result
|
||||
@@ -791,7 +816,12 @@ def ingest_stream(
|
||||
if custom_method and custom_method != ingest_stream:
|
||||
fallback = kwargs.pop("fallback_on_custom_error", False)
|
||||
result = call_custom_method(
|
||||
logger, method, custom_method, source, fallback_on_custom_error=fallback, **kwargs
|
||||
logger,
|
||||
method,
|
||||
custom_method,
|
||||
source,
|
||||
fallback_on_custom_error=fallback,
|
||||
**kwargs,
|
||||
)
|
||||
if result is not CUSTOM_METHOD_FELL_BACK:
|
||||
return result
|
||||
@@ -868,26 +898,29 @@ def ingest_repository(
|
||||
if custom_method and custom_method != ingest_repository:
|
||||
fallback = kwargs.pop("fallback_on_custom_error", False)
|
||||
result = call_custom_method(
|
||||
logger, method, custom_method, source, fallback_on_custom_error=fallback, **kwargs
|
||||
logger,
|
||||
method,
|
||||
custom_method,
|
||||
source,
|
||||
fallback_on_custom_error=fallback,
|
||||
**kwargs,
|
||||
)
|
||||
if result is not CUSTOM_METHOD_FELL_BACK:
|
||||
return result
|
||||
|
||||
try:
|
||||
try:
|
||||
from .repo_ingestor import RepoIngestor
|
||||
except ModuleNotFoundError as exc:
|
||||
if _is_missing_dependency(exc, "git"):
|
||||
raise _missing_optional_dependency(
|
||||
"Repository ingestion", "GitPython"
|
||||
) from exc
|
||||
raise
|
||||
from .repo_ingestor import RepoIngestor
|
||||
|
||||
# Get config
|
||||
config = ingest_config.get_method_config("repo")
|
||||
config.update(kwargs)
|
||||
|
||||
ingestor = RepoIngestor(**config)
|
||||
try:
|
||||
ingestor = RepoIngestor(**config)
|
||||
except ImportError as exc:
|
||||
raise _missing_optional_dependency(
|
||||
"Repository ingestion", "GitPython"
|
||||
) from exc
|
||||
|
||||
if method == "clone" or (
|
||||
isinstance(source, str)
|
||||
@@ -940,7 +973,12 @@ def ingest_email(
|
||||
if custom_method and custom_method != ingest_email:
|
||||
fallback = kwargs.pop("fallback_on_custom_error", False)
|
||||
result = call_custom_method(
|
||||
logger, method, custom_method, source, fallback_on_custom_error=fallback, **kwargs
|
||||
logger,
|
||||
method,
|
||||
custom_method,
|
||||
source,
|
||||
fallback_on_custom_error=fallback,
|
||||
**kwargs,
|
||||
)
|
||||
if result is not CUSTOM_METHOD_FELL_BACK:
|
||||
return result
|
||||
@@ -1019,7 +1057,12 @@ def ingest_ontology(
|
||||
if custom_method and custom_method != ingest_ontology:
|
||||
fallback = kwargs.pop("fallback_on_custom_error", False)
|
||||
result = call_custom_method(
|
||||
logger, method, custom_method, source, fallback_on_custom_error=fallback, **kwargs
|
||||
logger,
|
||||
method,
|
||||
custom_method,
|
||||
source,
|
||||
fallback_on_custom_error=fallback,
|
||||
**kwargs,
|
||||
)
|
||||
if result is not CUSTOM_METHOD_FELL_BACK:
|
||||
return result
|
||||
@@ -1085,7 +1128,12 @@ def ingest_database(
|
||||
if custom_method and custom_method != ingest_database:
|
||||
fallback = kwargs.pop("fallback_on_custom_error", False)
|
||||
result = call_custom_method(
|
||||
logger, method, custom_method, source, fallback_on_custom_error=fallback, **kwargs
|
||||
logger,
|
||||
method,
|
||||
custom_method,
|
||||
source,
|
||||
fallback_on_custom_error=fallback,
|
||||
**kwargs,
|
||||
)
|
||||
if result is not CUSTOM_METHOD_FELL_BACK:
|
||||
return result
|
||||
@@ -1233,105 +1281,122 @@ def ingest_salesforce(
|
||||
if custom_method and custom_method != ingest_salesforce:
|
||||
fallback = kwargs.pop("fallback_on_custom_error", False)
|
||||
result = call_custom_method(
|
||||
logger, method, custom_method, source,
|
||||
fallback_on_custom_error=fallback, **kwargs,
|
||||
logger,
|
||||
method,
|
||||
custom_method,
|
||||
source,
|
||||
fallback_on_custom_error=fallback,
|
||||
**kwargs,
|
||||
)
|
||||
if result is not CUSTOM_METHOD_FELL_BACK:
|
||||
return result
|
||||
|
||||
try:
|
||||
from .salesforce_ingestor import SalesforceIngestor
|
||||
except ModuleNotFoundError as exc:
|
||||
if _is_missing_dependency(exc, "simple_salesforce"):
|
||||
|
||||
# Unpack credential dict (if given); everything else stays in kwargs.
|
||||
creds: Dict[str, Any] = {}
|
||||
if source is not None:
|
||||
if not isinstance(source, dict):
|
||||
raise ProcessingError(
|
||||
"ingest_salesforce() source must be a credential dict or None. "
|
||||
"Pass sobject_name / soql as keyword arguments."
|
||||
)
|
||||
creds = dict(source)
|
||||
|
||||
# Merge any ingest_config method config under "salesforce".
|
||||
# get_method_config() now returns a copy, so this dict is safe to mutate.
|
||||
# We build the final connector config in order of increasing priority:
|
||||
# 1. base method config (lowest — global defaults set by operator)
|
||||
# 2. per-call credential dict supplied via `source`
|
||||
# 3. per-call connector params supplied as kwargs
|
||||
# Credentials are extracted from kwargs and removed so they don't also
|
||||
# flow into the ingest method call (which doesn't understand them).
|
||||
_CONNECTOR_PARAMS = frozenset(
|
||||
{
|
||||
"username",
|
||||
"password",
|
||||
"security_token",
|
||||
"domain",
|
||||
"instance_url",
|
||||
"session_id",
|
||||
"api_version",
|
||||
}
|
||||
)
|
||||
connector_kwargs = {k: v for k, v in kwargs.items() if k in _CONNECTOR_PARAMS}
|
||||
for k in _CONNECTOR_PARAMS:
|
||||
kwargs.pop(k, None)
|
||||
|
||||
# Build a fresh per-call config dict — never mutate the global store.
|
||||
config: Dict[str, Any] = {
|
||||
**ingest_config.get_method_config("salesforce"), # base (already a copy)
|
||||
**creds, # source dict credentials
|
||||
**connector_kwargs, # kwarg credentials
|
||||
}
|
||||
|
||||
try:
|
||||
ingestor = SalesforceIngestor(**config)
|
||||
except ImportError as exc:
|
||||
raise _missing_optional_dependency(
|
||||
"Salesforce ingestion", "simple-salesforce"
|
||||
) from exc
|
||||
|
||||
if method == "sobject":
|
||||
sobject_name = kwargs.pop("sobject_name", None)
|
||||
if not sobject_name:
|
||||
raise ProcessingError(
|
||||
"ingest_salesforce() with method='sobject' requires "
|
||||
"sobject_name keyword argument."
|
||||
)
|
||||
return ingestor.ingest_sobject(sobject_name, **kwargs)
|
||||
|
||||
elif method == "query":
|
||||
soql = kwargs.pop("soql", None)
|
||||
if not soql:
|
||||
raise ProcessingError(
|
||||
"ingest_salesforce() with method='query' requires "
|
||||
"soql keyword argument."
|
||||
)
|
||||
return ingestor.ingest_query(soql, **kwargs)
|
||||
|
||||
elif method == "list_sobjects":
|
||||
return ingestor.list_sobjects()
|
||||
|
||||
elif method == "schema":
|
||||
sobject_name = kwargs.pop("sobject_name", None)
|
||||
if not sobject_name:
|
||||
raise ProcessingError(
|
||||
"ingest_salesforce() with method='schema' requires "
|
||||
"sobject_name keyword argument."
|
||||
)
|
||||
return ingestor.get_sobject_schema(sobject_name)
|
||||
|
||||
elif method == "documents":
|
||||
sobject_name = kwargs.pop("sobject_name", None)
|
||||
if not sobject_name:
|
||||
raise ProcessingError(
|
||||
"ingest_salesforce() with method='documents' requires "
|
||||
"sobject_name keyword argument."
|
||||
)
|
||||
id_field = kwargs.pop("id_field", "Id")
|
||||
text_fields = kwargs.pop("text_fields", None)
|
||||
data = ingestor.ingest_sobject(sobject_name, **kwargs)
|
||||
return ingestor.export_as_documents(
|
||||
data, id_field=id_field, text_fields=text_fields
|
||||
)
|
||||
|
||||
else:
|
||||
raise ProcessingError(
|
||||
f"Unknown ingest_salesforce method: {method!r}. "
|
||||
"Valid methods: 'sobject', 'query', 'list_sobjects', 'schema', "
|
||||
"'documents'."
|
||||
)
|
||||
|
||||
except ConfigurationError:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to ingest salesforce: {e}")
|
||||
raise
|
||||
|
||||
# Unpack credential dict (if given); everything else stays in kwargs.
|
||||
creds: Dict[str, Any] = {}
|
||||
if source is not None:
|
||||
if not isinstance(source, dict):
|
||||
raise ProcessingError(
|
||||
"ingest_salesforce() source must be a credential dict or None. "
|
||||
"Pass sobject_name / soql as keyword arguments."
|
||||
)
|
||||
creds = dict(source)
|
||||
|
||||
# Merge any ingest_config method config under "salesforce".
|
||||
# get_method_config() now returns a copy, so this dict is safe to mutate.
|
||||
# We build the final connector config in order of increasing priority:
|
||||
# 1. base method config (lowest — global defaults set by operator)
|
||||
# 2. per-call credential dict supplied via `source`
|
||||
# 3. per-call connector params supplied as kwargs
|
||||
# Credentials are extracted from kwargs and removed so they don't also
|
||||
# flow into the ingest method call (which doesn't understand them).
|
||||
_CONNECTOR_PARAMS = frozenset({
|
||||
"username", "password", "security_token", "domain",
|
||||
"instance_url", "session_id", "api_version",
|
||||
})
|
||||
connector_kwargs = {k: v for k, v in kwargs.items() if k in _CONNECTOR_PARAMS}
|
||||
for k in _CONNECTOR_PARAMS:
|
||||
kwargs.pop(k, None)
|
||||
|
||||
# Build a fresh per-call config dict — never mutate the global store.
|
||||
config: Dict[str, Any] = {
|
||||
**ingest_config.get_method_config("salesforce"), # base (already a copy)
|
||||
**creds, # source dict credentials
|
||||
**connector_kwargs, # kwarg credentials
|
||||
}
|
||||
|
||||
ingestor = SalesforceIngestor(**config)
|
||||
|
||||
if method == "sobject":
|
||||
sobject_name = kwargs.pop("sobject_name", None)
|
||||
if not sobject_name:
|
||||
raise ProcessingError(
|
||||
"ingest_salesforce() with method='sobject' requires "
|
||||
"sobject_name keyword argument."
|
||||
)
|
||||
return ingestor.ingest_sobject(sobject_name, **kwargs)
|
||||
|
||||
elif method == "query":
|
||||
soql = kwargs.pop("soql", None)
|
||||
if not soql:
|
||||
raise ProcessingError(
|
||||
"ingest_salesforce() with method='query' requires "
|
||||
"soql keyword argument."
|
||||
)
|
||||
return ingestor.ingest_query(soql, **kwargs)
|
||||
|
||||
elif method == "list_sobjects":
|
||||
return ingestor.list_sobjects()
|
||||
|
||||
elif method == "schema":
|
||||
sobject_name = kwargs.pop("sobject_name", None)
|
||||
if not sobject_name:
|
||||
raise ProcessingError(
|
||||
"ingest_salesforce() with method='schema' requires "
|
||||
"sobject_name keyword argument."
|
||||
)
|
||||
return ingestor.get_sobject_schema(sobject_name)
|
||||
|
||||
elif method == "documents":
|
||||
sobject_name = kwargs.pop("sobject_name", None)
|
||||
if not sobject_name:
|
||||
raise ProcessingError(
|
||||
"ingest_salesforce() with method='documents' requires "
|
||||
"sobject_name keyword argument."
|
||||
)
|
||||
id_field = kwargs.pop("id_field", "Id")
|
||||
text_fields = kwargs.pop("text_fields", None)
|
||||
data = ingestor.ingest_sobject(sobject_name, **kwargs)
|
||||
return ingestor.export_as_documents(data, id_field=id_field,
|
||||
text_fields=text_fields)
|
||||
|
||||
else:
|
||||
raise ProcessingError(
|
||||
f"Unknown ingest_salesforce method: {method!r}. "
|
||||
"Valid methods: 'sobject', 'query', 'list_sobjects', 'schema', "
|
||||
"'documents'."
|
||||
)
|
||||
|
||||
|
||||
def ingest_mcp(
|
||||
@@ -1399,7 +1464,12 @@ def ingest_mcp(
|
||||
if custom_method and custom_method != ingest_mcp:
|
||||
fallback = kwargs.pop("fallback_on_custom_error", False)
|
||||
result = call_custom_method(
|
||||
logger, method, custom_method, source, fallback_on_custom_error=fallback, **kwargs
|
||||
logger,
|
||||
method,
|
||||
custom_method,
|
||||
source,
|
||||
fallback_on_custom_error=fallback,
|
||||
**kwargs,
|
||||
)
|
||||
if result is not CUSTOM_METHOD_FELL_BACK:
|
||||
return result
|
||||
@@ -1638,8 +1708,9 @@ def ingest(
|
||||
elif source_type == "mcp":
|
||||
return {"data": ingest_mcp(sources, method=method or "resources", **kwargs)}
|
||||
elif source_type == "salesforce":
|
||||
return {"data": ingest_salesforce(sources,
|
||||
method=method or "sobject", **kwargs)}
|
||||
return {
|
||||
"data": ingest_salesforce(sources, method=method or "sobject", **kwargs)
|
||||
}
|
||||
else:
|
||||
raise ProcessingError(f"Unknown source type: {source_type}")
|
||||
|
||||
|
||||
@@ -28,12 +28,29 @@ from typing import Any, Dict, List, Optional, Tuple
|
||||
from urllib.parse import parse_qs, urlparse
|
||||
|
||||
import requests
|
||||
from lxml import etree as lxml_etree
|
||||
|
||||
try:
|
||||
from lxml import etree as lxml_etree
|
||||
|
||||
_SAFE_XML_PARSER = lxml_etree.XMLParser(
|
||||
resolve_entities=False,
|
||||
no_network=True,
|
||||
recover=False,
|
||||
huge_tree=False,
|
||||
load_dtd=False,
|
||||
remove_comments=True,
|
||||
remove_pis=True,
|
||||
)
|
||||
_LXML_SYNTAX_ERRORS: Tuple[type, ...] = (lxml_etree.XMLSyntaxError,)
|
||||
except (ImportError, OSError):
|
||||
lxml_etree = None
|
||||
_SAFE_XML_PARSER = None
|
||||
_LXML_SYNTAX_ERRORS = ()
|
||||
|
||||
try:
|
||||
from defusedxml import ElementTree as safe_xml_etree
|
||||
from defusedxml.common import DefusedXmlException
|
||||
except ModuleNotFoundError: # pragma: no cover - fallback for minimal installs
|
||||
except (ImportError, OSError): # pragma: no cover - fallback for minimal installs
|
||||
safe_xml_etree = None
|
||||
|
||||
class DefusedXmlException(Exception):
|
||||
@@ -71,14 +88,6 @@ AUTH_PARAM_NAMES = {
|
||||
"subscription-key",
|
||||
}
|
||||
|
||||
_SAFE_XML_PARSER = lxml_etree.XMLParser(
|
||||
resolve_entities=False,
|
||||
no_network=True,
|
||||
recover=False,
|
||||
huge_tree=False,
|
||||
load_dtd=False,
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class PublicAPIExample:
|
||||
@@ -443,7 +452,9 @@ class PublicAPIIngestor(RESTIngestor):
|
||||
APIData: Normalized public API response and metadata
|
||||
"""
|
||||
self._validate_endpoint(endpoint)
|
||||
self._validate_no_auth_request(headers=headers, params=params, options=options, endpoint=endpoint)
|
||||
self._validate_no_auth_request(
|
||||
headers=headers, params=params, options=options, endpoint=endpoint
|
||||
)
|
||||
|
||||
tracking_id = self.progress_tracker.start_tracking(
|
||||
file=endpoint,
|
||||
@@ -604,7 +615,9 @@ class PublicAPIIngestor(RESTIngestor):
|
||||
for endpoint in endpoints:
|
||||
try:
|
||||
results.append(
|
||||
self.ingest_public_api(endpoint, method=method, **copy.deepcopy(options))
|
||||
self.ingest_public_api(
|
||||
endpoint, method=method, **copy.deepcopy(options)
|
||||
)
|
||||
)
|
||||
except Exception as exc:
|
||||
self.logger.warning(f"Failed to fetch public API {endpoint}: {exc}")
|
||||
@@ -730,7 +743,7 @@ class PublicAPIIngestor(RESTIngestor):
|
||||
raise ProcessingError(
|
||||
f"Failed to parse {detected_format.upper()} public API response"
|
||||
) from exc
|
||||
except (DefusedXmlException, lxml_etree.XMLSyntaxError) as exc:
|
||||
except (DefusedXmlException, *_LXML_SYNTAX_ERRORS) as exc:
|
||||
raise ProcessingError("Failed to parse XML public API response") from exc
|
||||
|
||||
def _detect_response_format(
|
||||
@@ -770,15 +783,40 @@ class PublicAPIIngestor(RESTIngestor):
|
||||
def _parse_xml(self, xml_text: str) -> Dict[str, Any]:
|
||||
if safe_xml_etree is not None:
|
||||
root = safe_xml_etree.fromstring(xml_text)
|
||||
else:
|
||||
elif lxml_etree is not None and _SAFE_XML_PARSER is not None:
|
||||
root = lxml_etree.fromstring(
|
||||
xml_text.encode("utf-8"),
|
||||
parser=_SAFE_XML_PARSER,
|
||||
)
|
||||
for elem in root.iter():
|
||||
if (
|
||||
elem.tag is lxml_etree.Comment
|
||||
or elem.tag is lxml_etree.PI
|
||||
or getattr(elem.tag, "__name__", "")
|
||||
in ("Comment", "ProcessingInstruction", "PI")
|
||||
):
|
||||
continue
|
||||
if callable(elem.tag) or not isinstance(elem.tag, str):
|
||||
raise ProcessingError("Failed to parse XML public API response")
|
||||
else:
|
||||
raise ProcessingError(
|
||||
"XML parsing requires 'defusedxml' or 'lxml'. "
|
||||
"Install it with: pip install 'semantica[documents]'"
|
||||
)
|
||||
return self._element_to_dict(root)
|
||||
|
||||
def _element_to_dict(self, element: Any) -> Dict[str, Any]:
|
||||
children = [self._element_to_dict(child) for child in list(element)]
|
||||
children = [
|
||||
self._element_to_dict(child)
|
||||
for child in list(element)
|
||||
if not (
|
||||
callable(child.tag)
|
||||
or (
|
||||
lxml_etree is not None
|
||||
and (child.tag is lxml_etree.Comment or child.tag is lxml_etree.PI)
|
||||
)
|
||||
)
|
||||
]
|
||||
return {
|
||||
"tag": self._strip_namespace(element.tag),
|
||||
"attributes": {
|
||||
@@ -789,7 +827,9 @@ class PublicAPIIngestor(RESTIngestor):
|
||||
"children": children,
|
||||
}
|
||||
|
||||
def _strip_namespace(self, value: str) -> str:
|
||||
def _strip_namespace(self, value: Any) -> str:
|
||||
if not isinstance(value, str):
|
||||
return str(value)
|
||||
if value.startswith("{") and "}" in value:
|
||||
return value.split("}", 1)[1]
|
||||
return value
|
||||
|
||||
@@ -29,6 +29,8 @@ Author: Semantica Contributors
|
||||
License: MIT
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import ipaddress
|
||||
import os
|
||||
import re
|
||||
@@ -44,7 +46,10 @@ from pathlib import Path
|
||||
from typing import Any, Dict, List, Optional, Set, Tuple, Union
|
||||
from urllib.parse import urlparse
|
||||
|
||||
import git
|
||||
try:
|
||||
import git
|
||||
except (ImportError, OSError):
|
||||
git = None
|
||||
|
||||
from ..utils.exceptions import ProcessingError, ValidationError
|
||||
from ..utils.logging import get_logger
|
||||
@@ -57,9 +62,7 @@ ALLOWED_CLONE_OPTIONS: Set[str] = {"depth", "branch", "single_branch", "no_tags"
|
||||
ALLOWED_REPO_URL_SCHEMES = frozenset({"https", "http", "git", "ssh"})
|
||||
# SCP-like SSH remotes: user@host:path/to/repo.git (no scheme)
|
||||
_SCP_LIKE_REPO_URL_RE = re.compile(r"^[^@\s]+@[^:\s]+:.+$")
|
||||
_ENV_VAR_TOKEN_RE = re.compile(
|
||||
r"\$(\{[A-Za-z_][A-Za-z0-9_]*\}|[A-Za-z_][A-Za-z0-9_]*)"
|
||||
)
|
||||
_ENV_VAR_TOKEN_RE = re.compile(r"\$(\{[A-Za-z_][A-Za-z0-9_]*\}|[A-Za-z_][A-Za-z0-9_]*)")
|
||||
# Short-lived DNS cache for host validation. This reduces repeated lookups but
|
||||
# does not eliminate DNS-rebinding / TOCTOU races between validate and clone —
|
||||
# network egress controls remain recommended.
|
||||
@@ -525,6 +528,11 @@ class RepoIngestor:
|
||||
**kwargs: Additional configuration parameters (merged into config)
|
||||
"""
|
||||
self.logger = get_logger("repo_ingestor")
|
||||
if git is None:
|
||||
raise ImportError(
|
||||
"GitPython is required for repository ingestion. "
|
||||
"Install it with: pip install 'semantica[ingest-git]'"
|
||||
)
|
||||
self.config = config or {}
|
||||
self.config.update(kwargs)
|
||||
|
||||
@@ -590,10 +598,7 @@ class RepoIngestor:
|
||||
networks). Those addresses are not SSRF-sensitive.
|
||||
"""
|
||||
return bool(
|
||||
ip.is_private
|
||||
or ip.is_loopback
|
||||
or ip.is_link_local
|
||||
or ip.is_unspecified
|
||||
ip.is_private or ip.is_loopback or ip.is_link_local or ip.is_unspecified
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
@@ -620,9 +625,7 @@ class RepoIngestor:
|
||||
# or hanging lookup for one host cannot stall cache access for
|
||||
# concurrent lookups of other hosts.
|
||||
try:
|
||||
addrinfos = socket.getaddrinfo(
|
||||
host, None, type=socket.SOCK_STREAM
|
||||
)
|
||||
addrinfos = socket.getaddrinfo(host, None, type=socket.SOCK_STREAM)
|
||||
except socket.gaierror as exc:
|
||||
raise ValidationError(
|
||||
f"Cannot resolve repository host {host!r}: {exc}"
|
||||
@@ -795,9 +798,7 @@ class RepoIngestor:
|
||||
f"Allowed schemes: {sorted(ALLOWED_REPO_URL_SCHEMES)}"
|
||||
)
|
||||
if not parsed.netloc or not host:
|
||||
raise ValidationError(
|
||||
f"Repository URL must include a host: {repo_url}"
|
||||
)
|
||||
raise ValidationError(f"Repository URL must include a host: {repo_url}")
|
||||
|
||||
RepoIngestor._validate_repo_host(host)
|
||||
|
||||
@@ -812,9 +813,7 @@ class RepoIngestor:
|
||||
"include_extensions",
|
||||
"max_depth",
|
||||
}
|
||||
candidate = {
|
||||
k: v for k, v in options.items() if k not in non_git_options
|
||||
}
|
||||
candidate = {k: v for k, v in options.items() if k not in non_git_options}
|
||||
unsafe = set(candidate) - ALLOWED_CLONE_OPTIONS
|
||||
if unsafe:
|
||||
raise ValidationError(
|
||||
@@ -893,9 +892,7 @@ class RepoIngestor:
|
||||
if "include_extensions" in options:
|
||||
# Normalize extensions to include dot prefix
|
||||
exts = options["include_extensions"]
|
||||
normalized_exts = [
|
||||
e if e.startswith(".") else f".{e}" for e in exts
|
||||
]
|
||||
normalized_exts = [e if e.startswith(".") else f".{e}" for e in exts]
|
||||
file_filters["extensions"] = normalized_exts
|
||||
|
||||
# Process code files
|
||||
@@ -1062,14 +1059,14 @@ class RepoIngestor:
|
||||
return code_files
|
||||
|
||||
def get_repository_info(
|
||||
self, repo_url: str, repo: Optional[git.Repo] = None
|
||||
self, repo_url: str, repo: Optional[Any] = None
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Get repository metadata and information.
|
||||
|
||||
Args:
|
||||
repo_url: Repository URL
|
||||
repo: Git repository object (optional)
|
||||
repo: Git repository object (git.Repo, optional)
|
||||
|
||||
Returns:
|
||||
dict: Repository information
|
||||
@@ -1111,6 +1108,7 @@ class RepoIngestor:
|
||||
def cleanup(self):
|
||||
"""Cleanup temporary repository files."""
|
||||
if self.temp_dir and os.path.exists(self.temp_dir):
|
||||
|
||||
def onexc(func, path, exc_info):
|
||||
"""
|
||||
Error handler for shutil.rmtree.
|
||||
@@ -1123,6 +1121,7 @@ class RepoIngestor:
|
||||
Usage : shutil.rmtree(path, onerror=onexc)
|
||||
"""
|
||||
import stat
|
||||
|
||||
if not os.access(path, os.W_OK):
|
||||
# Is the error an access error ?
|
||||
os.chmod(path, stat.S_IWUSR)
|
||||
|
||||
@@ -24,7 +24,10 @@ from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List, Optional, Tuple, Union
|
||||
|
||||
from lxml import etree
|
||||
try:
|
||||
from lxml import etree
|
||||
except (ImportError, OSError):
|
||||
etree = None
|
||||
|
||||
from ..utils.constants import FILE_SIZE_LIMITS
|
||||
from ..utils.exceptions import ProcessingError, ValidationError
|
||||
@@ -72,6 +75,11 @@ class XMLIngestor:
|
||||
**kwargs: Additional configuration values
|
||||
"""
|
||||
self.logger = get_logger("xml_ingestor")
|
||||
if etree is None:
|
||||
raise ImportError(
|
||||
"lxml is required for XMLIngestor. "
|
||||
"Install it with: pip install 'semantica[documents]'"
|
||||
)
|
||||
self.config = config or {}
|
||||
self.config.update(kwargs)
|
||||
self.progress_tracker = get_progress_tracker()
|
||||
@@ -784,8 +792,8 @@ class XMLIngestor:
|
||||
first_error = errors[0] if errors else "No detailed validation error available."
|
||||
return f"{prefix} for {source}: {first_error}"
|
||||
|
||||
def _format_xml_error(self, exc: etree.XMLSyntaxError) -> str:
|
||||
if exc.error_log:
|
||||
def _format_xml_error(self, exc: Any) -> str:
|
||||
if hasattr(exc, "error_log") and exc.error_log:
|
||||
return str(exc.error_log.last_error)
|
||||
return str(exc)
|
||||
|
||||
|
||||
@@ -50,7 +50,14 @@ class KGConfig:
|
||||
"""Initialize configuration manager."""
|
||||
self.logger = get_logger("kg_config")
|
||||
self._configs: Dict[str, Any] = {}
|
||||
self._method_configs: Dict[str, Dict] = {}
|
||||
self._method_configs: Dict[str, Dict] = {
|
||||
"build": {
|
||||
# How GraphBuilder treats a relationship endpoint that points at
|
||||
# an entity absent from the extracted set: "include" promotes a
|
||||
# synthetic UNKNOWN entity (default), "reject" drops the edge.
|
||||
"unknown_relation_endpoint": "include",
|
||||
},
|
||||
}
|
||||
self._load_config_file(config_file)
|
||||
self._load_env_vars()
|
||||
|
||||
|
||||
@@ -23,7 +23,7 @@ License: MIT
|
||||
"""
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Any, Dict, List, Optional, Tuple, Union
|
||||
from typing import Any, Dict, List, Optional, Set, Tuple, Union
|
||||
import time
|
||||
|
||||
|
||||
@@ -89,7 +89,29 @@ class GraphBuilder:
|
||||
self.track_history = track_history
|
||||
self.version_snapshots = version_snapshots
|
||||
self.graph_store = graph_store
|
||||
self.config = kwargs # Store additional config for extractors
|
||||
# The orchestrator builds with GraphBuilder(config=self.config.get("kg", {})).
|
||||
# That lands as a nested "config" keyword, so fold it into the option
|
||||
# mapping once here: every option ({entity_resolution,
|
||||
# conflict_detection, unknown_relation_endpoint, ...}) is then read the
|
||||
# same way whether it was given top-level or via the orchestrator path.
|
||||
kwargs = dict(kwargs)
|
||||
_nested = kwargs.pop("config", None)
|
||||
if isinstance(_nested, dict):
|
||||
for _key, _value in _nested.items():
|
||||
kwargs.setdefault(_key, _value)
|
||||
self.config = kwargs
|
||||
# unknown_relation_endpoint lives in the per-module build config
|
||||
# (semantica/kg/config.py); fall back to it so the option has a single
|
||||
# documented home. Popped out of kwargs so it is not forwarded to
|
||||
# extractors as an unused **config key.
|
||||
from .config import kg_config
|
||||
|
||||
self.unknown_relation_endpoint = kwargs.pop(
|
||||
"unknown_relation_endpoint",
|
||||
kg_config.get_method_config("build").get(
|
||||
"unknown_relation_endpoint", "include"
|
||||
),
|
||||
)
|
||||
# Extractors are reused across texts: NERExtractor loads its spaCy model
|
||||
# eagerly in __init__, so constructing one per text would reload the
|
||||
# model on every source in a multi-document build.
|
||||
@@ -102,6 +124,8 @@ class GraphBuilder:
|
||||
"extracted_relations": 0,
|
||||
"extracted_triplets": 0,
|
||||
}
|
||||
# Counts relationships dropped by the reject policy per build() call.
|
||||
self._rejected_relationships: int = 0
|
||||
|
||||
# Initialize logging
|
||||
from ..utils.logging import get_logger
|
||||
@@ -159,9 +183,81 @@ class GraphBuilder:
|
||||
}
|
||||
all_entities.append(entity_dict)
|
||||
elif hasattr(item, "subject") and hasattr(item, "predicate") and hasattr(item, "object"):
|
||||
# It's likely a Relation object
|
||||
# It's likely a Relation or Triplet object
|
||||
subj = item.subject
|
||||
obj = item.object
|
||||
# Relation extraction synthesizes an UNKNOWN Entity for an endpoint
|
||||
# that is absent from the NER entity list (metadata synthetic=True).
|
||||
# Triplet extraction does the same, but its endpoints are strings, so
|
||||
# the extractor records the absent endpoint texts instead. Only the
|
||||
# id/text survives in the relationship today, so the synthetic object
|
||||
# never reaches the entity collection and graph validation reports
|
||||
# DANGLING_EDGE. Promote those endpoint entities into the graph here.
|
||||
item_metadata = getattr(item, "metadata", None) or {}
|
||||
# Triplet LLM path tags endpoint texts it could not match to an entity.
|
||||
triplet_synthetic = [
|
||||
text
|
||||
for text in item_metadata.get("synthetic_endpoints", [])
|
||||
if isinstance(text, str)
|
||||
]
|
||||
synthetic_endpoints = []
|
||||
for endpoint in (subj, obj):
|
||||
if (
|
||||
not isinstance(endpoint, str)
|
||||
and isinstance(getattr(endpoint, "metadata", None), dict)
|
||||
and endpoint.metadata.get("synthetic")
|
||||
):
|
||||
synthetic_endpoints.append(endpoint)
|
||||
elif isinstance(endpoint, str) and endpoint in triplet_synthetic:
|
||||
synthetic_endpoints.append(endpoint)
|
||||
if synthetic_endpoints:
|
||||
endpoint_policy = self.unknown_relation_endpoint
|
||||
if endpoint_policy == "reject":
|
||||
self.logger.warning(
|
||||
"Dropping relationship %r->%r (%s): endpoint is synthetic and "
|
||||
"unknown_relation_endpoint='reject'",
|
||||
subj,
|
||||
obj,
|
||||
item.predicate,
|
||||
)
|
||||
self._rejected_relationships += 1
|
||||
return
|
||||
# The promoted set is rebuilt only for relationships that actually
|
||||
# carry a synthetic endpoint. Ordinary relationships (the common
|
||||
# case) must not pay an O(all_entities) scan per item, which made
|
||||
# build() quadratic on dense relation inputs.
|
||||
existing_ids: Set[Any] = set()
|
||||
for _ent in all_entities:
|
||||
if not isinstance(_ent, dict):
|
||||
continue
|
||||
for _key in ("id", "entity_id"):
|
||||
_cid = _ent.get(_key)
|
||||
if _cid is None:
|
||||
continue
|
||||
try:
|
||||
existing_ids.add(_cid)
|
||||
except TypeError:
|
||||
# Invalid/unhashable IDs are left for graph validation.
|
||||
continue
|
||||
for endpoint in synthetic_endpoints:
|
||||
if isinstance(endpoint, str):
|
||||
endpoint_id = endpoint
|
||||
endpoint_text = endpoint
|
||||
else:
|
||||
endpoint_id = endpoint.id if hasattr(endpoint, "id") else endpoint.text
|
||||
endpoint_text = endpoint.text
|
||||
if endpoint_id in existing_ids:
|
||||
continue
|
||||
all_entities.append(
|
||||
{
|
||||
"id": endpoint_id,
|
||||
"name": endpoint_text,
|
||||
"type": "UNKNOWN",
|
||||
"confidence": 0.8,
|
||||
"metadata": {"synthetic": True},
|
||||
}
|
||||
)
|
||||
existing_ids.add(endpoint_id)
|
||||
subj_id = getattr(subj, "id", getattr(subj, "text", str(subj))) if not isinstance(subj, str) else subj
|
||||
obj_id = getattr(obj, "id", getattr(obj, "text", str(obj))) if not isinstance(obj, str) else obj
|
||||
rel_dict = {
|
||||
@@ -543,6 +639,8 @@ class GraphBuilder:
|
||||
"extracted_relations": 0,
|
||||
"extracted_triplets": 0
|
||||
}
|
||||
# Reset per-run rejection counter.
|
||||
self._rejected_relationships = 0
|
||||
|
||||
tracking_id = self.progress_tracker.start_tracking(
|
||||
module="kg",
|
||||
@@ -782,6 +880,39 @@ class GraphBuilder:
|
||||
if has_merged_entities:
|
||||
self._remap_relationship_endpoints(resolved_entities, all_relationships)
|
||||
|
||||
# When a synthetic endpoint is promoted before the real entity arrives
|
||||
# (e.g. relation data precedes NER entity data for the same text),
|
||||
# the same id can appear once as synthetic and once as real. Prefer
|
||||
# the real entity so the graph does not carry a duplicated,
|
||||
# lower-confidence duplicate.
|
||||
_real_ids: Set[Any] = set()
|
||||
for _entity in resolved_entities:
|
||||
if (
|
||||
isinstance(_entity, dict)
|
||||
and not (_entity.get("metadata") or {}).get("synthetic")
|
||||
):
|
||||
for _cid in (_entity.get("id"), _entity.get("entity_id")):
|
||||
if _cid is None:
|
||||
continue
|
||||
try:
|
||||
_real_ids.add(_cid)
|
||||
except TypeError:
|
||||
# Invalid/unhashable IDs are left for graph validation
|
||||
# to report rather than failing graph construction here.
|
||||
continue
|
||||
if _real_ids:
|
||||
filtered = []
|
||||
for _entity in resolved_entities:
|
||||
if (
|
||||
isinstance(_entity, dict)
|
||||
and (_entity.get("metadata") or {}).get("synthetic")
|
||||
):
|
||||
_eid = _entity.get("id")
|
||||
if _eid in _real_ids:
|
||||
continue
|
||||
filtered.append(_entity)
|
||||
resolved_entities = filtered
|
||||
|
||||
if input_relationships_count > 0 and len(all_relationships) == 0:
|
||||
warning_msg = (
|
||||
f"All relationships were dropped during graph building: "
|
||||
@@ -801,6 +932,7 @@ class GraphBuilder:
|
||||
"temporal_enabled": self.enable_temporal,
|
||||
"timestamp": self._get_timestamp(),
|
||||
"entity_resolution_applied": resolver_to_use is not None,
|
||||
"rejected_relationships": self._rejected_relationships,
|
||||
},
|
||||
}
|
||||
structure_time = time.time() - structure_start
|
||||
|
||||
@@ -132,6 +132,53 @@ kg1 = builder.build(initial_sources)
|
||||
kg2 = builder.build(additional_sources)
|
||||
```
|
||||
|
||||
### Unknown relation endpoints
|
||||
|
||||
When a relationship endpoint names an entity that is absent from the extracted
|
||||
entity set (for example an LLM/HuggingFace extraction that synthesizes an
|
||||
endpoint), `GraphBuilder` promotes a synthetic `UNKNOWN` entity so the edge is
|
||||
not left dangling. This is the default (`"include"`). To drop such
|
||||
relationships instead, set `unknown_relation_endpoint="reject"`:
|
||||
|
||||
```python
|
||||
builder = GraphBuilder(
|
||||
unknown_relation_endpoint="reject",
|
||||
)
|
||||
kg = builder.build(sources) # edges with unknown endpoints are dropped
|
||||
```
|
||||
|
||||
Rejected relationships are observable at two levels:
|
||||
|
||||
- A **`WARNING`-level log** is emitted for every dropped edge, including the
|
||||
source/target and predicate, so it is visible without debug logging enabled.
|
||||
- `graph["metadata"]["rejected_relationships"]` holds the count of edges
|
||||
dropped in that build call, giving callers a machine-readable signal:
|
||||
|
||||
```python
|
||||
kg = builder.build(sources)
|
||||
if kg["metadata"]["rejected_relationships"]:
|
||||
print(f"{kg['metadata']['rejected_relationships']} edge(s) were rejected "
|
||||
f"because of unknown endpoints.")
|
||||
```
|
||||
|
||||
The option can also be set process-wide via the module's build configuration,
|
||||
which is the documented home for it:
|
||||
|
||||
```python
|
||||
from semantica.kg.config import kg_config
|
||||
|
||||
kg_config.set_method_config("build", unknown_relation_endpoint="reject")
|
||||
# All subsequent GraphBuilder instances use "reject" as the default.
|
||||
```
|
||||
|
||||
In a YAML/JSON/TOML config file, place it under the `kg_methods.build` key:
|
||||
|
||||
```yaml
|
||||
kg_methods:
|
||||
build:
|
||||
unknown_relation_endpoint: reject
|
||||
```
|
||||
|
||||
## Graph Algorithms
|
||||
|
||||
The knowledge graph module provides advanced algorithms for node embeddings, similarity calculations, path finding, link prediction, centrality measures, and community detection.
|
||||
|
||||
@@ -141,7 +141,7 @@ class NodeEmbedder:
|
||||
|
||||
if method == "node2vec" and not GENSIM_AVAILABLE:
|
||||
raise ImportError(
|
||||
"gensim is required for Node2Vec. Install with: pip install gensim"
|
||||
"gensim is required for Node2Vec. Install with: pip install 'semantica[graph-embeddings]'"
|
||||
)
|
||||
|
||||
def compute_embeddings(
|
||||
|
||||
@@ -32,12 +32,20 @@ from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List, Optional, Union
|
||||
|
||||
from docx import Document
|
||||
from docx.document import Document as DocxDocument
|
||||
from docx.oxml.table import CT_Tbl
|
||||
from docx.oxml.text.paragraph import CT_P
|
||||
from docx.table import Table
|
||||
from docx.text.paragraph import Paragraph
|
||||
try:
|
||||
from docx import Document
|
||||
from docx.document import Document as DocxDocument
|
||||
from docx.oxml.table import CT_Tbl
|
||||
from docx.oxml.text.paragraph import CT_P
|
||||
from docx.table import Table
|
||||
from docx.text.paragraph import Paragraph
|
||||
except (ImportError, OSError):
|
||||
Document = None
|
||||
DocxDocument = None
|
||||
CT_Tbl = None
|
||||
CT_P = None
|
||||
Table = None
|
||||
Paragraph = None
|
||||
|
||||
from ..utils.exceptions import ProcessingError, ValidationError
|
||||
from ..utils.logging import get_logger
|
||||
@@ -84,7 +92,9 @@ class DOCXParser:
|
||||
self.config = config
|
||||
self.progress_tracker = get_progress_tracker()
|
||||
|
||||
def parse(self, file_path: Union[str, Path], pipeline_id: Optional[str] = None, **options) -> Dict[str, Any]:
|
||||
def parse(
|
||||
self, file_path: Union[str, Path], pipeline_id: Optional[str] = None, **options
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Parse DOCX document.
|
||||
|
||||
@@ -99,6 +109,12 @@ class DOCXParser:
|
||||
Returns:
|
||||
dict: Parsed document data
|
||||
"""
|
||||
if Document is None:
|
||||
raise ProcessingError(
|
||||
"python-docx is required to parse DOCX files. "
|
||||
"Install it with: pip install 'semantica[documents]'"
|
||||
)
|
||||
|
||||
file_path = Path(file_path)
|
||||
|
||||
# Track DOCX parsing
|
||||
|
||||
@@ -33,7 +33,11 @@ from pathlib import Path
|
||||
from typing import Any, Dict, List, Optional, Union
|
||||
|
||||
import pandas as pd
|
||||
from openpyxl import load_workbook
|
||||
|
||||
try:
|
||||
from openpyxl import load_workbook
|
||||
except (ImportError, OSError):
|
||||
load_workbook = None
|
||||
|
||||
from ..utils.exceptions import ProcessingError, ValidationError
|
||||
from ..utils.logging import get_logger
|
||||
@@ -92,6 +96,12 @@ class ExcelParser:
|
||||
Returns:
|
||||
ExcelData or ExcelSheet: Parsed Excel data
|
||||
"""
|
||||
if load_workbook is None:
|
||||
raise ProcessingError(
|
||||
"openpyxl is required to parse Excel files. "
|
||||
"Install it with: pip install 'semantica[documents]'"
|
||||
)
|
||||
|
||||
file_path = Path(file_path)
|
||||
|
||||
# Track Excel parsing
|
||||
|
||||
@@ -33,7 +33,10 @@ from pathlib import Path
|
||||
from typing import Any, Dict, List, Optional, Union
|
||||
from urllib.parse import urljoin
|
||||
|
||||
from bs4 import BeautifulSoup
|
||||
try:
|
||||
from bs4 import BeautifulSoup
|
||||
except (ImportError, OSError):
|
||||
BeautifulSoup = None
|
||||
|
||||
from ..utils.exceptions import ProcessingError, ValidationError
|
||||
from ..utils.logging import get_logger
|
||||
@@ -113,6 +116,12 @@ class HTMLParser:
|
||||
Returns:
|
||||
HTMLData: Parsed HTML data
|
||||
"""
|
||||
if BeautifulSoup is None:
|
||||
raise ProcessingError(
|
||||
"beautifulsoup4 is required to parse HTML files. "
|
||||
"Install it with: pip install 'semantica[documents]'"
|
||||
)
|
||||
|
||||
# Track HTML parsing
|
||||
file_path = None
|
||||
if isinstance(html_content, Path) or (
|
||||
|
||||
+99
-33
@@ -123,7 +123,6 @@ Example Usage:
|
||||
from pathlib import Path
|
||||
from typing import Any, Callable, Dict, List, Optional, Union
|
||||
|
||||
from ..utils.exceptions import ConfigurationError, ProcessingError
|
||||
from ..utils.logging import get_logger
|
||||
from ..utils.custom_methods import CUSTOM_METHOD_FELL_BACK, call_custom_method
|
||||
from .code_parser import CodeParser
|
||||
@@ -178,10 +177,16 @@ def parse_document(
|
||||
>>> text = parse_document("document.pdf", method="default", extract_text=True)
|
||||
"""
|
||||
custom_method = method_registry.get("document", method)
|
||||
if custom_method:
|
||||
if custom_method and custom_method != parse_document:
|
||||
fallback = kwargs.pop("fallback_on_custom_error", False)
|
||||
result = call_custom_method(
|
||||
logger, method, custom_method, file_path, file_type, fallback_on_custom_error=fallback, **kwargs
|
||||
logger,
|
||||
method,
|
||||
custom_method,
|
||||
file_path,
|
||||
file_type,
|
||||
fallback_on_custom_error=fallback,
|
||||
**kwargs,
|
||||
)
|
||||
if result is not CUSTOM_METHOD_FELL_BACK:
|
||||
return result
|
||||
@@ -248,7 +253,8 @@ def parse_document_docling(
|
||||
|
||||
# Register Docling method
|
||||
try:
|
||||
from .docling_parser import DoclingParser
|
||||
from . import docling_parser # noqa: F401
|
||||
|
||||
method_registry.register("document", "docling", parse_document_docling)
|
||||
except (ImportError, OSError):
|
||||
# Docling not available, skip registration
|
||||
@@ -289,10 +295,17 @@ def parse_web_content(
|
||||
>>> html = parse_web_content("page.html", content_type="html", method="default")
|
||||
"""
|
||||
custom_method = method_registry.get("web", method)
|
||||
if custom_method:
|
||||
if custom_method and custom_method != parse_web_content:
|
||||
fallback = kwargs.pop("fallback_on_custom_error", False)
|
||||
result = call_custom_method(
|
||||
logger, method, custom_method, content, content_type, base_url, fallback_on_custom_error=fallback, **kwargs
|
||||
logger,
|
||||
method,
|
||||
custom_method,
|
||||
content,
|
||||
content_type,
|
||||
base_url,
|
||||
fallback_on_custom_error=fallback,
|
||||
**kwargs,
|
||||
)
|
||||
if result is not CUSTOM_METHOD_FELL_BACK:
|
||||
return result
|
||||
@@ -345,10 +358,16 @@ def parse_structured_data(
|
||||
>>> csv_data = parse_structured_data("data.csv", data_format="csv", method="default")
|
||||
"""
|
||||
custom_method = method_registry.get("structured", method)
|
||||
if custom_method:
|
||||
if custom_method and custom_method != parse_structured_data:
|
||||
fallback = kwargs.pop("fallback_on_custom_error", False)
|
||||
result = call_custom_method(
|
||||
logger, method, custom_method, data, data_format, fallback_on_custom_error=fallback, **kwargs
|
||||
logger,
|
||||
method,
|
||||
custom_method,
|
||||
data,
|
||||
data_format,
|
||||
fallback_on_custom_error=fallback,
|
||||
**kwargs,
|
||||
)
|
||||
if result is not CUSTOM_METHOD_FELL_BACK:
|
||||
return result
|
||||
@@ -392,10 +411,15 @@ def parse_email(
|
||||
>>> headers = parse_email("email.eml", method="headers")
|
||||
"""
|
||||
custom_method = method_registry.get("email", method)
|
||||
if custom_method:
|
||||
if custom_method and custom_method != parse_email:
|
||||
fallback = kwargs.pop("fallback_on_custom_error", False)
|
||||
result = call_custom_method(
|
||||
logger, method, custom_method, email_content, fallback_on_custom_error=fallback, **kwargs
|
||||
logger,
|
||||
method,
|
||||
custom_method,
|
||||
email_content,
|
||||
fallback_on_custom_error=fallback,
|
||||
**kwargs,
|
||||
)
|
||||
if result is not CUSTOM_METHOD_FELL_BACK:
|
||||
return result
|
||||
@@ -442,10 +466,16 @@ def parse_code(
|
||||
>>> structure = parse_code("script.py", method="ast")
|
||||
"""
|
||||
custom_method = method_registry.get("code", method)
|
||||
if custom_method:
|
||||
if custom_method and custom_method != parse_code:
|
||||
fallback = kwargs.pop("fallback_on_custom_error", False)
|
||||
result = call_custom_method(
|
||||
logger, method, custom_method, file_path, language, fallback_on_custom_error=fallback, **kwargs
|
||||
logger,
|
||||
method,
|
||||
custom_method,
|
||||
file_path,
|
||||
language,
|
||||
fallback_on_custom_error=fallback,
|
||||
**kwargs,
|
||||
)
|
||||
if result is not CUSTOM_METHOD_FELL_BACK:
|
||||
return result
|
||||
@@ -495,10 +525,16 @@ def parse_media(
|
||||
>>> video = parse_media("video.mp4", method="default")
|
||||
"""
|
||||
custom_method = method_registry.get("media", method)
|
||||
if custom_method:
|
||||
if custom_method and custom_method != parse_media:
|
||||
fallback = kwargs.pop("fallback_on_custom_error", False)
|
||||
result = call_custom_method(
|
||||
logger, method, custom_method, file_path, media_type, fallback_on_custom_error=fallback, **kwargs
|
||||
logger,
|
||||
method,
|
||||
custom_method,
|
||||
file_path,
|
||||
media_type,
|
||||
fallback_on_custom_error=fallback,
|
||||
**kwargs,
|
||||
)
|
||||
if result is not CUSTOM_METHOD_FELL_BACK:
|
||||
return result
|
||||
@@ -541,10 +577,15 @@ def parse_pdf(
|
||||
>>> pages = parse_pdf("document.pdf", method="default", pages=[1, 2, 3])
|
||||
"""
|
||||
custom_method = method_registry.get("document", method)
|
||||
if custom_method:
|
||||
if custom_method and custom_method not in (parse_pdf, parse_document):
|
||||
fallback = kwargs.pop("fallback_on_custom_error", False)
|
||||
result = call_custom_method(
|
||||
logger, method, custom_method, file_path, fallback_on_custom_error=fallback, **kwargs
|
||||
logger,
|
||||
method,
|
||||
custom_method,
|
||||
file_path,
|
||||
fallback_on_custom_error=fallback,
|
||||
**kwargs,
|
||||
)
|
||||
if result is not CUSTOM_METHOD_FELL_BACK:
|
||||
return result
|
||||
@@ -585,10 +626,15 @@ def parse_docx(
|
||||
>>> docx = parse_docx("document.docx", method="default")
|
||||
"""
|
||||
custom_method = method_registry.get("document", method)
|
||||
if custom_method:
|
||||
if custom_method and custom_method not in (parse_docx, parse_document):
|
||||
fallback = kwargs.pop("fallback_on_custom_error", False)
|
||||
result = call_custom_method(
|
||||
logger, method, custom_method, file_path, fallback_on_custom_error=fallback, **kwargs
|
||||
logger,
|
||||
method,
|
||||
custom_method,
|
||||
file_path,
|
||||
fallback_on_custom_error=fallback,
|
||||
**kwargs,
|
||||
)
|
||||
if result is not CUSTOM_METHOD_FELL_BACK:
|
||||
return result
|
||||
@@ -628,10 +674,15 @@ def parse_json(file_path: Union[str, Path], method: str = "default", **kwargs) -
|
||||
>>> flattened = parse_json("data.json", method="default", flatten=True)
|
||||
"""
|
||||
custom_method = method_registry.get("structured", method)
|
||||
if custom_method:
|
||||
if custom_method and custom_method not in (parse_json, parse_structured_data):
|
||||
fallback = kwargs.pop("fallback_on_custom_error", False)
|
||||
result = call_custom_method(
|
||||
logger, method, custom_method, file_path, fallback_on_custom_error=fallback, **kwargs
|
||||
logger,
|
||||
method,
|
||||
custom_method,
|
||||
file_path,
|
||||
fallback_on_custom_error=fallback,
|
||||
**kwargs,
|
||||
)
|
||||
if result is not CUSTOM_METHOD_FELL_BACK:
|
||||
return result
|
||||
@@ -675,10 +726,16 @@ def parse_csv(
|
||||
>>> tab_separated = parse_csv("data.tsv", delimiter="\t", method="default")
|
||||
"""
|
||||
custom_method = method_registry.get("structured", method)
|
||||
if custom_method:
|
||||
if custom_method and custom_method not in (parse_csv, parse_structured_data):
|
||||
fallback = kwargs.pop("fallback_on_custom_error", False)
|
||||
result = call_custom_method(
|
||||
logger, method, custom_method, file_path, delimiter, fallback_on_custom_error=fallback, **kwargs
|
||||
logger,
|
||||
method,
|
||||
custom_method,
|
||||
file_path,
|
||||
delimiter,
|
||||
fallback_on_custom_error=fallback,
|
||||
**kwargs,
|
||||
)
|
||||
if result is not CUSTOM_METHOD_FELL_BACK:
|
||||
return result
|
||||
@@ -714,10 +771,15 @@ def parse_xml(file_path: Union[str, Path], method: str = "default", **kwargs) ->
|
||||
>>> xml_data = parse_xml("data.xml", method="default")
|
||||
"""
|
||||
custom_method = method_registry.get("structured", method)
|
||||
if custom_method:
|
||||
if custom_method and custom_method not in (parse_xml, parse_structured_data):
|
||||
fallback = kwargs.pop("fallback_on_custom_error", False)
|
||||
result = call_custom_method(
|
||||
logger, method, custom_method, file_path, fallback_on_custom_error=fallback, **kwargs
|
||||
logger,
|
||||
method,
|
||||
custom_method,
|
||||
file_path,
|
||||
fallback_on_custom_error=fallback,
|
||||
**kwargs,
|
||||
)
|
||||
if result is not CUSTOM_METHOD_FELL_BACK:
|
||||
return result
|
||||
@@ -762,10 +824,15 @@ def parse_image(
|
||||
>>> ocr_text = image.get("ocr_result", {}).get("text", "")
|
||||
"""
|
||||
custom_method = method_registry.get("media", method)
|
||||
if custom_method:
|
||||
if custom_method and custom_method not in (parse_image, parse_media):
|
||||
fallback = kwargs.pop("fallback_on_custom_error", False)
|
||||
result = call_custom_method(
|
||||
logger, method, custom_method, file_path, fallback_on_custom_error=fallback, **kwargs
|
||||
logger,
|
||||
method,
|
||||
custom_method,
|
||||
file_path,
|
||||
fallback_on_custom_error=fallback,
|
||||
**kwargs,
|
||||
)
|
||||
if result is not CUSTOM_METHOD_FELL_BACK:
|
||||
return result
|
||||
@@ -792,10 +859,9 @@ def list_available_methods(task: Optional[str] = None) -> Dict[str, List[str]]:
|
||||
return method_registry.list_all(task)
|
||||
|
||||
|
||||
# Register default methods
|
||||
method_registry.register("document", "default", parse_document)
|
||||
method_registry.register("web", "default", parse_web_content)
|
||||
method_registry.register("structured", "default", parse_structured_data)
|
||||
method_registry.register("email", "default", parse_email)
|
||||
method_registry.register("code", "default", parse_code)
|
||||
method_registry.register("media", "default", parse_media)
|
||||
# NOTE: the built-in dispatchers (parse_document, parse_web_content, ...) must
|
||||
# NOT be registered under their own task's "default" method name. Each
|
||||
# dispatcher starts with method_registry.get(<task>, method) and would find
|
||||
# itself, re-entering infinitely until RecursionError. "default" is the
|
||||
# built-in code path and stays unregistered; users can still register their
|
||||
# own "default" (or any other name) to override it.
|
||||
|
||||
@@ -33,7 +33,10 @@ from pathlib import Path
|
||||
from typing import Any, Dict, List, Optional, Union
|
||||
from urllib.parse import urljoin, urlparse
|
||||
|
||||
from bs4 import BeautifulSoup
|
||||
try:
|
||||
from bs4 import BeautifulSoup
|
||||
except (ImportError, OSError):
|
||||
BeautifulSoup = None
|
||||
|
||||
from ..utils.exceptions import ProcessingError, ValidationError
|
||||
from ..utils.logging import get_logger
|
||||
@@ -187,6 +190,12 @@ class HTMLContentParser(HTMLParser):
|
||||
}
|
||||
|
||||
# Load HTML for structure extraction
|
||||
if BeautifulSoup is None:
|
||||
raise ProcessingError(
|
||||
"beautifulsoup4 is required for HTML structure extraction. "
|
||||
"Install it with: pip install 'semantica[documents]'"
|
||||
)
|
||||
|
||||
if isinstance(html_content, Path) or (
|
||||
isinstance(html_content, str) and Path(html_content).exists()
|
||||
):
|
||||
@@ -243,6 +252,12 @@ class HTMLContentParser(HTMLParser):
|
||||
else:
|
||||
html_string = html_content
|
||||
|
||||
if BeautifulSoup is None:
|
||||
raise ProcessingError(
|
||||
"beautifulsoup4 is required for HTML cleaning. "
|
||||
"Install it with: pip install 'semantica[documents]'"
|
||||
)
|
||||
|
||||
soup = BeautifulSoup(html_string, "html.parser")
|
||||
|
||||
# Remove scripts and styles
|
||||
|
||||
@@ -34,7 +34,10 @@ from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List, Optional, Union
|
||||
|
||||
from lxml import etree
|
||||
try:
|
||||
from lxml import etree
|
||||
except (ImportError, OSError):
|
||||
etree = None
|
||||
|
||||
from ..utils.exceptions import ProcessingError, ValidationError
|
||||
from ..utils.logging import get_logger
|
||||
@@ -105,7 +108,13 @@ class XMLParser:
|
||||
)
|
||||
|
||||
try:
|
||||
engine = options.get("engine", "lxml")
|
||||
explicit_engine = options.get("engine") or self.config.get("engine")
|
||||
engine = explicit_engine or ("lxml" if etree is not None else "etree")
|
||||
if engine == "lxml" and etree is None:
|
||||
raise ProcessingError(
|
||||
"lxml is required to parse XML with engine='lxml'. "
|
||||
"Install it with: pip install 'semantica[documents]'"
|
||||
)
|
||||
|
||||
# Load XML content
|
||||
if file_path_obj:
|
||||
@@ -147,7 +156,7 @@ class XMLParser:
|
||||
self, xml_string: str, source: str, options: Dict[str, Any]
|
||||
) -> XMLData:
|
||||
"""Parse XML using lxml."""
|
||||
parser = etree.XMLParser(remove_blank_text=True)
|
||||
parser = etree.XMLParser(remove_blank_text=True, remove_comments=True)
|
||||
root = etree.fromstring(xml_string.encode("utf-8"), parser)
|
||||
|
||||
# Extract namespaces
|
||||
@@ -188,8 +197,10 @@ class XMLParser:
|
||||
metadata={"source": source, "engine": "etree"},
|
||||
)
|
||||
|
||||
def _element_to_xml_element(self, element) -> XMLElement:
|
||||
def _element_to_xml_element(self, element) -> Optional[XMLElement]:
|
||||
"""Convert lxml element to XMLElement."""
|
||||
if not hasattr(element, "tag") or not isinstance(element.tag, str):
|
||||
return None
|
||||
tag = element.tag
|
||||
if "}" in tag:
|
||||
namespace, tag = tag.split("}", 1)
|
||||
@@ -206,12 +217,18 @@ class XMLParser:
|
||||
|
||||
# Process children
|
||||
for child in element:
|
||||
xml_elem.children.append(self._element_to_xml_element(child))
|
||||
child_elem = self._element_to_xml_element(child)
|
||||
if child_elem is not None:
|
||||
xml_elem.children.append(child_elem)
|
||||
|
||||
return xml_elem
|
||||
|
||||
def _etree_element_to_xml_element(self, element: ET.Element) -> XMLElement:
|
||||
def _etree_element_to_xml_element(
|
||||
self, element: ET.Element
|
||||
) -> Optional[XMLElement]:
|
||||
"""Convert ElementTree element to XMLElement."""
|
||||
if not hasattr(element, "tag") or not isinstance(element.tag, str):
|
||||
return None
|
||||
tag = element.tag
|
||||
if "}" in tag:
|
||||
namespace, tag = tag.split("}", 1)
|
||||
@@ -228,7 +245,9 @@ class XMLParser:
|
||||
|
||||
# Process children
|
||||
for child in element:
|
||||
xml_elem.children.append(self._etree_element_to_xml_element(child))
|
||||
child_elem = self._etree_element_to_xml_element(child)
|
||||
if child_elem is not None:
|
||||
xml_elem.children.append(child_elem)
|
||||
|
||||
return xml_elem
|
||||
|
||||
@@ -249,18 +268,30 @@ class XMLParser:
|
||||
xml_data = self.parse(file_path, **options)
|
||||
|
||||
# Use lxml for XPath queries
|
||||
if etree is None:
|
||||
raise ProcessingError(
|
||||
"lxml is required for find_elements (XPath queries). "
|
||||
"Install it with: pip install 'semantica[documents]'"
|
||||
)
|
||||
|
||||
xml_string = (
|
||||
file_path
|
||||
if isinstance(file_path, str) and not Path(file_path).exists()
|
||||
else Path(file_path).read_text()
|
||||
else Path(file_path).read_text(encoding="utf-8")
|
||||
)
|
||||
root = etree.fromstring(xml_string.encode("utf-8"))
|
||||
parser = etree.XMLParser(remove_blank_text=True, remove_comments=True)
|
||||
root = etree.fromstring(xml_string.encode("utf-8"), parser=parser)
|
||||
|
||||
# Register namespaces for XPath
|
||||
namespaces = xml_data.namespaces
|
||||
elements = root.xpath(xpath, namespaces=namespaces)
|
||||
|
||||
return [self._element_to_xml_element(elem) for elem in elements]
|
||||
results = []
|
||||
for elem in elements:
|
||||
xml_elem = self._element_to_xml_element(elem)
|
||||
if xml_elem is not None:
|
||||
results.append(xml_elem)
|
||||
return results
|
||||
|
||||
def extract_by_tag(
|
||||
self, file_path: Union[str, Path], tag_name: str, **options
|
||||
|
||||
@@ -34,11 +34,11 @@ Example Usage:
|
||||
>>> from semantica.semantic_extract import NamedEntityRecognizer
|
||||
>>> ner = NamedEntityRecognizer(confidence_threshold=0.7)
|
||||
>>> entities = ner.extract_entities("Steve Jobs founded Apple.")
|
||||
|
||||
|
||||
>>> from semantica.semantic_extract import RelationExtractor
|
||||
>>> rel_extractor = RelationExtractor(confidence_threshold=0.6)
|
||||
>>> relations = rel_extractor.extract_relations(text, entities=entities)
|
||||
|
||||
|
||||
>>> from semantica.semantic_extract import TripletExtractor
|
||||
>>> triplet_extractor = TripletExtractor(include_temporal=True)
|
||||
>>> triplets = triplet_extractor.extract_triplets(text)
|
||||
@@ -52,7 +52,6 @@ from __future__ import annotations
|
||||
import importlib
|
||||
from typing import Any, Dict, Tuple
|
||||
|
||||
|
||||
_LAZY_EXPORTS: Dict[str, Tuple[str, str]] = {
|
||||
# Named Entity Recognition
|
||||
"NamedEntityRecognizer": (".named_entity_recognizer", "NamedEntityRecognizer"),
|
||||
@@ -92,7 +91,10 @@ _LAZY_EXPORTS: Dict[str, Tuple[str, str]] = {
|
||||
"RoleLabeler": (".semantic_analyzer", "RoleLabeler"),
|
||||
"SemanticClusterer": (".semantic_analyzer", "SemanticClusterer"),
|
||||
# Semantic Network
|
||||
"SemanticNetworkExtractor": (".semantic_network_extractor", "SemanticNetworkExtractor"),
|
||||
"SemanticNetworkExtractor": (
|
||||
".semantic_network_extractor",
|
||||
"SemanticNetworkExtractor",
|
||||
),
|
||||
"SemanticNode": (".semantic_network_extractor", "SemanticNode"),
|
||||
"SemanticEdge": (".semantic_network_extractor", "SemanticEdge"),
|
||||
"SemanticNetwork": (".semantic_network_extractor", "SemanticNetwork"),
|
||||
@@ -103,6 +105,10 @@ _LAZY_EXPORTS: Dict[str, Tuple[str, str]] = {
|
||||
# Validation
|
||||
"ExtractionValidator": (".extraction_validator", "ExtractionValidator"),
|
||||
"ValidationResult": (".extraction_validator", "ValidationResult"),
|
||||
# Schema-guided validation
|
||||
"ExtractionSchema": (".schema", "ExtractionSchema"),
|
||||
"Predicate": (".schema", "Predicate"),
|
||||
"SchemaValidator": (".schema_validator", "SchemaValidator"),
|
||||
# Providers
|
||||
"BaseProvider": (".providers", "BaseProvider"),
|
||||
"OpenAIProvider": (".providers", "OpenAIProvider"),
|
||||
@@ -139,6 +145,7 @@ def __getattr__(name: str) -> Any:
|
||||
globals()[name] = value
|
||||
return value
|
||||
|
||||
|
||||
__all__ = [
|
||||
# Named Entity Recognition
|
||||
"NamedEntityRecognizer",
|
||||
@@ -188,6 +195,10 @@ __all__ = [
|
||||
# Validation
|
||||
"ExtractionValidator",
|
||||
"ValidationResult",
|
||||
# Schema-guided validation
|
||||
"ExtractionSchema",
|
||||
"Predicate",
|
||||
"SchemaValidator",
|
||||
# Providers
|
||||
"BaseProvider",
|
||||
"OpenAIProvider",
|
||||
|
||||
@@ -209,6 +209,11 @@ def load_spacy_model(name: str):
|
||||
Raises whatever ``spacy.load`` raises (``OSError`` for a missing model), so
|
||||
callers keep their existing fallback behavior.
|
||||
"""
|
||||
if spacy is None:
|
||||
raise ImportError(
|
||||
"spaCy is not installed. Install with: pip install 'semantica[nlp-spacy]'"
|
||||
)
|
||||
|
||||
cached = _spacy_model_cache.get(name)
|
||||
if cached is not None and cached[0] is spacy:
|
||||
return cached[1]
|
||||
@@ -2316,7 +2321,7 @@ def extract_triplets_rules(
|
||||
|
||||
|
||||
def extract_triplets_huggingface(
|
||||
text: str, model: str, device: Optional[str] = None, **kwargs
|
||||
text: str, model: str, device: Optional[str] = None, entities: Optional[List[Entity]] = None, **kwargs
|
||||
) -> List[Triplet]:
|
||||
"""HuggingFace triplet extraction."""
|
||||
loader = HuggingFaceModelLoader(device=device)
|
||||
@@ -2348,16 +2353,30 @@ def extract_triplets_huggingface(
|
||||
tail = match.group("tail").strip()
|
||||
|
||||
if head and relation and tail:
|
||||
# Head/tail are raw decoded strings from the model. Tag any
|
||||
# that match no known entity so the GraphBuilder promotes
|
||||
# them instead of leaving a dangling edge (#1463).
|
||||
# TripletExtractor dispatches with entities=..., so this is
|
||||
# the real NER list here, not a dead comparison.
|
||||
hf_entities = entities or []
|
||||
synthetic_endpoints = [
|
||||
endpoint_text
|
||||
for endpoint_text in (head, tail)
|
||||
if not match_entity(endpoint_text, hf_entities)
|
||||
]
|
||||
hf_metadata = {
|
||||
"model": model,
|
||||
"extraction_method": "huggingface_rebel",
|
||||
}
|
||||
if synthetic_endpoints:
|
||||
hf_metadata["synthetic_endpoints"] = synthetic_endpoints
|
||||
triplets.append(
|
||||
Triplet(
|
||||
subject=head,
|
||||
predicate=relation,
|
||||
object=tail,
|
||||
confidence=0.9, # Model generation doesn't provide per-triplet confidence
|
||||
metadata={
|
||||
"model": model,
|
||||
"extraction_method": "huggingface_rebel"
|
||||
}
|
||||
metadata=hf_metadata
|
||||
)
|
||||
)
|
||||
|
||||
@@ -2517,16 +2536,27 @@ Text to extract from:
|
||||
# Convert back to internal Triplet format
|
||||
triplets = []
|
||||
for t_out in result_obj.triplets:
|
||||
# An LLM triple may reference an endpoint that does not match any
|
||||
# entity extracted by NER. Record those endpoints so the GraphBuilder
|
||||
# can promote them as synthetic entities instead of leaving a
|
||||
# dangling edge (see issue #1463).
|
||||
synthetic_endpoints = []
|
||||
for endpoint_text in (t_out.subject, t_out.object):
|
||||
if not match_entity(endpoint_text, entities or []):
|
||||
synthetic_endpoints.append(endpoint_text)
|
||||
metadata = {
|
||||
"provider": provider,
|
||||
"model": model,
|
||||
"extraction_method": "llm_typed",
|
||||
}
|
||||
if synthetic_endpoints:
|
||||
metadata["synthetic_endpoints"] = synthetic_endpoints
|
||||
triplets.append(Triplet(
|
||||
subject=t_out.subject,
|
||||
predicate=t_out.predicate,
|
||||
object=t_out.object,
|
||||
confidence=t_out.confidence,
|
||||
metadata={
|
||||
"provider": provider,
|
||||
"model": model,
|
||||
"extraction_method": "llm_typed"
|
||||
}
|
||||
metadata=metadata,
|
||||
))
|
||||
|
||||
logger.info(f"Successfully extracted {len(triplets)} triplets using {provider}/{model} (typed)")
|
||||
|
||||
@@ -37,7 +37,7 @@ Key Features:
|
||||
* LLM-based: Large language model extraction
|
||||
- Fallback chain support: Try methods in order until one succeeds
|
||||
- Robust Fallbacks: Prevents empty results via ML -> Pattern -> Last Resort chain
|
||||
- Ensemble voting: Combine results from multiple methods
|
||||
- Explicit merge strategies: fallback, union, and consensus
|
||||
- Post-processing: Entity boundary validation
|
||||
- Multiple entity type support (PERSON, ORG, GPE, DATE, etc.)
|
||||
- Confidence scoring and filtering
|
||||
@@ -62,15 +62,20 @@ Example Usage:
|
||||
>>> extractor = NERExtractor(method="huggingface", huggingface_model="dslim/bert-base-NER")
|
||||
>>> entities = extractor.extract_entities("Apple Inc. was founded in 1976.")
|
||||
>>>
|
||||
>>> # Using fallback chain
|
||||
>>> extractor = NERExtractor(method=["llm", "ml", "pattern"], ensemble_voting=True)
|
||||
>>> # Require agreement between multiple extraction methods
|
||||
>>> extractor = NERExtractor(
|
||||
... method=["llm", "ml"], merge_strategy="consensus", min_votes=2
|
||||
... )
|
||||
>>> entities = extractor.extract_entities("Apple Inc. was founded in 1976.")
|
||||
|
||||
Author: Semantica Contributors
|
||||
License: MIT
|
||||
"""
|
||||
|
||||
from typing import Any, Dict, List, Optional, Tuple, Union
|
||||
import math
|
||||
import re
|
||||
import warnings
|
||||
from typing import Any, Dict, List, Optional, Sequence, Tuple, Union
|
||||
|
||||
from ..utils.exceptions import ProcessingError
|
||||
from ..utils.helpers import safe_import
|
||||
@@ -84,6 +89,31 @@ spacy, SPACY_AVAILABLE = safe_import("spacy")
|
||||
class NERExtractor:
|
||||
"""Named Entity Recognition extractor."""
|
||||
|
||||
_VALID_MERGE_STRATEGIES = {"fallback", "union", "consensus"}
|
||||
_MERGE_OPTION_KEYS = (
|
||||
"merge_strategy",
|
||||
"min_votes",
|
||||
"min_agreement",
|
||||
"method_weights",
|
||||
"eligible_methods",
|
||||
)
|
||||
_MIN_SPAN_IOU = 0.5
|
||||
_LABEL_ALIASES = {
|
||||
"PER": "PERSON",
|
||||
"PERSON": "PERSON",
|
||||
"ORGANIZATION": "ORG",
|
||||
"ORG": "ORG",
|
||||
"LOCATION": "GPE",
|
||||
"LOC": "GPE",
|
||||
"GPE": "GPE",
|
||||
"TIME": "DATE",
|
||||
"DATE": "DATE",
|
||||
"CURRENCY": "MONEY",
|
||||
"MONEY": "MONEY",
|
||||
"PERCENTAGE": "PERCENT",
|
||||
"PERCENT": "PERCENT",
|
||||
}
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
method: Union[str, List[str]] = "ml",
|
||||
@@ -115,9 +145,16 @@ class NERExtractor:
|
||||
third-party servers (Qwen, LLaMA gateways, etc.) that do
|
||||
not implement the full function-calling protocol still
|
||||
return correctly structured results.
|
||||
- device: Device for HuggingFace models ("cuda" or "cpu")
|
||||
- min_confidence: Minimum confidence threshold
|
||||
- ensemble_voting: Enable ensemble voting (default: False)
|
||||
- device: Device for HuggingFace models ("cuda" or "cpu")
|
||||
- min_confidence: Minimum confidence threshold
|
||||
- merge_strategy: "fallback" (default), "union", or "consensus"
|
||||
- min_votes: Required supporting methods for consensus (default: 2)
|
||||
- min_agreement: Optional minimum support ratio for consensus
|
||||
- method_weights: Optional method weights for exact-span
|
||||
cross-label tie-breaking
|
||||
- eligible_methods: Optional subset of configured methods to count
|
||||
as consensus voters
|
||||
- ensemble_voting: Deprecated alias for merge_strategy="union"
|
||||
- post_process: Enable post-processing (default: False)
|
||||
"""
|
||||
self.logger = get_logger("ner_extractor")
|
||||
@@ -133,6 +170,15 @@ class NERExtractor:
|
||||
self.language = config.get("language", "en")
|
||||
self.min_confidence = config.get("min_confidence", 0.5)
|
||||
self.ensemble_voting = config.get("ensemble_voting", False)
|
||||
self.merge_strategy = self._resolve_merge_strategy(config)
|
||||
self.min_votes = self._validate_min_votes(config.get("min_votes", 2))
|
||||
self.min_agreement = self._validate_min_agreement(
|
||||
config.get("min_agreement")
|
||||
)
|
||||
self.method_weights = self._validate_method_weights(
|
||||
config.get("method_weights")
|
||||
)
|
||||
self.eligible_methods = config.get("eligible_methods")
|
||||
self.post_process = config.get("post_process", False)
|
||||
self.progress_tracker = get_progress_tracker()
|
||||
# Ensure progress tracker is enabled
|
||||
@@ -164,6 +210,240 @@ class NERExtractor:
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
def _resolve_merge_strategy(self, config: Dict[str, Any]) -> str:
|
||||
"""Resolve the explicit merge strategy and the deprecated legacy flag."""
|
||||
configured_strategy = config.get("merge_strategy")
|
||||
if configured_strategy is None:
|
||||
if self.ensemble_voting:
|
||||
warnings.warn(
|
||||
"ensemble_voting is deprecated because it historically "
|
||||
"performed a union, not voting. Use merge_strategy='union' "
|
||||
"or merge_strategy='consensus' explicitly.",
|
||||
DeprecationWarning,
|
||||
stacklevel=3,
|
||||
)
|
||||
return "union"
|
||||
return "fallback"
|
||||
|
||||
strategy = self._validate_merge_strategy(configured_strategy)
|
||||
if self.ensemble_voting:
|
||||
warnings.warn(
|
||||
"ensemble_voting is deprecated and ignored when merge_strategy "
|
||||
"is provided.",
|
||||
DeprecationWarning,
|
||||
stacklevel=3,
|
||||
)
|
||||
return strategy
|
||||
|
||||
@classmethod
|
||||
def _validate_merge_strategy(cls, strategy: Any) -> str:
|
||||
"""Return a normalized merge strategy or raise a useful configuration error."""
|
||||
if not isinstance(strategy, str):
|
||||
raise ValueError(
|
||||
"merge_strategy must be one of: fallback, union, consensus"
|
||||
)
|
||||
|
||||
normalized = strategy.lower()
|
||||
if normalized not in cls._VALID_MERGE_STRATEGIES:
|
||||
raise ValueError(
|
||||
"merge_strategy must be one of: fallback, union, consensus"
|
||||
)
|
||||
return normalized
|
||||
|
||||
@staticmethod
|
||||
def _validate_min_votes(min_votes: Any) -> int:
|
||||
"""Validate the number of method votes required for consensus."""
|
||||
if isinstance(min_votes, bool) or not isinstance(min_votes, int):
|
||||
raise ValueError("min_votes must be a positive integer")
|
||||
if min_votes < 1:
|
||||
raise ValueError("min_votes must be a positive integer")
|
||||
return min_votes
|
||||
|
||||
@staticmethod
|
||||
def _validate_min_agreement(min_agreement: Any) -> Optional[float]:
|
||||
"""Validate an optional consensus support ratio."""
|
||||
if min_agreement is None:
|
||||
return None
|
||||
|
||||
try:
|
||||
normalized = float(min_agreement)
|
||||
except (TypeError, ValueError):
|
||||
raise ValueError("min_agreement must be a number between 0 and 1")
|
||||
|
||||
if not math.isfinite(normalized) or not 0.0 <= normalized <= 1.0:
|
||||
raise ValueError("min_agreement must be a number between 0 and 1")
|
||||
return normalized
|
||||
|
||||
@classmethod
|
||||
def _validate_method_weights(cls, method_weights: Any) -> Dict[str, float]:
|
||||
"""Validate optional positive method weights used for deterministic ties."""
|
||||
if method_weights is None:
|
||||
return {}
|
||||
if not isinstance(method_weights, dict):
|
||||
raise ValueError("method_weights must be a mapping of method names to weights")
|
||||
|
||||
normalized = {}
|
||||
for method_name, weight in method_weights.items():
|
||||
if not isinstance(method_name, str):
|
||||
raise ValueError("method_weights keys must be method names")
|
||||
try:
|
||||
numeric_weight = float(weight)
|
||||
except (TypeError, ValueError):
|
||||
raise ValueError("method_weights values must be positive numbers")
|
||||
if not math.isfinite(numeric_weight) or numeric_weight <= 0:
|
||||
raise ValueError("method_weights values must be positive numbers")
|
||||
identity = cls._method_identity(method_name)
|
||||
existing_weight = normalized.get(identity)
|
||||
if existing_weight is not None and existing_weight != numeric_weight:
|
||||
raise ValueError(
|
||||
"method_weights assigns conflicting values to aliases for "
|
||||
f"backend '{identity}'"
|
||||
)
|
||||
normalized[identity] = numeric_weight
|
||||
return normalized
|
||||
|
||||
@staticmethod
|
||||
def _method_identity(method_name: str) -> str:
|
||||
"""Normalize aliases that share one extraction backend for vote counting."""
|
||||
normalized = method_name.lower()
|
||||
return "ml" if normalized in {"ml", "spacy"} else method_name
|
||||
|
||||
def _resolve_eligible_methods(
|
||||
self,
|
||||
methods: Sequence[str],
|
||||
configured_methods: Any = None,
|
||||
) -> List[str]:
|
||||
"""Resolve the configured method names that are eligible consensus voters."""
|
||||
available = []
|
||||
seen = set()
|
||||
for method_name in methods:
|
||||
identity = self._method_identity(method_name)
|
||||
if identity not in seen:
|
||||
available.append((identity, method_name))
|
||||
seen.add(identity)
|
||||
|
||||
configured = (
|
||||
self.eligible_methods
|
||||
if configured_methods is None
|
||||
else configured_methods
|
||||
)
|
||||
if configured is None:
|
||||
return [method_name for _, method_name in available]
|
||||
if isinstance(configured, str):
|
||||
configured = [configured]
|
||||
|
||||
try:
|
||||
configured = list(configured)
|
||||
except TypeError:
|
||||
raise ValueError("eligible_methods must be a sequence of method names")
|
||||
|
||||
requested_identities = set()
|
||||
for method_name in configured:
|
||||
if not isinstance(method_name, str):
|
||||
raise ValueError("eligible_methods must be a sequence of method names")
|
||||
requested_identities.add(self._method_identity(method_name))
|
||||
|
||||
available_identities = {identity for identity, _ in available}
|
||||
unknown_methods = [
|
||||
method_name
|
||||
for method_name in configured
|
||||
if self._method_identity(method_name) not in available_identities
|
||||
]
|
||||
if unknown_methods:
|
||||
raise ValueError(
|
||||
"eligible_methods contains methods not configured for extraction: "
|
||||
+ ", ".join(unknown_methods)
|
||||
)
|
||||
|
||||
return [
|
||||
method_name
|
||||
for identity, method_name in available
|
||||
if identity in requested_identities
|
||||
]
|
||||
|
||||
def _align_entities_to_text(
|
||||
self, entities: List[Entity], text: str
|
||||
) -> List[Entity]:
|
||||
"""Resolve missing offsets before span-based methods are merged.
|
||||
|
||||
Some providers, notably typed LLM extraction, can return text and
|
||||
labels without offsets. For a single method that is harmless, but a
|
||||
span-based merge needs document locations. Missing spans are therefore
|
||||
aligned by a deterministic, per-label text search. Valid provider
|
||||
offsets are preserved; candidates that cannot be aligned are excluded
|
||||
because union and consensus cannot safely merge them.
|
||||
"""
|
||||
next_offsets = {}
|
||||
occupied_offsets = {}
|
||||
aligned = []
|
||||
|
||||
for entity in entities:
|
||||
needle = entity.text
|
||||
if not isinstance(needle, str) or not needle:
|
||||
continue
|
||||
|
||||
key = (needle.casefold(), self._canonical_label(entity.label))
|
||||
start_char = entity.start_char
|
||||
end_char = entity.end_char
|
||||
has_valid_span = (
|
||||
isinstance(start_char, int)
|
||||
and isinstance(end_char, int)
|
||||
and 0 <= start_char < end_char <= len(text)
|
||||
and text[start_char:end_char].casefold() == needle.casefold()
|
||||
)
|
||||
if has_valid_span:
|
||||
aligned.append(entity)
|
||||
next_offsets[key] = max(next_offsets.get(key, 0), end_char)
|
||||
occupied_offsets.setdefault(key, set()).add((start_char, end_char))
|
||||
continue
|
||||
|
||||
prior_offset = next_offsets.get(key, 0)
|
||||
hinted_start = start_char if isinstance(start_char, int) else 0
|
||||
search_start = max(prior_offset, min(max(hinted_start, 0), len(text)))
|
||||
occupied = occupied_offsets.setdefault(key, set())
|
||||
match = None
|
||||
match_offset = 0
|
||||
left_boundary = (
|
||||
r"(?<!\w)" if needle[0].isalnum() or needle[0] == "_" else ""
|
||||
)
|
||||
right_boundary = (
|
||||
r"(?!\w)" if needle[-1].isalnum() or needle[-1] == "_" else ""
|
||||
)
|
||||
pattern = re.compile(
|
||||
left_boundary + re.escape(needle) + right_boundary,
|
||||
re.IGNORECASE,
|
||||
)
|
||||
for segment, offset in ((text[search_start:], search_start), (text, 0)):
|
||||
for candidate in pattern.finditer(segment):
|
||||
candidate_start = offset + candidate.start()
|
||||
candidate_end = offset + candidate.end()
|
||||
if (candidate_start, candidate_end) not in occupied:
|
||||
match = candidate
|
||||
match_offset = offset
|
||||
break
|
||||
if match is not None:
|
||||
break
|
||||
|
||||
if match is None:
|
||||
continue
|
||||
|
||||
resolved_start = match_offset + match.start()
|
||||
resolved_end = match_offset + match.end()
|
||||
aligned.append(
|
||||
Entity(
|
||||
text=entity.text,
|
||||
label=entity.label,
|
||||
start_char=resolved_start,
|
||||
end_char=resolved_end,
|
||||
confidence=entity.confidence,
|
||||
metadata=dict(entity.metadata or {}),
|
||||
)
|
||||
)
|
||||
next_offsets[key] = resolved_end
|
||||
occupied.add((resolved_start, resolved_end))
|
||||
|
||||
return aligned
|
||||
|
||||
def extract(self, text: Union[str, List[Dict[str, Any]], List[str]], pipeline_id: Optional[str] = None, **kwargs) -> Union[List[Entity], List[List[Entity]]]:
|
||||
"""
|
||||
Alias for extract_entities.
|
||||
@@ -347,11 +627,39 @@ class NERExtractor:
|
||||
)
|
||||
return []
|
||||
|
||||
# Use method from options if provided, otherwise use instance method
|
||||
methods = options.get("method", self.method)
|
||||
if isinstance(methods, str):
|
||||
methods = [methods]
|
||||
methods = self._filter_unusable_methods(methods)
|
||||
# Use method from options if provided, otherwise use instance method.
|
||||
# Keep the requested list separate from the executable list: in
|
||||
# consensus mode, a configured method with no result is still an
|
||||
# eligible non-supporting vote.
|
||||
requested_methods = options.get("method", self.method)
|
||||
if isinstance(requested_methods, str):
|
||||
requested_methods = [requested_methods]
|
||||
|
||||
merge_strategy = self._validate_merge_strategy(
|
||||
options.get("merge_strategy", self.merge_strategy)
|
||||
)
|
||||
if merge_strategy == "consensus":
|
||||
eligible_methods = self._resolve_eligible_methods(
|
||||
requested_methods,
|
||||
options.get("eligible_methods", self.eligible_methods),
|
||||
)
|
||||
else:
|
||||
# eligible_methods is a consensus-only setting. Union should
|
||||
# retain every configured method's complementary output.
|
||||
eligible_methods = self._resolve_eligible_methods(
|
||||
requested_methods, requested_methods
|
||||
)
|
||||
methods = self._filter_unusable_methods(requested_methods)
|
||||
|
||||
min_votes = self._validate_min_votes(
|
||||
options.get("min_votes", self.min_votes)
|
||||
)
|
||||
min_agreement = self._validate_min_agreement(
|
||||
options.get("min_agreement", self.min_agreement)
|
||||
)
|
||||
method_weights = self._validate_method_weights(
|
||||
options.get("method_weights", self.method_weights)
|
||||
)
|
||||
|
||||
min_confidence = options.get("min_confidence", self.min_confidence)
|
||||
entity_types = options.get("entity_types", self.entity_types)
|
||||
@@ -361,7 +669,9 @@ class NERExtractor:
|
||||
if entity_types:
|
||||
all_options["entity_types"] = entity_types
|
||||
|
||||
# Try each method in order (fallback chain)
|
||||
# Try each method in order. Fallback returns the first non-empty
|
||||
# result; union and consensus keep empty method results so their
|
||||
# denominators retain configured method provenance.
|
||||
all_entities = []
|
||||
for method_name in methods:
|
||||
try:
|
||||
@@ -373,6 +683,8 @@ class NERExtractor:
|
||||
|
||||
# Prepare method-specific options
|
||||
method_options = all_options.copy()
|
||||
for merge_option in self._MERGE_OPTION_KEYS:
|
||||
method_options.pop(merge_option, None)
|
||||
if method_name == "huggingface":
|
||||
# Prioritize runtime options over config/defaults
|
||||
method_options["model"] = (
|
||||
@@ -400,6 +712,8 @@ class NERExtractor:
|
||||
method_options["api_key"] = api_key
|
||||
|
||||
entities = method_func(text, **method_options)
|
||||
if merge_strategy != "fallback":
|
||||
entities = self._align_entities_to_text(entities, text)
|
||||
|
||||
# Apply weighted scoring if entity_types are provided
|
||||
if entity_types:
|
||||
@@ -418,15 +732,14 @@ class NERExtractor:
|
||||
# Filter by confidence
|
||||
filtered = [e for e in entities if e.confidence >= min_confidence]
|
||||
|
||||
if filtered:
|
||||
all_entities.append((method_name, filtered))
|
||||
|
||||
# If not using ensemble, return first successful result
|
||||
if not self.ensemble_voting:
|
||||
if merge_strategy == "fallback":
|
||||
if filtered:
|
||||
# Ensure default metadata
|
||||
for e in filtered:
|
||||
if e.metadata is None: e.metadata = {}
|
||||
if "batch_index" not in e.metadata: e.metadata["batch_index"] = 0
|
||||
if e.metadata is None:
|
||||
e.metadata = {}
|
||||
if "batch_index" not in e.metadata:
|
||||
e.metadata["batch_index"] = 0
|
||||
|
||||
self.progress_tracker.stop_tracking(
|
||||
tracking_id,
|
||||
@@ -434,6 +747,8 @@ class NERExtractor:
|
||||
message=f"Extracted {len(filtered)} entities using {method_name}",
|
||||
)
|
||||
return filtered
|
||||
else:
|
||||
all_entities.append((method_name, filtered))
|
||||
|
||||
except Exception as e:
|
||||
self.logger.warning(
|
||||
@@ -441,15 +756,23 @@ class NERExtractor:
|
||||
)
|
||||
continue
|
||||
|
||||
# Ensemble voting if enabled
|
||||
if self.ensemble_voting and len(all_entities) > 1:
|
||||
if merge_strategy == "consensus":
|
||||
entities = self._vote_entities(
|
||||
[entities for _, entities in all_entities]
|
||||
all_entities,
|
||||
eligible_methods=eligible_methods,
|
||||
min_votes=min_votes,
|
||||
min_agreement=min_agreement,
|
||||
method_weights=method_weights,
|
||||
)
|
||||
elif merge_strategy == "union":
|
||||
entities = self._union_entities(
|
||||
all_entities,
|
||||
eligible_methods=eligible_methods,
|
||||
method_weights=method_weights,
|
||||
)
|
||||
elif all_entities:
|
||||
entities = all_entities[0][1] # Use first successful method
|
||||
else:
|
||||
# Fallback to pattern-based extraction if all models fail
|
||||
# Only the explicit fallback strategy may introduce its own
|
||||
# pattern candidates after every configured method fails.
|
||||
entities = self._extract_fallback(text)
|
||||
|
||||
# Post-processing if enabled
|
||||
@@ -488,30 +811,466 @@ class NERExtractor:
|
||||
return filtered
|
||||
|
||||
def _vote_entities(
|
||||
self, results: List[List[Entity]], threshold: float = 0.5
|
||||
self,
|
||||
results: Sequence[Union[List[Entity], Tuple[str, List[Entity]]]],
|
||||
threshold: Optional[float] = None,
|
||||
*,
|
||||
eligible_methods: Optional[Sequence[str]] = None,
|
||||
min_votes: Optional[int] = None,
|
||||
min_agreement: Optional[float] = None,
|
||||
method_weights: Optional[Dict[str, float]] = None,
|
||||
) -> List[Entity]:
|
||||
"""Vote on entities across methods."""
|
||||
entity_counts = {}
|
||||
total_methods = len(results)
|
||||
"""Merge method results using span-aligned cross-method consensus.
|
||||
|
||||
for entities in results:
|
||||
``results`` accepts the historical ``List[List[Entity]]`` shape as
|
||||
well as ``(method_name, entities)`` pairs. The latter retains method
|
||||
provenance, while anonymous historical inputs receive stable generated
|
||||
names. ``threshold`` remains a compatibility alias for
|
||||
``min_agreement``; confidence is never used as a substitute for votes.
|
||||
"""
|
||||
resolved_min_votes = self._validate_min_votes(
|
||||
self.min_votes if min_votes is None else min_votes
|
||||
)
|
||||
if min_agreement is None:
|
||||
min_agreement = threshold if threshold is not None else self.min_agreement
|
||||
resolved_min_agreement = self._validate_min_agreement(min_agreement)
|
||||
resolved_method_weights = self._validate_method_weights(
|
||||
self.method_weights if method_weights is None else method_weights
|
||||
)
|
||||
|
||||
return self._merge_method_results(
|
||||
results,
|
||||
merge_strategy="consensus",
|
||||
eligible_methods=eligible_methods,
|
||||
min_votes=resolved_min_votes,
|
||||
min_agreement=resolved_min_agreement,
|
||||
method_weights=resolved_method_weights,
|
||||
)
|
||||
|
||||
def _union_entities(
|
||||
self,
|
||||
results: Sequence[Union[List[Entity], Tuple[str, List[Entity]]]],
|
||||
*,
|
||||
eligible_methods: Optional[Sequence[str]] = None,
|
||||
method_weights: Optional[Dict[str, float]] = None,
|
||||
) -> List[Entity]:
|
||||
"""Merge all method results while retaining single-method candidates."""
|
||||
resolved_method_weights = self._validate_method_weights(
|
||||
self.method_weights if method_weights is None else method_weights
|
||||
)
|
||||
return self._merge_method_results(
|
||||
results,
|
||||
merge_strategy="union",
|
||||
eligible_methods=eligible_methods,
|
||||
min_votes=1,
|
||||
min_agreement=None,
|
||||
method_weights=resolved_method_weights,
|
||||
)
|
||||
|
||||
def _merge_method_results(
|
||||
self,
|
||||
results: Sequence[Union[List[Entity], Tuple[str, List[Entity]]]],
|
||||
*,
|
||||
merge_strategy: str,
|
||||
eligible_methods: Optional[Sequence[str]],
|
||||
min_votes: int,
|
||||
min_agreement: Optional[float],
|
||||
method_weights: Dict[str, float],
|
||||
) -> List[Entity]:
|
||||
"""Align overlapping mentions and merge them with a named strategy."""
|
||||
method_results = self._normalize_method_results(results)
|
||||
eligible_methods = self._normalize_eligible_method_names(
|
||||
eligible_methods, method_results
|
||||
)
|
||||
if not eligible_methods:
|
||||
return []
|
||||
|
||||
eligible_identities = {
|
||||
self._method_identity(method_name) for method_name in eligible_methods
|
||||
}
|
||||
clusters = self._cluster_entities(method_results, eligible_identities)
|
||||
merged = []
|
||||
|
||||
for cluster in clusters:
|
||||
entity = self._build_merged_entity(
|
||||
cluster,
|
||||
eligible_methods=eligible_methods,
|
||||
merge_strategy=merge_strategy,
|
||||
min_votes=min_votes,
|
||||
min_agreement=min_agreement,
|
||||
)
|
||||
if entity is None:
|
||||
continue
|
||||
|
||||
merged.append(entity)
|
||||
|
||||
if merge_strategy == "consensus":
|
||||
merged = self._resolve_consensus_label_conflicts(
|
||||
merged, method_weights
|
||||
)
|
||||
|
||||
return sorted(
|
||||
merged,
|
||||
key=lambda entity: (
|
||||
entity.start_char,
|
||||
entity.end_char,
|
||||
entity.label,
|
||||
entity.text.casefold(),
|
||||
),
|
||||
)
|
||||
|
||||
def _normalize_method_results(
|
||||
self,
|
||||
results: Sequence[Union[List[Entity], Tuple[str, List[Entity]]]],
|
||||
) -> List[Tuple[str, List[Entity]]]:
|
||||
"""Coalesce alias methods so one backend cannot cast two votes."""
|
||||
normalized = {}
|
||||
for index, result in enumerate(results):
|
||||
if (
|
||||
isinstance(result, tuple)
|
||||
and len(result) == 2
|
||||
and isinstance(result[0], str)
|
||||
):
|
||||
method_name, entities = result
|
||||
else:
|
||||
method_name, entities = f"method_{index + 1}", result
|
||||
|
||||
identity = self._method_identity(method_name)
|
||||
if identity not in normalized:
|
||||
normalized[identity] = {"name": method_name, "entities": []}
|
||||
if entities:
|
||||
normalized[identity]["entities"].extend(entities)
|
||||
|
||||
return [
|
||||
(data["name"], data["entities"])
|
||||
for data in normalized.values()
|
||||
]
|
||||
|
||||
def _normalize_eligible_method_names(
|
||||
self,
|
||||
eligible_methods: Optional[Sequence[str]],
|
||||
method_results: Sequence[Tuple[str, List[Entity]]],
|
||||
) -> List[str]:
|
||||
"""Keep configured failed methods in the consensus denominator."""
|
||||
if eligible_methods is None:
|
||||
eligible_methods = [method_name for method_name, _ in method_results]
|
||||
|
||||
known_names = {
|
||||
self._method_identity(method_name): method_name
|
||||
for method_name, _ in method_results
|
||||
}
|
||||
normalized = []
|
||||
seen = set()
|
||||
for method_name in eligible_methods:
|
||||
identity = self._method_identity(method_name)
|
||||
if identity in seen:
|
||||
continue
|
||||
normalized.append(known_names.get(identity, method_name))
|
||||
seen.add(identity)
|
||||
return normalized
|
||||
|
||||
def _cluster_entities(
|
||||
self,
|
||||
method_results: Sequence[Tuple[str, List[Entity]]],
|
||||
eligible_identities: set,
|
||||
) -> List[List[Tuple[str, Entity]]]:
|
||||
"""Align same-label mentions with deterministic one-to-one matching.
|
||||
|
||||
Each method is matched to existing candidates as a batch, ordered by
|
||||
descending span IoU. This prevents an early, weaker boundary variant
|
||||
from consuming a method's only vote before its exact match is seen.
|
||||
Different labels stay separate here and are reconciled only after
|
||||
each label's independent support has been counted.
|
||||
"""
|
||||
clusters_by_label = {}
|
||||
ordered_results = sorted(
|
||||
method_results,
|
||||
key=lambda result: (
|
||||
self._method_identity(result[0]),
|
||||
result[0],
|
||||
),
|
||||
)
|
||||
|
||||
for method_name, entities in ordered_results:
|
||||
method_identity = self._method_identity(method_name)
|
||||
if method_identity not in eligible_identities:
|
||||
continue
|
||||
|
||||
unique_entities = {}
|
||||
for entity in entities:
|
||||
key = (entity.text.lower(), entity.label)
|
||||
if key not in entity_counts:
|
||||
entity_counts[key] = {"entity": entity, "score": 0.0, "count": 0}
|
||||
entity_counts[key]["score"] += entity.confidence
|
||||
entity_counts[key]["count"] += 1
|
||||
label = self._canonical_label(entity.label)
|
||||
key = (label, entity.start_char, entity.end_char)
|
||||
existing = unique_entities.get(key)
|
||||
if existing is None or self._entity_order_key(
|
||||
entity
|
||||
) < self._entity_order_key(existing):
|
||||
unique_entities[key] = entity
|
||||
|
||||
# Return entities that meet threshold
|
||||
voted = []
|
||||
for key, data in entity_counts.items():
|
||||
avg_score = data["score"] / data["count"]
|
||||
if avg_score >= threshold:
|
||||
entity = data["entity"]
|
||||
entity.confidence = avg_score
|
||||
voted.append(entity)
|
||||
entities_by_label = {}
|
||||
for entity in unique_entities.values():
|
||||
label = self._canonical_label(entity.label)
|
||||
entities_by_label.setdefault(label, []).append(entity)
|
||||
|
||||
return voted
|
||||
for label in sorted(entities_by_label):
|
||||
candidates = sorted(
|
||||
entities_by_label[label], key=self._entity_order_key
|
||||
)
|
||||
label_clusters = clusters_by_label.setdefault(label, [])
|
||||
edges = []
|
||||
for candidate_index, candidate in enumerate(candidates):
|
||||
for cluster_index, cluster in enumerate(label_clusters):
|
||||
if any(
|
||||
self._method_identity(cluster_method) == method_identity
|
||||
for cluster_method, _ in cluster
|
||||
):
|
||||
continue
|
||||
# A cluster represents one consensus mention, so a
|
||||
# candidate must overlap *every* vote already in it.
|
||||
# Using a best-pair score here would let A~B and B~C
|
||||
# turn into a false A/B/C consensus when A !~ C.
|
||||
scores = [
|
||||
self._span_iou(candidate, clustered_entity)
|
||||
for _, clustered_entity in cluster
|
||||
]
|
||||
score = min(scores)
|
||||
if score >= self._MIN_SPAN_IOU:
|
||||
edges.append((score, candidate_index, cluster_index))
|
||||
|
||||
matched_candidates = set()
|
||||
matched_clusters = set()
|
||||
for _, candidate_index, cluster_index in sorted(
|
||||
edges,
|
||||
key=lambda item: (
|
||||
-item[0],
|
||||
self._entity_order_key(candidates[item[1]]),
|
||||
item[2],
|
||||
),
|
||||
):
|
||||
if (
|
||||
candidate_index in matched_candidates
|
||||
or cluster_index in matched_clusters
|
||||
):
|
||||
continue
|
||||
label_clusters[cluster_index].append(
|
||||
(method_name, candidates[candidate_index])
|
||||
)
|
||||
matched_candidates.add(candidate_index)
|
||||
matched_clusters.add(cluster_index)
|
||||
|
||||
for candidate_index, candidate in enumerate(candidates):
|
||||
if candidate_index not in matched_candidates:
|
||||
label_clusters.append([(method_name, candidate)])
|
||||
|
||||
return [
|
||||
cluster
|
||||
for label in sorted(clusters_by_label)
|
||||
for cluster in clusters_by_label[label]
|
||||
]
|
||||
|
||||
def _resolve_consensus_label_conflicts(
|
||||
self,
|
||||
entities: Sequence[Entity],
|
||||
method_weights: Dict[str, float],
|
||||
) -> List[Entity]:
|
||||
"""Choose one deterministic label when candidates share one span.
|
||||
|
||||
Cross-label candidates only conflict when their final document spans
|
||||
are identical. Nested entities at different spans remain distinct.
|
||||
"""
|
||||
resolved = {}
|
||||
|
||||
def conflict_order_key(entity: Entity) -> Tuple[Any, ...]:
|
||||
metadata = entity.metadata or {}
|
||||
support_weight = sum(
|
||||
self._method_weight(method_name, method_weights)
|
||||
for method_name in metadata.get("supporting_methods", [])
|
||||
)
|
||||
confidence = self._numeric_confidence(entity.confidence)
|
||||
confidence_key = -confidence if confidence is not None else float("inf")
|
||||
return (
|
||||
-support_weight,
|
||||
-metadata.get("vote_count", 0),
|
||||
confidence_key,
|
||||
entity.label,
|
||||
entity.text.casefold(),
|
||||
)
|
||||
|
||||
for entity in entities:
|
||||
key = (entity.start_char, entity.end_char)
|
||||
existing = resolved.get(key)
|
||||
if existing is None or conflict_order_key(entity) < conflict_order_key(
|
||||
existing
|
||||
):
|
||||
resolved[key] = entity
|
||||
|
||||
return list(resolved.values())
|
||||
|
||||
@staticmethod
|
||||
def _span_iou(first: Entity, second: Entity) -> float:
|
||||
"""Return overlap-over-union for two document spans."""
|
||||
intersection = max(
|
||||
0,
|
||||
min(first.end_char, second.end_char)
|
||||
- max(first.start_char, second.start_char),
|
||||
)
|
||||
if not intersection:
|
||||
return 0.0
|
||||
union = max(first.end_char, second.end_char) - min(
|
||||
first.start_char, second.start_char
|
||||
)
|
||||
return intersection / union if union else 0.0
|
||||
|
||||
@classmethod
|
||||
def _canonical_label(cls, label: str) -> str:
|
||||
"""Normalize common NER aliases and BIO prefixes before label voting."""
|
||||
normalized = str(label).strip().upper()
|
||||
if "-" in normalized:
|
||||
prefix, remainder = normalized.split("-", 1)
|
||||
if prefix in {"B", "I", "L", "U", "E", "S"}:
|
||||
normalized = remainder
|
||||
return cls._LABEL_ALIASES.get(normalized, normalized)
|
||||
|
||||
@staticmethod
|
||||
def _numeric_confidence(confidence: Any) -> Optional[float]:
|
||||
"""Convert a usable confidence score without treating missing scores as zero."""
|
||||
if confidence is None:
|
||||
return None
|
||||
try:
|
||||
normalized = float(confidence)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
return normalized if math.isfinite(normalized) else None
|
||||
|
||||
@classmethod
|
||||
def _entity_order_key(cls, entity: Entity) -> Tuple[Any, ...]:
|
||||
"""Provide a deterministic winner for boundary and confidence variants."""
|
||||
confidence = cls._numeric_confidence(entity.confidence)
|
||||
confidence_key = -confidence if confidence is not None else float("inf")
|
||||
return (
|
||||
confidence_key,
|
||||
-(entity.end_char - entity.start_char),
|
||||
entity.start_char,
|
||||
entity.end_char,
|
||||
entity.text.casefold(),
|
||||
entity.label.casefold(),
|
||||
)
|
||||
|
||||
def _method_weight(
|
||||
self,
|
||||
method_name: str,
|
||||
method_weights: Dict[str, float],
|
||||
) -> float:
|
||||
"""Read a weight using the canonical backend name."""
|
||||
identity = self._method_identity(method_name)
|
||||
return method_weights.get(identity, 1.0)
|
||||
|
||||
def _build_merged_entity(
|
||||
self,
|
||||
cluster: Sequence[Tuple[str, Entity]],
|
||||
*,
|
||||
eligible_methods: Sequence[str],
|
||||
merge_strategy: str,
|
||||
min_votes: int,
|
||||
min_agreement: Optional[float],
|
||||
) -> Optional[Entity]:
|
||||
"""Resolve one same-label, offset-aligned candidate."""
|
||||
selected_by_method = {}
|
||||
for method_name, entity in cluster:
|
||||
identity = self._method_identity(method_name)
|
||||
existing = selected_by_method.get(identity)
|
||||
if existing is None or (
|
||||
self._entity_order_key(entity)
|
||||
< self._entity_order_key(existing[1])
|
||||
):
|
||||
selected_by_method[identity] = (method_name, entity)
|
||||
|
||||
eligible_records = []
|
||||
seen = set()
|
||||
for method_name in eligible_methods:
|
||||
identity = self._method_identity(method_name)
|
||||
if identity not in seen:
|
||||
eligible_records.append((identity, method_name))
|
||||
seen.add(identity)
|
||||
if not eligible_records:
|
||||
return None
|
||||
|
||||
if not selected_by_method:
|
||||
return None
|
||||
|
||||
supporting_entries = [
|
||||
(identity, method_name, entity)
|
||||
for identity, (method_name, entity) in selected_by_method.items()
|
||||
]
|
||||
vote_count = len(supporting_entries)
|
||||
agreement = vote_count / len(eligible_records)
|
||||
if merge_strategy == "consensus" and (
|
||||
vote_count < min_votes
|
||||
or (min_agreement is not None and agreement < min_agreement)
|
||||
):
|
||||
return None
|
||||
|
||||
representative = min(
|
||||
(entity for _, _, entity in supporting_entries), key=self._entity_order_key
|
||||
)
|
||||
canonical_label = self._canonical_label(representative.label)
|
||||
|
||||
supporting_by_identity = {
|
||||
identity: (method_name, entity)
|
||||
for identity, method_name, entity in supporting_entries
|
||||
}
|
||||
supporting_methods = [
|
||||
method_name
|
||||
for identity, method_name in eligible_records
|
||||
if identity in supporting_by_identity
|
||||
]
|
||||
method_scores = {
|
||||
method_name: (
|
||||
self._numeric_confidence(supporting_by_identity[identity][1].confidence)
|
||||
if identity in supporting_by_identity
|
||||
else None
|
||||
)
|
||||
for identity, method_name in eligible_records
|
||||
}
|
||||
|
||||
confidence_scores = []
|
||||
for identity, method_name in eligible_records:
|
||||
if identity not in supporting_by_identity:
|
||||
continue
|
||||
score = self._numeric_confidence(
|
||||
supporting_by_identity[identity][1].confidence
|
||||
)
|
||||
if score is not None:
|
||||
confidence_scores.append(score)
|
||||
|
||||
if confidence_scores:
|
||||
confidence = sum(confidence_scores) / len(confidence_scores)
|
||||
else:
|
||||
confidence = representative.confidence
|
||||
|
||||
metadata = dict(representative.metadata or {})
|
||||
metadata.update(
|
||||
{
|
||||
"merge_strategy": merge_strategy,
|
||||
"supporting_methods": supporting_methods,
|
||||
"vote_count": len(supporting_methods),
|
||||
"eligible_method_count": len(eligible_records),
|
||||
"agreement": agreement,
|
||||
"method_scores": method_scores,
|
||||
}
|
||||
)
|
||||
|
||||
return Entity(
|
||||
text=representative.text,
|
||||
label=(
|
||||
canonical_label
|
||||
if merge_strategy == "consensus"
|
||||
else representative.label
|
||||
),
|
||||
start_char=representative.start_char,
|
||||
end_char=representative.end_char,
|
||||
confidence=confidence,
|
||||
metadata=metadata,
|
||||
)
|
||||
|
||||
def _post_process_entities(self, entities: List[Entity], text: str) -> List[Entity]:
|
||||
"""Post-process entities for refinement."""
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,234 @@
|
||||
"""Domain schema view over an ontology, for schema-guided extraction.
|
||||
|
||||
An :class:`ExtractionSchema` is a lightweight, read-only view over a domain
|
||||
ontology: the set of allowed *concept* names (entity labels) and the allowed
|
||||
*predicates* with optional ``domain`` / ``range`` constraints. It is what
|
||||
:class:`~semantica.semantic_extract.schema_validator.SchemaValidator` checks
|
||||
extraction output against.
|
||||
|
||||
The schema deliberately **reuses the project's existing OWL ontology
|
||||
representation** instead of introducing a parallel "template" type. Build one
|
||||
from the dict produced by :func:`semantica.ontology.generate_ontology`
|
||||
(:meth:`ExtractionSchema.from_ontology`), or from an OWL / Turtle file or string
|
||||
(:meth:`ExtractionSchema.from_owl`).
|
||||
|
||||
An empty ``domain`` / ``range`` means "unconstrained", matching OWL's convention
|
||||
that an object property with no ``rdfs:domain`` / ``rdfs:range`` places no
|
||||
restriction on its subjects / objects.
|
||||
|
||||
Reference
|
||||
---------
|
||||
Using an ontology to constrain what may be extracted is the defining idea of
|
||||
ontology-based information extraction (OBIE): Wimalasuriya & Dou, "Ontology-Based
|
||||
Information Extraction: An Introduction and a Survey" (2010).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any, Dict, FrozenSet, Iterable, Mapping, Optional, Set
|
||||
|
||||
|
||||
def _as_name_set(value: Any) -> Set[str]:
|
||||
"""Coerce a ``domain`` / ``range`` value to a set of concept names.
|
||||
|
||||
Accepts a string, an iterable of strings / mappings, a mapping (reads its
|
||||
``name`` / ``label``), or ``None``. ``None`` / empty yields an empty set,
|
||||
interpreted downstream as "unconstrained".
|
||||
"""
|
||||
if value is None:
|
||||
return set()
|
||||
if isinstance(value, str):
|
||||
return {value}
|
||||
if isinstance(value, Mapping):
|
||||
name = value.get("name") or value.get("label")
|
||||
return {str(name)} if name else {str(k) for k in value}
|
||||
if isinstance(value, Iterable):
|
||||
out: Set[str] = set()
|
||||
for item in value:
|
||||
out |= _as_name_set(item)
|
||||
return out
|
||||
return {str(value)}
|
||||
|
||||
|
||||
_OWL_THING = {
|
||||
"owl:Thing",
|
||||
"Thing",
|
||||
"http://www.w3.org/2002/07/owl#Thing",
|
||||
}
|
||||
|
||||
|
||||
def _drop_thing(names: Set[str]) -> Set[str]:
|
||||
"""Collapse an ``owl:Thing`` domain / range to *unconstrained* (empty set).
|
||||
|
||||
``owl:Thing`` is the universal class, so a property whose ``domain`` / ``range``
|
||||
is ``owl:Thing`` places no restriction. ``OntologyGenerator`` emits it as the
|
||||
fallback when it cannot resolve endpoint types; keeping it as a literal
|
||||
``{"Thing"}`` constraint would reject every real endpoint, so we treat its
|
||||
presence as "any concept".
|
||||
"""
|
||||
return set() if names & _OWL_THING else names
|
||||
|
||||
|
||||
def _constraint_set(value: Any) -> Set[str]:
|
||||
"""A ``domain`` / ``range`` constraint set, with ``owl:Thing`` meaning unconstrained."""
|
||||
return _drop_thing(_as_name_set(value))
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Predicate:
|
||||
"""An allowed predicate with optional ``domain`` / ``range`` constraints.
|
||||
|
||||
Empty ``domain`` / ``range`` means any concept is allowed in that position.
|
||||
"""
|
||||
|
||||
name: str
|
||||
domain: FrozenSet[str] = frozenset()
|
||||
range: FrozenSet[str] = frozenset()
|
||||
|
||||
|
||||
@dataclass
|
||||
class ExtractionSchema:
|
||||
"""Read-only view over a domain ontology used to gate extraction.
|
||||
|
||||
Names are matched **exactly**: the schema vocabulary and the extraction labels
|
||||
must share a normalization convention. ``OntologyGenerator`` normalizes concept
|
||||
names to PascalCase and predicate names to camelCase, so entity labels /
|
||||
relation predicates validated against a generated schema should follow the same
|
||||
convention (e.g. label entities ``Person`` rather than ``person``).
|
||||
"""
|
||||
|
||||
concepts: FrozenSet[str] = field(default_factory=frozenset)
|
||||
predicates: Dict[str, Predicate] = field(default_factory=dict)
|
||||
|
||||
# ---- constructors -------------------------------------------------
|
||||
|
||||
@classmethod
|
||||
def from_ontology(cls, ontology: Any) -> "ExtractionSchema":
|
||||
"""Build a schema from a ``generate_ontology``-style ontology.
|
||||
|
||||
Accepts the mapping returned by :func:`semantica.ontology.generate_ontology`,
|
||||
or an object exposing such a mapping via a ``.data`` attribute — e.g. the
|
||||
``OntologyData`` returned by ``semantica.ingest.OntologyIngestor``.
|
||||
|
||||
Reads ``ontology["classes"]`` (each carrying a ``name`` / ``label``) as
|
||||
concepts and ``ontology["properties"]`` (each carrying a ``name`` and
|
||||
optional ``domain`` / ``range``) as predicates. Missing ``domain`` /
|
||||
``range`` means unconstrained; unrecognised keys are ignored.
|
||||
|
||||
Endpoint types named in a property's ``domain`` / ``range`` are also folded
|
||||
into the concept set (consistent with :meth:`from_owl`), so a type referenced
|
||||
only as an endpoint — e.g. one that didn't clear the class-frequency gate
|
||||
during induction — is still a known concept.
|
||||
"""
|
||||
if not isinstance(ontology, Mapping) and hasattr(ontology, "data"):
|
||||
ontology = ontology.data # unwrap OntologyData-like objects
|
||||
|
||||
concepts: Set[str] = set()
|
||||
for c in ontology.get("classes", []) or []:
|
||||
name = (c.get("name") or c.get("label")) if isinstance(c, Mapping) else c
|
||||
if name:
|
||||
concepts.add(str(name))
|
||||
|
||||
predicates: Dict[str, Predicate] = {}
|
||||
for p in ontology.get("properties", []) or []:
|
||||
if not isinstance(p, Mapping):
|
||||
continue
|
||||
name = p.get("name") or p.get("label")
|
||||
if not name:
|
||||
continue
|
||||
domain = _constraint_set(p.get("domain"))
|
||||
rng = _constraint_set(p.get("range"))
|
||||
predicates[str(name)] = Predicate(
|
||||
name=str(name),
|
||||
domain=frozenset(domain),
|
||||
range=frozenset(rng),
|
||||
)
|
||||
concepts |= domain | rng
|
||||
return cls(concepts=frozenset(concepts), predicates=predicates)
|
||||
|
||||
@classmethod
|
||||
def from_owl(
|
||||
cls, source: str, *, format: Optional[str] = None
|
||||
) -> "ExtractionSchema":
|
||||
"""Build a schema from an OWL / RDF file path or serialized string.
|
||||
|
||||
``owl:Class`` / ``rdfs:Class`` become concepts; ``owl:ObjectProperty`` with
|
||||
``rdfs:domain`` / ``rdfs:range`` becomes a predicate (its domain / range
|
||||
names are folded into the concept set, with ``owl:Thing`` treated as
|
||||
unconstrained). Names prefer an explicit ``rdfs:label``, falling back to the
|
||||
URI's local name, so the vocabulary matches :meth:`from_ontology`. Requires
|
||||
``rdflib`` (an existing project dependency).
|
||||
"""
|
||||
from rdflib import OWL, RDF, RDFS, Graph, URIRef
|
||||
|
||||
graph = Graph()
|
||||
if os.path.exists(source):
|
||||
graph.parse(source, format=format)
|
||||
else:
|
||||
graph.parse(data=source, format=format or "turtle")
|
||||
|
||||
def _local(term: Any) -> str:
|
||||
text = str(term)
|
||||
for sep in ("#", "/"):
|
||||
if sep in text:
|
||||
text = text.rsplit(sep, 1)[-1]
|
||||
return text
|
||||
|
||||
def _name_of(term: Any) -> str:
|
||||
label = graph.value(term, RDFS.label)
|
||||
return str(label) if label is not None else _local(term)
|
||||
|
||||
concepts: Set[str] = {
|
||||
_name_of(c)
|
||||
for class_type in (OWL.Class, RDFS.Class)
|
||||
for c in graph.subjects(RDF.type, class_type)
|
||||
if isinstance(c, URIRef)
|
||||
}
|
||||
predicates: Dict[str, Predicate] = {}
|
||||
for prop in graph.subjects(RDF.type, OWL.ObjectProperty):
|
||||
name = _name_of(prop)
|
||||
domain = _drop_thing(
|
||||
{_name_of(d) for d in graph.objects(prop, RDFS.domain)}
|
||||
)
|
||||
rng = _drop_thing({_name_of(r) for r in graph.objects(prop, RDFS.range)})
|
||||
predicates[name] = Predicate(
|
||||
name=name, domain=frozenset(domain), range=frozenset(rng)
|
||||
)
|
||||
concepts |= domain | rng
|
||||
return cls(concepts=frozenset(concepts), predicates=predicates)
|
||||
|
||||
# ---- queries ------------------------------------------------------
|
||||
|
||||
def has_concept(self, name: str) -> bool:
|
||||
"""Whether ``name`` is an allowed concept (entity label)."""
|
||||
return name in self.concepts
|
||||
|
||||
def has_predicate(self, name: str) -> bool:
|
||||
"""Whether ``name`` is an allowed predicate."""
|
||||
return name in self.predicates
|
||||
|
||||
def allows_relation(
|
||||
self, subject_label: str, predicate: str, object_label: str
|
||||
) -> bool:
|
||||
"""Whether a relation conforms to the schema.
|
||||
|
||||
True iff subject and object are known concepts, the predicate is known,
|
||||
and subject / object satisfy the predicate's ``domain`` / ``range``
|
||||
(an empty ``domain`` / ``range`` allows any concept).
|
||||
|
||||
Membership is exact; ``subClassOf`` hierarchies are not traversed, so a
|
||||
subclass endpoint is not accepted for a superclass ``domain`` / ``range``
|
||||
(subclass-aware validation is a possible follow-up).
|
||||
"""
|
||||
if subject_label not in self.concepts or object_label not in self.concepts:
|
||||
return False
|
||||
pred = self.predicates.get(predicate)
|
||||
if pred is None:
|
||||
return False
|
||||
if pred.domain and subject_label not in pred.domain:
|
||||
return False
|
||||
if pred.range and object_label not in pred.range:
|
||||
return False
|
||||
return True
|
||||
@@ -0,0 +1,196 @@
|
||||
"""Schema-guided validation for semantic extractions.
|
||||
|
||||
:class:`SchemaValidator` is a sibling of
|
||||
:class:`~semantica.semantic_extract.extraction_validator.ExtractionValidator`:
|
||||
same two entry points (:meth:`validate_entities` / :meth:`validate_relations`)
|
||||
returning the same :class:`ValidationResult`, so the two compose back-to-back.
|
||||
|
||||
The two validators check **orthogonal** axes. ``ExtractionValidator`` checks
|
||||
*confidence* and structural sanity; ``SchemaValidator`` checks *conformance to a
|
||||
domain ontology*:
|
||||
|
||||
* every entity label must be a concept in the schema;
|
||||
* every relation predicate must be in the schema and satisfy its ``domain`` /
|
||||
``range``.
|
||||
|
||||
This is the deterministic core of ontology-based information extraction (OBIE,
|
||||
Wimalasuriya & Dou 2010): it needs no LLM and is fully unit-testable.
|
||||
:meth:`validate_entities` / :meth:`validate_relations` *report* conformance;
|
||||
:meth:`filter_by_schema` / :meth:`filter_relations_by_schema` return the
|
||||
conforming subset (mirroring ``ExtractionValidator.filter_by_confidence``).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Dict, List, Optional, Union
|
||||
|
||||
from .extraction_validator import ValidationResult
|
||||
from .ner_extractor import Entity
|
||||
from .relation_extractor import Relation
|
||||
from .schema import ExtractionSchema
|
||||
|
||||
|
||||
class SchemaValidator:
|
||||
"""Validate extractions against a domain ontology (:class:`ExtractionSchema`)."""
|
||||
|
||||
def __init__(
|
||||
self, schema: ExtractionSchema, method: Optional[str] = None, **config: Any
|
||||
) -> None:
|
||||
"""Initialize the validator.
|
||||
|
||||
Args:
|
||||
schema: The domain ontology view to validate against.
|
||||
method: Reserved for future method-specific validation (unused),
|
||||
mirroring ``ExtractionValidator``.
|
||||
**config: Reserved configuration options.
|
||||
"""
|
||||
self.schema = schema
|
||||
self.method = method
|
||||
self.config = config
|
||||
|
||||
def validate_entities(
|
||||
self, entities: Union[List[Entity], List[List[Entity]]], **options: Any
|
||||
) -> Union[ValidationResult, List[ValidationResult]]:
|
||||
"""Validate that entity labels are concepts in the schema.
|
||||
|
||||
Handles both a single list and a batch (list of lists), like
|
||||
``ExtractionValidator.validate_entities``.
|
||||
"""
|
||||
if entities and isinstance(entities, list) and isinstance(entities[0], list):
|
||||
results = []
|
||||
for idx, batch in enumerate(entities):
|
||||
res = self.validate_entities(batch, **options)
|
||||
if "batch_index" not in res.metadata:
|
||||
res.metadata["batch_index"] = idx
|
||||
results.append(res)
|
||||
return results
|
||||
|
||||
errors: List[str] = []
|
||||
warnings: List[str] = []
|
||||
|
||||
out_of_vocab = [e for e in entities if not self.schema.has_concept(e.label)]
|
||||
unknown_labels = sorted({e.label for e in out_of_vocab})
|
||||
if out_of_vocab:
|
||||
errors.append(
|
||||
f"{len(out_of_vocab)} entities with labels outside the schema: "
|
||||
f"{', '.join(unknown_labels)}"
|
||||
)
|
||||
|
||||
total = len(entities)
|
||||
conforming = total - len(out_of_vocab)
|
||||
metrics = {
|
||||
"total_entities": total,
|
||||
"in_vocabulary": conforming,
|
||||
"out_of_vocabulary": len(out_of_vocab),
|
||||
"unknown_labels": unknown_labels,
|
||||
"schema_concepts": len(self.schema.concepts),
|
||||
}
|
||||
score = conforming / total if total else 1.0
|
||||
|
||||
return ValidationResult(
|
||||
valid=not errors,
|
||||
score=score,
|
||||
errors=errors,
|
||||
warnings=warnings,
|
||||
metrics=metrics,
|
||||
metadata=self._metadata(entities),
|
||||
)
|
||||
|
||||
def validate_relations(
|
||||
self, relations: Union[List[Relation], List[List[Relation]]], **options: Any
|
||||
) -> Union[ValidationResult, List[ValidationResult]]:
|
||||
"""Validate relation predicates and ``domain`` / ``range`` against the schema.
|
||||
|
||||
Handles both a single list and a batch (list of lists), like
|
||||
``ExtractionValidator.validate_relations``.
|
||||
"""
|
||||
if relations and isinstance(relations, list) and isinstance(relations[0], list):
|
||||
results = []
|
||||
for idx, batch in enumerate(relations):
|
||||
res = self.validate_relations(batch, **options)
|
||||
if "batch_index" not in res.metadata:
|
||||
res.metadata["batch_index"] = idx
|
||||
results.append(res)
|
||||
return results
|
||||
|
||||
errors: List[str] = []
|
||||
warnings: List[str] = []
|
||||
|
||||
# Guard malformed relations (missing subject/object) before dereferencing
|
||||
# their endpoints, matching ExtractionValidator's own leniency.
|
||||
malformed = [r for r in relations if not r.subject or not r.object]
|
||||
well_formed = [r for r in relations if r.subject and r.object]
|
||||
|
||||
unknown_predicate = [
|
||||
r for r in well_formed if not self.schema.has_predicate(r.predicate)
|
||||
]
|
||||
dr_violation = [
|
||||
r
|
||||
for r in well_formed
|
||||
if self.schema.has_predicate(r.predicate)
|
||||
and not self.schema.allows_relation(
|
||||
r.subject.label, r.predicate, r.object.label
|
||||
)
|
||||
]
|
||||
if malformed:
|
||||
errors.append(f"{len(malformed)} relations missing a subject or object")
|
||||
if unknown_predicate:
|
||||
preds = sorted({r.predicate for r in unknown_predicate})
|
||||
errors.append(
|
||||
f"{len(unknown_predicate)} relations with predicates outside the "
|
||||
f"schema: {', '.join(preds)}"
|
||||
)
|
||||
if dr_violation:
|
||||
errors.append(
|
||||
f"{len(dr_violation)} relations violating domain/range constraints"
|
||||
)
|
||||
|
||||
total = len(relations)
|
||||
conforming = total - len(malformed) - len(unknown_predicate) - len(dr_violation)
|
||||
metrics = {
|
||||
"total_relations": total,
|
||||
"conforming": conforming,
|
||||
"malformed": len(malformed),
|
||||
"unknown_predicate": len(unknown_predicate),
|
||||
"domain_range_violation": len(dr_violation),
|
||||
"schema_predicates": len(self.schema.predicates),
|
||||
}
|
||||
score = conforming / total if total else 1.0
|
||||
|
||||
return ValidationResult(
|
||||
valid=not errors,
|
||||
score=score,
|
||||
errors=errors,
|
||||
warnings=warnings,
|
||||
metrics=metrics,
|
||||
metadata=self._metadata(relations),
|
||||
)
|
||||
|
||||
def filter_by_schema(self, entities: List[Entity]) -> List[Entity]:
|
||||
"""Return only entities whose label is a concept in the schema."""
|
||||
return [e for e in entities if self.schema.has_concept(e.label)]
|
||||
|
||||
def filter_relations_by_schema(self, relations: List[Relation]) -> List[Relation]:
|
||||
"""Return only relations that fully conform to the schema."""
|
||||
return [
|
||||
r
|
||||
for r in relations
|
||||
if r.subject
|
||||
and r.object
|
||||
and self.schema.has_predicate(r.predicate)
|
||||
and self.schema.allows_relation(
|
||||
r.subject.label, r.predicate, r.object.label
|
||||
)
|
||||
]
|
||||
|
||||
@staticmethod
|
||||
def _metadata(items: List[Any]) -> Dict[str, Any]:
|
||||
"""Carry ``batch_index`` / ``document_id`` through, like ``ExtractionValidator``."""
|
||||
metadata: Dict[str, Any] = {}
|
||||
if items:
|
||||
first = items[0]
|
||||
if getattr(first, "metadata", None):
|
||||
for key in ("batch_index", "document_id"):
|
||||
if key in first.metadata:
|
||||
metadata[key] = first.metadata[key]
|
||||
return metadata
|
||||
+21
-13
@@ -38,15 +38,15 @@ Example Usage:
|
||||
>>> from semantica.utils import clean_text, normalize_entities
|
||||
>>> cleaned = clean_text(" Hello World ")
|
||||
>>> entities = normalize_entities([{"id": "e1", "text": "John", "type": "PERSON"}])
|
||||
>>>
|
||||
>>>
|
||||
>>> from semantica.utils import hash_data, safe_filename
|
||||
>>> data_hash = hash_data({"key": "value"})
|
||||
>>> safe_name = safe_filename("my file.txt")
|
||||
>>>
|
||||
>>>
|
||||
>>> from semantica.utils import merge_dicts, get_nested_value
|
||||
>>> merged = merge_dicts({"a": 1}, {"b": 2}, deep=True)
|
||||
>>> value = get_nested_value(config, "database.host", default="localhost")
|
||||
>>>
|
||||
>>>
|
||||
>>> from semantica.utils import retry_on_error
|
||||
>>> @retry_on_error(max_retries=3, delay=1.0)
|
||||
... def fetch_data():
|
||||
@@ -457,7 +457,9 @@ def chunk_list(items: List[Any], chunk_size: int) -> List[List[Any]]:
|
||||
Returns:
|
||||
List of chunks
|
||||
"""
|
||||
return [items[i : i + chunk_size] for i in range(0, len(items), chunk_size)]
|
||||
return [items[i : i + chunk_size] for i in range(0, len(items), chunk_size)]
|
||||
|
||||
|
||||
def flatten_dict(
|
||||
d: Dict[str, Any], parent_key: str = "", sep: str = "."
|
||||
) -> Dict[str, Any]:
|
||||
@@ -556,27 +558,27 @@ def safe_import(
|
||||
) -> Tuple[Any, bool]:
|
||||
"""
|
||||
Safely import an optional module, handling both ImportError and OSError.
|
||||
|
||||
|
||||
This is useful for optional dependencies that may fail to import due to:
|
||||
- Missing package (ImportError)
|
||||
- DLL loading failures on Windows, e.g., PyTorch (OSError)
|
||||
|
||||
|
||||
Args:
|
||||
module_name: Name of the module to import (e.g., "spacy", "docling.document_converter")
|
||||
package: Optional package name for relative imports
|
||||
default: Default value to return if import fails
|
||||
error_message: Optional custom error message for logging
|
||||
|
||||
|
||||
Returns:
|
||||
Tuple of (module_or_default, success_flag):
|
||||
- If import succeeds: (imported_module, True)
|
||||
- If import fails: (default, False)
|
||||
|
||||
|
||||
Example:
|
||||
>>> spacy, available = safe_import("spacy")
|
||||
>>> if available:
|
||||
... doc = spacy.load("en_core_web_sm")
|
||||
>>>
|
||||
>>>
|
||||
>>> converter, available = safe_import("docling.document_converter", default=None)
|
||||
>>> if available:
|
||||
... converter = converter()
|
||||
@@ -587,11 +589,13 @@ def safe_import(
|
||||
else:
|
||||
module = importlib.import_module(module_name)
|
||||
return module, True
|
||||
except (ImportError, ModuleNotFoundError, OSError) as e:
|
||||
except (ImportError, OSError) as e:
|
||||
if error_message:
|
||||
import sys
|
||||
|
||||
if "logging" in sys.modules:
|
||||
from .logging import get_logger
|
||||
|
||||
logger = get_logger("utils.helpers")
|
||||
logger.debug(f"{error_message}: {e}")
|
||||
return default, False
|
||||
@@ -807,9 +811,13 @@ def _is_record(value: Any) -> bool:
|
||||
exporters rather than a ``ValidationError`` at the boundary where the
|
||||
problem is visible.
|
||||
"""
|
||||
return isinstance(value, Mapping) or is_dataclass(value) or (
|
||||
hasattr(value, "__dict__")
|
||||
and not isinstance(value, (types.ModuleType, type))
|
||||
return (
|
||||
isinstance(value, Mapping)
|
||||
or is_dataclass(value)
|
||||
or (
|
||||
hasattr(value, "__dict__")
|
||||
and not isinstance(value, (types.ModuleType, type))
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -128,6 +128,11 @@ class FAISSIndex:
|
||||
self.index_type = index_type
|
||||
self.vector_ids: List[str] = []
|
||||
self.metadata: Dict[str, Dict[str, Any]] = {}
|
||||
# Monotonic counter for default ID generation, mirroring FAISSStore._next_id.
|
||||
# Persisted in the .meta.json sidecar so that load_index restores the
|
||||
# correct value rather than deriving it from ntotal (which underestimates
|
||||
# when vectors have been deleted and sparse gaps exist).
|
||||
self.next_id: int = 0
|
||||
|
||||
def add_vectors(self, vectors: np.ndarray, ids: Optional[List[str]] = None):
|
||||
"""
|
||||
@@ -199,6 +204,98 @@ class FAISSIndex:
|
||||
"""Get metadata by ID."""
|
||||
return self.metadata.get(vector_id)
|
||||
|
||||
def delete_vectors(self, vector_ids_to_delete: List[str]) -> Dict[str, Any]:
|
||||
"""Remove vectors by their external string IDs.
|
||||
|
||||
Translates each requested external ID to its sequential internal FAISS
|
||||
position, calls ``index.remove_ids`` with an ``IDSelectorBatch`` of
|
||||
those positions, then updates ``vector_ids`` and ``metadata`` to match
|
||||
the compacted index. The invariant ``len(self.vector_ids) ==
|
||||
self.index.ntotal`` is re-checked after the operation.
|
||||
|
||||
**Persistence:** the deletion is in-memory only. Call
|
||||
:meth:`FAISSStore.save_index` afterwards to write the updated state to
|
||||
disk; without that call the deleted vectors will reappear on the next
|
||||
process restart.
|
||||
|
||||
Args:
|
||||
vector_ids_to_delete: External string IDs to remove. Unknown IDs
|
||||
are silently ignored. Duplicate entries are deduplicated.
|
||||
|
||||
Returns:
|
||||
``{"delete_count": N}`` where *N* is the number of vectors
|
||||
actually removed from the FAISS index (0 if none existed).
|
||||
|
||||
Raises:
|
||||
NotImplementedError: If the underlying FAISS index type does not
|
||||
support ``remove_ids`` (e.g. ``IndexHNSWFlat``). No state is
|
||||
mutated before this is raised.
|
||||
ProcessingError: For any other unexpected FAISS error.
|
||||
"""
|
||||
if not vector_ids_to_delete:
|
||||
return {"delete_count": 0}
|
||||
|
||||
delete_set = set(vector_ids_to_delete)
|
||||
|
||||
# Map external string IDs to sequential internal FAISS positions.
|
||||
positions = [
|
||||
pos
|
||||
for pos, vid in enumerate(self.vector_ids)
|
||||
if vid in delete_set
|
||||
]
|
||||
if not positions:
|
||||
return {"delete_count": 0}
|
||||
|
||||
# IVF-family indices (IndexIVFFlat, etc.) do NOT compact their internal
|
||||
# labels after remove_ids: the surviving vectors keep their original
|
||||
# sequential labels. The current architecture interprets search-result
|
||||
# labels as offsets into vector_ids, so a non-compacting removal would
|
||||
# silently return wrong external IDs and cause IndexError on labels
|
||||
# beyond the compacted list length. Raise NotImplementedError here so
|
||||
# callers get STATUS_UNSUPPORTED rather than silent data corruption.
|
||||
# (Flat and PQ indices DO compact labels, so they are safe.)
|
||||
if FAISS_AVAILABLE and isinstance(self.index, faiss.IndexIVF):
|
||||
raise NotImplementedError(
|
||||
f"The underlying FAISS index type ({type(self.index).__name__}) "
|
||||
"does not compact internal labels after remove_ids, which would "
|
||||
"desynchronize search labels from the vector_ids mapping. Use a "
|
||||
"Flat index for deletion support, or rebuild the IVF index without "
|
||||
"the deleted vectors."
|
||||
)
|
||||
|
||||
sel = faiss.IDSelectorBatch(np.array(positions, dtype=np.int64))
|
||||
try:
|
||||
removed = self.index.remove_ids(sel)
|
||||
except RuntimeError as exc:
|
||||
if "not implemented" in str(exc).lower():
|
||||
# HNSW and a handful of other index types do not implement
|
||||
# remove_ids. Raise NotImplementedError so callers (and the
|
||||
# ErasureCoordinator) can distinguish "unsupported" from a
|
||||
# transient failure worth retrying.
|
||||
raise NotImplementedError(
|
||||
f"The underlying FAISS index type "
|
||||
f"({type(self.index).__name__}) does not support "
|
||||
"remove_ids(). Use a Flat index for deletion support, "
|
||||
"or rebuild the index without the deleted vectors."
|
||||
) from exc
|
||||
raise ProcessingError(f"FAISS remove_ids failed: {exc}") from exc
|
||||
|
||||
# Keep state consistent: update the Python-side list and metadata
|
||||
# dict to mirror the now-compacted FAISS array. The list comprehension
|
||||
# cannot raise, so the index and its metadata are always updated
|
||||
# together (no partial-mutation window).
|
||||
self.vector_ids = [vid for vid in self.vector_ids if vid not in delete_set]
|
||||
for vid in delete_set:
|
||||
self.metadata.pop(vid, None)
|
||||
|
||||
if len(self.vector_ids) != self.index.ntotal:
|
||||
raise ProcessingError(
|
||||
f"FAISSIndex invariant broken after delete_vectors: "
|
||||
f"vector_ids={len(self.vector_ids)}, ntotal={self.index.ntotal}. "
|
||||
"This indicates a bug in FAISS remove_ids or the deletion logic."
|
||||
)
|
||||
return {"delete_count": removed}
|
||||
|
||||
def save(self, path: Union[str, Path]):
|
||||
"""Save index to disk.
|
||||
|
||||
@@ -223,6 +320,7 @@ class FAISSIndex:
|
||||
"metadata": self.metadata,
|
||||
"dimension": self.dimension,
|
||||
"index_type": self.index_type,
|
||||
"next_id": self.next_id,
|
||||
},
|
||||
cls=_LosslessJSONEncoder,
|
||||
)
|
||||
@@ -245,7 +343,9 @@ class FAISSIndex:
|
||||
was originally saved.
|
||||
"""
|
||||
if not FAISS_AVAILABLE:
|
||||
raise ProcessingError("FAISS not available")
|
||||
raise ProcessingError(
|
||||
"FAISS not available. Install with: pip install 'semantica[vectorstore-faiss]' (or 'semantica[gpu]' for CUDA)"
|
||||
)
|
||||
|
||||
path = Path(path)
|
||||
index = faiss.read_index(str(path))
|
||||
@@ -262,6 +362,12 @@ class FAISSIndex:
|
||||
if persisted_index_type is not None:
|
||||
index_type = persisted_index_type
|
||||
|
||||
# Restore the monotonic ID counter. Older sidecar files written
|
||||
# before this field was added will not have the key; fall back to
|
||||
# ntotal, which equals the counter value for stores that have never
|
||||
# had a deletion (no gaps in label space).
|
||||
persisted_next_id = data.get("next_id")
|
||||
|
||||
# Check for vector count vs sidecar ID count mismatch
|
||||
if len(vector_ids) != index.ntotal:
|
||||
raise ProcessingError(
|
||||
@@ -279,10 +385,28 @@ class FAISSIndex:
|
||||
)
|
||||
vector_ids = []
|
||||
metadata = {}
|
||||
persisted_next_id = None
|
||||
|
||||
obj = cls(index, dimension, index_type)
|
||||
obj.vector_ids = vector_ids
|
||||
obj.metadata = metadata
|
||||
# Restore the monotonic counter. Always clamp to at least the
|
||||
# highest inferred vec_N ID, so a stale or corrupted persisted value
|
||||
# (e.g. written before a deletion that shifted the gap) cannot cause
|
||||
# future default IDs to collide with existing vector IDs.
|
||||
_vec_nums = [
|
||||
int(v[4:]) + 1
|
||||
for v in vector_ids
|
||||
if v.startswith("vec_") and v[4:].isdigit()
|
||||
]
|
||||
_inferred = max(_vec_nums) if _vec_nums else index.ntotal
|
||||
if persisted_next_id is not None:
|
||||
# Trust the persisted value but never go below the inferred minimum
|
||||
# (guards against stale/corrupted sidecars).
|
||||
obj.next_id = max(int(persisted_next_id), _inferred)
|
||||
else:
|
||||
# Older sidecar files lack this field. Use the inferred value.
|
||||
obj.next_id = _inferred
|
||||
return obj
|
||||
|
||||
|
||||
@@ -315,7 +439,7 @@ class FAISSSearch:
|
||||
|
||||
results = []
|
||||
for i, (dist, idx) in enumerate(zip(distances[0], indices[0])):
|
||||
if idx < len(self.index.vector_ids):
|
||||
if idx < len(self.index.vector_ids) and idx >= 0:
|
||||
vector_id = self.index.vector_ids[idx]
|
||||
dist_val = float(dist)
|
||||
|
||||
@@ -360,7 +484,7 @@ class FAISSIndexBuilder:
|
||||
"""
|
||||
if not FAISS_AVAILABLE:
|
||||
raise ProcessingError(
|
||||
"FAISS is not available. Install it with: pip install faiss-cpu or faiss-gpu"
|
||||
"FAISS is not available. Install it with: pip install 'semantica[vectorstore-faiss]' (or 'semantica[gpu]' for CUDA)"
|
||||
)
|
||||
|
||||
# Create index based on type
|
||||
@@ -422,11 +546,17 @@ class FAISSStore:
|
||||
self.index: Optional[FAISSIndex] = None
|
||||
self.index_builder = FAISSIndexBuilder(dimension)
|
||||
self.search_engine: Optional[FAISSSearch] = None
|
||||
# Path remembered by load_index so delete_vectors can auto-save.
|
||||
self._index_path: Optional[Path] = None
|
||||
# Monotonic counter for default ID generation. Incremented on every
|
||||
# successful add, never decremented on deletion, so ids generated by
|
||||
# consecutive add_vectors calls can never collide with surviving IDs.
|
||||
self._next_id: int = 0
|
||||
|
||||
# Check FAISS availability
|
||||
if not FAISS_AVAILABLE:
|
||||
self.logger.warning(
|
||||
"FAISS not available. Install with: pip install faiss-cpu or faiss-gpu"
|
||||
"FAISS not available. Install with: pip install 'semantica[vectorstore-faiss]' (or 'semantica[gpu]' for CUDA)"
|
||||
)
|
||||
|
||||
def create_index(
|
||||
@@ -498,13 +628,25 @@ class FAISSStore:
|
||||
|
||||
vectors = vectors.astype(np.float32)
|
||||
|
||||
# Generate IDs if not provided
|
||||
# Generate IDs if not provided. Use a monotonic counter so
|
||||
# that default IDs never collide with surviving IDs after a
|
||||
# deletion (len(vector_ids) would decrease, potentially reusing
|
||||
# a label that still exists in the index).
|
||||
if ids is None:
|
||||
ids = [
|
||||
f"vec_{len(self.index.vector_ids) + i}" for i in range(len(vectors))
|
||||
]
|
||||
_existing = set(self.index.vector_ids)
|
||||
generated: List[str] = []
|
||||
while len(generated) < len(vectors):
|
||||
cand = f"vec_{self._next_id}"
|
||||
self._next_id += 1
|
||||
if cand not in _existing:
|
||||
generated.append(cand)
|
||||
_existing.add(cand)
|
||||
ids = generated
|
||||
# Sync FAISSIndex.next_id so save() persists the correct value.
|
||||
self.index.next_id = self._next_id
|
||||
|
||||
# Store metadata
|
||||
# Assign metadata before the duplicate-skip filter so callers
|
||||
# always get up-to-date metadata even for already-present ids.
|
||||
if metadata:
|
||||
self.progress_tracker.update_tracking(
|
||||
tracking_id, message="Storing metadata..."
|
||||
@@ -613,7 +755,9 @@ class FAISSStore:
|
||||
FAISSIndex instance
|
||||
"""
|
||||
if not FAISS_AVAILABLE:
|
||||
raise ProcessingError("FAISS not available")
|
||||
raise ProcessingError(
|
||||
"FAISS not available. Install with: pip install 'semantica[vectorstore-faiss]' (or 'semantica[gpu]' for CUDA)"
|
||||
)
|
||||
|
||||
path = Path(path)
|
||||
if path.exists() and not _metadata_path(path).exists():
|
||||
@@ -625,6 +769,12 @@ class FAISSStore:
|
||||
|
||||
self.index = FAISSIndex.load(path, self.dimension, index_type)
|
||||
self.search_engine = FAISSSearch(self.index)
|
||||
# Remember the path so delete_vectors can auto-save to the same location.
|
||||
self._index_path = path
|
||||
# Restore the monotonic counter from the sidecar (via FAISSIndex.next_id)
|
||||
# rather than using ntotal. After a deletion ntotal is smaller than the
|
||||
# highest generated ID, so ntotal would cause ID collisions on the next add.
|
||||
self._next_id = self.index.next_id
|
||||
|
||||
self.logger.info(f"Loaded FAISS index from {path}")
|
||||
return self.index
|
||||
@@ -735,10 +885,62 @@ class FAISSStore:
|
||||
"""Return the number of vectors currently tracked in this store.
|
||||
|
||||
Returns the length of the ``vector_ids`` list maintained by
|
||||
``FAISSIndex``. FAISSStore does not implement vector deletion, so
|
||||
this list is strictly append-only and is always consistent with the
|
||||
underlying FAISS index (``index.ntotal``).
|
||||
``FAISSIndex``. This list is always kept consistent with the
|
||||
underlying FAISS index (``index.ntotal``), including after deletions.
|
||||
"""
|
||||
if self.index is None:
|
||||
return 0
|
||||
return len(self.index.vector_ids)
|
||||
|
||||
def delete_vectors(self, vector_ids: List[str], **options) -> Dict[str, Any]:
|
||||
"""Delete vectors by their external string IDs.
|
||||
|
||||
Delegates to :meth:`FAISSIndex.delete_vectors`. When the store was
|
||||
loaded from disk via :meth:`load_index`, the updated index and sidecar
|
||||
are written back to disk before this method returns, so the deletion is
|
||||
durable across process restarts without the caller needing a separate
|
||||
:meth:`save_index` call. Note: only the ``.meta.json`` sidecar write
|
||||
is atomic (temp-file + rename); the ``.faiss`` binary is written in
|
||||
place. A process crash between those two writes would leave the files
|
||||
inconsistent, but the mismatch guard in :meth:`FAISSIndex.load` would
|
||||
detect it on the next load rather than silently returning wrong data.
|
||||
|
||||
No-op deletions (all requested IDs unknown, or empty input) do not
|
||||
trigger a disk write.
|
||||
|
||||
When the store was created in memory (no :meth:`load_index` call), the
|
||||
deletion is in-memory only and the caller must invoke
|
||||
:meth:`save_index` to persist it.
|
||||
|
||||
IVF indices do not support deletion because their internal labels do
|
||||
not compact after ``remove_ids``, which would desynchronize search
|
||||
labels from the ``vector_ids`` mapping. HNSW indices also do not
|
||||
support ``remove_ids``. Both raise ``NotImplementedError``, which the
|
||||
:class:`ErasureCoordinator` translates to ``STATUS_UNSUPPORTED``.
|
||||
|
||||
Args:
|
||||
vector_ids: External string IDs to delete. Unknown IDs are
|
||||
silently ignored. Duplicates are deduplicated.
|
||||
**options: Accepted for API parity with other backends; unused.
|
||||
|
||||
Returns:
|
||||
``{"delete_count": N}``
|
||||
|
||||
Raises:
|
||||
ProcessingError: If no index has been initialized.
|
||||
NotImplementedError: If the underlying index type (IVF or HNSW)
|
||||
does not support safe deletion.
|
||||
"""
|
||||
if self.index is None:
|
||||
raise ProcessingError(
|
||||
"Index not initialized. Call create_index() first."
|
||||
)
|
||||
result = self.index.delete_vectors(vector_ids)
|
||||
# If the store was loaded from disk (load_index recorded the path),
|
||||
# persist the deletion so that the vectors cannot be resurrected by a
|
||||
# process restart. Only write when something was actually removed:
|
||||
# a no-op deletion (all IDs unknown or empty list) must not trigger
|
||||
# a full index rewrite.
|
||||
if self._index_path is not None and result.get("delete_count", 0) > 0:
|
||||
self.index.save(self._index_path)
|
||||
return result
|
||||
|
||||
@@ -627,6 +627,46 @@ class MilvusStore:
|
||||
)
|
||||
raise
|
||||
|
||||
def delete_vectors(self, vector_ids: List[str], **options) -> Dict[str, Any]:
|
||||
"""Delete vectors from collection by their ids.
|
||||
|
||||
Args:
|
||||
vector_ids: Vector ids to delete
|
||||
**options: Additional options
|
||||
|
||||
Returns:
|
||||
A dict with the number of matching entities that were deleted
|
||||
(``delete_count``).
|
||||
"""
|
||||
if self.collection is None:
|
||||
raise ProcessingError(
|
||||
"Collection not initialized. Call create_collection() or get_collection() first."
|
||||
)
|
||||
|
||||
if not vector_ids:
|
||||
return {"delete_count": 0}
|
||||
|
||||
try:
|
||||
# Milvus DELETE deletes by expression. Escape each id so a quote or
|
||||
# backslash in an id cannot break out of the string literal.
|
||||
if len(vector_ids) == 1:
|
||||
expr = f"id == {_format_milvus_value(vector_ids[0])}"
|
||||
else:
|
||||
formatted = ", ".join(_format_milvus_value(i) for i in vector_ids)
|
||||
expr = f"id in [{formatted}]"
|
||||
result = self.collection.collection.delete(expr=expr, **options)
|
||||
delete_count = getattr(result, "delete_count", 0)
|
||||
if delete_count is None:
|
||||
delete_count = 0
|
||||
elif isinstance(delete_count, (str, bytes)):
|
||||
try:
|
||||
delete_count = int(delete_count)
|
||||
except (TypeError, ValueError):
|
||||
delete_count = 0
|
||||
return {"delete_count": delete_count}
|
||||
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 not MILVUS_AVAILABLE or not self.collection:
|
||||
|
||||
@@ -153,9 +153,12 @@ class QdrantCollection:
|
||||
raise ProcessingError("Qdrant not available")
|
||||
|
||||
try:
|
||||
search_results = self.client.search(
|
||||
# qdrant-client >=1.10.0: query_points() supersedes the removed search().
|
||||
# It returns a QueryResponse whose .points attribute is a list of
|
||||
# ScoredPoint objects (id, score, payload, …).
|
||||
response = self.client.query_points(
|
||||
collection_name=self.collection_name,
|
||||
query_vector=query_vector.tolist(),
|
||||
query=query_vector.tolist(),
|
||||
limit=limit,
|
||||
query_filter=query_filter,
|
||||
with_payload=True,
|
||||
@@ -164,19 +167,19 @@ class QdrantCollection:
|
||||
)
|
||||
|
||||
results = []
|
||||
for result in search_results:
|
||||
for point in response.points:
|
||||
results.append(
|
||||
{
|
||||
"id": result.id,
|
||||
"id": point.id,
|
||||
# See pinecone_store.py PineconeIndex.search_vectors for why
|
||||
# this uses x/(1+|x|) rather than clamping distance-to-zero:
|
||||
# Qdrant's Dot distance metric is unbounded, and the old
|
||||
# clamped formula collapsed every score >= 1.0 to 1.0.
|
||||
"score": (
|
||||
float(result.score) / (1.0 + abs(float(result.score))) + 1.0
|
||||
float(point.score) / (1.0 + abs(float(point.score))) + 1.0
|
||||
)
|
||||
/ 2.0,
|
||||
"metadata": result.payload or {},
|
||||
"metadata": point.payload or {},
|
||||
"vector": None,
|
||||
"distance": None,
|
||||
}
|
||||
@@ -381,6 +384,27 @@ class QdrantStore:
|
||||
except Exception as e:
|
||||
raise ProcessingError(f"Failed to get collection: {str(e)}")
|
||||
|
||||
def _ensure_default_collection(self, dim: int = 384) -> QdrantCollection:
|
||||
"""Lazily attach the configured collection, creating it on first use.
|
||||
|
||||
Mirrors FAISSStore's automatic index creation so the VectorStore
|
||||
facade can read/write without an explicit create_collection() call.
|
||||
Reuses the existing collection if a previous process created it.
|
||||
"""
|
||||
# ``collection_name`` is the option the VectorStore facade and the
|
||||
# docs pass through; accept the legacy ``collection`` spelling too.
|
||||
name = (
|
||||
self.config.get("collection_name")
|
||||
or self.config.get("collection")
|
||||
or "semantica_default"
|
||||
)
|
||||
try:
|
||||
self.create_collection(name, vector_size=dim)
|
||||
except ProcessingError:
|
||||
self.get_collection(name)
|
||||
self.logger.info(f"Auto-initialized Qdrant collection '{name}' (dim={dim})")
|
||||
return self.collection
|
||||
|
||||
def insert_vectors(
|
||||
self,
|
||||
vectors: List[Union[np.ndarray, List[float]]],
|
||||
@@ -400,6 +424,14 @@ class QdrantStore:
|
||||
Returns:
|
||||
Insert response
|
||||
"""
|
||||
if len(ids) != len(vectors):
|
||||
# Points are paired with zip(vectors, ids), so a mismatched ID
|
||||
# list would silently drop the unpaired vectors while the
|
||||
# completion message still reports the full batch as inserted.
|
||||
raise ValidationError(
|
||||
f"Number of ids ({len(ids)}) must match number of vectors ({len(vectors)})"
|
||||
)
|
||||
|
||||
tracking_id = self.progress_tracker.start_tracking(
|
||||
module="vector_store",
|
||||
submodule="QdrantStore",
|
||||
@@ -408,12 +440,10 @@ class QdrantStore:
|
||||
|
||||
try:
|
||||
if self.collection is None:
|
||||
self.progress_tracker.stop_tracking(
|
||||
tracking_id, status="failed", message="Collection not initialized"
|
||||
)
|
||||
raise ProcessingError(
|
||||
"Collection not initialized. Call create_collection() or get_collection() first."
|
||||
)
|
||||
# len() not truthiness: vectors may be a 2-D ndarray, whose
|
||||
# truth value is ambiguous.
|
||||
dim = int(len(vectors[0])) if len(vectors) else 384
|
||||
self._ensure_default_collection(dim)
|
||||
|
||||
if not QDRANT_AVAILABLE:
|
||||
self.progress_tracker.stop_tracking(
|
||||
@@ -478,12 +508,7 @@ class QdrantStore:
|
||||
|
||||
try:
|
||||
if self.search_engine is None:
|
||||
self.progress_tracker.stop_tracking(
|
||||
tracking_id, status="failed", message="Collection not initialized"
|
||||
)
|
||||
raise ProcessingError(
|
||||
"Collection not initialized. Call create_collection() or get_collection() first."
|
||||
)
|
||||
self._ensure_default_collection(int(len(query_vector)))
|
||||
|
||||
self.progress_tracker.update_tracking(
|
||||
tracking_id, message="Performing similarity search..."
|
||||
@@ -695,9 +720,31 @@ class QdrantStore:
|
||||
collection_info = self.client.get_collection(
|
||||
self.collection.collection_name
|
||||
)
|
||||
# vectors_count was removed in qdrant-client 1.16.0.
|
||||
# When it is absent, only infer the total from points_count if we
|
||||
# can confirm the collection uses a single unnamed vector per point
|
||||
# (VectorParams). Named/multi-vector collections (dict of VectorParams)
|
||||
# have an unknown multiplier, so return None rather than a wrong value.
|
||||
# get_collection() accepts externally-created collections without schema
|
||||
# validation, so the schema must be inspected at stats time.
|
||||
vectors_count_fallback: Optional[int]
|
||||
try:
|
||||
vectors_cfg = collection_info.config.params.vectors
|
||||
vectors_count_fallback = (
|
||||
collection_info.points_count
|
||||
if QDRANT_AVAILABLE and isinstance(vectors_cfg, VectorParams)
|
||||
else None
|
||||
)
|
||||
except Exception:
|
||||
vectors_count_fallback = None
|
||||
|
||||
return {
|
||||
"points_count": collection_info.points_count,
|
||||
"vectors_count": collection_info.vectors_count,
|
||||
"vectors_count": getattr(
|
||||
collection_info,
|
||||
"vectors_count",
|
||||
vectors_count_fallback,
|
||||
),
|
||||
"status": str(collection_info.status)
|
||||
if hasattr(collection_info, "status")
|
||||
else "unknown",
|
||||
|
||||
@@ -68,6 +68,8 @@ License: MIT
|
||||
from typing import Any, Dict, List, Optional, Tuple, TypedDict, Union, cast
|
||||
import concurrent.futures
|
||||
import inspect
|
||||
import threading
|
||||
import uuid
|
||||
|
||||
import numpy as np
|
||||
|
||||
@@ -141,6 +143,14 @@ class VectorStore:
|
||||
if self.backend == "inmemory":
|
||||
self.vectors: Dict[str, np.ndarray] = {}
|
||||
self.metadata: Dict[str, Dict[str, Any]] = {}
|
||||
# Monotonic counter for default ID generation. Never decremented
|
||||
# on deletion, so IDs generated by consecutive store_vectors calls
|
||||
# can never collide with surviving IDs (fixes #1029).
|
||||
self._next_id: int = 0
|
||||
# Reentrant lock protecting all in-memory state mutations:
|
||||
# _next_id, vectors, metadata, and index rebuilds. Matches the
|
||||
# threading model used by SQLiteVecStore and AgentMemory.
|
||||
self._inmemory_lock = threading.RLock()
|
||||
|
||||
# Initialize backend-specific indexer
|
||||
# Avoid duplicate dimension argument
|
||||
@@ -482,7 +492,11 @@ class VectorStore:
|
||||
doc_meta = doc.metadata
|
||||
elif isinstance(doc, dict):
|
||||
doc_meta = doc.get("metadata", {})
|
||||
|
||||
elif isinstance(doc, str):
|
||||
# Plain-text documents: keep the text itself in the
|
||||
# payload, otherwise it is silently dropped.
|
||||
doc_meta = {"document": doc}
|
||||
|
||||
final_metadata[i].update(doc_meta)
|
||||
|
||||
return self.store_vectors(vectors, metadata=final_metadata, **options)
|
||||
@@ -525,6 +539,16 @@ class VectorStore:
|
||||
if supports_metadata:
|
||||
return self._backend_store.add_vectors(vectors, metadata=metadata, **options)
|
||||
return self._backend_store.add_vectors(vectors, **options)
|
||||
elif hasattr(self._backend_store, 'insert_vectors'):
|
||||
# QdrantStore: insert_vectors(vectors, ids, payloads=None)
|
||||
# upserts the points but returns the client's status dict,
|
||||
# while this facade promises callers the stored vector IDs
|
||||
# (decision storage indexes the result at position 0).
|
||||
# metadata entries already carry the source document (folded
|
||||
# in by store()), so they map directly to Qdrant payloads.
|
||||
ids = options.pop('ids', None) or [str(uuid.uuid4()) for _ in range(len(vectors))]
|
||||
self._backend_store.insert_vectors(vectors, ids, payloads=metadata, **options)
|
||||
return ids
|
||||
else:
|
||||
raise NotImplementedError(f"Backend store {type(self._backend_store).__name__} does not have add or add_vectors method")
|
||||
|
||||
@@ -542,18 +566,47 @@ class VectorStore:
|
||||
self.progress_tracker.update_tracking(
|
||||
tracking_id, message="Storing vectors..."
|
||||
)
|
||||
start_idx = len(self.vectors)
|
||||
for i, (vector, meta) in enumerate(zip(vectors, metadata)):
|
||||
vector_id = f"vec_{start_idx + i}"
|
||||
self.vectors[vector_id] = vector
|
||||
self.metadata[vector_id] = meta
|
||||
vector_ids.append(vector_id)
|
||||
# Hold the lock for the entire ID-allocation → dict-write →
|
||||
# index-rebuild sequence so concurrent callers cannot observe
|
||||
# half-written state or generate the same candidate ID.
|
||||
with self._inmemory_lock:
|
||||
# Snapshot the live key-set at lock-entry so the generator
|
||||
# and the within-batch de-dupe use a consistent view.
|
||||
pre_existing = set(self.vectors)
|
||||
# within_batch tracks IDs chosen during *this* call so the
|
||||
# same candidate is never returned twice in one batch.
|
||||
within_batch: set = set()
|
||||
|
||||
# Update index
|
||||
self.progress_tracker.update_tracking(
|
||||
tracking_id, message="Updating vector index..."
|
||||
)
|
||||
self.indexer.create_index(list(self.vectors.values()), vector_ids)
|
||||
for vector, meta in zip(vectors, metadata):
|
||||
# Advance the monotonic counter until we find a candidate
|
||||
# that is free both in the live store and in this batch.
|
||||
#
|
||||
# The counter is never decremented on deletion, so under
|
||||
# normal operation every candidate it produces is genuinely
|
||||
# fresh. The only reason a candidate can be occupied is
|
||||
# that a caller pre-inserted a ``vec_N`` key ahead of the
|
||||
# counter (e.g. manually writing to self.vectors). Skipping
|
||||
# over such keys is intentional and matches FAISSStore's
|
||||
# identical behaviour. Nothing is overwritten: the loop
|
||||
# breaks only on a candidate that is absent from both
|
||||
# pre_existing and within_batch.
|
||||
while True:
|
||||
candidate = f"vec_{self._next_id}"
|
||||
self._next_id += 1
|
||||
if candidate not in pre_existing and candidate not in within_batch:
|
||||
break
|
||||
|
||||
within_batch.add(candidate)
|
||||
self.vectors[candidate] = vector
|
||||
self.metadata[candidate] = meta
|
||||
vector_ids.append(candidate)
|
||||
|
||||
# Update index inside the lock so readers always see a
|
||||
# consistent (vectors, index) pair.
|
||||
self.progress_tracker.update_tracking(
|
||||
tracking_id, message="Updating vector index..."
|
||||
)
|
||||
self.indexer.create_index(list(self.vectors.values()), list(self.vectors.keys()))
|
||||
|
||||
self.progress_tracker.stop_tracking(
|
||||
tracking_id,
|
||||
@@ -595,7 +648,11 @@ class VectorStore:
|
||||
"metadata": getattr(self, "metadata", {}),
|
||||
"config": self.config,
|
||||
"backend": self.backend,
|
||||
"dimension": self.dimension
|
||||
"dimension": self.dimension,
|
||||
# Persist the monotonic counter so that load() can restore it
|
||||
# rather than re-deriving it from len(vectors), which would be
|
||||
# too small after a deletion and cause ID collisions (issue #1029).
|
||||
"next_id": getattr(self, "_next_id", None),
|
||||
}
|
||||
|
||||
with open(os.path.join(path, "store_data.json"), "w", encoding="utf-8") as f:
|
||||
@@ -642,6 +699,25 @@ class VectorStore:
|
||||
self.config = data.get("config", {})
|
||||
self.backend = data.get("backend", "faiss")
|
||||
self.dimension = data.get("dimension", 768)
|
||||
|
||||
# Restore the monotonic ID counter. Always clamp to at least
|
||||
# max(vec_N suffix)+1 so a stale or missing persisted value (e.g.
|
||||
# written before this field was added, or written before a deletion
|
||||
# that lowered the count) cannot produce IDs that collide with
|
||||
# existing vectors (issue #1029).
|
||||
if self.backend == "inmemory":
|
||||
_vec_nums = [
|
||||
int(v[4:]) + 1
|
||||
for v in self.vectors
|
||||
if v.startswith("vec_") and v[4:].isdigit()
|
||||
]
|
||||
_inferred = max(_vec_nums) if _vec_nums else 0
|
||||
persisted_next_id = data.get("next_id")
|
||||
if persisted_next_id is not None:
|
||||
self._next_id = max(int(persisted_next_id), _inferred)
|
||||
else:
|
||||
# Older store files lack this field; use the safe inferred value.
|
||||
self._next_id = _inferred
|
||||
|
||||
# Restore backend-specific index
|
||||
indexer = getattr(self, "indexer", None)
|
||||
@@ -722,14 +798,25 @@ class VectorStore:
|
||||
)
|
||||
return []
|
||||
|
||||
# Snapshot vectors and metadata together under the lock so a
|
||||
# concurrent delete_vectors / store_vectors cannot cause
|
||||
# "RuntimeError: dictionary changed size during iteration" and
|
||||
# cannot produce an inconsistent (values, keys) pair where one
|
||||
# list is shorter than the other. The lock is released before
|
||||
# the (potentially slow) similarity computation.
|
||||
with self._inmemory_lock:
|
||||
snapshot_vectors = list(self.vectors.values())
|
||||
snapshot_keys = list(self.vectors.keys())
|
||||
snapshot_metadata = dict(self.metadata)
|
||||
|
||||
# Use retriever for similarity search
|
||||
self.progress_tracker.update_tracking(
|
||||
tracking_id, message="Performing similarity search..."
|
||||
)
|
||||
results = self.retriever.search_similar(
|
||||
query_vector,
|
||||
list(self.vectors.values()),
|
||||
list(self.vectors.keys()),
|
||||
snapshot_vectors,
|
||||
snapshot_keys,
|
||||
k=k,
|
||||
**options,
|
||||
)
|
||||
@@ -737,8 +824,8 @@ class VectorStore:
|
||||
# Add metadata to results; guarantee the key always exists.
|
||||
for result in results:
|
||||
vector_id = result.get("id")
|
||||
if vector_id and vector_id in self.metadata:
|
||||
result["metadata"] = self.metadata[vector_id]
|
||||
if vector_id and vector_id in snapshot_metadata:
|
||||
result["metadata"] = snapshot_metadata[vector_id]
|
||||
elif "metadata" not in result:
|
||||
result["metadata"] = {}
|
||||
|
||||
@@ -767,19 +854,21 @@ class VectorStore:
|
||||
else:
|
||||
raise NotImplementedError(f"Backend store {type(self._backend_store).__name__} does not have update or update_vectors method")
|
||||
|
||||
for vec_id, new_vec in zip(vector_ids, new_vectors):
|
||||
if vec_id in self.vectors:
|
||||
self.vectors[vec_id] = new_vec
|
||||
with self._inmemory_lock:
|
||||
for vec_id, new_vec in zip(vector_ids, new_vectors):
|
||||
if vec_id in self.vectors:
|
||||
self.vectors[vec_id] = new_vec
|
||||
|
||||
if metadata:
|
||||
for vec_id, meta in zip(vector_ids, metadata):
|
||||
if vec_id in self.metadata:
|
||||
self.metadata[vec_id] = meta
|
||||
if metadata:
|
||||
for vec_id, meta in zip(vector_ids, metadata):
|
||||
if vec_id in self.metadata:
|
||||
self.metadata[vec_id] = meta
|
||||
|
||||
# Rebuild index
|
||||
self.indexer.create_index(
|
||||
list(self.vectors.values()), list(self.vectors.keys())
|
||||
)
|
||||
# Rebuild index under the lock so readers see a consistent
|
||||
# (vectors, index) pair, matching store_vectors and delete_vectors.
|
||||
self.indexer.create_index(
|
||||
list(self.vectors.values()), list(self.vectors.keys())
|
||||
)
|
||||
|
||||
return True
|
||||
|
||||
@@ -794,15 +883,17 @@ class VectorStore:
|
||||
else:
|
||||
raise NotImplementedError(f"Backend store {type(self._backend_store).__name__} does not have delete or delete_vectors method")
|
||||
|
||||
for vec_id in vector_ids:
|
||||
self.vectors.pop(vec_id, None)
|
||||
self.metadata.pop(vec_id, None)
|
||||
with self._inmemory_lock:
|
||||
for vec_id in vector_ids:
|
||||
self.vectors.pop(vec_id, None)
|
||||
self.metadata.pop(vec_id, None)
|
||||
|
||||
# Rebuild index
|
||||
if self.vectors:
|
||||
self.indexer.create_index(
|
||||
list(self.vectors.values()), list(self.vectors.keys())
|
||||
)
|
||||
# Rebuild index under the lock so a concurrent search cannot
|
||||
# see vectors without a corresponding index entry.
|
||||
if self.vectors:
|
||||
self.indexer.create_index(
|
||||
list(self.vectors.values()), list(self.vectors.keys())
|
||||
)
|
||||
|
||||
return True
|
||||
|
||||
|
||||
@@ -85,7 +85,7 @@ class AnalyticsVisualizer:
|
||||
if px is None or go is None:
|
||||
raise ProcessingError(
|
||||
"Plotly is required for analytics visualization. "
|
||||
"Install with: pip install plotly"
|
||||
"Install with: pip install 'semantica[viz]'"
|
||||
)
|
||||
if np is None:
|
||||
raise ProcessingError(
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
"""
|
||||
Embedding Visualizer Module
|
||||
|
||||
This module provides comprehensive visualization capabilities for vector embeddings in the
|
||||
Semantica framework, including 2D/3D dimensionality reduction projections, similarity heatmaps,
|
||||
clustering visualizations, multi-modal comparisons, and quality metrics analysis.
|
||||
This module provides comprehensive visualization capabilities for vector
|
||||
embeddings in the Semantica framework, including 2D/3D dimensionality
|
||||
reduction projections, similarity heatmaps, clustering visualizations,
|
||||
multi-modal comparisons, and quality metrics analysis.
|
||||
|
||||
Key Features:
|
||||
- 2D and 3D dimensionality reduction (UMAP, t-SNE, PCA)
|
||||
@@ -31,9 +32,8 @@ License: MIT
|
||||
"""
|
||||
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List, Optional, Tuple, Union
|
||||
from typing import Any, Dict, List, Optional, Union
|
||||
|
||||
import matplotlib.pyplot as plt
|
||||
import numpy as np
|
||||
|
||||
try:
|
||||
@@ -45,8 +45,12 @@ except (ImportError, OSError):
|
||||
go = None
|
||||
make_subplots = None
|
||||
|
||||
from sklearn.decomposition import PCA
|
||||
from sklearn.manifold import TSNE
|
||||
try:
|
||||
from sklearn.decomposition import PCA
|
||||
from sklearn.manifold import TSNE
|
||||
except (ImportError, OSError):
|
||||
PCA = None
|
||||
TSNE = None
|
||||
|
||||
try:
|
||||
import umap
|
||||
@@ -57,7 +61,7 @@ from ..utils.exceptions import ProcessingError
|
||||
from ..utils.logging import get_logger
|
||||
from ..utils.progress_tracker import get_progress_tracker
|
||||
from .utils.color_schemes import ColorPalette, ColorScheme
|
||||
from .utils.export_formats import export_matplotlib_figure, export_plotly_figure
|
||||
from .utils.export_formats import export_plotly_figure
|
||||
|
||||
|
||||
class EmbeddingVisualizer:
|
||||
@@ -94,12 +98,17 @@ class EmbeddingVisualizer:
|
||||
self.color_scheme = ColorScheme.DEFAULT
|
||||
self.point_size = config.get("point_size", 5)
|
||||
|
||||
def _check_dependencies(self):
|
||||
def _check_dependencies(self, require_sklearn: bool = False):
|
||||
"""Check if dependencies are available."""
|
||||
if px is None or go is None:
|
||||
raise ProcessingError(
|
||||
"Plotly is required for embedding visualization. "
|
||||
"Install with: pip install plotly"
|
||||
"Install with: pip install 'semantica[viz]'"
|
||||
)
|
||||
if require_sklearn and (PCA is None or TSNE is None):
|
||||
raise ProcessingError(
|
||||
"scikit-learn is required for dimensionality reduction. "
|
||||
"Reinstall scikit-learn or install dependencies."
|
||||
)
|
||||
|
||||
def visualize_2d_projection(
|
||||
@@ -150,10 +159,12 @@ class EmbeddingVisualizer:
|
||||
|
||||
try:
|
||||
self.logger.info(f"Visualizing 2D projection using {method}")
|
||||
|
||||
|
||||
# Step 2: Data Analysis
|
||||
n_samples, n_features = embeddings.shape
|
||||
self.logger.info(f"Embedding Analysis: {n_samples} samples, {n_features} dimensions")
|
||||
self.logger.info(
|
||||
f"Embedding Analysis: {n_samples} samples, {n_features} dimensions"
|
||||
)
|
||||
|
||||
if embeddings.shape[1] <= 2:
|
||||
# Already 2D or less, use directly
|
||||
@@ -165,28 +176,32 @@ class EmbeddingVisualizer:
|
||||
self.progress_tracker.update_tracking(
|
||||
tracking_id, message=f"Reducing dimensions using {method}..."
|
||||
)
|
||||
dim_options = dict(options)
|
||||
n_comp = dim_options.pop("n_components", 2)
|
||||
projected = self._reduce_dimensions(
|
||||
embeddings, method=method, n_components=2, **options
|
||||
embeddings, method=method, n_components=n_comp, **dim_options
|
||||
)
|
||||
|
||||
self.progress_tracker.update_tracking(
|
||||
tracking_id, message="Generating visualization..."
|
||||
)
|
||||
result = self._visualize_2d_plotly(
|
||||
projected,
|
||||
labels,
|
||||
output,
|
||||
file_path,
|
||||
projected,
|
||||
labels,
|
||||
output,
|
||||
file_path,
|
||||
color_by=color_by,
|
||||
size_by=size_by,
|
||||
hover_data=hover_data,
|
||||
**options
|
||||
**options,
|
||||
)
|
||||
|
||||
self.progress_tracker.stop_tracking(
|
||||
tracking_id,
|
||||
status="completed",
|
||||
message=f"2D projection visualization generated: {len(projected)} points",
|
||||
message=(
|
||||
f"2D projection visualization generated: {len(projected)} points"
|
||||
),
|
||||
)
|
||||
return result
|
||||
except Exception as e:
|
||||
@@ -237,8 +252,10 @@ class EmbeddingVisualizer:
|
||||
self.progress_tracker.update_tracking(
|
||||
tracking_id, message=f"Reducing dimensions using {method}..."
|
||||
)
|
||||
dim_options = dict(options)
|
||||
n_comp = dim_options.pop("n_components", 3)
|
||||
projected = self._reduce_dimensions(
|
||||
embeddings, method=method, n_components=3, **options
|
||||
embeddings, method=method, n_components=n_comp, **dim_options
|
||||
)
|
||||
|
||||
self.progress_tracker.update_tracking(
|
||||
@@ -251,7 +268,9 @@ class EmbeddingVisualizer:
|
||||
self.progress_tracker.stop_tracking(
|
||||
tracking_id,
|
||||
status="completed",
|
||||
message=f"3D projection visualization generated: {len(projected)} points",
|
||||
message=(
|
||||
f"3D projection visualization generated: {len(projected)} points"
|
||||
),
|
||||
)
|
||||
return result
|
||||
except Exception as e:
|
||||
@@ -342,7 +361,10 @@ class EmbeddingVisualizer:
|
||||
self.progress_tracker.stop_tracking(
|
||||
tracking_id,
|
||||
status="completed",
|
||||
message=f"Similarity heatmap generated: {len(embeddings)}x{len(embeddings)} matrix",
|
||||
message=(
|
||||
f"Similarity heatmap generated: "
|
||||
f"{len(embeddings)}x{len(embeddings)} matrix"
|
||||
),
|
||||
)
|
||||
return fig
|
||||
elif file_path:
|
||||
@@ -404,8 +426,10 @@ class EmbeddingVisualizer:
|
||||
self.progress_tracker.update_tracking(
|
||||
tracking_id, message=f"Reducing dimensions using {method}..."
|
||||
)
|
||||
dim_options = dict(options)
|
||||
n_comp = dim_options.pop("n_components", 2)
|
||||
projected = self._reduce_dimensions(
|
||||
embeddings, method=method, n_components=2, **options
|
||||
embeddings, method=method, n_components=n_comp, **dim_options
|
||||
)
|
||||
|
||||
num_clusters = len(set(cluster_labels))
|
||||
@@ -445,7 +469,10 @@ class EmbeddingVisualizer:
|
||||
self.progress_tracker.stop_tracking(
|
||||
tracking_id,
|
||||
status="completed",
|
||||
message=f"Clustering visualization generated: {num_clusters} clusters, {len(embeddings)} points",
|
||||
message=(
|
||||
f"Clustering visualization generated: "
|
||||
f"{num_clusters} clusters, {len(embeddings)} points"
|
||||
),
|
||||
)
|
||||
return fig
|
||||
elif file_path:
|
||||
@@ -541,8 +568,13 @@ class EmbeddingVisualizer:
|
||||
self.progress_tracker.update_tracking(
|
||||
tracking_id, message=f"Reducing dimensions using {method}..."
|
||||
)
|
||||
dim_options = dict(options)
|
||||
n_comp = dim_options.pop("n_components", 2)
|
||||
projected = self._reduce_dimensions(
|
||||
combined_embeddings, method=method, n_components=2, **options
|
||||
combined_embeddings,
|
||||
method=method,
|
||||
n_components=n_comp,
|
||||
**dim_options,
|
||||
)
|
||||
|
||||
# Color by type
|
||||
@@ -579,7 +611,10 @@ class EmbeddingVisualizer:
|
||||
self.progress_tracker.stop_tracking(
|
||||
tracking_id,
|
||||
status="completed",
|
||||
message=f"Multi-modal comparison generated: {len(combined_embeddings)} embeddings",
|
||||
message=(
|
||||
f"Multi-modal comparison generated: "
|
||||
f"{len(combined_embeddings)} embeddings"
|
||||
),
|
||||
)
|
||||
return fig
|
||||
elif file_path:
|
||||
@@ -598,8 +633,6 @@ class EmbeddingVisualizer:
|
||||
)
|
||||
raise
|
||||
|
||||
|
||||
|
||||
def _reduce_dimensions(
|
||||
self,
|
||||
embeddings: np.ndarray,
|
||||
@@ -608,39 +641,56 @@ class EmbeddingVisualizer:
|
||||
**options,
|
||||
) -> np.ndarray:
|
||||
"""Reduce embedding dimensions using specified method."""
|
||||
opts = dict(options)
|
||||
opts.pop("n_components", None)
|
||||
|
||||
if method == "pca":
|
||||
pca = PCA(n_components=n_components, **options)
|
||||
if PCA is None:
|
||||
raise ProcessingError(
|
||||
"scikit-learn is required for dimensionality reduction. "
|
||||
"Reinstall scikit-learn or install dependencies."
|
||||
)
|
||||
pca = PCA(n_components=n_components, **opts)
|
||||
return pca.fit_transform(embeddings)
|
||||
|
||||
elif method == "tsne":
|
||||
perplexity = options.get("perplexity", min(30, len(embeddings) - 1))
|
||||
if TSNE is None:
|
||||
raise ProcessingError(
|
||||
"scikit-learn is required for dimensionality reduction. "
|
||||
"Reinstall scikit-learn or install dependencies."
|
||||
)
|
||||
perplexity = opts.pop("perplexity", min(30, len(embeddings) - 1))
|
||||
random_state = opts.pop("random_state", 42)
|
||||
tsne = TSNE(
|
||||
n_components=n_components,
|
||||
perplexity=perplexity,
|
||||
random_state=42,
|
||||
**options,
|
||||
random_state=random_state,
|
||||
**opts,
|
||||
)
|
||||
return tsne.fit_transform(embeddings)
|
||||
|
||||
elif method == "umap":
|
||||
if umap is not None:
|
||||
n_neighbors = options.get("n_neighbors", min(15, len(embeddings) - 1))
|
||||
n_neighbors = opts.pop("n_neighbors", min(15, len(embeddings) - 1))
|
||||
reducer = umap.UMAP(
|
||||
n_components=n_components, n_neighbors=n_neighbors, **options
|
||||
n_components=n_components, n_neighbors=n_neighbors, **opts
|
||||
)
|
||||
return reducer.fit_transform(embeddings)
|
||||
else:
|
||||
# Fallback to PCA if UMAP not available
|
||||
self.logger.warning(
|
||||
"UMAP not available, using PCA. Install with: pip install umap-learn"
|
||||
raise ProcessingError(
|
||||
"UMAP is required for UMAP dimensionality reduction. "
|
||||
"Install with: pip install 'semantica[viz]'"
|
||||
)
|
||||
pca = PCA(n_components=n_components)
|
||||
return pca.fit_transform(embeddings)
|
||||
|
||||
else:
|
||||
if PCA is None:
|
||||
raise ProcessingError(
|
||||
"scikit-learn is required for dimensionality reduction. "
|
||||
"Reinstall scikit-learn or install dependencies."
|
||||
)
|
||||
# Fallback to PCA
|
||||
self.logger.warning(f"Method {method} not available, using PCA")
|
||||
pca = PCA(n_components=n_components)
|
||||
pca = PCA(n_components=n_components, **opts)
|
||||
return pca.fit_transform(embeddings)
|
||||
|
||||
def _visualize_2d_plotly(
|
||||
|
||||
@@ -124,7 +124,7 @@ class KGVisualizer:
|
||||
if px is None or go is None:
|
||||
raise ProcessingError(
|
||||
"Plotly is required for KG visualization. "
|
||||
"Install with: pip install plotly"
|
||||
"Install with: pip install 'semantica[viz]'"
|
||||
)
|
||||
|
||||
def _convert_knowledge_graph(self, kg: Any) -> Dict[str, Any]:
|
||||
|
||||
@@ -35,8 +35,14 @@ License: MIT
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List, Optional, Union
|
||||
|
||||
import matplotlib.patches as mpatches
|
||||
import matplotlib.pyplot as plt
|
||||
try:
|
||||
import matplotlib.patches as mpatches
|
||||
import matplotlib.pyplot as plt
|
||||
from matplotlib.patches import FancyBboxPatch
|
||||
except (ImportError, OSError):
|
||||
mpatches = None
|
||||
plt = None
|
||||
FancyBboxPatch = None
|
||||
|
||||
try:
|
||||
import plotly.express as px
|
||||
@@ -47,8 +53,6 @@ except (ImportError, OSError):
|
||||
go = None
|
||||
make_subplots = None
|
||||
|
||||
from matplotlib.patches import FancyBboxPatch
|
||||
|
||||
try:
|
||||
import graphviz
|
||||
except (ImportError, OSError):
|
||||
@@ -106,13 +110,13 @@ class OntologyVisualizer:
|
||||
if graphviz is None:
|
||||
raise ProcessingError(
|
||||
"Graphviz is required for DOT export. "
|
||||
"Install with: pip install graphviz"
|
||||
"Install with: pip install 'semantica[viz]'"
|
||||
)
|
||||
else:
|
||||
if px is None or go is None:
|
||||
if go is None:
|
||||
raise ProcessingError(
|
||||
"Plotly is required for ontology visualization. "
|
||||
"Install with: pip install plotly"
|
||||
"Install with: pip install 'semantica[viz]'"
|
||||
)
|
||||
|
||||
def visualize_hierarchy(
|
||||
@@ -892,7 +896,7 @@ class OntologyVisualizer:
|
||||
"""Create Graphviz hierarchy visualization."""
|
||||
if graphviz is None:
|
||||
raise ProcessingError(
|
||||
"Graphviz not available. Install with: pip install graphviz"
|
||||
"Graphviz not available. Install with: pip install 'semantica[viz]'"
|
||||
)
|
||||
|
||||
dot = graphviz.Digraph(comment="Ontology Hierarchy")
|
||||
|
||||
@@ -74,7 +74,7 @@ class SemanticNetworkVisualizer:
|
||||
if px is None or go is None:
|
||||
raise ProcessingError(
|
||||
"Plotly is required for semantic network visualization. "
|
||||
"Install with: pip install plotly"
|
||||
"Install with: pip install 'semantica[viz]'"
|
||||
)
|
||||
|
||||
def visualize_network(
|
||||
|
||||
@@ -83,7 +83,7 @@ class TemporalVisualizer:
|
||||
if px is None or go is None:
|
||||
raise ProcessingError(
|
||||
"Plotly is required for temporal visualization. "
|
||||
"Install with: pip install plotly"
|
||||
"Install with: pip install 'semantica[viz]'"
|
||||
)
|
||||
|
||||
def visualize_temporal_dashboard(
|
||||
|
||||
@@ -289,4 +289,122 @@ GET_ANALYTICS = {
|
||||
},
|
||||
}
|
||||
|
||||
STORE_DOCUMENT = {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"content": {
|
||||
"type": "string",
|
||||
"description": "Document text to chunk and store for semantic retrieval",
|
||||
},
|
||||
"source": {
|
||||
"type": "string",
|
||||
"description": "Provenance identifier, e.g. 'policy_manual_v2#page12'",
|
||||
},
|
||||
"authority": {
|
||||
"type": "string",
|
||||
"description": "Authority level of the content, e.g. 'official', 'draft', 'external'",
|
||||
},
|
||||
"version": {
|
||||
"type": "string",
|
||||
"description": "Document version tag used together with source as the upsert key (default: 'v1')",
|
||||
},
|
||||
"project": {
|
||||
"type": "string",
|
||||
"description": "Optional project namespace for later filtering",
|
||||
},
|
||||
"metadata": {
|
||||
"type": "object",
|
||||
"description": "Additional key-value properties stored on every chunk",
|
||||
},
|
||||
"chunk_size": {
|
||||
"type": "integer",
|
||||
"minimum": 100,
|
||||
"description": "Chunk window in characters (default: 1000)",
|
||||
},
|
||||
"chunk_overlap": {
|
||||
"type": "integer",
|
||||
"minimum": 0,
|
||||
"description": "Overlap between consecutive chunks in characters (default: 200)",
|
||||
},
|
||||
},
|
||||
"required": ["content", "source", "authority"],
|
||||
}
|
||||
|
||||
RETRIEVE_CONTEXT = {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"query": {
|
||||
"type": "string",
|
||||
"description": "Natural language query to embed and search for",
|
||||
},
|
||||
"top_k": {
|
||||
"type": "integer",
|
||||
"minimum": 1,
|
||||
"maximum": 10,
|
||||
"description": "Maximum number of chunks to return (default: 5, capped at 10)",
|
||||
},
|
||||
"project": {
|
||||
"type": "string",
|
||||
"description": "Only return chunks stored under this project namespace (optional)",
|
||||
},
|
||||
},
|
||||
"required": ["query"],
|
||||
}
|
||||
|
||||
UPDATE_DOCUMENT = {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"content": {
|
||||
"type": "string",
|
||||
"description": "New document text replacing the stored version",
|
||||
},
|
||||
"source": {
|
||||
"type": "string",
|
||||
"description": "Provenance identifier of the document to update",
|
||||
},
|
||||
"version": {
|
||||
"type": "string",
|
||||
"description": "Version tag identifying which stored version to replace (default: 'v1')",
|
||||
},
|
||||
"authority": {
|
||||
"type": "string",
|
||||
"description": "Updated authority level (defaults to the stored value)",
|
||||
},
|
||||
"project": {
|
||||
"type": "string",
|
||||
"description": "Updated project namespace (defaults to the stored value)",
|
||||
},
|
||||
"metadata": {
|
||||
"type": "object",
|
||||
"description": "Additional key-value properties merged into chunk metadata",
|
||||
},
|
||||
"chunk_size": {
|
||||
"type": "integer",
|
||||
"minimum": 100,
|
||||
"description": "Chunk window in characters (default: 1000)",
|
||||
},
|
||||
"chunk_overlap": {
|
||||
"type": "integer",
|
||||
"minimum": 0,
|
||||
"description": "Overlap between consecutive chunks in characters (default: 200)",
|
||||
},
|
||||
},
|
||||
"required": ["content", "source"],
|
||||
}
|
||||
|
||||
REMOVE_DOCUMENT = {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"source": {
|
||||
"type": "string",
|
||||
"description": "Provenance identifier of the document to remove",
|
||||
},
|
||||
"version": {
|
||||
"type": "string",
|
||||
"description": "Version tag identifying which stored version to remove (default: 'v1')",
|
||||
},
|
||||
},
|
||||
"required": ["source"],
|
||||
}
|
||||
|
||||
EMPTY = {"type": "object", "properties": {}}
|
||||
|
||||
@@ -51,6 +51,27 @@ _INTERNAL_ERROR = -32603
|
||||
_TOOL_INDEX: dict[str, dict] = {t["name"]: t for t in TOOL_DEFINITIONS}
|
||||
|
||||
|
||||
class UnknownToolError(Exception):
|
||||
"""Raised by :func:`call_tool` when the tool name is not in the catalog.
|
||||
|
||||
A dedicated type (rather than ``KeyError``) so callers can distinguish
|
||||
a bad tool name from a ``KeyError`` raised inside a handler indexing a
|
||||
required argument (e.g. ``args["category"]``).
|
||||
"""
|
||||
|
||||
|
||||
def call_tool(name: str, arguments: dict) -> dict:
|
||||
"""Invoke a tool in-process by name and return its raw result dict.
|
||||
|
||||
Shared by the JSON-RPC ``tools/call`` handler and ``semantica mcp call``
|
||||
(issue #1355), so both expose exactly the same tool set.
|
||||
"""
|
||||
tool = _TOOL_INDEX.get(name)
|
||||
if tool is None:
|
||||
raise UnknownToolError(f"Unknown tool: {name}")
|
||||
return tool["_handler"](arguments)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Request handlers
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -85,12 +106,10 @@ def _handle_tools_call(req_id: Any, params: dict) -> dict:
|
||||
name = params.get("name", "")
|
||||
args = params.get("arguments", {}) or {}
|
||||
|
||||
tool = _TOOL_INDEX.get(name)
|
||||
if tool is None:
|
||||
return _err(req_id, _METHOD_NOT_FOUND, f"Unknown tool: {name}")
|
||||
|
||||
try:
|
||||
result = tool["_handler"](args)
|
||||
result = call_tool(name, args)
|
||||
except UnknownToolError as exc:
|
||||
return _err(req_id, _METHOD_NOT_FOUND, str(exc))
|
||||
except Exception as exc:
|
||||
log.exception("Tool %s raised an exception", name)
|
||||
# The exception's class name (e.g. "ValidationError", "TimeoutError")
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user