Compare commits

...
Author SHA1 Message Date
1928f80104 fix(plugins): resolve Qodo-flagged bugs in rewritten skill examples
Addresses all 7 findings from the automated review on this PR:
- ontology: OntologyEngine() has no store configured, so
  list_concepts/list_vocabularies always raise ProcessingError; construct
  it with a TripletStore
- ontology: ValidationResult's field is `valid`, not `is_valid`
- change: state_at()["nodes"] is a list of dicts, so set(...) on it raises
  TypeError: unhashable type: 'dict' — diff by node id instead
- provenance/change: storage_path is passed to sqlite3.connect() unexpanded,
  so a literal "~/.semantica/prov.db" fails to open — expanduser + mkdir
- change/query: load_from_file() checks the literal path, so "~/..." never
  resolves and the graph loads empty — expanduser before calling
- query: QueryEngine.execute_query requires an object exposing
  execute_sparql(); the TripletStore wrapper doesn't expose that, only the
  raw backend (e.g. OxigraphStore) does
- query: the Cypher example constructed Neo4jStore but never called
  execute_query()

Co-Authored-By: gyro <zhuffwct@gmail.com>
Co-Authored-By: KaifAhmad1 <mohammadk78600@gmail.com>
2026-09-11 16:45:17 +05:30
gyroandClaude Opus 5 e883facedb fix(plugins): repair CP1252 bytes in the decision skill
Two em dashes were stored as the raw CP1252 byte 0x97 rather than UTF-8, so
the file is not valid UTF-8. Strict UTF-8 readers fail on it, and lenient ones
render the frontmatter description as "Semantica <?> record".

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-10 16:19:54 +08:00
gyroandClaude Opus 5 b4a0bc4063 fix(plugins): correct the extraction cache import in extract and validate
Both skills instruct the agent to clear the result cache via
`from semantica.semantic_extract.cache import _result_cache`, but the module
exports the `ExtractionCache` singleton as `extraction_cache`. The private
name never existed, so the first step of both skills raises ImportError.

`extraction_cache.clear()` is the equivalent call.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-10 16:19:54 +08:00
gyroandClaude Opus 5 cbd33fba67 fix(plugins): rewrite five skills against the real v0.7.0 API
The ontology, policy, provenance, change and query skills documented classes
and methods that do not exist in the package. Every import in them fails, so
following the skill produces ImportError or AttributeError immediately:

| Documented | Reality in 0.7.0 |
| --- | --- |
| `semantica.policy.PolicyEngine` | no `semantica.policy` module; `PolicyEngine` is in `semantica.context` |
| `semantica.query.QueryEngine` | no `semantica.query` module; `QueryEngine` is in `semantica.triplet_store` |
| `semantica.ontology.OntologyManager` | no such class; use `OntologyEngine` / `OntologyValidator` |
| `semantica.provenance.ProvenanceTracer` | no such class; use `ProvenanceManager` |
| `semantica.provenance.change_tracker.ChangeTracker` | no such module; ontology versioning lives in `semantica.change_management` |

The method names were wrong too, so a path-only fix was not possible:
`.check()`, `.list_rules()`, `.trace_node()`, `.get_audit_log()`,
`.compute_diff()`, `.get_node_history()`, `.query_sparql()`, `.query_cypher()`
and `.search()` do not exist on any of the real classes.

Each skill is rewritten against signatures verified by introspection on an
installed 0.7.0. Two notes on scope:

- policy now leads with `ContextGraph.check_decision_rules()` /
  `enforce_decision_policy()`, which need no graph store, and keeps
  `context.PolicyEngine` as the managed-policy path.
- change previously conflated graph-state-over-time with ontology versioning.
  These are separate mechanisms in 0.7.0, so the skill now documents
  `ContextGraph.state_at()` and `change_management.VersionManager` separately.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-10 16:19:54 +08:00
Wei TaoandClaude Code 9d93a80840 fix(explorer): label the graph view control "Focus" (#1552)
* fix(explorer): label the graph view control "Focus"

The control read "Focused" before it was ever activated, which describes a
state the selection had already reached rather than the action available.
The view mode value, tooltip, enablement and active styling are unchanged.

The legend e2e drove this button by its accessible name, so the selector
moves with the label; it now also asserts the visible text, the
selection-dependent enablement and the active state.

Closes #1551

Co-Authored-By: Claude Code <noreply@anthropic.com>

* fix(explorer): name the control in the grouped-selection hint, and trim the test

"Activate Focused mode" instructed the reader to press a control that no
longer carries that name. The surrounding strings describe the mode itself,
which is still called focused, so they stay.

Drop the label and active-state assertions from the colour-legend test: the
getByRole locator already fails when the accessible name is wrong, and the
rest belonged to the control's contract rather than to the legend's.

Co-Authored-By: Claude Code <noreply@anthropic.com>

---------

Co-authored-by: Claude Code <noreply@anthropic.com>
2026-09-10 09:42:17 +05:30
a9f1b29292 fix(explorer): resolve ontology ownership on the backend for entity deep links (#1439)
* fix(explorer): resolve ontology ownership on the backend

Which registered ontology owns an entity was decided twice: the backend
applies nested-namespace boundaries, while the Ontology Editor did a bare
prefix match. The two had already drifted, so a deep link to an entity in an
unregistered nested namespace selected the parent ontology whose /graph
response excludes that entity, and the selection silently failed.

/api/ontology/entity now returns owning_ontology, resolved with the same rule
the graph endpoint filters by, and the editor prefers it. The frontend
namespace guess stays as the fallback for a missing verdict, documented as
non-authoritative.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(explorer): keep the backend's no-owner verdict authoritative

Review follow-up: loadOntologyEntityOwner collapsed the backend's
explicit owning_ontology: null into undefined, re-activating the
namespace prefix guess for exactly the unregistered-nested-namespace
case this PR exists to fix. The owner verdict is now three-state
(owner / authoritative none / unavailable) and resolveEditorOntology
in the model suppresses inference on an authoritative none; only an
unavailable verdict may fall back. Model tests pin all three states.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* refactor(explorer): trust the backend to send owning_ontology

The Explorer bundle ships in the same wheel as the route that emits this
field, so the legacy-response branch could never run. Dropping it lets
the type say what the wire actually carries, leaving undefined to mean
only what it should: the request failed.

Note why the endpoint derives its ontology-URI set inline rather than
calling _known_ontology_uris, so the next reader does not consolidate a
graph scan back in.

Co-Authored-By: Claude Code <noreply@anthropic.com>

* fix(explorer): stop the registry default overriding a no-owner verdict

resolveEditorOntology returned string | undefined, so the caller wrote
`resolved || entries[0]?.uri` and an authoritative "nothing owns this
entity" fell straight through to an arbitrary registry entry. When that
entry happened to be the parent, the deep link opened the parent whose
graph excludes the entity — the bug the verdict exists to prevent. Only
the ordering of the registry in the earlier test hid it.

It now returns a union: unowned and unresolved both mean "no ontology to
open" but the editor treats them oppositely, so collapsing them with ||
is a type error rather than a silent regression. An unowned entity is
reported on the canvas instead of quietly opening the wrong ontology.

Also:

- /entity resolves ownership through _known_ontology_uris, the same
  helper /graph uses, instead of deriving it from a get_nodes scan capped
  at 999,999. Past that cap the set was silently truncated and the two
  endpoints could disagree about who owns a node.
- _resolve_owning_ontology does one pass over the candidates rather than
  one pass per candidate, each rescanning the whole set: 516us -> 9us at
  50 ontologies, 125ms -> 138us at 800, same answers throughout. A test
  pins it against _node_belongs_to_ontology so the hand-rolled version
  cannot drift from the membership rule it has to mirror.
- An explicit scheme_uri is honoured even when the registry does not list
  it, on both sides. Discarding it and guessing by namespace answered a
  question nobody asked; an unregistered owner now surfaces as an
  explicit error from /graph instead.
- A missing owning_ontology field reads as "no verdict", not as the
  authoritative "nothing owns this". That claim now suppresses selection
  outright, so it must not be inferred from an absent field.

Co-Authored-By: Claude Code <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Co-authored-by: Sameer Kadam <sskadam6305@gmail.com>
Co-authored-by: Sameer Kadam <sameerkadam@Mac.lan>
2026-09-09 23:39:39 +05:30
Wei TaoandClaude Fable 5 0338f90bca refactor(explorer): own Ontology Hub URL state in one module (#1440)
The deep-link protocol introduced in #1278 lived as bare "ontologyTab" /
"ontologyEntity" literals in five places across three files, each with its own
URLSearchParams plumbing and try/catch. Nothing tied the pieces together: in
particular the rule that a selection written under one ontology must be cleared
when the active ontology changes — otherwise a reload resolves the stale entity
and jumps back to the old ontology — was a comment at one call site with no
mechanism behind it.

ontologyUrlState.ts now owns the parameter names as private constants and
exposes the protocol as intent-named operations, with the write/clear pairing
documented where both halves live. Parsing and serialization are pure functions
over a search string, so they are covered by tests without a DOM; the window
and history.replaceState interaction stays in thin shells.

Absent parameters still read as undefined while blank ones read as empty
strings, which preserves the differing presence checks the workspace shell and
the tab selector each relied on.

One behavior change, inherited from all five original call sites: writing the
query string dropped any URL fragment, because replaceState with a bare "?..."
replaces the whole tail. updateSearch now carries window.location.hash across,
which fixes it for every writer at once — this module is the only place that
knows how the URL is written, so it is the only place the fix belongs.

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-09-09 21:50:13 +05:30
Sameer KadamandSameer Kadam def18cd552 fix(explorer): remove stale 2030 temporal bound (#1549)
Co-authored-by: Sameer Kadam <sameerkadam@Mac.lan>
2026-09-09 17:41:11 +05:30
Wei TaoandSameer Kadam 6b55cf1101 fix(explorer): end the temporal scrubber at now instead of inventing 2030 (#1541)
TimelinePanel fell back to a hardcoded 2030-01-01 whenever
/api/temporal/bounds reported max: null, then placed the playhead at the
midpoint of that fabricated window. session.get_temporal_bounds leaves max
open for any graph whose nodes carry valid_from instants and no
valid_until, so this was the normal response shape rather than bad data:
the header advertised a range no data supported and the workspace's first
/api/temporal/snapshot request asked about a time years ahead of the
present.

The fallback is now a `now` captured once per mount, and defaultTime is
that same `now` clamped into the range, so the initial snapshot describes
the current state. Three settings tuned for the fictional ~60-year window
follow from it: zoomMin drops from a year to a day, the timeAxis/format
pinning to 5-year ticks is removed so vis-timeline picks a granularity for
the real span, and the fixed 6-month play step becomes span/60 with a
one-day floor, which keeps a play-through at roughly 60 frames whether the
graph covers months or a decade.

The bound and step arithmetic moves into temporalScrubberBounds.ts so it
can be asserted directly, alongside the existing temporalLifecycle
predicate tests.

Closes #1536

Co-authored-by: Sameer Kadam <sskadam6305@gmail.com>
2026-09-09 17:26:23 +05:30
bd41a5a24f fix(vector-store): prevent in-memory vector ID reuse (#1546)
* fix(vector-store): prevent in-memory vector id reuse

* fix(vector-store): synchronize in-memory mutations

* fix(vector-store): clarify no-silent-overwrite guarantee for in-memory ID generation

The while-loop in store_vectors() already prevents any generated candidate
from landing on a live key: it breaks only when the candidate is absent
from both pre_existing (the live store at lock-entry) and within_batch
(IDs chosen earlier in the same call).

This commit:
- Renames 'existing' to 'pre_existing' and introduces 'within_batch' to
  make the two-level de-dupe explicit and self-documenting.
- Tightens the break condition to 'not in pre_existing and not in
  within_batch' so within-batch duplicates are also guarded.
- Adds test_auto_generated_id_never_silently_overwrites_live_vector:
  stores 5, deletes 3, stores 2 more and asserts none of the new IDs
  land on a surviving vec_N.
- Adds test_collision_detection_no_silent_overwrite_even_with_corrupted_counter:
  rewinds _next_id to 0 with live vectors present and proves the loop
  finds a free slot without overwriting either existing vector or its
  metadata — the no-silent-overwrite invariant holds even under counter
  corruption.

The skip-over-occupied-key behaviour for caller-inserted vec_N IDs is
intentional and preserved (test_counter_skips_explicit_vec_n_ids), matching
FAISSStore's identical pattern.

Closes reviewer finding: 'Vector collisions do not fail writes'.

* fix(vector-store): protect concurrent in-memory access

---------

Co-authored-by: Sameer Kadam <sameerkadam@Mac.lan>
Co-authored-by: Zohaib Hassnain <109234410+ZohaibHassan16@users.noreply.github.com>
2026-09-09 17:02:27 +05:30
dependabot[bot] fa8002e554 docker(deps): bump python from 3.13-slim to 3.14-slim (#1547)
Bumps python from 3.13-slim to 3.14-slim.

---
updated-dependencies:
- dependency-name: python
  dependency-version: 3.14-slim
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-09-09 15:29:53 +05:30
Mohd KaifandClaude Sonnet 5 d4127131ba fix(ci): match pip-audit ignore list against vuln aliases, not just id (#1543)
#1540 added GHSA-4j2p-28q2-5m79 to security-scan.yml's IGNORED_VULN_IDS to
suppress the open, unpatched accelerate<=1.14.0 path traversal advisory
(Semantica doesn't call load_checkpoint_in_model/load_checkpoint_and_dispatch).
That commit's own CI run still failed: pip-audit's OSV-backed report picked
CVE-2026-69112 as the vuln's canonical `id` and demoted the GHSA id to an
alias, but the shell/JS matching only ever compared against `.id`.

List both identifiers and match against `.id` plus `.aliases` (which
pip-audit includes by default for JSON output) in the audit gate, the
"Vulnerability details" printer, and the PR-comment script, so an ignored
advisory is excluded regardless of which alias the report surfaces as
canonical. Verified against the actual failing report from run 34296586683 -
the corrected filter now yields 0 actionable vulnerabilities.

Also add osv-scanner.toml (per GHSA-4j2p-28q2-5m79's Scorecard code-scanning
remediation) so the weekly Scorecard "Vulnerabilities" check stops flagging
the same accepted, unpatched advisory.

Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-09 12:46:02 +05:30
Zohaib Hassnain 42bbe51382 fix(ci): ignore upstream accelerate vulnerability in security scan (#1540)
Add GHSA-4j2p-28q2-5m79 to IGNORED_VULN_IDS in security-scan.yml.

accelerate<=1.14.0 has an open path traversal advisory (GHSA-4j2p-28q2-5m79) in load_checkpoint_in_model. 1.14.0 is currently the latest available release on PyPI, so no upstream patch exists yet. Semantica does not expose or call sharded checkpoint loading, making this non-actionable. Re-evaluate once an updated accelerate release is published.
2026-09-09 05:48:45 +05:00
Shubham Srivastava 4b11660f00 fix(cli): report the graph backend actually used, not a nonexistent memory one (#1539)
The status panel and doctor both defaulted to reporting a 'memory' graph
store, but GraphStore._initialize_store_backend only branches on neo4j,
falkordb, neptune and age - GraphStore(backend='memory') raises
ValidationError. doctor short-circuited on that name and returned a
passing check for a store no command could construct.

Both sites now report the backend _get_graph_store actually resolves to,
and doctor probes it instead of skipping. _get_graph_store's default is
unchanged, so no existing setup behaves differently.

Refs #1481
2026-09-09 02:29:25 +05:00
Besokus 44c0c7a76d feat(semantic_extract): schema-guided validation — SchemaValidator + ExtractionSchema (PR 1/3) (#1527)
Add a deterministic, ontology-based schema validator as a sibling to the
confidence-based ExtractionValidator. Both validators implement the same
validate_entities() and validate_relations() interfaces and return the
shared ValidationResult structure, enabling orthogonal composition across
extraction confidence and ontological conformance.

Key Additions & Behaviors:
- ExtractionSchema: Read-only view over domain ontologies (allowed concepts
  and predicates with optional domain/range constraints). Supports loading
  from dictionary representations (generate_ontology() or OntologyData from
  OntologyIngestor) via ExtractionSchema.from_ontology(), and from OWL/Turtle
  files/strings via ExtractionSchema.from_owl().
- SchemaValidator: Verifies entity labels against schema concepts and
  validates relation predicates against defined domain/range constraints.
  Provides filter_by_schema() and filter_relations_by_schema() to extract
  conforming subsets without mutating source data.
- Domain/Range Wildcarding: Treats owl:Thing (and unqualified Thing) as
  unconstrained wildcards so fallback types do not reject valid endpoints.
- Constructor Parity: Folds relation domain/range endpoint types into the
  concepts set across both from_ontology() and from_owl() to ensure logical
  consistency.
- Ingest Pipeline Interop: Automatically unwraps OntologyData-like objects
  via duck-typing on .data.
- Resilient Endpoint Resolution: Guards malformed relations missing subject
  or object endpoints without raising AttributeError, and prefers rdfs:label
  over URI suffixes while supporting both owl:Class and rdfs:Class.

Part of #1510.
2026-09-08 20:02:17 +05:00
Sameer Kadam 1c6296a5ab fix(mcp): repair GraphML and Parquet exports (#1367)
Repair broken GraphML and Parquet export handling in the MCP
`export_graph` tool.

Previously, both formats failed in the MCP handler: GraphML referenced a
non-existent `GraphMLExporter` class and passed unformatted graph objects,
while Parquet passed raw `ContextGraph` instances to `export()`, causing
runtime type errors and producing empty responses.

Key changes:
- Route GraphML export through `GraphExporter(format="graphml")` via
  `export_knowledge_graph()` with automatic `TemporaryDirectory` cleanup.
- Harden GraphML XML generation in `GraphExporter`: declare missing
  `label` and `confidence` keys with `for="all"` scope and use
  `xml.sax.saxutils` (`quoteattr`, `escape`) to properly escape XML
  attributes and node text.
- Route Parquet export through `ParquetExporter.export_knowledge_graph()`,
  returning generated `.parquet` files base64-encoded over the JSON MCP
  transport.
- Gracefully handle missing `pyarrow` dependencies with structured error
  responses.
- Preserve existing JSON, CSV, and RDF export behavior.
- Add comprehensive regression tests in
  `tests/test_mcp_package_export_graphml_parquet.py`.
2026-09-08 18:48:57 +05:00
Nilay Mallik 2383d228c7 fix(explorer): place static causal-distance route before dynamic decision_id route (#1532)
Move the static `GET /api/decisions/causal-distance` endpoint above the
dynamic `GET /api/decisions/{decision_id}` route in the Explorer router.

Starlette evaluates routes sequentially in declaration order. Because
`/{decision_id}` was previously defined first, requests targeting
`/api/decisions/causal-distance` were captured by the parameter route
with `decision_id="causal-distance"`, causing the endpoint to query the
graph for a non-existent decision and return 404.

Changes:
- Reorder `causal_distance` above `get_decision` in
  `semantica/explorer/routes/decisions.py` and add a comment guard
  against future route-ordering regressions.
- Add regression test suite in
  `tests/explorer/test_decisions_causal_distance_route.py` verifying
  proper distance reporting, multi-hop traversal, and that unmatched
  decision IDs still return 404.

Closes #1531
2026-09-08 17:12:05 +05:00
5a0d6ea431 Fix/parse and qdrant vector store (#1508)
* fix(parse): stop infinite recursion in default method dispatch

parse_document and its five sibling dispatchers (parse_web_content,
parse_structured_data, parse_email, parse_code, parse_media) were
registered in the method registry under their own task's "default"
name. Every dispatcher begins with method_registry.get(<task>, method),
so calling e.g. parse_document(file, method="default") found itself in
the registry and re-entered infinitely until RecursionError --
`semantica parse <any file>` crashed before any parsing ran.

Drop the six self-registrations. "default" remains the built-in code
path; users can still register their own "default" (or any other name)
to override it, and the existing "docling" registration is unaffected.

Verified: `semantica parse demo.pdf` now parses successfully (was
RecursionError). No repo code or tests consume
get_parse_method("document", "default"), so removing the entries
changes no behavior besides fixing the crash.

Co-Authored-By: Claude Code <noreply@anthropic.com>

* fix(deps): make resolution satisfiable on Python 3.9 and add parse-pdf extra

Dependency fixes so `uv lock` / `pip install semantica[all]` resolves
across the supported matrix (3.9-3.12):

- requires-python >=3.8 was unsatisfiable (numpy>=2.0.2 needs >=3.9) and
  3.9.0/3.9.1 can never resolve (every cryptography release excludes
  them) -> bump to >=3.9.2 and drop the 3.8 classifier.
- Split recently-raised floors that dropped 3.9 into marker pairs
  (3.9-capped / 3.10-unconstrained), following the pattern already used
  for scikit-learn/requests/etc.: pyarrow extras (>=24 needs 3.10),
  pre-commit 4.6, snowflake-connector 4.6, fastapi 0.129 + starlette 0.53
  (older fastapi caps starlette<0.53).
- Gate docling, litellm (its only 3.9 release pins
  python-dotenv==1.0.1, conflicting with the >=1.2.1 core floor), and
  crewai (no un-yanked 3.9 release) to >=3.10.

Also add a parse-pdf extra: the default PDFParser requires pdfplumber,
but no extra installed it, so `semantica parse file.pdf` failed on a
default install. Follows the parse-docling convention.

Co-Authored-By: Claude Code <noreply@anthropic.com>

* fix(vector-store): make the qdrant backend usable through VectorStore

The qdrant backend could not be used at all through the VectorStore
facade in 0.6.8 - every path raised:

1. store_vectors() only dispatched to backend add/add_vectors methods;
   QdrantStore exposes insert_vectors, so vs.store() raised
   NotImplementedError. Add an insert_vectors branch (uuid-generated
   ids, metadata -> payloads).
2. QdrantStore required an explicit create_collection() before any read
   or write, unlike FAISSStore's automatic index creation. Lazily attach
   the configured collection (config key "collection", default
   "semantica_default") on first insert/search, reusing an existing one.
3. search_points() called client.search(), removed from qdrant-client in
   favor of query_points() - use it when available, fall back otherwise.
4. store() silently dropped plain-string documents (it only extracted
   doc.metadata), so payloads lost the source text; keep them under
   payload "document".

Verified end-to-end against a Qdrant 5 server (docker): embed ->
store -> semantic search returns correctly ranked results whose payloads
carry the original documents.

Co-Authored-By: Claude Code <noreply@anthropic.com>

* fix(vector-store): address Qodo review findings on the qdrant write path

Four findings from the Qodo review of #1508:

1. store_vectors() returned QdrantStore.insert_vectors()' upsert status
   dict although the facade promises the stored vector IDs (decision
   storage indexes the result at position 0). Return the generated or
   caller-supplied IDs after a successful insert instead.
2. insert_vectors() pairs points with zip(vectors, ids), so a shorter
   non-empty id list silently dropped the unpaired vectors while the
   completion message still reported the full batch as inserted. Reject
   the mismatch with ValidationError before any write.
3. _ensure_default_collection() looked up the legacy "collection" config
   key, so the documented collection_name=... option was ignored and
   lazy init always fell back to semantica_default. Prefer
   collection_name, keep "collection" as an alias.
4. The new parse-pdf extra was missing from both aggregate "all"
   bundles, so semantica[all] still shipped without pdfplumber and the
   default PDFParser raised ProcessingError on first use.

Also removes the now-stale strict xfail for qdrant's write dispatch in
test_backend_facade_contract.py — that marker exists precisely to fail
once the wiring lands.

Co-Authored-By: Claude Code <noreply@anthropic.com>

* test(vector-store): migrate qdrant mocks to the query_points API

The qdrant search path now prefers client.query_points() (qdrant-client
removed client.search), but these tests still mocked the legacy call.
A MagicMock exposes query_points too, so the code took the modern path,
read .points off an unconfigured mock, and all three tests failed on
the branch. Return the hits in response.points as the real client does.

Co-Authored-By: Claude Code <noreply@anthropic.com>

* fix(ci): regenerate requirements-ci.txt for the parse-pdf extra

The CI lockfile check re-resolves pyproject.toml --extra all and diffs
the pinned versions against requirements-ci.txt. The parse-pdf extra
added pdfplumber (+pdfminer-six) to the 'all' bundle without refreshing
the lockfile, so the diff failed and the build job exited 1.

Regenerated with the command from the file header. Only the two new
pins and their 'via' comments changed - every other version is
identical. Verified locally with the CI's own check command (diff of
pkg==ver lines exits clean).

Co-Authored-By: Claude Code <noreply@anthropic.com>

* fix(tests): update stale default-method-registration assertion

test_parse_methods_dynamic_default_resolution asserted that "default"
resolves through the registry to parse_document, which was true only
because of the self-registration this PR removes (it's what caused the
infinite recursion in the first place). Update the assertion to match
the intended post-fix state: "default" is not registered at all, and
falls through to the built-in dispatch path unconditionally.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

---------

Co-authored-by: yanyu <yanyu@polixir.com>
Co-authored-by: Claude Code <noreply@anthropic.com>
Co-authored-by: Mohd Kaif <98801504+KaifAhmad1@users.noreply.github.com>
Co-authored-by: KaifAhmad1 <kaifahmad087@gmail.com>
2026-09-08 15:47:05 +05:30
Mohd Kaif 384c69293a Merge pull request #1530 from semantica-agi/fix/qdrant-client-search
fix: update Qdrant client compatibility
2026-09-08 14:01:07 +05:30
KaifAhmad1 5606767a29 Merge remote-tracking branch 'origin/main' into fix/qdrant-client-search
# Conflicts:
#	pyproject.toml
2026-09-08 13:46:53 +05:30
Mohd Kaif 7dbad0b167 Merge pull request #1528 from semantica-agi/slim-core-dependencies
feat(deps): slim core dependencies and move 22 heavy packages to opti…
2026-09-08 13:13:46 +05:30
KaifAhmad1andClaude Sonnet 5 b6538740e9 fix(tests): guard lxml-specific assertions in slim-core comment tests
test_xml_parser_handles_comments and test_public_api_ingestor_handles_xml_comments
called XMLParser(engine="lxml") / forced the lxml fallback path unconditionally,
but the core-only CI step installs from base-deps.txt, which no longer bundles
lxml or defusedxml under this PR's slim dependency layout. Skip the lxml-only
assertions when lxml/defusedxml aren't installed, while still exercising the
always-available etree path.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-08 12:47:26 +05:30
Mohd Kaif 0b31d3a370 Merge branch 'main' into slim-core-dependencies 2026-09-08 11:56:58 +05:30
BingEdward c449209168 fix(cli): dispatch mcp call through semantica_mcp.mcp.server (#1368)
Route `semantica mcp call` and `semantica mcp list-tools` through the
canonical `semantica_mcp.mcp.server` packaged module instead of the
nonexistent `MCPSession` import.

Previously, `semantica mcp call` imported `MCPSession` from
`semantica_mcp.mcp.session`, which never defined it, causing every
call to fail with "MCP module not available". Meanwhile,
`semantica mcp list-tools` inspected `semantica_mcp.mcp.tools.__all__`,
which evaluated to `["TOOL_DEFINITIONS"]` and printed a single tool
named "TOOL_DEFINITIONS".

Key changes:
- Implement `semantica_mcp.mcp.server.call_tool(name, arguments)` as the
  shared in-process entry point used by both the CLI and the JSON-RPC
  `tools/call` handler, ensuring both surfaces expose identical tools.
- Add `UnknownToolError` to distinguish missing tools (JSON-RPC -32601)
  from handler-level `KeyError` exceptions (JSON-RPC -32603).
- Update `semantica mcp list-tools` to source tool names directly from
  `TOOL_DEFINITIONS` in `semantica_mcp.mcp.tools`.
- Validate `--args` payloads and reject non-object JSON inputs (arrays,
  primitives, null) with a clean `ClickException`.
- Update `tests/test_mcp_stdio_roundtrip.py` to spawn `semantica_mcp.mcp`
  instead of the pre-relocation `mcp` module, restoring clean stdio
  framing tests.

Fixes #1355
2026-09-08 02:40:53 +05:00
Sameer KadamandSameer Kadam 7be1582786 fix: correct vector store installation extras (#1529)
Co-authored-by: Sameer Kadam <sameerkadam@Mac.lan>
2026-09-08 01:42:20 +05:00
Zohaib Hassnain 1ff890abdd fix(deps): address review feedback on import exceptions and probes (#1513) 2026-09-08 01:27:42 +05:00
Sameer Kadam 0646601219 fix: handle Qdrant vector count compatibility 2026-09-08 00:54:16 +05:30
Sameer Kadam ad0fbcf235 fix: update Qdrant client compatibility 2026-09-08 00:43:06 +05:30
Mohd Kaif a23f1edb9a Merge branch 'main' into slim-core-dependencies 2026-09-07 22:37:25 +05:30
Zohaib Hassnain a0de5c2cdd fix(deps): address review feedback on slim core dependencies (#1513)
- Remove thinc direct constraint from nlp-spacy in pyproject.toml and update README/CHANGELOG
- Raise ProcessingError with install hint when UMAP is requested but unavailable in EmbeddingVisualizer
- Guard PCA and TSNE dimensionality reducers against None with actionable error messages
- Prevent keyword collisions in EmbeddingVisualizer dimensionality reduction (_reduce_dimensions)
- Add core-only install & test step to .github/workflows/ci.yml using base-deps.txt
- Prevent AttributeError on module load in repo_ingestor.py and xml_ingestor.py when optional dependencies are absent
- Make TOML loading in tests/test_issue_1513_slim_core.py portable across Python versions via UTF-8 text decode and loads
- Expand slim-core test suite to 17 test cases covering 2D/3D projections, core imports, and options handling
2026-09-07 20:32:00 +05:00
Sakshi JainandMohd Kaif 3a69721abf test(gemini): cover legacy-SDK per-model instance cache (#1512)
Co-authored-by: Mohd Kaif <98801504+KaifAhmad1@users.noreply.github.com>
2026-09-07 20:32:30 +05:30
Zohaib Hassnain 86ffa05dd4 feat(deps): slim core dependencies and move 22 heavy packages to optional extras (#1513)
- Reduce direct core dependencies in pyproject.toml from 44 to 22
- Move heavy and specialized packages into modular optional extras:
  * models-huggingface: torch, transformers
  * embeddings-local: sentence-transformers, fastembed, onnxruntime, tokenizers
  * nlp-spacy: spacy, thinc
  * viz: matplotlib, seaborn, plotly, ipywidgets, umap-learn (expanded)
  * media: librosa, opencv-python
  * vectorstore-faiss: faiss-cpu
  * documents: python-docx, openpyxl, lxml, beautifulsoup4
  * ingest-git: GitPython
  * graph-embeddings: gensim
- Update semantica[all] and semantica[vectorstore-all] to encompass all extras
- Ensure lazy parser construction (DOCXParser, ExcelParser, HTMLParser, XMLParser) without error on __init__(), failing only inside .parse() with clear hints
- Add stdlib xml.etree fallback in XMLParser when lxml is missing
- Guard unguarded matplotlib imports in EmbeddingVisualizer and OntologyVisualizer
- Standardize user-facing error messages to point to pip install 'semantica[extra]'
- Bump version to 0.7.0 in pyproject.toml, semantica/__init__.py, and CITATION.cff
- Add migration notes to README.md and CHANGELOG.md
- Recompile CI and Docker requirements lockfiles
- Add dedicated test suite tests/test_issue_1513_slim_core.py
2026-09-07 19:53:09 +05:00
Sameer Kadam 07c74cc544 feat(faiss): add native vector deletion support (#1507)
Adds native vector deletion support for FAISS Flat indices via remove_ids
and IDSelectorBatch (Closes #1374):

- Adds FAISSIndex.delete_vectors() and FAISSStore.delete_vectors() to
  translate external string IDs to sequential internal positions, remove
  vectors via remove_ids, and keep vector_ids and metadata parallel with
  the compacted index.
- Auto-saves index and metadata sidecar after deletion when the store was
  loaded from disk via load_index(), skipping redundant disk writes for
  no-op deletions.
- Rejects IVF and HNSW index deletions with NotImplementedError: IVF does
  not compact internal labels upon remove_ids (causing search/offset
  desynchronization), and HNSW lacks remove_ids entirely. Both cleanly
  map to STATUS_UNSUPPORTED in ErasureCoordinator.

Robust ID Management & Invariant Hardening:
- Generates default IDs with a monotonic candidate-check loop against
  existing vector_ids, eliminating collisions and metadata overwrites
  when explicit vec_N IDs coexist.
- Persists next_id in the .meta.json sidecar and clamps on load() to
  max(persisted, max(vec_N) + 1), preventing stale sidecars from
  re-introducing collisions across restarts or legacy migrations.
- Guards against FAISS -1 sentinels (0 <= idx < len(vector_ids)) in
  search results when k > ntotal, preventing negative index wrapping
  to vector_ids[-1].
- Replaces bare post-delete assert with an explicit ProcessingError check
  that survives python -O.

Adds 43 comprehensive tests in test_faiss_delete_vectors.py covering
deletion, search exclusion, metadata cleanup, persistence roundtrips,
IVF/HNSW rejection, ErasureCoordinator receipts, and mock-spied
deterministic no-op saves.
2026-09-07 17:39:17 +05:00
Kevin 9c38fd49e5 feat(mcp): semantic retrieval tools (store, retrieve, update, remove) (#1250)
Implements four semantic retrieval tools on top of VectorStore wired
into the MCP server tool registry (Closes #1235):

- store_document: Chunks content with a configurable sliding window
  (1000/200 default) and attaches full provenance metadata to every
  chunk (chunk_id, source, authority, version, hash, status, offsets,
  and project). Re-storing identical content under the same
  (source, version) is a no-op keyed on content hash to skip redundant
  re-embedding.
- retrieve_context: Embeds natural-language queries, ranks scored
  chunks with provenance (top_k capped at 10; project filter support),
  and attaches 1-hop graph relationships for hit sources from ContextGraph.
- update_document: Replaces stored content for (source, version). Returns
  not_found if the document does not exist, and snapshots existing rows
  for atomic rollback if writing replacement chunks fails.
- remove_document: Deletes all chunks matching (source, version) and
  returns not_found if the document does not exist.

Architecture & Session Management:
- Adds lazy session singletons get_embedder() and get_vector_store()
  in semantica_mcp.mcp.session.
- Stores derive dimension directly from the active embedder to keep
  indexing and query spaces aligned.
- Restricts backends to inmemory (default) and sqlite (requires
  SEMANTICA_VECTOR_DB_PATH); backends lacking metadata-scoped delete
  fail fast on startup.
- In-memory updates and removals safely rebuild the store to prevent
  vector ID collisions (#1029).
- Fails fast on startup if SEMANTICA_VECTOR_PATH is corrupt or dimension
  mismatched, preventing empty stores from overwriting existing corpora.

Hardening & Provenance Protections:
- System-owned provenance: user metadata is additive and cannot overwrite
  reserved identity or provenance fields.
- Document size capped at 10,000 chunks to prevent stale chunk retention
  against persistent metadata limits.
- Mutation responses return an explicit persisted flag.

Includes a comprehensive test suite in tests/test_mcp_semantic_retrieval.py
using a deterministic FakeEmbedder for network-free, hermetic execution.
2026-09-07 17:30:34 +05:00
BingEdward 0babf0d787 fix(cli): route JSON-mode failures to stderr as structured JSON (#1504)
The global --json flag documents "machine-readable JSON to stdout;
errors to stderr", but commands failing inside _run_with_error_handling()
rendered a Rich error panel through the module-level Console, which
writes to stdout. Any pipeline or automation parsing stdout as JSON
broke on the first failure:

    $ semantica --json mcp call extract_entities --args '[1,2' 1>/tmp/out 2>/tmp/err
    exit=1 stdout_bytes=634 stderr_bytes=0

Branch in _show_error_card(), the single renderer that callers of
_run_with_error_handling() route through. When JSON mode is active
(global --json on the CLI context or a subcommand's local --json flag,
uniformly named local_json), emit a structured {"error": ..., "type": ...}
JSON line to stderr and leave stdout untouched. Exit codes and interactive
Rich panels in non-JSON mode remain unchanged.

Add regression tests verifying stdout remains empty and stderr produces
parseable JSON under both global and local --json flag scopes.

Split out of #1368 as an independent fix.
2026-09-07 16:34:56 +05:00
Zohaib Hassnain 82fd1b8d88 docs(learning-more): fix broken pipeline and dedup snippets (#1511)
* docs(learning-more): fix broken pipeline and dedup snippets

* docs(learning-more): address review feedback on snippets and config defaults

- Define sample tasks in concurrency snippet to avoid NameError

- Define document_text in batch extraction snippet

- Define sample entities in deduplication snippet

- Correct GRAPH_STORE_DEFAULT_BACKEND default to neo4j

- Replace ineffective SEMANTICA_PORT with SEMANTICA_API_KEY in config table
2026-09-07 16:21:57 +05:00
dependabot[bot]andMohd Kaif 97840da51b security(deps): bump pinecone from 9.1.0 to 10.0.0 (#1495)
Bumps [pinecone](https://github.com/pinecone-io/python-sdk) from 9.1.0 to 10.0.0.
- [Release notes](https://github.com/pinecone-io/python-sdk/releases)
- [Commits](https://github.com/pinecone-io/python-sdk/compare/v9.1.0...v10.0.0)

---
updated-dependencies:
- dependency-name: pinecone
  dependency-version: 10.0.0
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Mohd Kaif <98801504+KaifAhmad1@users.noreply.github.com>
2026-09-07 16:23:13 +05:30
Mohd KaifandClaude Sonnet 5 b8011eb44a fix(docker): revert runtime base image to python:3.13-slim (#1509)
#1290 bumped the runtime stage to python:3.14-slim. gensim is a base
(non-extras-gated) dependency and publishes no cp314 wheel on PyPI, 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'

This has broken the Docker build (and Container Security Scan) on
every push to main since #1290 merged. Confirmed via
`pip index versions gensim` / the PyPI files listing that gensim 4.4.0
ships cp313 wheels but no cp314 ones; the earlier lockfile fix in
#1290 only validated that `uv pip compile` could *resolve* dependencies
for 3.14, which reads sdist metadata and doesn't need a compiler --
it doesn't validate that `pip install` can actually *build* them,
which is what fails here.

Reverts to the exact base image digest that was building successfully
before #1290 and regenerates explorer-extra-py313.txt against today's
PyPI state. Once gensim (and anything else pulled in transitively)
ships cp314 wheels, the 3.14 bump can be retried.

Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-07 15:29:41 +05:30
6ad2e8b615 docs(mcp): update stale MCP tool count from 12 to 15 (#1505)
* docs(mcp): update stale MCP tool count from 12 to 15

TOOLS in semantica/mcp_server/__init__.py ships 15 tools, but the README
table stopped at get_graph_summary and docs/guides/mcp-server.md still
said 12 in its opening line and setup steps while saying 15 elsewhere on
the same page. Add the three missing rows (query_graph, update_node,
delete_node) to the README table and align the guide's counts. Also fix
the same stale count in the openclaw integration docstring.

Fixes #1487

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* docs(openclaw): fix stale MCP tool count 12 → 15 and add missing tool rows

---------

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
Co-authored-by: Sameer Kadam <sskadam6305@gmail.com>
Co-authored-by: Sameer Kadam <sameerkadam@Mac.lan>
2026-09-07 14:49:16 +05:30
1773d8b5ab docker(deps): bump python from 3.13-slim to 3.14-slim (#1290)
* docker(deps): bump python from 3.13-slim to 3.14-slim

Bumps python from 3.13-slim to 3.14-slim.

---
updated-dependencies:
- dependency-name: python
  dependency-version: 3.14-slim
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>

* fix(docker): regenerate explorer-extra lockfile for Python 3.14

The base image bump to python:3.14-slim left explorer-extra-py313.txt
in place, a lockfile uv resolved specifically for Python 3.13 and
installed with --require-hashes. Re-resolving the explorer extra for
3.14 adds cloudpickle, which has no hash entry in the 3.13 file, so
the runtime stage's pip install would fail outright.

Regenerates the lockfile as explorer-extra-py314.txt and updates every
reference to it (Dockerfile, .dockerignore, container-scan.yml's path
trigger, ci.yml's comment, and the requirements README).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

---------

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Mohd Kaif <98801504+KaifAhmad1@users.noreply.github.com>
Co-authored-by: KaifAhmad1 <kaifahmad087@gmail.com>
Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-07 13:50:54 +05:30
28f4fc7bc4 feat(milvus): add delete_vectors to MilvusStore (#1391)
* feat(milvus): add delete_vectors to MilvusStore

Adds delete_vectors(ids) so the ErasureCoordinator can erase embeddings
on a Milvus backend. Ids are escaped with _format_milvus_value before
building the delete expression, and the backend delete count is returned
so a delete that removed nothing is distinguishable from a failure.

* test(milvus): cover delete_vectors incl. erasure integration

Unit tests assert the single-id equality expression, multi-id in
expression, id escaping, empty-id noop, missing-collection and backend
error paths. Integration tests bind MilvusStore as a VectorStore backend
and assert ErasureCoordinator reports the vector leg erased.

* test(milvus): cover vector store delete facade

---------

Co-authored-by: Mohd Kaif <98801504+KaifAhmad1@users.noreply.github.com>
Co-authored-by: Sameer Kadam <sskadam6305@gmail.com>
Co-authored-by: Sameer Kadam <sameerkadam@Mac.lan>
2026-09-07 12:02:19 +05:30
Wei Tao 008b9e0b64 fix(explorer): align visible legend with semantic node colors (#1483)
Replace the static biomedical category key in Explorer with a live node color legend derived from the active display graph, sharing the canvas's baseColor -> color -> theme fallback resolver.

- Preserve original semantic colors on focused display clones via `semanticBaseColor` so interaction styling (selection, path, neighbor highlights) does not bleed into the legend.
- Suppress the semantic legend while distance visualization modes are active.
- Refactor plugin fallback legend panels to use the shared legend builder with composite (group, color) keys.
- Consolidate duplicate `"test:graph-workspace"` scripts in `explorer/package.json`, recovering 26 previously shadowed tests across markdown and explorer capability suites.
- Add `tests/graphColorLegend.test.ts` to workspace test runs and wire `test:graph-legend-e2e` into `.github/workflows/ci.yml`.
- Closes #1479
2026-09-07 03:56:12 +05:00
1ec8de5d25 fix(kg): promote synthetic relation endpoints into the graph (#1464)
* fix(kg): promote synthetic relation endpoints into the graph

Relation extraction synthesizes an UNKNOWN Entity for a relationship endpoint
that is absent from the NER entity list (metadata synthetic=True), but
GraphBuilder kept only the endpoint string, so the synthetic object never
reached the entity collection. GraphValidator then reported DANGLING_EDGE,
breaking the native NER -> relation -> graph -> validation pipeline.

The relation branch now collects synthetic endpoint entities and promotes them
into the entity collection (deduplicated by id, keeping confidence=0.8 and the
synthetic metadata). A new unknown_relation_endpoint option defaults to
include; set it to "reject" to drop relationships whose endpoints are
synthetic instead.

Closes #1463

* fix(kg): reconcile synthetic endpoints after resolution

Address Qodo review feedback on #1464:

- Read unknown_relation_endpoint through the nested "config" mapping the
  orchestrator passes (GraphBuilder(config=...)), so the policy actually takes
  effect end to end.
- Prefer a real entity over an earlier-promoted synthetic one with the same id,
  deduplicating at build tail (unhashable ids are skipped defensively).
- Tag LLM triple endpoints that match no extracted entity and promote them too,
  so triplet relationships no longer leave dangling edges.
- Log when the reject policy drops a relationship.

Closes #1463

* fix(kg): key dedup on both id and entity_id; tag HF triple endpoints

Follow-up hardening on #1464's synthetic-endpoint reconcile:

- Dedup promoted endpoints against both `id` and `entity_id` (the canonical
  keys the builder itself recognizes), in both the relation branch and the
  build-tail reconciliation. The build-tail pass now also skips unhashable
  ids defensively.
- Tag HuggingFace REBEL endpoint texts that match no extracted entity, same
  as the LLM typed path, so HF triplets no longer leave dangling edges.
- Tests: use a real `Triplet`, cover the `entity_id` dedup case, exercise
  `merge_entities=True` explicitly (the prior test mislabeled the default),
  and lock in the dict-relationship out-of-scope contract.

* fix(kg): guard metadata=None and gate endpoint promotion scan

_real_ids and the reconcile filter both did .get("metadata", {}).get(...),
which returns None (not {}) when the key is present with a None value and
raised on entities carrying metadata=None.

The synthetic-endpoint promotion block in _process_item rebuilt existing_ids
by scanning all_entities for every relationship, making build() quadratic on
dense relation inputs. Gate it on if synthetic_endpoints so ordinary
relationships pay nothing (6000+6000 no-synthetic builds in 0.018s vs 9.04s).

* config(kg): home unknown_relation_endpoint in build config and document it

The option now has a default in the per-module build method config
(kg_methods.build.unknown_relation_endpoint) and kg_usage.md documents both
values. __init__ folds a config= dict into the option mapping once, which also
fixes entity_resolution/conflict_detection being silently dropped on the
orchestrator path; every option reads the same way now.

* fix(extract): thread entities into HF REBEL triplet path

extract_triplets_huggingface read entities from kwargs.get("entities") which
is always empty on this path, making the synthetic-endpoint tag a no-op
comparison. Declare entities as a parameter so it matches the real NER list
TripletExtractor already forwards.

* fix(kg): finalize synthetic relation endpoint handling

- Add concrete Python API (kg_config.set_method_config) and YAML config
  file example to kg_usage.md; the previous text referenced only an opaque
  config-file key path with no runnable code.

- Add three targeted tests to test_dangling_synthetic_endpoint.py:
  * test_real_entity_with_entity_id_only_wins_over_prior_synthetic: covers
    the _real_ids dedup pass when the real entity carries its id under
    entity_id only (no 'id' field).
  * test_llm_relation_synthetic_endpoints_promoted: exercises the full
    _parse_relation_result → Relation → GraphBuilder promotion path end-to-end.
  * test_reject_policy_emits_warning_when_all_rels_dropped: verifies that
    the 'all relationships were dropped' warning fires correctly when the
    reject policy drops every relationship from a dict-style source.

- Add import pytest (required for caplog fixture used in the new test).
- Fix missing EOF newline in the test file.

* fix(kg): make reject-policy edge drops observable

The reject policy logged at INFO and returned no machine-readable count,
making it effectively silent at WARNING-level logging thresholds.

Issue #1463 requires that rejected relationships are 'reported explicitly'.

Changes:
- Elevate the per-rejection log from INFO to WARNING so it is visible
  without debug logging enabled.
- Add self._rejected_relationships counter (reset per build() call,
  incremented per dropped edge).
- Expose the count in graph['metadata']['rejected_relationships'] so
  callers can check the outcome programmatically without log parsing.
- Document both observability mechanisms in kg_usage.md.
- Add four focused regression tests:
  * rejected count matches number of dropped edges
  * include policy always yields count=0
  * WARNING log is emitted once per dropped edge (not just INFO)
  * counter resets between successive build() calls on the same builder

---------

Co-authored-by: Sameer Kadam <sskadam6305@gmail.com>
Co-authored-by: Sameer Kadam <sameerkadam@Mac-11.lan>
2026-09-07 00:39:03 +05:30
Mohd Kaif 9cb85d1f66 Merge pull request #1488 from sakshi04-ui/test/gemini-per-call-model-override
test(gemini): regression tests for per-call model override
2026-09-06 22:19:54 +05:30
Mohd Kaif fea4ab8098 Merge branch 'main' into test/gemini-per-call-model-override 2026-09-06 22:13:36 +05:30
Mohd Kaif 7e8c47ba73 Merge pull request #1318 from Inference1/fix/ner-consensus-1283
fix(NER): Add explicit consensus merge strategy
2026-09-06 22:07:40 +05:30
Mohd Kaif 5a5706a7c9 Merge branch 'main' into fix/ner-consensus-1283 2026-09-06 21:36:32 +05:30
cxzg007 ec55385b78 fix(context): dedupe heuristic causes in trace_decision_causality (#1360)
In `trace_decision_causality()`, potential heuristic causes were collected in a list inside a loop over the decision's entities. When two decisions shared multiple entities, the earlier decision was appended once per shared entity, resulting in duplicate "influences" chains and redundant recursive subtree traversals.

Deduplicate `potential_causes` by decision ID using an insertion-ordered dict so each cause is reported and traversed exactly once, preserving deterministic iteration order across runs.

- Add regression test `test_heuristic_cause_reported_once_per_shared_entity_pair`
- Fixes #1358
2026-09-06 17:55:28 +05:00
Sakshi Jain 847aa86b34 test(gemini): regression tests for per-call model override 2026-09-06 18:16:23 +05:30
8460ede201 feat(explorer): complete ARIA tab pattern for markdown viewer (#1117) (#1196)
* feat(explorer): complete ARIA tab pattern for markdown viewer (#1117)

The Preview/Source controls exposed role="tablist"/role="tab"/aria-selected
but never connected to the content they switch, so assistive technology could
not tell which panel the tabs controlled, and the tablist was two separate Tab
stops with no arrow-key navigation.

Wire the full pattern:

- ids from useId(), matching the GraphWorkspace search combobox, so two viewers
  mounted at once cannot collide on hardcoded ids
- aria-controls on both tabs, role="tabpanel" + aria-labelledby on the panel
- roving tabindex, so the tablist is a single Tab stop
- Arrow Left/Right with wrapping, plus Home/End, all preventDefault'd

Two decisions worth recording.

Activation is manual (arrows move focus, Enter/Space selects) rather than the
automatic activation the APG suggests by default. The APG permits manual
activation where switching panels is expensive, and here it is: activating
Preview re-runs the whole markdown parse, measured at 385ms for a 1000-row GFM
table and 1433ms at 2000 rows while profiling #1118. Automatic activation would
freeze the main thread on every arrow keypress.

The panel wraps all three render branches, including the empty state, and both
tabs point aria-controls at that one id. Only the active view is ever rendered,
so per-tab panel ids would leave the inactive tab referencing an element absent
from the DOM -- and scoping the panel to the two content branches would dangle
the reference for empty nodes.

No visible focus style was added: index.css already applies a global
:focus-visible ring and tabBtnStyle does not suppress it.

Five tests cover the wiring, the empty-state branch, roving tabindex and id
uniqueness; each was confirmed to fail against the previous component. Keyboard
behaviour is not reachable from the SSR-based suite, so it was verified
separately in headless Chromium: Tab enters the tablist on the selected tab,
ArrowRight moves focus without activating, Enter and Space activate, ArrowRight
wraps, and Home/End work. All 71 tests pass and lint is unchanged.

* feat(explorer): complete markdown viewer tab accessibility

Complete the Preview/Source ARIA tab pattern with collision-safe IDs, tabpanel wiring, roving tabindex, and manual keyboard activation.

Use ref-based focus tracking to prevent keyboard navigation from triggering Markdown re-parsing, and synchronize the roving tabindex after React renders.

Add regression coverage for keyboard navigation, wrapping, ARIA labelling, and focus-state invariants.

* fix(explorer): move focusedModeRef resource-reset out of render phase

The render-phase ref write (focusedModeRef.current = defaultMode inside
the activeResourceKey !== resourceKey guard) correctly reset the ref but
triggered a react-hooks/refs lint error: refs must not be written during
render.

Replace with a useLayoutEffect([resourceKey, defaultMode]) that fires
synchronously before paint after every resource/defaultMode change. This
achieves the same invariant — focusedModeRef is reset before the no-deps
tabIndex-correction effect reads it — while satisfying the linter.

Lint: npx eslint MarkdownContentViewer.tsx -> 0 errors
Tests: npm run test:graph-workspace -> 107/107 pass

---------

Co-authored-by: Pravit Ampapathini <pravit.amp@gmail.com>
Co-authored-by: Sameer Kadam <sskadam6305@gmail.com>
Co-authored-by: Sameer Kadam <sameerkadam@Mac.lan>
2026-09-06 18:13:16 +05:30
Aldrin JosephandSameer Kadam 34fe19601c fix(scripts): broaden Windows Mintlify noise filter to EBUSY (#1427) (#1474)
Co-authored-by: Sameer Kadam <sskadam6305@gmail.com>
2026-09-06 16:32:02 +05:30
Quinn Xu 2b866c8638 docs(embeddings): tighten reference prose (#1484) 2026-09-06 15:32:06 +05:00
30fc6447fd fix(cli): prevent doctor Note/Hint columns from wrapping into unreadable fragments (#1428) (#1475)
* fix(cli): prevent doctor Note/Hint columns from wrapping into unreadable fragments (#1428)

* fix(cli): keep doctor check labels readable

---------

Co-authored-by: Sameer Kadam <sskadam6305@gmail.com>
Co-authored-by: Sameer Kadam <sameerkadam@Sameers-MacBook-Air.local>
2026-09-05 21:55:12 +05:30
Wei Tao c25b88c07e perf(explorer): bound the ontology graph fetch and hydrate external nodes concurrently (#1441)
`GET /api/ontology/graph` used to fetch every node and edge for each relevant type with `limit=2**63-1`, normalize all of it, and only then check the result against `_MAX_ANALYSIS_NODES`. So the cap limited the response size, but not the amount of work or memory needed to build it.

Using the existing `paginate_nodes`/`paginate_edges` APIs wouldn't help much either. Each call still loads and normalizes the full matching set before slicing out a page, so paging through the results would repeat that cost for every page.

This adds `GraphSession.iter_nodes` and `iter_edges`, which yield matches one at a time instead of materializing everything up front. The graph endpoint now applies the ownership filter while scanning, so nodes from other ontologies are dropped before they're normalized or kept in memory. It also stops as soon as the requested ontology's own nodes or selected edges exceed the cap.

The ownership check intentionally happens before the cap check. That way, a graph containing a large number of nodes from unrelated ontologies can't make the requested ontology appear too large to open.

The old `candidates_by_id` map is gone as well. We now only retain nodes actually owned by the requested ontology instead of building payloads for every schema-typed node in the graph.

I also split the endpoint into `_known_ontology_uris`, `_collect_core_nodes`, and `_select_structure_edges` so the main flow is a little easier to follow.

External edge endpoints were previously hydrated one at a time with an `await` for each node. With a few thousand selected edges, that meant a few thousand sequential thread dispatches. They're now hydrated concurrently with `asyncio.gather` over `to_thread` calls, using the default thread pool. The final node and edge lists are sorted before building the response, so completion order doesn't affect the output.

This isn't fully lazy all the way down. `iter_edges` still gets the full result for an edge type from `ContextGraph.find_edges` before it starts yielding, and `iter_nodes` still snapshots and sorts all node IDs for a type first. A very large type such as `rdf:type` can therefore still do work proportional to its graph-wide size before the cap gets a chance to stop the scan.

What this change avoids is the more expensive part: normalizing every schema node in the graph and keeping the full candidate payload map in memory. Making `ContextGraph.find_edges` lazy would address the remaining issue, but that's a lower-level API used elsewhere and needs its own locking design, so that's better handled separately.

The old final cap check is removed too. The streaming collectors now raise as soon as their running count exceeds `_MAX_ANALYSIS_NODES`, so by the time collection finishes, `core_node_ids` and `selected_edges` are already guaranteed to be within the limit. There's a comment at that boundary documenting the invariant instead of keeping a redundant check around.
2026-09-05 20:05:24 +05:00
14d25cabcd fix(cli): dispatch reason run and store connect to real APIs (#1372)
* fix(cli): dispatch reason run and store connect to real APIs

semantica reason run called Reasoner.run(), which does not exist --
the facade's API is infer_facts(facts, rules). The command now reads
nodes/relationships from the configured graph store as fact strings
(the same conventions Reasoner.add_fact applies to KG-style dicts),
loads --rules as a YAML list/mapping or plain-text lines, and reports
the inferred facts.

semantica store connect called get_graph_store_method(backend), which
is the (task, method_name) method registry, not a backend factory, so
it raised a TypeError before any connection attempt and failed
identically with or without valid credentials. It now builds the store
via GraphStore(backend=...) and probes connect(), so real
connectivity/auth errors surface.

Fixes #1354

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(cli): use backend relationship keys, all labels, and honest engine dispatch in reason run

Review follow-ups on the reason run fact conversion:

- Relationships from every graph store backend carry start_node_id /
  end_node_id; _graph_store_facts() read start_id / end_id, so each
  relationship became TYPE(None, None) and relationship rules never
  matched. Read the real keys and cover it with a rule that matches the
  relationship fact.
- Emit a fact for every node label, not just the first, so rules on
  secondary labels can match.
- reason run only executes the Reasoner facade's forward-chaining
  inference; --engine values with different input paradigms (datalog,
  sparql, abductive, deductive, graph) now fail with a clear message
  (pointing SPARQL/Datalog at reason query) instead of reporting an
  engine that never ran.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(cli): harden reason run rule loading

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Co-authored-by: Sameer Kadam <sskadam6305@gmail.com>
Co-authored-by: Sameer Kadam <sameerkadam@Sameers-MacBook-Air.local>
2026-09-05 20:29:46 +05:30
KaifAhmad1 c2bde11f3c test: verify SSH commit signing 2026-09-05 19:48:19 +05:30
Inference_ 058527bf4a fix(ner): discard unresolved merge spans 2026-08-31 11:40:48 -04:00
Inference_ 0bbd674d89 fix(ner): add explicit ensemble merge strategies 2026-08-31 11:40:48 -04:00
133 changed files with 14062 additions and 9797 deletions
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
View File
@@ -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
+33 -10
View File
@@ -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
View File
Binary file not shown.
+35
View File
@@ -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
View File
@@ -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
View File
@@ -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 \
+42 -21
View File
@@ -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.
+2 -2
View File
@@ -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
View File
@@ -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.
+19 -19
View File
@@ -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.
+35
View File
@@ -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
+4 -4
View File
@@ -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
View File
@@ -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
+2 -2
View File
@@ -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": {
+2 -9
View File
@@ -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 = `
+2
View File
@@ -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);
}
+114
View File
@@ -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, []);
});
+103
View File
@@ -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);
});
+102
View File
@@ -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);
});
+89 -83
View File
@@ -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",
]
]
+4 -1
View File
@@ -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`.
+2 -2
View File
@@ -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
-------------
+1 -1
View File
@@ -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
+20
View File
@@ -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.
"""
+56 -14
View File
@@ -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.
+2 -2
View File
@@ -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
+2 -2
View File
@@ -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:**
+65 -13
View File
@@ -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)
```
+42 -13
View File
@@ -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.
+50 -17
View File
@@ -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
```
+54 -19
View File
@@ -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]"`.
+2 -2
View File
@@ -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
View File
@@ -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
View File
@@ -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
+1 -1
View File
@@ -10,7 +10,7 @@ Main exports:
- Config: Configuration management
"""
__version__ = "0.6.8"
__version__ = "0.7.0"
__author__ = "Semantica Contributors"
__license__ = "MIT"
+138 -35
View File
@@ -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:
+7 -3
View File
@@ -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 -9
View File
@@ -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",
+1 -1
View File
@@ -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}")
+4 -2
View File
@@ -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."
)
+17 -14
View File
@@ -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,
+162 -75
View File
@@ -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,
+47 -1
View File
@@ -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.
+38 -15
View File
@@ -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>")
+1 -1
View File
@@ -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
+67 -11
View File
@@ -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
View File
@@ -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}")
+56 -16
View File
@@ -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
+21 -22
View File
@@ -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)
+11 -3
View File
@@ -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)
+8 -1
View File
@@ -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()
+135 -3
View File
@@ -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
+47
View File
@@ -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.
+1 -1
View File
@@ -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(
+23 -7
View File
@@ -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
+11 -1
View File
@@ -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
+10 -1
View File
@@ -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
View File
@@ -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.
+16 -1
View File
@@ -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
+41 -10
View File
@@ -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
+15 -4
View File
@@ -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",
+40 -10
View File
@@ -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)")
+804 -45
View File
@@ -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
+234
View File
@@ -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
View File
@@ -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))
)
)
+215 -13
View File
@@ -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
+40
View File
@@ -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:
+66 -19
View File
@@ -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",
+127 -36
View File
@@ -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(
+90 -40
View File
@@ -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(
+1 -1
View File
@@ -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]:
+12 -8
View File
@@ -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(
+118
View File
@@ -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": {}}
+24 -5
View File
@@ -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